Initial commit
This commit is contained in:
445
skills/product-design/functions/design_analyzer.py
Executable file
445
skills/product-design/functions/design_analyzer.py
Executable file
@@ -0,0 +1,445 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Analyze Figma design data and extract patterns, components, and tokens.
|
||||
Compares against existing UI kit to identify new components and potential reuse opportunities.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import argparse
|
||||
from typing import Dict, List, Any
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
|
||||
def calculate_similarity(str1: str, str2: str) -> float:
|
||||
"""
|
||||
Calculate similarity ratio between two strings.
|
||||
|
||||
Args:
|
||||
str1: First string
|
||||
str2: Second string
|
||||
|
||||
Returns:
|
||||
float: Similarity ratio (0.0 to 1.0)
|
||||
"""
|
||||
return SequenceMatcher(None, str1.lower(), str2.lower()).ratio()
|
||||
|
||||
|
||||
def extract_components_from_metadata(metadata: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Extract component information from Figma metadata.
|
||||
|
||||
Args:
|
||||
metadata: Figma MCP get_metadata response or manual structure
|
||||
|
||||
Returns:
|
||||
List of components with their properties
|
||||
"""
|
||||
components = []
|
||||
|
||||
def traverse_nodes(node, depth=0):
|
||||
"""Recursively traverse Figma node tree."""
|
||||
if not isinstance(node, dict):
|
||||
return
|
||||
|
||||
node_type = node.get('type', '')
|
||||
node_name = node.get('name', 'Unnamed')
|
||||
node_id = node.get('id', '')
|
||||
|
||||
# Identify components (COMPONENT, COMPONENT_SET, or instances)
|
||||
if node_type in ['COMPONENT', 'COMPONENT_SET', 'INSTANCE']:
|
||||
components.append({
|
||||
'id': node_id,
|
||||
'name': node_name,
|
||||
'type': node_type,
|
||||
'depth': depth,
|
||||
'properties': extract_node_properties(node)
|
||||
})
|
||||
|
||||
# Traverse children
|
||||
children = node.get('children', [])
|
||||
for child in children:
|
||||
traverse_nodes(child, depth + 1)
|
||||
|
||||
# Handle both MCP format and manual format
|
||||
if 'document' in metadata:
|
||||
traverse_nodes(metadata['document'])
|
||||
elif 'nodes' in metadata:
|
||||
for node in metadata['nodes']:
|
||||
traverse_nodes(node)
|
||||
elif isinstance(metadata, dict):
|
||||
traverse_nodes(metadata)
|
||||
|
||||
return components
|
||||
|
||||
|
||||
def extract_node_properties(node: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Extract relevant properties from Figma node.
|
||||
|
||||
Args:
|
||||
node: Figma node data
|
||||
|
||||
Returns:
|
||||
Dictionary of extracted properties
|
||||
"""
|
||||
properties = {}
|
||||
|
||||
# Extract layout properties
|
||||
if 'layoutMode' in node:
|
||||
properties['layout'] = {
|
||||
'mode': node.get('layoutMode'),
|
||||
'direction': node.get('layoutDirection'),
|
||||
'gap': node.get('itemSpacing'),
|
||||
'padding': {
|
||||
'top': node.get('paddingTop'),
|
||||
'right': node.get('paddingRight'),
|
||||
'bottom': node.get('paddingBottom'),
|
||||
'left': node.get('paddingLeft')
|
||||
}
|
||||
}
|
||||
|
||||
# Extract sizing
|
||||
if 'absoluteBoundingBox' in node:
|
||||
bbox = node['absoluteBoundingBox']
|
||||
properties['size'] = {
|
||||
'width': bbox.get('width'),
|
||||
'height': bbox.get('height')
|
||||
}
|
||||
|
||||
# Extract variant properties
|
||||
if 'componentProperties' in node:
|
||||
properties['variants'] = node['componentProperties']
|
||||
|
||||
return properties
|
||||
|
||||
|
||||
def categorize_component_by_name(component_name: str) -> str:
|
||||
"""
|
||||
Categorize component by atomic design level based on name patterns.
|
||||
|
||||
Args:
|
||||
component_name: Component name from Figma
|
||||
|
||||
Returns:
|
||||
'atom', 'molecule', 'organism', or 'template'
|
||||
"""
|
||||
name_lower = component_name.lower()
|
||||
|
||||
# Atoms: Basic elements
|
||||
atoms = ['button', 'input', 'icon', 'text', 'badge', 'avatar', 'checkbox',
|
||||
'radio', 'switch', 'label', 'link', 'image']
|
||||
|
||||
# Molecules: Simple combinations
|
||||
molecules = ['field', 'card', 'list-item', 'menu-item', 'tab', 'breadcrumb',
|
||||
'tooltip', 'dropdown', 'search', 'pagination']
|
||||
|
||||
# Organisms: Complex components
|
||||
organisms = ['header', 'footer', 'sidebar', 'navigation', 'modal', 'form',
|
||||
'table', 'dashboard', 'profile', 'chart', 'grid']
|
||||
|
||||
for atom in atoms:
|
||||
if atom in name_lower:
|
||||
return 'atom'
|
||||
|
||||
for molecule in molecules:
|
||||
if molecule in name_lower:
|
||||
return 'molecule'
|
||||
|
||||
for organism in organisms:
|
||||
if organism in name_lower:
|
||||
return 'organism'
|
||||
|
||||
# Default to molecule if unclear
|
||||
return 'molecule'
|
||||
|
||||
|
||||
def find_similar_components(new_component: Dict[str, Any],
|
||||
ui_kit_inventory: List[Dict[str, Any]],
|
||||
threshold: float = 0.7) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Find similar components in existing UI kit.
|
||||
|
||||
Args:
|
||||
new_component: Component from Figma design
|
||||
ui_kit_inventory: List of existing UI kit components
|
||||
threshold: Similarity threshold (0.0 to 1.0)
|
||||
|
||||
Returns:
|
||||
List of similar components with similarity scores
|
||||
"""
|
||||
similar = []
|
||||
new_name = new_component.get('name', '')
|
||||
|
||||
for existing in ui_kit_inventory:
|
||||
existing_name = existing.get('name', '')
|
||||
similarity = calculate_similarity(new_name, existing_name)
|
||||
|
||||
if similarity >= threshold:
|
||||
similar.append({
|
||||
'name': existing_name,
|
||||
'path': existing.get('path', ''),
|
||||
'similarity': similarity,
|
||||
'recommendation': generate_recommendation(similarity, new_name, existing_name)
|
||||
})
|
||||
|
||||
# Sort by similarity descending
|
||||
similar.sort(key=lambda x: x['similarity'], reverse=True)
|
||||
|
||||
return similar
|
||||
|
||||
|
||||
def generate_recommendation(similarity: float, new_name: str, existing_name: str) -> str:
|
||||
"""
|
||||
Generate recommendation based on similarity score.
|
||||
|
||||
Args:
|
||||
similarity: Similarity ratio
|
||||
new_name: New component name
|
||||
existing_name: Existing component name
|
||||
|
||||
Returns:
|
||||
Recommendation string
|
||||
"""
|
||||
if similarity >= 0.9:
|
||||
return f"Very similar to {existing_name}. Consider reusing existing component."
|
||||
elif similarity >= 0.7:
|
||||
return f"Similar to {existing_name}. Consider extending with new variant/prop."
|
||||
else:
|
||||
return f"Some similarity to {existing_name}. Review for potential shared patterns."
|
||||
|
||||
|
||||
def analyze_design(figma_data: Dict[str, Any],
|
||||
ui_kit_inventory: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Main analysis function: extract patterns from Figma and compare with UI kit.
|
||||
|
||||
Args:
|
||||
figma_data: Combined Figma MCP data (metadata, variables, code_connect_map)
|
||||
ui_kit_inventory: Current UI kit inventory
|
||||
|
||||
Returns:
|
||||
Analysis results with new tokens, components, similarities, breaking changes
|
||||
"""
|
||||
results = {
|
||||
'new_tokens': [],
|
||||
'new_components': [],
|
||||
'similar_components': [],
|
||||
'breaking_changes': [],
|
||||
'summary': {}
|
||||
}
|
||||
|
||||
# Extract components from Figma metadata
|
||||
metadata = figma_data.get('metadata', {})
|
||||
figma_components = extract_components_from_metadata(metadata)
|
||||
|
||||
# Extract existing UI kit components
|
||||
existing_components = ui_kit_inventory.get('components', [])
|
||||
|
||||
# Analyze each Figma component
|
||||
for figma_comp in figma_components:
|
||||
comp_name = figma_comp.get('name', '')
|
||||
|
||||
# Skip system components (starting with _, . or #)
|
||||
if comp_name.startswith(('_', '.', '#')):
|
||||
continue
|
||||
|
||||
# Find similar components
|
||||
similar = find_similar_components(figma_comp, existing_components, threshold=0.7)
|
||||
|
||||
if similar:
|
||||
# Component has similarities - potential reuse
|
||||
results['similar_components'].append({
|
||||
'figma_component': comp_name,
|
||||
'figma_id': figma_comp.get('id'),
|
||||
'category': categorize_component_by_name(comp_name),
|
||||
'similar_to': similar,
|
||||
'properties': figma_comp.get('properties', {})
|
||||
})
|
||||
else:
|
||||
# New component - needs creation
|
||||
results['new_components'].append({
|
||||
'name': comp_name,
|
||||
'id': figma_comp.get('id'),
|
||||
'category': categorize_component_by_name(comp_name),
|
||||
'properties': figma_comp.get('properties', {}),
|
||||
'depth': figma_comp.get('depth', 0)
|
||||
})
|
||||
|
||||
# Analyze design tokens from variables
|
||||
variables = figma_data.get('variables', {})
|
||||
if variables:
|
||||
results['new_tokens'] = analyze_tokens(variables, ui_kit_inventory)
|
||||
|
||||
# Analyze breaking changes
|
||||
code_connect_map = figma_data.get('code_connect_map', {})
|
||||
if code_connect_map:
|
||||
results['breaking_changes'] = detect_breaking_changes(
|
||||
figma_components,
|
||||
code_connect_map,
|
||||
existing_components
|
||||
)
|
||||
|
||||
# Generate summary
|
||||
results['summary'] = {
|
||||
'total_figma_components': len(figma_components),
|
||||
'new_components_count': len(results['new_components']),
|
||||
'similar_components_count': len(results['similar_components']),
|
||||
'new_tokens_count': len(results['new_tokens']),
|
||||
'breaking_changes_count': len(results['breaking_changes']),
|
||||
'reuse_potential': f"{(len(results['similar_components']) / max(len(figma_components), 1)) * 100:.1f}%"
|
||||
}
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def analyze_tokens(variables: Dict[str, Any],
|
||||
ui_kit_inventory: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Analyze design tokens from Figma variables.
|
||||
|
||||
Args:
|
||||
variables: Figma variables data
|
||||
ui_kit_inventory: Current UI kit inventory with existing tokens
|
||||
|
||||
Returns:
|
||||
List of new tokens not in current inventory
|
||||
"""
|
||||
new_tokens = []
|
||||
existing_tokens = ui_kit_inventory.get('tokens', {})
|
||||
|
||||
# Handle different variable formats
|
||||
for var_name, var_data in variables.items():
|
||||
if isinstance(var_data, dict):
|
||||
value = var_data.get('$value') or var_data.get('value')
|
||||
var_type = var_data.get('$type') or var_data.get('type')
|
||||
else:
|
||||
value = var_data
|
||||
var_type = infer_token_type(var_name, value)
|
||||
|
||||
# Check if token exists
|
||||
if var_name not in existing_tokens:
|
||||
new_tokens.append({
|
||||
'name': var_name,
|
||||
'value': value,
|
||||
'type': var_type,
|
||||
'status': 'new'
|
||||
})
|
||||
|
||||
return new_tokens
|
||||
|
||||
|
||||
def infer_token_type(name: str, value: Any) -> str:
|
||||
"""
|
||||
Infer token type from name and value.
|
||||
|
||||
Args:
|
||||
name: Token name
|
||||
value: Token value
|
||||
|
||||
Returns:
|
||||
Token type string
|
||||
"""
|
||||
name_lower = name.lower()
|
||||
|
||||
if 'color' in name_lower or (isinstance(value, str) and value.startswith('#')):
|
||||
return 'color'
|
||||
elif 'spacing' in name_lower or 'gap' in name_lower or 'padding' in name_lower:
|
||||
return 'dimension'
|
||||
elif 'font' in name_lower or 'typography' in name_lower:
|
||||
return 'typography'
|
||||
elif 'radius' in name_lower or 'border' in name_lower:
|
||||
return 'dimension'
|
||||
elif 'shadow' in name_lower:
|
||||
return 'shadow'
|
||||
else:
|
||||
return 'unknown'
|
||||
|
||||
|
||||
def detect_breaking_changes(figma_components: List[Dict[str, Any]],
|
||||
code_connect_map: Dict[str, Any],
|
||||
existing_components: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Detect breaking changes in component mappings.
|
||||
|
||||
Args:
|
||||
figma_components: Components from Figma
|
||||
code_connect_map: Figma Code Connect mappings
|
||||
existing_components: Existing UI kit components
|
||||
|
||||
Returns:
|
||||
List of breaking changes detected
|
||||
"""
|
||||
breaking_changes = []
|
||||
|
||||
for figma_comp in figma_components:
|
||||
comp_id = figma_comp.get('id')
|
||||
comp_name = figma_comp.get('name')
|
||||
|
||||
# Check if component was previously mapped
|
||||
if comp_id in code_connect_map:
|
||||
mapping = code_connect_map[comp_id]
|
||||
mapped_path = mapping.get('codeConnectSrc')
|
||||
|
||||
# Check if mapped component still exists
|
||||
exists = any(
|
||||
existing.get('path') == mapped_path
|
||||
for existing in existing_components
|
||||
)
|
||||
|
||||
if not exists:
|
||||
breaking_changes.append({
|
||||
'figma_component': comp_name,
|
||||
'figma_id': comp_id,
|
||||
'previous_mapping': mapped_path,
|
||||
'issue': 'Mapped component no longer exists in codebase',
|
||||
'recommendation': 'Re-map to new component or create new implementation'
|
||||
})
|
||||
|
||||
return breaking_changes
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Analyze Figma design data and compare with UI kit'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--figma-data',
|
||||
required=True,
|
||||
help='Path to JSON file with Figma MCP data'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--ui-kit-inventory',
|
||||
required=True,
|
||||
help='Path to UI kit inventory JSON file'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--output',
|
||||
help='Output file path (default: stdout)'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Load Figma data
|
||||
with open(args.figma_data, 'r') as f:
|
||||
figma_data = json.load(f)
|
||||
|
||||
# Load UI kit inventory
|
||||
with open(args.ui_kit_inventory, 'r') as f:
|
||||
ui_kit_inventory = json.load(f)
|
||||
|
||||
# Run analysis
|
||||
results = analyze_design(figma_data, ui_kit_inventory)
|
||||
|
||||
# Output results
|
||||
output_json = json.dumps(results, indent=2)
|
||||
|
||||
if args.output:
|
||||
with open(args.output, 'w') as f:
|
||||
f.write(output_json)
|
||||
else:
|
||||
print(output_json)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user