768 lines
30 KiB
Python
768 lines
30 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Figma Style Auditor - Design system analysis and consistency checking
|
||
Analyzes files for brand compliance, accessibility, and design system health.
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
import json
|
||
import math
|
||
import time
|
||
from typing import Dict, List, Optional, Union, Any, Tuple
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
import argparse
|
||
import colorsys
|
||
try:
|
||
from figma_client import FigmaClient
|
||
except ImportError:
|
||
# Handle case where script is run directly
|
||
import sys
|
||
from pathlib import Path
|
||
sys.path.append(str(Path(__file__).parent))
|
||
from figma_client import FigmaClient
|
||
|
||
@dataclass
|
||
class AuditConfig:
|
||
"""Configuration for design system audits"""
|
||
check_accessibility: bool = True
|
||
check_brand_compliance: bool = True
|
||
check_consistency: bool = True
|
||
generate_report: bool = True
|
||
min_contrast_ratio: float = 4.5 # WCAG AA standard
|
||
min_touch_target: float = 44 # iOS/Material Design standard
|
||
brand_colors: List[str] = field(default_factory=list)
|
||
brand_fonts: List[str] = field(default_factory=list)
|
||
|
||
@dataclass
|
||
class AuditIssue:
|
||
"""Represents a design issue found during audit"""
|
||
severity: str # 'error', 'warning', 'info'
|
||
category: str # 'accessibility', 'brand', 'consistency'
|
||
message: str
|
||
node_id: str = None
|
||
node_name: str = None
|
||
suggestions: List[str] = field(default_factory=list)
|
||
details: Dict[str, Any] = field(default_factory=dict)
|
||
|
||
class StyleAuditor:
|
||
"""Comprehensive design system auditor for Figma files"""
|
||
|
||
def __init__(self, figma_client: FigmaClient, config: AuditConfig = None):
|
||
self.client = figma_client
|
||
self.config = config or AuditConfig()
|
||
self.issues: List[AuditIssue] = []
|
||
|
||
def audit_file(self, file_key: str) -> Dict[str, Any]:
|
||
"""Perform comprehensive audit of a Figma file"""
|
||
|
||
print(f"Starting audit of file: {file_key}")
|
||
self.issues.clear()
|
||
|
||
# Get file data
|
||
file_data = self.client.get_file(file_key)
|
||
|
||
# Run audit checks
|
||
if self.config.check_accessibility:
|
||
self._audit_accessibility(file_data)
|
||
|
||
if self.config.check_brand_compliance:
|
||
self._audit_brand_compliance(file_data)
|
||
|
||
if self.config.check_consistency:
|
||
self._audit_consistency(file_data)
|
||
|
||
# Generate summary
|
||
summary = self._generate_summary()
|
||
|
||
print(f"Audit completed: {len(self.issues)} issues found")
|
||
return {
|
||
'file_key': file_key,
|
||
'file_name': file_data.get('name', 'Unknown'),
|
||
'audit_timestamp': time.time(),
|
||
'summary': summary,
|
||
'issues': [self._issue_to_dict(issue) for issue in self.issues],
|
||
'recommendations': self._generate_recommendations()
|
||
}
|
||
|
||
def audit_multiple_files(self, file_keys: List[str]) -> Dict[str, Any]:
|
||
"""Audit multiple files and generate comparative analysis"""
|
||
|
||
all_results = {}
|
||
aggregated_issues = []
|
||
|
||
for file_key in file_keys:
|
||
try:
|
||
result = self.audit_file(file_key)
|
||
all_results[file_key] = result
|
||
aggregated_issues.extend(self.issues)
|
||
print(f"✓ Audited {result['file_name']}: {len(result['issues'])} issues")
|
||
except Exception as e:
|
||
print(f"✗ Failed to audit {file_key}: {e}")
|
||
all_results[file_key] = {'error': str(e)}
|
||
|
||
# Generate cross-file analysis
|
||
cross_file_analysis = self._analyze_cross_file_patterns(all_results)
|
||
|
||
return {
|
||
'individual_audits': all_results,
|
||
'cross_file_analysis': cross_file_analysis,
|
||
'total_files': len(file_keys),
|
||
'successful_audits': len([r for r in all_results.values() if 'error' not in r])
|
||
}
|
||
|
||
def _audit_accessibility(self, file_data: Dict[str, Any]):
|
||
"""Check accessibility compliance (WCAG guidelines)"""
|
||
|
||
def audit_node_accessibility(node):
|
||
node_type = node.get('type', '')
|
||
node_name = node.get('name', '')
|
||
node_id = node.get('id', '')
|
||
|
||
# Check text contrast
|
||
if node_type == 'TEXT':
|
||
self._check_text_contrast(node)
|
||
|
||
# Check touch targets
|
||
if node_type in ['COMPONENT', 'INSTANCE', 'FRAME'] and 'button' in node_name.lower():
|
||
self._check_touch_target_size(node)
|
||
|
||
# Check focus indicators
|
||
if 'interactive' in node_name.lower() or 'button' in node_name.lower():
|
||
self._check_focus_indicators(node)
|
||
|
||
# Recursively check children
|
||
for child in node.get('children', []):
|
||
audit_node_accessibility(child)
|
||
|
||
if 'document' in file_data:
|
||
audit_node_accessibility(file_data['document'])
|
||
|
||
def _audit_brand_compliance(self, file_data: Dict[str, Any]):
|
||
"""Check compliance with brand guidelines"""
|
||
|
||
if not self.config.brand_colors and not self.config.brand_fonts:
|
||
return # Skip if no brand guidelines configured
|
||
|
||
def audit_node_brand(node):
|
||
# Check color compliance
|
||
if 'fills' in node:
|
||
self._check_brand_colors(node)
|
||
|
||
# Check font compliance
|
||
if node.get('type') == 'TEXT':
|
||
self._check_brand_fonts(node)
|
||
|
||
# Recursively check children
|
||
for child in node.get('children', []):
|
||
audit_node_brand(child)
|
||
|
||
if 'document' in file_data:
|
||
audit_node_brand(file_data['document'])
|
||
|
||
def _audit_consistency(self, file_data: Dict[str, Any]):
|
||
"""Check internal consistency within the file"""
|
||
|
||
# Collect all styles for analysis
|
||
colors_used = []
|
||
fonts_used = []
|
||
spacing_used = []
|
||
|
||
def collect_styles(node):
|
||
# Collect colors
|
||
if 'fills' in node:
|
||
for fill in node.get('fills', []):
|
||
if fill.get('type') == 'SOLID':
|
||
color = fill.get('color', {})
|
||
if color:
|
||
colors_used.append({
|
||
'color': color,
|
||
'node_id': node.get('id'),
|
||
'node_name': node.get('name', '')
|
||
})
|
||
|
||
# Collect fonts
|
||
if node.get('type') == 'TEXT':
|
||
style = node.get('style', {})
|
||
if style:
|
||
fonts_used.append({
|
||
'font_family': style.get('fontFamily', ''),
|
||
'font_size': style.get('fontSize', 0),
|
||
'font_weight': style.get('fontWeight', 400),
|
||
'node_id': node.get('id'),
|
||
'node_name': node.get('name', '')
|
||
})
|
||
|
||
# Collect spacing (approximation from layout)
|
||
if 'children' in node and len(node['children']) > 1:
|
||
# This would need more sophisticated analysis
|
||
pass
|
||
|
||
# Recursively collect from children
|
||
for child in node.get('children', []):
|
||
collect_styles(child)
|
||
|
||
if 'document' in file_data:
|
||
collect_styles(file_data['document'])
|
||
|
||
# Analyze collected styles
|
||
self._analyze_color_consistency(colors_used)
|
||
self._analyze_typography_consistency(fonts_used)
|
||
|
||
def _check_text_contrast(self, text_node: Dict[str, Any]):
|
||
"""Check if text has sufficient contrast against background"""
|
||
|
||
# This is a simplified implementation
|
||
# Real implementation would need to calculate actual contrast
|
||
fills = text_node.get('fills', [])
|
||
if not fills:
|
||
return
|
||
|
||
text_color = fills[0].get('color', {})
|
||
if not text_color:
|
||
return
|
||
|
||
# For now, assume white background (would need parent background detection)
|
||
bg_color = {'r': 1, 'g': 1, 'b': 1} # White
|
||
|
||
contrast_ratio = self._calculate_contrast_ratio(text_color, bg_color)
|
||
|
||
if contrast_ratio < self.config.min_contrast_ratio:
|
||
self.issues.append(AuditIssue(
|
||
severity='error',
|
||
category='accessibility',
|
||
message=f'Insufficient color contrast: {contrast_ratio:.1f}:1 (minimum: {self.config.min_contrast_ratio}:1)',
|
||
node_id=text_node.get('id'),
|
||
node_name=text_node.get('name', ''),
|
||
suggestions=[
|
||
'Darken text color or lighten background',
|
||
'Use high contrast color combinations',
|
||
'Test with accessibility tools'
|
||
],
|
||
details={'contrast_ratio': contrast_ratio, 'text_color': text_color}
|
||
))
|
||
|
||
def _check_touch_target_size(self, node: Dict[str, Any]):
|
||
"""Check if interactive elements meet minimum touch target size"""
|
||
|
||
bounds = node.get('absoluteBoundingBox', {})
|
||
if not bounds:
|
||
return
|
||
|
||
width = bounds.get('width', 0)
|
||
height = bounds.get('height', 0)
|
||
|
||
if width < self.config.min_touch_target or height < self.config.min_touch_target:
|
||
self.issues.append(AuditIssue(
|
||
severity='warning',
|
||
category='accessibility',
|
||
message=f'Touch target too small: {width}×{height}px (minimum: {self.config.min_touch_target}×{self.config.min_touch_target}px)',
|
||
node_id=node.get('id'),
|
||
node_name=node.get('name', ''),
|
||
suggestions=[
|
||
f'Increase size to at least {self.config.min_touch_target}×{self.config.min_touch_target}px',
|
||
'Add padding around interactive elements',
|
||
'Consider user interaction patterns'
|
||
],
|
||
details={'current_size': {'width': width, 'height': height}}
|
||
))
|
||
|
||
def _check_focus_indicators(self, node: Dict[str, Any]):
|
||
"""Check if interactive elements have proper focus indicators"""
|
||
|
||
# This would check for focus states, outlines, etc.
|
||
# For now, just flag interactive elements that might need focus indicators
|
||
|
||
effects = node.get('effects', [])
|
||
has_focus_effect = any(
|
||
effect.get('type') == 'DROP_SHADOW' and
|
||
'focus' in str(effect).lower()
|
||
for effect in effects
|
||
)
|
||
|
||
if not has_focus_effect:
|
||
self.issues.append(AuditIssue(
|
||
severity='info',
|
||
category='accessibility',
|
||
message='Interactive element may need focus indicator',
|
||
node_id=node.get('id'),
|
||
node_name=node.get('name', ''),
|
||
suggestions=[
|
||
'Add focus state with visible outline',
|
||
'Use consistent focus indicator style',
|
||
'Test keyboard navigation'
|
||
]
|
||
))
|
||
|
||
def _check_brand_colors(self, node: Dict[str, Any]):
|
||
"""Check if colors match brand guidelines"""
|
||
|
||
if not self.config.brand_colors:
|
||
return
|
||
|
||
fills = node.get('fills', [])
|
||
for fill in fills:
|
||
if fill.get('type') == 'SOLID':
|
||
color = fill.get('color', {})
|
||
if color:
|
||
hex_color = self._rgb_to_hex(color)
|
||
|
||
if hex_color not in self.config.brand_colors:
|
||
# Check if it's close to a brand color
|
||
closest_brand_color = self._find_closest_brand_color(hex_color)
|
||
|
||
self.issues.append(AuditIssue(
|
||
severity='warning',
|
||
category='brand',
|
||
message=f'Non-brand color used: {hex_color}',
|
||
node_id=node.get('id'),
|
||
node_name=node.get('name', ''),
|
||
suggestions=[
|
||
f'Consider using brand color: {closest_brand_color}',
|
||
'Check brand color palette',
|
||
'Use design system colors'
|
||
],
|
||
details={'used_color': hex_color, 'suggested_color': closest_brand_color}
|
||
))
|
||
|
||
def _check_brand_fonts(self, text_node: Dict[str, Any]):
|
||
"""Check if fonts match brand guidelines"""
|
||
|
||
if not self.config.brand_fonts:
|
||
return
|
||
|
||
style = text_node.get('style', {})
|
||
font_family = style.get('fontFamily', '')
|
||
|
||
if font_family and font_family not in self.config.brand_fonts:
|
||
self.issues.append(AuditIssue(
|
||
severity='warning',
|
||
category='brand',
|
||
message=f'Non-brand font used: {font_family}',
|
||
node_id=text_node.get('id'),
|
||
node_name=text_node.get('name', ''),
|
||
suggestions=[
|
||
f'Use brand fonts: {", ".join(self.config.brand_fonts)}',
|
||
'Check typography guidelines',
|
||
'Maintain font consistency'
|
||
],
|
||
details={'used_font': font_family, 'brand_fonts': self.config.brand_fonts}
|
||
))
|
||
|
||
def _analyze_color_consistency(self, colors_used: List[Dict[str, Any]]):
|
||
"""Analyze color usage patterns for consistency issues"""
|
||
|
||
# Group similar colors
|
||
color_groups = {}
|
||
|
||
for color_data in colors_used:
|
||
color = color_data['color']
|
||
hex_color = self._rgb_to_hex(color)
|
||
|
||
# Find similar colors (within tolerance)
|
||
similar_group = None
|
||
for group_color in color_groups.keys():
|
||
if self._colors_are_similar(hex_color, group_color):
|
||
similar_group = group_color
|
||
break
|
||
|
||
if similar_group:
|
||
color_groups[similar_group].append(color_data)
|
||
else:
|
||
color_groups[hex_color] = [color_data]
|
||
|
||
# Flag groups with multiple similar but not identical colors
|
||
for base_color, group in color_groups.items():
|
||
if len(group) > 1:
|
||
unique_colors = set(self._rgb_to_hex(item['color']) for item in group)
|
||
if len(unique_colors) > 1:
|
||
self.issues.append(AuditIssue(
|
||
severity='info',
|
||
category='consistency',
|
||
message=f'Multiple similar colors found: {", ".join(unique_colors)}',
|
||
suggestions=[
|
||
'Standardize similar colors',
|
||
'Use design system color tokens',
|
||
'Review color palette'
|
||
],
|
||
details={'similar_colors': list(unique_colors), 'usage_count': len(group)}
|
||
))
|
||
|
||
def _analyze_typography_consistency(self, fonts_used: List[Dict[str, Any]]):
|
||
"""Analyze typography usage for consistency"""
|
||
|
||
# Group by font family and size
|
||
font_combinations = {}
|
||
|
||
for font_data in fonts_used:
|
||
key = f"{font_data['font_family']}-{font_data['font_size']}pt-{font_data['font_weight']}"
|
||
if key not in font_combinations:
|
||
font_combinations[key] = []
|
||
font_combinations[key].append(font_data)
|
||
|
||
# Look for too many font variations
|
||
families = set(font['font_family'] for font in fonts_used)
|
||
sizes = set(font['font_size'] for font in fonts_used)
|
||
|
||
if len(families) > 3:
|
||
self.issues.append(AuditIssue(
|
||
severity='warning',
|
||
category='consistency',
|
||
message=f'Too many font families: {len(families)} ({", ".join(families)})',
|
||
suggestions=[
|
||
'Reduce to 2-3 font families maximum',
|
||
'Establish typography hierarchy',
|
||
'Use consistent font pairing'
|
||
]
|
||
))
|
||
|
||
if len(sizes) > 8:
|
||
self.issues.append(AuditIssue(
|
||
severity='info',
|
||
category='consistency',
|
||
message=f'Many font sizes used: {len(sizes)} different sizes',
|
||
suggestions=[
|
||
'Create modular typography scale',
|
||
'Reduce to 6-8 standard sizes',
|
||
'Use consistent size progression'
|
||
]
|
||
))
|
||
|
||
def _calculate_contrast_ratio(self, color1: Dict[str, float], color2: Dict[str, float]) -> float:
|
||
"""Calculate WCAG contrast ratio between two colors"""
|
||
|
||
def get_luminance(color):
|
||
"""Calculate relative luminance"""
|
||
def linearize(val):
|
||
if val <= 0.03928:
|
||
return val / 12.92
|
||
else:
|
||
return pow((val + 0.055) / 1.055, 2.4)
|
||
|
||
r = linearize(color.get('r', 0))
|
||
g = linearize(color.get('g', 0))
|
||
b = linearize(color.get('b', 0))
|
||
|
||
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||
|
||
lum1 = get_luminance(color1)
|
||
lum2 = get_luminance(color2)
|
||
|
||
lighter = max(lum1, lum2)
|
||
darker = min(lum1, lum2)
|
||
|
||
return (lighter + 0.05) / (darker + 0.05)
|
||
|
||
def _rgb_to_hex(self, color: Dict[str, float]) -> str:
|
||
"""Convert RGB color to hex string"""
|
||
r = int(color.get('r', 0) * 255)
|
||
g = int(color.get('g', 0) * 255)
|
||
b = int(color.get('b', 0) * 255)
|
||
return f"#{r:02x}{g:02x}{b:02x}"
|
||
|
||
def _colors_are_similar(self, color1: str, color2: str, tolerance: int = 30) -> bool:
|
||
"""Check if two hex colors are similar within tolerance"""
|
||
|
||
def hex_to_rgb(hex_color):
|
||
hex_color = hex_color.lstrip('#')
|
||
return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
|
||
|
||
rgb1 = hex_to_rgb(color1)
|
||
rgb2 = hex_to_rgb(color2)
|
||
|
||
distance = math.sqrt(sum((a - b) ** 2 for a, b in zip(rgb1, rgb2)))
|
||
return distance < tolerance
|
||
|
||
def _find_closest_brand_color(self, hex_color: str) -> str:
|
||
"""Find the closest brand color to the given color"""
|
||
|
||
if not self.config.brand_colors:
|
||
return hex_color
|
||
|
||
min_distance = float('inf')
|
||
closest_color = self.config.brand_colors[0]
|
||
|
||
for brand_color in self.config.brand_colors:
|
||
if self._colors_are_similar(hex_color, brand_color, tolerance=255): # Use max tolerance for distance calc
|
||
distance = self._color_distance(hex_color, brand_color)
|
||
if distance < min_distance:
|
||
min_distance = distance
|
||
closest_color = brand_color
|
||
|
||
return closest_color
|
||
|
||
def _color_distance(self, color1: str, color2: str) -> float:
|
||
"""Calculate Euclidean distance between two colors"""
|
||
|
||
def hex_to_rgb(hex_color):
|
||
hex_color = hex_color.lstrip('#')
|
||
return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
|
||
|
||
rgb1 = hex_to_rgb(color1)
|
||
rgb2 = hex_to_rgb(color2)
|
||
|
||
return math.sqrt(sum((a - b) ** 2 for a, b in zip(rgb1, rgb2)))
|
||
|
||
def _generate_summary(self) -> Dict[str, Any]:
|
||
"""Generate audit summary statistics"""
|
||
|
||
summary = {
|
||
'total_issues': len(self.issues),
|
||
'by_severity': {
|
||
'error': len([i for i in self.issues if i.severity == 'error']),
|
||
'warning': len([i for i in self.issues if i.severity == 'warning']),
|
||
'info': len([i for i in self.issues if i.severity == 'info'])
|
||
},
|
||
'by_category': {
|
||
'accessibility': len([i for i in self.issues if i.category == 'accessibility']),
|
||
'brand': len([i for i in self.issues if i.category == 'brand']),
|
||
'consistency': len([i for i in self.issues if i.category == 'consistency'])
|
||
}
|
||
}
|
||
|
||
# Calculate overall score (0-100)
|
||
max_score = 100
|
||
error_penalty = 10
|
||
warning_penalty = 3
|
||
info_penalty = 1
|
||
|
||
penalty = (summary['by_severity']['error'] * error_penalty +
|
||
summary['by_severity']['warning'] * warning_penalty +
|
||
summary['by_severity']['info'] * info_penalty)
|
||
|
||
summary['score'] = max(0, max_score - penalty)
|
||
summary['grade'] = self._score_to_grade(summary['score'])
|
||
|
||
return summary
|
||
|
||
def _score_to_grade(self, score: int) -> str:
|
||
"""Convert numeric score to letter grade"""
|
||
if score >= 90:
|
||
return 'A'
|
||
elif score >= 80:
|
||
return 'B'
|
||
elif score >= 70:
|
||
return 'C'
|
||
elif score >= 60:
|
||
return 'D'
|
||
else:
|
||
return 'F'
|
||
|
||
def _generate_recommendations(self) -> List[str]:
|
||
"""Generate overall recommendations based on audit results"""
|
||
|
||
recommendations = []
|
||
|
||
error_count = len([i for i in self.issues if i.severity == 'error'])
|
||
warning_count = len([i for i in self.issues if i.severity == 'warning'])
|
||
|
||
if error_count > 0:
|
||
recommendations.append(f"Fix {error_count} critical accessibility issues immediately")
|
||
|
||
if warning_count > 5:
|
||
recommendations.append("Review and address design consistency issues")
|
||
|
||
brand_issues = len([i for i in self.issues if i.category == 'brand'])
|
||
if brand_issues > 0:
|
||
recommendations.append("Establish and enforce brand guidelines")
|
||
|
||
consistency_issues = len([i for i in self.issues if i.category == 'consistency'])
|
||
if consistency_issues > 3:
|
||
recommendations.append("Create and apply design system standards")
|
||
|
||
if not recommendations:
|
||
recommendations.append("Great work! Consider periodic design system reviews")
|
||
|
||
return recommendations
|
||
|
||
def _analyze_cross_file_patterns(self, all_results: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""Analyze patterns across multiple files"""
|
||
|
||
# This would analyze common issues across files
|
||
# For now, return basic aggregation
|
||
|
||
total_issues = 0
|
||
common_issues = {}
|
||
|
||
for file_key, result in all_results.items():
|
||
if 'error' not in result:
|
||
total_issues += result['summary']['total_issues']
|
||
|
||
for issue in result['issues']:
|
||
issue_type = f"{issue['category']}:{issue['message'].split(':')[0]}"
|
||
common_issues[issue_type] = common_issues.get(issue_type, 0) + 1
|
||
|
||
# Find most common issues
|
||
most_common = sorted(common_issues.items(), key=lambda x: x[1], reverse=True)[:5]
|
||
|
||
return {
|
||
'total_issues_across_files': total_issues,
|
||
'most_common_issues': most_common,
|
||
'files_with_errors': len([r for r in all_results.values() if 'error' not in r and r['summary']['by_severity']['error'] > 0])
|
||
}
|
||
|
||
def _issue_to_dict(self, issue: AuditIssue) -> Dict[str, Any]:
|
||
"""Convert AuditIssue to dictionary for JSON serialization"""
|
||
return {
|
||
'severity': issue.severity,
|
||
'category': issue.category,
|
||
'message': issue.message,
|
||
'node_id': issue.node_id,
|
||
'node_name': issue.node_name,
|
||
'suggestions': issue.suggestions,
|
||
'details': issue.details
|
||
}
|
||
|
||
def generate_report(self, audit_results: Dict[str, Any], output_path: str = None) -> str:
|
||
"""Generate comprehensive audit report"""
|
||
|
||
if not output_path:
|
||
output_path = 'figma-audit-report.html'
|
||
|
||
html_report = self._create_html_report(audit_results)
|
||
|
||
with open(output_path, 'w') as f:
|
||
f.write(html_report)
|
||
|
||
print(f"Audit report generated: {output_path}")
|
||
return output_path
|
||
|
||
def _create_html_report(self, audit_results: Dict[str, Any]) -> str:
|
||
"""Create HTML audit report"""
|
||
|
||
# This would generate a comprehensive HTML report
|
||
# For now, return basic HTML structure
|
||
|
||
html = f"""<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<title>Figma Design Audit Report</title>
|
||
<style>
|
||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; margin: 40px; }}
|
||
.header {{ border-bottom: 2px solid #007AFF; padding-bottom: 20px; margin-bottom: 30px; }}
|
||
.summary {{ background: #f8f9fa; padding: 20px; border-radius: 8px; margin-bottom: 30px; }}
|
||
.issue {{ margin-bottom: 20px; padding: 15px; border-left: 4px solid #ddd; }}
|
||
.error {{ border-color: #dc3545; background: #f8d7da; }}
|
||
.warning {{ border-color: #ffc107; background: #fff3cd; }}
|
||
.info {{ border-color: #17a2b8; background: #d1ecf1; }}
|
||
.grade {{ font-size: 48px; font-weight: bold; color: #007AFF; }}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="header">
|
||
<h1>Figma Design Audit Report</h1>
|
||
<p>File: {audit_results.get('file_name', 'Unknown')}</p>
|
||
<p>Audit Date: {time.strftime('%Y-%m-%d %H:%M:%S')}</p>
|
||
</div>
|
||
|
||
<div class="summary">
|
||
<h2>Summary</h2>
|
||
<div style="display: flex; align-items: center; gap: 40px;">
|
||
<div>
|
||
<div class="grade">{audit_results['summary']['grade']}</div>
|
||
<div>Score: {audit_results['summary']['score']}/100</div>
|
||
</div>
|
||
<div>
|
||
<p><strong>Total Issues:</strong> {audit_results['summary']['total_issues']}</p>
|
||
<p><strong>Errors:</strong> {audit_results['summary']['by_severity']['error']}</p>
|
||
<p><strong>Warnings:</strong> {audit_results['summary']['by_severity']['warning']}</p>
|
||
<p><strong>Info:</strong> {audit_results['summary']['by_severity']['info']}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<h2>Issues Found</h2>
|
||
"""
|
||
|
||
for issue in audit_results['issues']:
|
||
severity_class = issue['severity']
|
||
html += f"""
|
||
<div class="issue {severity_class}">
|
||
<h3>{issue['category'].title()}: {issue['message']}</h3>
|
||
<p><strong>Node:</strong> {issue.get('node_name', 'N/A')} ({issue.get('node_id', 'N/A')})</p>
|
||
<p><strong>Suggestions:</strong></p>
|
||
<ul>
|
||
"""
|
||
for suggestion in issue.get('suggestions', []):
|
||
html += f"<li>{suggestion}</li>\n"
|
||
|
||
html += "</ul></div>\n"
|
||
|
||
html += """
|
||
<h2>Recommendations</h2>
|
||
<ul>
|
||
"""
|
||
|
||
for rec in audit_results['recommendations']:
|
||
html += f"<li>{rec}</li>\n"
|
||
|
||
html += """
|
||
</ul>
|
||
</body>
|
||
</html>"""
|
||
|
||
return html
|
||
|
||
def main():
|
||
"""CLI interface for style auditing"""
|
||
parser = argparse.ArgumentParser(description='Figma Style Auditor')
|
||
parser.add_argument('command', choices=['audit-file', 'audit-multiple', 'audit-brand'])
|
||
parser.add_argument('file_keys', help='File key(s) or path to file list')
|
||
parser.add_argument('--output', help='Output file for audit report')
|
||
parser.add_argument('--brand-colors', help='Comma-separated list of brand hex colors')
|
||
parser.add_argument('--brand-fonts', help='Comma-separated list of brand fonts')
|
||
parser.add_argument('--min-contrast', type=float, default=4.5, help='Minimum contrast ratio')
|
||
parser.add_argument('--generate-html', action='store_true', help='Generate HTML report')
|
||
|
||
args = parser.parse_args()
|
||
|
||
try:
|
||
client = FigmaClient()
|
||
|
||
# Configure auditor
|
||
config = AuditConfig(
|
||
min_contrast_ratio=args.min_contrast,
|
||
brand_colors=args.brand_colors.split(',') if args.brand_colors else [],
|
||
brand_fonts=args.brand_fonts.split(',') if args.brand_fonts else [],
|
||
generate_report=args.generate_html
|
||
)
|
||
|
||
auditor = StyleAuditor(client, config)
|
||
|
||
if args.command == 'audit-file':
|
||
file_key = client.parse_file_url(args.file_keys)
|
||
result = auditor.audit_file(file_key)
|
||
|
||
elif args.command == 'audit-multiple':
|
||
# Parse file keys (could be comma-separated or from file)
|
||
if os.path.isfile(args.file_keys):
|
||
with open(args.file_keys) as f:
|
||
file_keys = [line.strip() for line in f if line.strip()]
|
||
else:
|
||
file_keys = args.file_keys.split(',')
|
||
|
||
file_keys = [client.parse_file_url(key) for key in file_keys]
|
||
result = auditor.audit_multiple_files(file_keys)
|
||
|
||
# Output results
|
||
output_content = json.dumps(result, indent=2)
|
||
|
||
if args.output:
|
||
with open(args.output, 'w') as f:
|
||
f.write(output_content)
|
||
print(f"Audit results saved to {args.output}")
|
||
else:
|
||
print(output_content)
|
||
|
||
# Generate HTML report if requested
|
||
if args.generate_html and args.command == 'audit-file':
|
||
html_path = args.output.replace('.json', '.html') if args.output else 'audit-report.html'
|
||
auditor.generate_report(result, html_path)
|
||
|
||
except Exception as e:
|
||
print(f"Error: {e}", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
if __name__ == '__main__':
|
||
main() |