Initial commit with translated description

This commit is contained in:
2026-03-29 13:02:21 +08:00
commit 8efab1561a
12 changed files with 3787 additions and 0 deletions

View File

@@ -0,0 +1,565 @@
#!/usr/bin/env python3
"""
Figma Accessibility Checker - WCAG compliance validation
Specialized accessibility audit with detailed WCAG compliance checking.
"""
import os
import sys
import json
import math
import time
from typing import Dict, List, Optional, Union, Any, Tuple
import argparse
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
class AccessibilityChecker:
"""WCAG-focused accessibility checker for Figma designs"""
def __init__(self, figma_client: FigmaClient):
self.client = figma_client
def check_wcag_compliance(self, file_key: str, level: str = 'AA') -> Dict[str, Any]:
"""Comprehensive WCAG compliance check"""
print(f"Checking WCAG {level} compliance for file: {file_key}")
file_data = self.client.get_file(file_key)
results = {
'file_key': file_key,
'file_name': file_data.get('name', 'Unknown'),
'wcag_level': level,
'timestamp': time.time(),
'compliance_score': 0,
'issues': [],
'summary': {}
}
# Run all WCAG checks
self._check_color_contrast(file_data, results, level)
self._check_touch_targets(file_data, results)
self._check_text_sizing(file_data, results)
self._check_focus_indicators(file_data, results)
# Calculate compliance score
results['compliance_score'] = self._calculate_compliance_score(results)
results['summary'] = self._generate_summary(results)
return results
def _check_color_contrast(self, file_data: Dict[str, Any], results: Dict[str, Any], level: str):
"""Check color contrast ratios against WCAG standards"""
contrast_requirements = {
'AA': {'normal_text': 4.5, 'large_text': 3.0, 'ui_components': 3.0},
'AAA': {'normal_text': 7.0, 'large_text': 4.5, 'ui_components': 4.5}
}
requirements = contrast_requirements[level]
def check_node_contrast(node):
if node.get('type') == 'TEXT':
# Get text color
fills = node.get('fills', [])
if not fills:
return
text_color = fills[0].get('color', {})
if not text_color:
return
# Estimate background color (simplified - would need parent analysis)
bg_color = {'r': 1, 'g': 1, 'b': 1} # Assume white background
contrast_ratio = self._calculate_contrast_ratio(text_color, bg_color)
# Determine if text is large
style = node.get('style', {})
font_size = style.get('fontSize', 16)
font_weight = style.get('fontWeight', 400)
is_large_text = font_size >= 18 or (font_size >= 14 and font_weight >= 700)
required_ratio = requirements['large_text'] if is_large_text else requirements['normal_text']
if contrast_ratio < required_ratio:
results['issues'].append({
'type': 'color_contrast',
'severity': 'error' if level == 'AA' else 'warning',
'message': f'Insufficient contrast: {contrast_ratio:.1f}:1 (required: {required_ratio}:1)',
'node_id': node.get('id'),
'node_name': node.get('name', ''),
'wcag_criterion': '1.4.3' if level == 'AA' else '1.4.6',
'details': {
'contrast_ratio': contrast_ratio,
'required_ratio': required_ratio,
'text_color': self._rgb_to_hex(text_color),
'is_large_text': is_large_text
}
})
# Check children
for child in node.get('children', []):
check_node_contrast(child)
if 'document' in file_data:
check_node_contrast(file_data['document'])
def _check_touch_targets(self, file_data: Dict[str, Any], results: Dict[str, Any]):
"""Check minimum touch target sizes (WCAG 2.5.5)"""
min_size = 44 # iOS/WCAG standard
def check_node_size(node):
# Look for interactive elements
node_name = node.get('name', '').lower()
node_type = node.get('type', '')
is_interactive = (
'button' in node_name or
'link' in node_name or
node_type in ['COMPONENT', 'INSTANCE'] and
any(keyword in node_name for keyword in ['btn', 'tap', 'click', 'interactive'])
)
if is_interactive:
bounds = node.get('absoluteBoundingBox', {})
width = bounds.get('width', 0)
height = bounds.get('height', 0)
if width < min_size or height < min_size:
results['issues'].append({
'type': 'touch_target',
'severity': 'warning',
'message': f'Touch target too small: {width:.0f}×{height:.0f}px (minimum: {min_size}×{min_size}px)',
'node_id': node.get('id'),
'node_name': node.get('name', ''),
'wcag_criterion': '2.5.5',
'details': {
'width': width,
'height': height,
'min_size': min_size
}
})
# Check children
for child in node.get('children', []):
check_node_size(child)
if 'document' in file_data:
check_node_size(file_data['document'])
def _check_text_sizing(self, file_data: Dict[str, Any], results: Dict[str, Any]):
"""Check minimum text sizes for readability"""
min_size = 12 # Minimum readable size
recommended_size = 16 # Recommended for body text
def check_text_size(node):
if node.get('type') == 'TEXT':
style = node.get('style', {})
font_size = style.get('fontSize', 16)
if font_size < min_size:
results['issues'].append({
'type': 'text_size',
'severity': 'error',
'message': f'Text too small: {font_size}px (minimum: {min_size}px)',
'node_id': node.get('id'),
'node_name': node.get('name', ''),
'wcag_criterion': '1.4.4',
'details': {
'font_size': font_size,
'min_size': min_size,
'characters': node.get('characters', '')[:50]
}
})
elif font_size < recommended_size:
results['issues'].append({
'type': 'text_size',
'severity': 'info',
'message': f'Text smaller than recommended: {font_size}px (recommended: {recommended_size}px)',
'node_id': node.get('id'),
'node_name': node.get('name', ''),
'wcag_criterion': '1.4.4',
'details': {
'font_size': font_size,
'recommended_size': recommended_size
}
})
# Check children
for child in node.get('children', []):
check_text_size(child)
if 'document' in file_data:
check_text_size(file_data['document'])
def _check_focus_indicators(self, file_data: Dict[str, Any], results: Dict[str, Any]):
"""Check for focus indicators on interactive elements"""
def check_focus_states(node):
node_name = node.get('name', '').lower()
node_type = node.get('type', '')
is_interactive = (
'button' in node_name or
'link' in node_name or
'input' in node_name or
node_type in ['COMPONENT', 'INSTANCE']
)
if is_interactive:
# Check for focus-related effects or states
effects = node.get('effects', [])
has_focus_indicator = any(
'focus' in str(effect).lower() or
effect.get('type') == 'DROP_SHADOW'
for effect in effects
)
if not has_focus_indicator:
results['issues'].append({
'type': 'focus_indicator',
'severity': 'info',
'message': 'Interactive element may need focus indicator',
'node_id': node.get('id'),
'node_name': node.get('name', ''),
'wcag_criterion': '2.4.7',
'details': {
'suggestion': 'Add visible focus state for keyboard navigation'
}
})
# Check children
for child in node.get('children', []):
check_focus_states(child)
if 'document' in file_data:
check_focus_states(file_data['document'])
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):
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 _calculate_compliance_score(self, results: Dict[str, Any]) -> int:
"""Calculate overall compliance score (0-100)"""
error_count = len([i for i in results['issues'] if i['severity'] == 'error'])
warning_count = len([i for i in results['issues'] if i['severity'] == 'warning'])
info_count = len([i for i in results['issues'] if i['severity'] == 'info'])
# Scoring: errors are -10 points, warnings -3 points, info -1 point
penalty = error_count * 10 + warning_count * 3 + info_count * 1
score = max(0, 100 - penalty)
return score
def _generate_summary(self, results: Dict[str, Any]) -> Dict[str, Any]:
"""Generate summary of accessibility results"""
issues_by_type = {}
issues_by_severity = {'error': 0, 'warning': 0, 'info': 0}
for issue in results['issues']:
issue_type = issue['type']
severity = issue['severity']
issues_by_type[issue_type] = issues_by_type.get(issue_type, 0) + 1
issues_by_severity[severity] += 1
compliance_level = 'FAIL'
if issues_by_severity['error'] == 0:
if issues_by_severity['warning'] == 0:
compliance_level = 'AAA'
elif issues_by_severity['warning'] <= 2:
compliance_level = 'AA'
else:
compliance_level = 'A'
return {
'total_issues': len(results['issues']),
'issues_by_type': issues_by_type,
'issues_by_severity': issues_by_severity,
'compliance_level': compliance_level,
'score': results['compliance_score']
}
def generate_accessibility_report(self, results: Dict[str, Any], output_path: str = None) -> str:
"""Generate detailed accessibility report"""
if not output_path:
output_path = f"accessibility-report-{int(time.time())}.html"
html_report = self._create_accessibility_html_report(results)
with open(output_path, 'w') as f:
f.write(html_report)
print(f"Accessibility report generated: {output_path}")
return output_path
def _create_accessibility_html_report(self, results: Dict[str, Any]) -> str:
"""Create comprehensive HTML accessibility report"""
# Color coding for compliance levels
level_colors = {
'AAA': '#28a745',
'AA': '#17a2b8',
'A': '#ffc107',
'FAIL': '#dc3545'
}
level_color = level_colors.get(results['summary']['compliance_level'], '#6c757d')
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Accessibility Report - {results['file_name']}</title>
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
margin: 40px;
line-height: 1.6;
color: #333;
}}
.header {{
border-bottom: 3px solid {level_color};
padding-bottom: 20px;
margin-bottom: 30px;
}}
.compliance-badge {{
display: inline-block;
background: {level_color};
color: white;
padding: 10px 20px;
border-radius: 25px;
font-weight: bold;
font-size: 18px;
margin: 10px 0;
}}
.score {{
font-size: 48px;
font-weight: bold;
color: {level_color};
}}
.summary {{
background: #f8f9fa;
padding: 25px;
border-radius: 8px;
margin-bottom: 30px;
border-left: 5px solid {level_color};
}}
.issue {{
margin-bottom: 25px;
padding: 20px;
border-left: 4px solid #ddd;
background: #fff;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}}
.error {{ border-left-color: #dc3545; background: #f8d7da; }}
.warning {{ border-left-color: #ffc107; background: #fff3cd; }}
.info {{ border-left-color: #17a2b8; background: #d1ecf1; }}
.wcag-criterion {{
background: #e9ecef;
padding: 4px 8px;
border-radius: 4px;
font-family: monospace;
font-size: 12px;
}}
.node-info {{
color: #6c757d;
font-size: 14px;
margin-top: 5px;
}}
.stats {{
display: flex;
gap: 20px;
margin: 20px 0;
}}
.stat {{
background: white;
padding: 15px;
border-radius: 8px;
text-align: center;
border: 1px solid #dee2e6;
}}
.stat-number {{
font-size: 24px;
font-weight: bold;
color: {level_color};
}}
.recommendations {{
background: #e7f3ff;
border: 1px solid #b8daff;
border-radius: 8px;
padding: 20px;
margin: 30px 0;
}}
</style>
</head>
<body>
<div class="header">
<h1>🔍 Accessibility Report</h1>
<p><strong>File:</strong> {results['file_name']}</p>
<p><strong>WCAG Level:</strong> {results['wcag_level']}</p>
<p><strong>Generated:</strong> {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(results['timestamp']))}</p>
<div class="compliance-badge">
WCAG {results['summary']['compliance_level']} Compliance
</div>
</div>
<div class="summary">
<h2>📊 Summary</h2>
<div class="stats">
<div class="stat">
<div class="stat-number">{results['summary']['score']}</div>
<div>Score</div>
</div>
<div class="stat">
<div class="stat-number">{results['summary']['total_issues']}</div>
<div>Total Issues</div>
</div>
<div class="stat">
<div class="stat-number">{results['summary']['issues_by_severity']['error']}</div>
<div>Errors</div>
</div>
<div class="stat">
<div class="stat-number">{results['summary']['issues_by_severity']['warning']}</div>
<div>Warnings</div>
</div>
</div>
</div>
"""
if results['issues']:
html += "<h2>🐛 Issues Found</h2>\n"
for issue in results['issues']:
severity_class = issue['severity']
html += f"""
<div class="issue {severity_class}">
<h3>{issue['type'].replace('_', ' ').title()}: {issue['message']}</h3>
<span class="wcag-criterion">WCAG {issue['wcag_criterion']}</span>
<div class="node-info">
<strong>Element:</strong> {issue.get('node_name', 'N/A')}
(ID: {issue.get('node_id', 'N/A')})
</div>
"""
if 'details' in issue and issue['details']:
html += "<div style='margin-top: 10px;'><strong>Details:</strong><ul>"
for key, value in issue['details'].items():
html += f"<li><strong>{key.replace('_', ' ').title()}:</strong> {value}</li>"
html += "</ul></div>"
html += "</div>\n"
else:
html += """
<div class="recommendations">
<h2>🎉 Excellent Work!</h2>
<p>No accessibility issues found in this design. This indicates strong adherence to WCAG guidelines.</p>
</div>
"""
html += """
<div class="recommendations">
<h2>💡 Recommendations</h2>
<ul>
<li><strong>Manual Testing:</strong> Automated checks catch many issues, but manual testing with assistive technologies is still essential.</li>
<li><strong>User Testing:</strong> Include users with disabilities in your testing process.</li>
<li><strong>Regular Audits:</strong> Run accessibility checks throughout the design process, not just at the end.</li>
<li><strong>Design System:</strong> Build accessibility into your component library to prevent issues.</li>
</ul>
</div>
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #dee2e6; color: #6c757d; font-size: 14px;">
<p>Generated by Figma Accessibility Checker | Learn more about WCAG at <a href="https://www.w3.org/WAI/WCAG21/quickref/">WCAG Quick Reference</a></p>
</div>
</body>
</html>"""
return html
def main():
"""CLI interface for accessibility checking"""
parser = argparse.ArgumentParser(description='Figma Accessibility Checker')
parser.add_argument('file_key', help='Figma file key or URL')
parser.add_argument('--level', choices=['AA', 'AAA'], default='AA', help='WCAG compliance level')
parser.add_argument('--output', help='Output file for accessibility report')
parser.add_argument('--format', choices=['json', 'html'], default='json', help='Output format')
args = parser.parse_args()
try:
client = FigmaClient()
checker = AccessibilityChecker(client)
file_key = client.parse_file_url(args.file_key)
results = checker.check_wcag_compliance(file_key, args.level)
if args.format == 'html':
output_path = args.output or f"accessibility-report-{file_key}.html"
checker.generate_accessibility_report(results, output_path)
else:
output_content = json.dumps(results, indent=2)
if args.output:
with open(args.output, 'w') as f:
f.write(output_content)
print(f"Accessibility results saved to {args.output}")
else:
print(output_content)
# Print summary
print(f"\n🔍 Accessibility Summary:")
print(f" Score: {results['summary']['score']}/100")
print(f" Compliance Level: WCAG {results['summary']['compliance_level']}")
print(f" Total Issues: {results['summary']['total_issues']}")
print(f" Errors: {results['summary']['issues_by_severity']['error']}")
print(f" Warnings: {results['summary']['issues_by_severity']['warning']}")
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main()

559
scripts/export_manager.py Normal file
View File

@@ -0,0 +1,559 @@
#!/usr/bin/env python3
"""
Figma Export Manager - Batch asset export with intelligent organization
Handles multiple formats, naming conventions, and export workflows.
"""
import os
import sys
import json
import asyncio
import aiohttp
from pathlib import Path
from typing import Dict, List, Optional, Union, Any
from dataclasses import dataclass, field
from concurrent.futures import ThreadPoolExecutor
import argparse
import time
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 ExportConfig:
"""Configuration for export operations"""
formats: List[str] = field(default_factory=lambda: ['png'])
scales: List[float] = field(default_factory=lambda: [1.0])
output_dir: str = './figma-exports'
naming_pattern: str = '{name}_{id}.{format}'
create_manifest: bool = True
skip_existing: bool = False
max_concurrent: int = 5
organize_by_format: bool = True
class ExportManager:
"""Professional-grade Figma asset export manager"""
def __init__(self, figma_client: FigmaClient, config: ExportConfig = None):
self.client = figma_client
self.config = config or ExportConfig()
# Create output directory
Path(self.config.output_dir).mkdir(parents=True, exist_ok=True)
def export_frames(self, file_key: str, frame_ids: List[str] = None,
frame_names: List[str] = None) -> Dict[str, Any]:
"""Export all frames or specific frames from a file"""
# Get file data to identify frames
file_data = self.client.get_file(file_key)
if not frame_ids and not frame_names:
# Export all frames
frame_nodes = self._find_frames(file_data)
else:
# Filter specific frames
all_frames = self._find_frames(file_data)
frame_nodes = []
for frame in all_frames:
if frame_ids and frame['id'] in frame_ids:
frame_nodes.append(frame)
elif frame_names and frame['name'] in frame_names:
frame_nodes.append(frame)
if not frame_nodes:
print("No frames found to export")
return {'exported': 0, 'files': []}
print(f"Found {len(frame_nodes)} frames to export")
return self._export_nodes(file_key, frame_nodes)
def export_components(self, file_key: str, component_names: List[str] = None) -> Dict[str, Any]:
"""Export all components or specific components from a file"""
file_data = self.client.get_file(file_key)
component_nodes = self._find_components(file_data)
if component_names:
component_nodes = [c for c in component_nodes if c['name'] in component_names]
if not component_nodes:
print("No components found to export")
return {'exported': 0, 'files': []}
print(f"Found {len(component_nodes)} components to export")
return self._export_nodes(file_key, component_nodes)
def export_pages(self, file_key: str, page_names: List[str] = None) -> Dict[str, Any]:
"""Export all pages or specific pages as complete images"""
file_data = self.client.get_file(file_key)
pages = []
for child in file_data.get('document', {}).get('children', []):
if child.get('type') == 'CANVAS':
if not page_names or child.get('name') in page_names:
pages.append(child)
if not pages:
print("No pages found to export")
return {'exported': 0, 'files': []}
print(f"Found {len(pages)} pages to export")
return self._export_nodes(file_key, pages)
def export_custom_selection(self, file_key: str, node_ids: List[str]) -> Dict[str, Any]:
"""Export specific nodes by ID"""
# Get node information
nodes_data = self.client.get_file_nodes(file_key, node_ids)
if 'nodes' not in nodes_data:
print("No nodes found with provided IDs")
return {'exported': 0, 'files': []}
nodes = []
for node_id, node_info in nodes_data['nodes'].items():
if 'document' in node_info:
node = node_info['document']
node['id'] = node_id # Ensure ID is present
nodes.append(node)
print(f"Found {len(nodes)} nodes to export")
return self._export_nodes(file_key, nodes)
def export_design_tokens(self, file_key: str, output_format: str = 'json') -> str:
"""Export design tokens (colors, typography, effects) in various formats"""
file_data = self.client.get_file(file_key)
# Extract design tokens
tokens = {
'colors': self._extract_color_tokens(file_data),
'typography': self._extract_typography_tokens(file_data),
'effects': self._extract_effect_tokens(file_data),
'spacing': self._extract_spacing_tokens(file_data)
}
# Format output
if output_format == 'css':
output_content = self._tokens_to_css(tokens)
output_file = Path(self.config.output_dir) / 'design-tokens.css'
elif output_format == 'scss':
output_content = self._tokens_to_scss(tokens)
output_file = Path(self.config.output_dir) / 'design-tokens.scss'
elif output_format == 'js':
output_content = self._tokens_to_js(tokens)
output_file = Path(self.config.output_dir) / 'design-tokens.js'
else: # json
output_content = json.dumps(tokens, indent=2)
output_file = Path(self.config.output_dir) / 'design-tokens.json'
# Write output file
with open(output_file, 'w') as f:
f.write(output_content)
print(f"Design tokens exported to {output_file}")
return str(output_file)
def create_client_package(self, file_key: str, package_name: str = None) -> str:
"""Create a complete client delivery package with all assets"""
if not package_name:
file_data = self.client.get_file(file_key)
package_name = file_data.get('name', 'figma-package').replace(' ', '-').lower()
package_dir = Path(self.config.output_dir) / package_name
package_dir.mkdir(parents=True, exist_ok=True)
# Export all asset types
results = {}
# 1. Export all frames
self.config.output_dir = str(package_dir / 'frames')
Path(self.config.output_dir).mkdir(exist_ok=True)
results['frames'] = self.export_frames(file_key)
# 2. Export all components
self.config.output_dir = str(package_dir / 'components')
Path(self.config.output_dir).mkdir(exist_ok=True)
results['components'] = self.export_components(file_key)
# 3. Export design tokens
self.config.output_dir = str(package_dir)
results['tokens'] = {
'json': self.export_design_tokens(file_key, 'json'),
'css': self.export_design_tokens(file_key, 'css'),
'scss': self.export_design_tokens(file_key, 'scss')
}
# 4. Create documentation
doc_file = package_dir / 'README.md'
self._create_package_documentation(file_key, doc_file, results)
print(f"Client package created at {package_dir}")
return str(package_dir)
def _export_nodes(self, file_key: str, nodes: List[Dict[str, Any]]) -> Dict[str, Any]:
"""Export nodes with all configured formats and scales"""
exported_files = []
total_exports = len(nodes) * len(self.config.formats) * len(self.config.scales)
current_export = 0
for node in nodes:
node_id = node['id']
node_name = self._sanitize_filename(node.get('name', 'untitled'))
for format in self.config.formats:
for scale in self.config.scales:
current_export += 1
print(f"Exporting {current_export}/{total_exports}: {node_name} ({format} @ {scale}x)")
# Get export URLs from Figma
try:
export_data = self.client.export_images(
file_key, [node_id],
format=format, scale=scale
)
if 'images' in export_data and node_id in export_data['images']:
image_url = export_data['images'][node_id]
if image_url:
# Generate filename
filename = self.config.naming_pattern.format(
name=node_name,
id=node_id,
format=format,
scale=f'{scale}x' if scale != 1.0 else ''
)
# Organize by format if configured
if self.config.organize_by_format:
format_dir = Path(self.config.output_dir) / format
format_dir.mkdir(exist_ok=True)
output_path = format_dir / filename
else:
output_path = Path(self.config.output_dir) / filename
# Skip if file exists and skip_existing is True
if self.config.skip_existing and output_path.exists():
print(f" Skipping existing file: {output_path}")
continue
# Download the image
self.client.download_image(str(image_url), str(output_path))
exported_files.append({
'path': str(output_path),
'node_id': node_id,
'node_name': node_name,
'format': format,
'scale': scale,
'url': image_url
})
print(f" Saved: {output_path}")
else:
print(f" Warning: No image URL returned for {node_name}")
except Exception as e:
print(f" Error exporting {node_name}: {e}")
continue
# Rate limiting
time.sleep(0.5)
# Create manifest file
if self.config.create_manifest:
manifest_path = Path(self.config.output_dir) / 'export-manifest.json'
manifest_data = {
'exported_at': time.strftime('%Y-%m-%d %H:%M:%S'),
'file_key': file_key,
'total_files': len(exported_files),
'config': {
'formats': self.config.formats,
'scales': self.config.scales,
'naming_pattern': self.config.naming_pattern
},
'files': exported_files
}
with open(manifest_path, 'w') as f:
json.dump(manifest_data, f, indent=2)
print(f"Manifest created: {manifest_path}")
return {
'exported': len(exported_files),
'files': exported_files
}
def _find_frames(self, file_data: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Find all frames in the file"""
frames = []
def traverse_node(node):
if node.get('type') == 'FRAME':
frames.append(node)
for child in node.get('children', []):
traverse_node(child)
if 'document' in file_data:
traverse_node(file_data['document'])
return frames
def _find_components(self, file_data: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Find all components in the file"""
components = []
def traverse_node(node):
if node.get('type') == 'COMPONENT':
components.append(node)
for child in node.get('children', []):
traverse_node(child)
if 'document' in file_data:
traverse_node(file_data['document'])
return components
def _extract_color_tokens(self, file_data: Dict[str, Any]) -> Dict[str, str]:
"""Extract color design tokens from file styles"""
colors = {}
for style_id, style in file_data.get('styles', {}).items():
if style.get('styleType') == 'FILL':
name = style.get('name', '').replace('/', '-').lower()
# This would need to be enhanced with actual color values
# from the style definition
colors[name] = f"#{style_id[:6]}" # Placeholder
return colors
def _extract_typography_tokens(self, file_data: Dict[str, Any]) -> Dict[str, Dict]:
"""Extract typography design tokens"""
typography = {}
for style_id, style in file_data.get('styles', {}).items():
if style.get('styleType') == 'TEXT':
name = style.get('name', '').replace('/', '-').lower()
typography[name] = {
'fontSize': '16px', # Placeholder - would need actual values
'fontWeight': '400',
'lineHeight': '1.5',
'fontFamily': 'Inter'
}
return typography
def _extract_effect_tokens(self, file_data: Dict[str, Any]) -> Dict[str, str]:
"""Extract effect design tokens (shadows, etc.)"""
effects = {}
for style_id, style in file_data.get('styles', {}).items():
if style.get('styleType') == 'EFFECT':
name = style.get('name', '').replace('/', '-').lower()
effects[name] = "0 2px 4px rgba(0,0,0,0.1)" # Placeholder
return effects
def _extract_spacing_tokens(self, file_data: Dict[str, Any]) -> Dict[str, str]:
"""Extract spacing tokens from layout patterns"""
# This would analyze common spacing patterns in the design
return {
'xs': '4px',
'sm': '8px',
'md': '16px',
'lg': '24px',
'xl': '32px'
}
def _tokens_to_css(self, tokens: Dict[str, Any]) -> str:
"""Convert tokens to CSS custom properties"""
css_content = ":root {\n"
# Colors
for name, value in tokens['colors'].items():
css_content += f" --color-{name}: {value};\n"
# Typography
for name, values in tokens['typography'].items():
for prop, value in values.items():
css_content += f" --{name}-{prop.lower()}: {value};\n"
# Effects
for name, value in tokens['effects'].items():
css_content += f" --effect-{name}: {value};\n"
# Spacing
for name, value in tokens['spacing'].items():
css_content += f" --spacing-{name}: {value};\n"
css_content += "}\n"
return css_content
def _tokens_to_scss(self, tokens: Dict[str, Any]) -> str:
"""Convert tokens to SCSS variables"""
scss_content = "// Design Tokens\n\n"
# Colors
scss_content += "// Colors\n"
for name, value in tokens['colors'].items():
scss_content += f"${name.replace('-', '_')}: {value};\n"
scss_content += "\n// Typography\n"
for name, values in tokens['typography'].items():
for prop, value in values.items():
scss_content += f"${name.replace('-', '_')}_{prop.lower()}: {value};\n"
return scss_content
def _tokens_to_js(self, tokens: Dict[str, Any]) -> str:
"""Convert tokens to JavaScript/JSON module"""
return f"export const designTokens = {json.dumps(tokens, indent=2)};\n\nexport default designTokens;\n"
def _sanitize_filename(self, name: str) -> str:
"""Convert name to safe filename"""
# Remove/replace invalid characters
safe_name = "".join(c for c in name if c.isalnum() or c in (' ', '-', '_'))
safe_name = safe_name.strip().replace(' ', '-').lower()
return safe_name or 'untitled'
def _create_package_documentation(self, file_key: str, doc_path: Path, results: Dict[str, Any]):
"""Create documentation for the exported package"""
file_data = self.client.get_file(file_key)
doc_content = f"""# {file_data.get('name', 'Figma Export')}
Exported from Figma on {time.strftime('%Y-%m-%d %H:%M:%S')}
## File Information
- **File Key**: {file_key}
- **Last Modified**: {file_data.get('lastModified', 'Unknown')}
- **Version**: {file_data.get('version', 'Unknown')}
## Package Contents
### Frames ({results.get('frames', {}).get('exported', 0)} files)
All page frames exported in configured formats
Location: `./frames/`
### Components ({results.get('components', {}).get('exported', 0)} files)
All reusable components exported for development handoff
Location: `./components/`
### Design Tokens
Design system tokens in multiple formats:
- `design-tokens.json` - Raw token data
- `design-tokens.css` - CSS custom properties
- `design-tokens.scss` - SCSS variables
## Usage
### Web Development
```css
/* Import CSS tokens */
@import './design-tokens.css';
.my-component {{
color: var(--color-primary);
font-size: var(--typography-body-fontsize);
}}
```
### React/JavaScript
```javascript
import tokens from './design-tokens.js';
const MyComponent = () => (
<div style={{{{color: tokens.colors.primary}}}}>
Content
</div>
);
```
## Support
For questions about this export or design implementation, contact your design team.
"""
with open(doc_path, 'w') as f:
f.write(doc_content)
def main():
"""CLI interface for export operations"""
parser = argparse.ArgumentParser(description='Figma Export Manager')
parser.add_argument('command', choices=[
'export-frames', 'export-components', 'export-pages', 'export-nodes',
'export-tokens', 'client-package'
])
parser.add_argument('file_key', help='Figma file key or URL')
parser.add_argument('node_ids', nargs='?', help='Comma-separated node IDs for export-nodes')
parser.add_argument('--formats', default='png', help='Export formats (comma-separated)')
parser.add_argument('--scales', default='1.0', help='Export scales (comma-separated)')
parser.add_argument('--output-dir', default='./figma-exports', help='Output directory')
parser.add_argument('--token-format', default='json', choices=['json', 'css', 'scss', 'js'])
parser.add_argument('--package-name', help='Name for client package')
parser.add_argument('--frame-names', help='Specific frame names to export (comma-separated)')
parser.add_argument('--component-names', help='Specific component names to export (comma-separated)')
args = parser.parse_args()
try:
client = FigmaClient()
file_key = client.parse_file_url(args.file_key)
# Configure export settings
config = ExportConfig(
formats=args.formats.split(','),
scales=[float(s) for s in args.scales.split(',')],
output_dir=args.output_dir
)
manager = ExportManager(client, config)
if args.command == 'export-frames':
frame_names = args.frame_names.split(',') if args.frame_names else None
result = manager.export_frames(file_key, frame_names=frame_names)
elif args.command == 'export-components':
component_names = args.component_names.split(',') if args.component_names else None
result = manager.export_components(file_key, component_names=component_names)
elif args.command == 'export-pages':
result = manager.export_pages(file_key)
elif args.command == 'export-nodes':
if not args.node_ids:
parser.error('node_ids required for export-nodes command')
result = manager.export_custom_selection(file_key, args.node_ids.split(','))
elif args.command == 'export-tokens':
result = manager.export_design_tokens(file_key, args.token_format)
print(f"Design tokens exported: {result}")
return
elif args.command == 'client-package':
result = manager.create_client_package(file_key, args.package_name)
print(f"Client package created: {result}")
return
print(f"Export completed: {result['exported']} files exported")
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main()

313
scripts/figma_client.py Normal file
View File

@@ -0,0 +1,313 @@
#!/usr/bin/env python3
"""
Figma API Client - Complete wrapper for Figma REST API
Handles authentication, rate limiting, and all major endpoints.
"""
import os
import sys
import json
import time
import requests
from typing import Dict, List, Optional, Union, Any
from urllib.parse import urlencode
import argparse
from dataclasses import dataclass
@dataclass
class FigmaConfig:
"""Configuration for Figma API client"""
access_token: str
base_url: str = "https://api.figma.com/v1"
rate_limit_delay: float = 0.5
max_retries: int = 3
class FigmaClient:
"""Professional-grade Figma API client with rate limiting and error handling"""
def __init__(self, access_token: str = None):
self.config = FigmaConfig(
access_token=access_token or os.getenv('FIGMA_ACCESS_TOKEN')
)
if not self.config.access_token:
raise ValueError("Figma access token required. Set FIGMA_ACCESS_TOKEN env var or pass token.")
self.session = requests.Session()
self.session.headers.update({
'X-Figma-Token': self.config.access_token,
'Content-Type': 'application/json'
})
def _request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]:
"""Make authenticated request with rate limiting and retry logic"""
url = f"{self.config.base_url}/{endpoint.lstrip('/')}"
for attempt in range(self.config.max_retries):
try:
# Rate limiting
time.sleep(self.config.rate_limit_delay)
response = self.session.request(method, url, **kwargs)
response.raise_for_status()
return response.json()
except requests.exceptions.HTTPError as e:
if response.status_code == 429: # Rate limited
wait_time = 2 ** attempt
print(f"Rate limited. Waiting {wait_time}s before retry {attempt + 1}/{self.config.max_retries}")
time.sleep(wait_time)
continue
elif response.status_code == 403:
raise ValueError("Access denied. Check your Figma token permissions.")
elif response.status_code == 404:
raise ValueError(f"File or resource not found: {url}")
else:
raise
except requests.exceptions.RequestException as e:
if attempt == self.config.max_retries - 1:
raise
print(f"Request failed, retrying {attempt + 1}/{self.config.max_retries}: {e}")
time.sleep(2 ** attempt)
# ========== FILE OPERATIONS ==========
def get_file(self, file_key: str, **params) -> Dict[str, Any]:
"""Get complete file data including components and styles"""
return self._request('GET', f'/files/{file_key}', params=params)
def get_file_nodes(self, file_key: str, node_ids: Union[str, List[str]], **params) -> Dict[str, Any]:
"""Get specific nodes from a file"""
if isinstance(node_ids, list):
node_ids = ','.join(node_ids)
params['ids'] = node_ids
return self._request('GET', f'/files/{file_key}/nodes', params=params)
def get_file_versions(self, file_key: str) -> Dict[str, Any]:
"""Get version history for a file"""
return self._request('GET', f'/files/{file_key}/versions')
def get_file_components(self, file_key: str) -> Dict[str, Any]:
"""Get all components in a file"""
return self._request('GET', f'/files/{file_key}/components')
def get_file_styles(self, file_key: str) -> Dict[str, Any]:
"""Get all styles in a file"""
return self._request('GET', f'/files/{file_key}/styles')
# ========== IMAGE EXPORTS ==========
def export_images(self, file_key: str, node_ids: Union[str, List[str]],
format: str = 'png', scale: float = 1.0, **params) -> Dict[str, Any]:
"""Export nodes as images"""
if isinstance(node_ids, list):
node_ids = ','.join(node_ids)
params.update({
'ids': node_ids,
'format': format,
'scale': scale
})
return self._request('GET', f'/images/{file_key}', params=params)
def get_image_fills(self, file_key: str) -> Dict[str, Any]:
"""Get image fill metadata from a file"""
return self._request('GET', f'/files/{file_key}/images')
# ========== TEAM & PROJECT OPERATIONS ==========
def get_team_projects(self, team_id: str) -> Dict[str, Any]:
"""Get projects for a team"""
return self._request('GET', f'/teams/{team_id}/projects')
def get_project_files(self, project_id: str) -> Dict[str, Any]:
"""Get files in a project"""
return self._request('GET', f'/projects/{project_id}/files')
# ========== COMPONENT & STYLE OPERATIONS ==========
def get_team_components(self, team_id: str, **params) -> Dict[str, Any]:
"""Get team component library"""
return self._request('GET', f'/teams/{team_id}/components', params=params)
def get_component(self, component_key: str) -> Dict[str, Any]:
"""Get individual component metadata"""
return self._request('GET', f'/components/{component_key}')
def get_team_styles(self, team_id: str, **params) -> Dict[str, Any]:
"""Get team style library"""
return self._request('GET', f'/teams/{team_id}/styles', params=params)
def get_style(self, style_key: str) -> Dict[str, Any]:
"""Get individual style metadata"""
return self._request('GET', f'/styles/{style_key}')
# ========== UTILITY METHODS ==========
def parse_file_url(self, url: str) -> str:
"""Extract file key from Figma URL"""
# https://www.figma.com/file/ABC123/File-Name
if '/file/' in url:
return url.split('/file/')[1].split('/')[0]
return url # Assume it's already a file key
def get_user_info(self) -> Dict[str, Any]:
"""Get current user information"""
return self._request('GET', '/me')
def download_image(self, image_url: str, output_path: str) -> str:
"""Download image from Figma CDN"""
response = requests.get(image_url)
response.raise_for_status()
with open(output_path, 'wb') as f:
f.write(response.content)
return output_path
# ========== ANALYSIS HELPERS ==========
def extract_colors(self, file_data: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Extract all colors used in a file"""
colors = []
def traverse_node(node):
if 'fills' in node:
for fill in node.get('fills', []):
if fill.get('type') == 'SOLID':
color = fill.get('color', {})
if color:
colors.append({
'r': color.get('r', 0),
'g': color.get('g', 0),
'b': color.get('b', 0),
'a': color.get('a', 1),
'node_id': node.get('id'),
'node_name': node.get('name', '')
})
for child in node.get('children', []):
traverse_node(child)
if 'document' in file_data:
traverse_node(file_data['document'])
return colors
def extract_text_styles(self, file_data: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Extract all text styles used in a file"""
text_styles = []
def traverse_node(node):
if node.get('type') == 'TEXT':
style = node.get('style', {})
if style:
text_styles.append({
'font_family': style.get('fontFamily', ''),
'font_size': style.get('fontSize', 0),
'font_weight': style.get('fontWeight', 400),
'line_height': style.get('lineHeightPx', 0),
'letter_spacing': style.get('letterSpacing', 0),
'node_id': node.get('id'),
'node_name': node.get('name', ''),
'text': node.get('characters', '')
})
for child in node.get('children', []):
traverse_node(child)
if 'document' in file_data:
traverse_node(file_data['document'])
return text_styles
def main():
"""CLI interface for Figma operations"""
parser = argparse.ArgumentParser(description='Figma API Client')
parser.add_argument('command', choices=[
'get-file', 'export-images', 'get-components', 'get-styles',
'extract-colors', 'extract-typography', 'user-info'
])
parser.add_argument('file_key', nargs='?', help='Figma file key or URL')
parser.add_argument('--node-ids', help='Comma-separated node IDs')
parser.add_argument('--format', default='png', choices=['png', 'svg', 'pdf'])
parser.add_argument('--scale', type=float, default=1.0)
parser.add_argument('--output', help='Output file path')
parser.add_argument('--token', help='Figma access token (overrides env var)')
args = parser.parse_args()
try:
client = FigmaClient(access_token=args.token)
if args.command == 'get-file':
if not args.file_key:
parser.error('file_key required for get-file command')
file_key = client.parse_file_url(args.file_key)
result = client.get_file(file_key)
elif args.command == 'export-images':
if not args.file_key or not args.node_ids:
parser.error('file_key and --node-ids required for export-images command')
file_key = client.parse_file_url(args.file_key)
result = client.export_images(
file_key,
args.node_ids.split(','),
format=args.format,
scale=args.scale
)
elif args.command == 'get-components':
if not args.file_key:
parser.error('file_key required for get-components command')
file_key = client.parse_file_url(args.file_key)
result = client.get_file_components(file_key)
elif args.command == 'get-styles':
if not args.file_key:
parser.error('file_key required for get-styles command')
file_key = client.parse_file_url(args.file_key)
result = client.get_file_styles(file_key)
elif args.command == 'extract-colors':
if not args.file_key:
parser.error('file_key required for extract-colors command')
file_key = client.parse_file_url(args.file_key)
file_data = client.get_file(file_key)
result = client.extract_colors(file_data)
elif args.command == 'extract-typography':
if not args.file_key:
parser.error('file_key required for extract-typography command')
file_key = client.parse_file_url(args.file_key)
file_data = client.get_file(file_key)
result = client.extract_text_styles(file_data)
elif args.command == 'user-info':
result = client.get_user_info()
# Output result
output = json.dumps(result, indent=2)
if args.output:
with open(args.output, 'w') as f:
f.write(output)
print(f"Output saved to {args.output}")
else:
print(output)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main()

768
scripts/style_auditor.py Normal file
View File

@@ -0,0 +1,768 @@
#!/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()