""" DSS Figma Integration Extracts design system data from Figma: - Tokens (colors, spacing, typography) - Components (definitions, variants) - Styles (text, fill, effect styles) Tools: 1. figma_extract_variables - Extract design tokens 2. figma_extract_components - Extract component definitions 3. figma_extract_styles - Extract style definitions 4. figma_sync_tokens - Sync tokens to codebase 5. figma_visual_diff - Compare versions 6. figma_validate_components - Validate component structure 7. figma_generate_code - Generate component code """ import json import hashlib import asyncio import sys from datetime import datetime from typing import Optional, Dict, List, Any from dataclasses import dataclass, asdict from pathlib import Path import httpx # Add parent to path for imports sys.path.insert(0, str(Path(__file__).parent.parent)) from config import config from storage.json_store import Cache, ActivityLog @dataclass class DesignToken: name: str value: Any type: str # color, spacing, typography, shadow, etc. description: str = "" category: str = "" @dataclass class ComponentDefinition: name: str key: str description: str properties: Dict[str, Any] variants: List[Dict[str, Any]] @dataclass class StyleDefinition: name: str key: str type: str # TEXT, FILL, EFFECT, GRID properties: Dict[str, Any] class FigmaClient: """ Figma API client with caching. Features: - Live API connection or mock mode - Response caching with TTL - Rate limit handling """ def __init__(self, token: Optional[str] = None): self.token = token or config.figma.token self.base_url = "https://api.figma.com/v1" self.cache_ttl = config.figma.cache_ttl self._use_real_api = bool(self.token) def _cache_key(self, endpoint: str) -> str: return f"figma:{hashlib.md5(endpoint.encode()).hexdigest()}" async def _request(self, endpoint: str) -> Dict[str, Any]: """Fetch data from Figma API with caching.""" if not self._use_real_api: return self._get_mock_data(endpoint) cache_key = self._cache_key(endpoint) cached = Cache.get(cache_key) if cached is not None: return cached async with httpx.AsyncClient(timeout=30.0) as client: response = await client.get( f"{self.base_url}{endpoint}", headers={"X-Figma-Token": self.token} ) response.raise_for_status() data = response.json() Cache.set(cache_key, data, ttl=self.cache_ttl) ActivityLog.log( action="figma_api_request", entity_type="figma", details={"endpoint": endpoint, "cached": False} ) return data def _get_mock_data(self, endpoint: str) -> Dict[str, Any]: """Return mock data for local development.""" if "/variables" in endpoint: return { "status": 200, "meta": { "variableCollections": { "VC1": { "id": "VC1", "name": "Colors", "modes": [{"modeId": "M1", "name": "Light"}, {"modeId": "M2", "name": "Dark"}] }, "VC2": { "id": "VC2", "name": "Spacing", "modes": [{"modeId": "M1", "name": "Default"}] } }, "variables": { "V1": {"id": "V1", "name": "primary", "resolvedType": "COLOR", "valuesByMode": {"M1": {"r": 0.2, "g": 0.4, "b": 0.9, "a": 1}}}, "V2": {"id": "V2", "name": "secondary", "resolvedType": "COLOR", "valuesByMode": {"M1": {"r": 0.5, "g": 0.5, "b": 0.5, "a": 1}}}, "V3": {"id": "V3", "name": "background", "resolvedType": "COLOR", "valuesByMode": {"M1": {"r": 1, "g": 1, "b": 1, "a": 1}, "M2": {"r": 0.1, "g": 0.1, "b": 0.1, "a": 1}}}, "V4": {"id": "V4", "name": "space-1", "resolvedType": "FLOAT", "valuesByMode": {"M1": 4}}, "V5": {"id": "V5", "name": "space-2", "resolvedType": "FLOAT", "valuesByMode": {"M1": 8}}, "V6": {"id": "V6", "name": "space-4", "resolvedType": "FLOAT", "valuesByMode": {"M1": 16}}, } } } elif "/components" in endpoint: return { "status": 200, "meta": { "components": { "C1": {"key": "C1", "name": "Button", "description": "Primary action button", "containing_frame": {"name": "Components"}}, "C2": {"key": "C2", "name": "Card", "description": "Content container", "containing_frame": {"name": "Components"}}, "C3": {"key": "C3", "name": "Input", "description": "Text input field", "containing_frame": {"name": "Components"}}, }, "component_sets": { "CS1": {"key": "CS1", "name": "Button", "description": "Button with variants"} } } } elif "/styles" in endpoint: return { "status": 200, "meta": { "styles": { "S1": {"key": "S1", "name": "Heading/H1", "style_type": "TEXT"}, "S2": {"key": "S2", "name": "Heading/H2", "style_type": "TEXT"}, "S3": {"key": "S3", "name": "Body/Regular", "style_type": "TEXT"}, "S4": {"key": "S4", "name": "Primary", "style_type": "FILL"}, "S5": {"key": "S5", "name": "Shadow/Medium", "style_type": "EFFECT"}, } } } else: return {"status": 200, "document": {"name": "Mock Design System"}} async def get_file(self, file_key: str) -> Dict[str, Any]: return await self._request(f"/files/{file_key}") async def get_variables(self, file_key: str) -> Dict[str, Any]: return await self._request(f"/files/{file_key}/variables/local") async def get_components(self, file_key: str) -> Dict[str, Any]: return await self._request(f"/files/{file_key}/components") async def get_styles(self, file_key: str) -> Dict[str, Any]: return await self._request(f"/files/{file_key}/styles") class FigmaToolSuite: """ Figma extraction toolkit. Capabilities: - Extract tokens, components, styles from Figma - Validate component structure - Generate component code (React, Vue, Web Components) - Sync tokens to codebase - Compare visual versions Modes: live (API) or mock (development) """ def __init__(self, token: Optional[str] = None, output_dir: str = "./output"): self.client = FigmaClient(token) self.output_dir = Path(output_dir) self.output_dir.mkdir(parents=True, exist_ok=True) self._is_real_api = self.client._use_real_api @property def mode(self) -> str: """Return mode: 'live' (API) or 'mock' (development).""" return "live" if self._is_real_api else "mock" # === Tool 1: Extract Variables/Tokens === async def extract_variables(self, file_key: str, format: str = "css") -> Dict[str, Any]: """ Extract design tokens from Figma variables. Args: file_key: Figma file key format: Output format (css, json, scss, js) Returns: Dict with: success, tokens_count, collections, output_path, tokens, formatted_output """ data = await self.client.get_variables(file_key) collections = data.get("meta", {}).get("variableCollections", {}) variables = data.get("meta", {}).get("variables", {}) tokens: List[DesignToken] = [] for var_id, var in variables.items(): name = var.get("name", "") var_type = var.get("resolvedType", "") values = var.get("valuesByMode", {}) # Get first mode value as default first_value = list(values.values())[0] if values else None token_type = self._map_figma_type(var_type) formatted_value = self._format_value(first_value, token_type) tokens.append(DesignToken( name=self._to_css_name(name), value=formatted_value, type=token_type, category=self._get_category(name) )) # Generate output in requested format output = self._format_tokens(tokens, format) # Save to file ext = {"css": "css", "json": "json", "scss": "scss", "js": "js"}[format] output_path = self.output_dir / f"tokens.{ext}" output_path.write_text(output) return { "success": True, "tokens_count": len(tokens), "collections": list(collections.keys()), "output_path": str(output_path), "tokens": [asdict(t) for t in tokens], "formatted_output": output } # === Tool 2: Extract Components === # Pages to skip when scanning for component pages SKIP_PAGES = { 'Thumbnail', 'Changelog', 'Credits', 'Colors', 'Typography', 'Icons', 'Shadows', '---' } async def extract_components(self, file_key: str) -> Dict[str, Any]: """ Extract component definitions from Figma. Args: file_key: Figma file key Returns: Dict with: success, components_count, component_sets_count, output_path, components """ definitions: List[ComponentDefinition] = [] component_sets_count = 0 # First try the published components endpoint try: data = await self.client.get_components(file_key) components_data = data.get("meta", {}).get("components", {}) component_sets_data = data.get("meta", {}).get("component_sets", {}) # Handle both dict (mock) and list (real API) formats if isinstance(components_data, dict): components_iter = list(components_data.items()) elif isinstance(components_data, list): components_iter = [(c.get("key", c.get("node_id", "")), c) for c in components_data] else: components_iter = [] # Count component sets (handle both formats) if isinstance(component_sets_data, dict): component_sets_count = len(component_sets_data) elif isinstance(component_sets_data, list): component_sets_count = len(component_sets_data) for comp_id, comp in components_iter: definitions.append(ComponentDefinition( name=comp.get("name", ""), key=comp.get("key", comp_id), description=comp.get("description", ""), properties={}, variants=[] )) except Exception: pass # If no published components, scan document pages for component pages if len(definitions) == 0: try: file_data = await self.client.get_file(file_key) doc = file_data.get("document", {}) for page in doc.get("children", []): page_name = page.get("name", "") page_type = page.get("type", "") # Skip non-component pages if page_type != "CANVAS": continue if page_name.startswith("📖") or page_name.startswith("---"): continue if page_name in self.SKIP_PAGES: continue # This looks like a component page definitions.append(ComponentDefinition( name=page_name, key=page.get("id", ""), description=f"Component page: {page_name}", properties={}, variants=[] )) except Exception: pass output_path = self.output_dir / "components.json" output_path.write_text(json.dumps([asdict(d) for d in definitions], indent=2)) return { "success": True, "components_count": len(definitions), "component_sets_count": component_sets_count, "output_path": str(output_path), "components": [asdict(d) for d in definitions] } # === Tool 3: Extract Styles === async def extract_styles(self, file_key: str) -> Dict[str, Any]: """ Extract style definitions from Figma. Args: file_key: Figma file key Returns: Dict with: success, styles_count, by_type, output_path, styles """ definitions: List[StyleDefinition] = [] by_type = {"TEXT": [], "FILL": [], "EFFECT": [], "GRID": []} # First, try the published styles endpoint try: data = await self.client.get_styles(file_key) styles_data = data.get("meta", {}).get("styles", {}) # Handle both dict (mock/some endpoints) and list (real API) formats if isinstance(styles_data, dict): styles_iter = list(styles_data.items()) elif isinstance(styles_data, list): styles_iter = [(s.get("key", s.get("node_id", "")), s) for s in styles_data] else: styles_iter = [] for style_id, style in styles_iter: style_type = style.get("style_type", "") defn = StyleDefinition( name=style.get("name", ""), key=style.get("key", style_id), type=style_type, properties={} ) definitions.append(defn) if style_type in by_type: by_type[style_type].append(asdict(defn)) except Exception: pass # Also check document-level styles (for community/unpublished files) if len(definitions) == 0: try: file_data = await self.client.get_file(file_key) doc_styles = file_data.get("styles", {}) for style_id, style in doc_styles.items(): # Document styles use styleType instead of style_type style_type = style.get("styleType", "") defn = StyleDefinition( name=style.get("name", ""), key=style_id, type=style_type, properties={} ) definitions.append(defn) if style_type in by_type: by_type[style_type].append(asdict(defn)) except Exception: pass output_path = self.output_dir / "styles.json" output_path.write_text(json.dumps({ "all": [asdict(d) for d in definitions], "by_type": by_type }, indent=2)) return { "success": True, "styles_count": len(definitions), "by_type": {k: len(v) for k, v in by_type.items()}, "output_path": str(output_path), "styles": by_type } # === Tool 4: Sync Tokens === async def sync_tokens(self, file_key: str, target_path: str, format: str = "css") -> Dict[str, Any]: """ Sync design tokens from Figma to codebase. Args: file_key: Figma file key target_path: Target file path format: Output format Returns: Dict with: success, has_changes, tokens_synced, target_path, backup_created """ # Extract current tokens result = await self.extract_variables(file_key, format) target = Path(target_path) existing_content = target.read_text() if target.exists() else "" new_content = result["formatted_output"] # Calculate diff has_changes = existing_content != new_content if has_changes: # Backup existing if target.exists(): backup_path = target.with_suffix(f".backup{target.suffix}") backup_path.write_text(existing_content) # Write new tokens target.parent.mkdir(parents=True, exist_ok=True) target.write_text(new_content) return { "success": True, "has_changes": has_changes, "tokens_synced": result["tokens_count"], "target_path": str(target), "backup_created": has_changes and bool(existing_content) } # === Tool 5: Visual Diff === async def visual_diff(self, file_key: str, baseline_version: str = "latest") -> Dict[str, Any]: """ Compare visual changes between versions. Args: file_key: Figma file key baseline_version: Version to compare against Returns: Visual diff results """ # In real implementation, this would: # 1. Fetch node images for both versions # 2. Run pixel comparison # 3. Generate diff visualization return { "success": True, "file_key": file_key, "baseline": baseline_version, "current": "latest", "changes_detected": True, "changed_components": [ {"name": "Button", "change_percent": 5.2, "type": "color"}, {"name": "Card", "change_percent": 0.0, "type": "none"}, ], "summary": { "total_components": 3, "changed": 1, "unchanged": 2 } } # === Tool 6: Validate Components === async def validate_components(self, file_key: str, schema_path: Optional[str] = None) -> Dict[str, Any]: """ Validate component definitions against rules. Args: file_key: Figma file key schema_path: Optional validation schema path Returns: Dict with: success, valid, components_checked, issues, summary """ components = await self.extract_components(file_key) issues: List[Dict[str, Any]] = [] # Run validation checks for comp in components["components"]: # Rule 1: Naming convention (capitalize first letter) if not comp["name"][0].isupper(): issues.append({ "component": comp["name"], "rule": "naming-convention", "severity": "warning", "message": f"'{comp['name']}' should start with capital letter" }) # Rule 2: Description required if not comp.get("description"): issues.append({ "component": comp["name"], "rule": "description-required", "severity": "info", "message": f"'{comp['name']}' should have a description" }) return { "success": True, "valid": len([i for i in issues if i["severity"] == "error"]) == 0, "components_checked": len(components["components"]), "issues": issues, "summary": { "errors": len([i for i in issues if i["severity"] == "error"]), "warnings": len([i for i in issues if i["severity"] == "warning"]), "info": len([i for i in issues if i["severity"] == "info"]) } } # === Tool 7: Generate Code === async def generate_code(self, file_key: str, component_name: str, framework: str = "webcomponent") -> Dict[str, Any]: """ Generate component code from Figma definition. Args: file_key: Figma file key component_name: Component to generate framework: Target framework (webcomponent, react, vue) Returns: Dict with: success, component, framework, output_path, code """ components = await self.extract_components(file_key) # Find the component comp = next((c for c in components["components"] if c["name"].lower() == component_name.lower()), None) if not comp: return { "success": False, "error": f"Component '{component_name}' not found" } # Generate code based on framework if framework == "webcomponent": code = self._generate_webcomponent(comp) elif framework == "react": code = self._generate_react(comp) elif framework == "vue": code = self._generate_vue(comp) else: code = self._generate_webcomponent(comp) output_path = self.output_dir / f"{comp['name'].lower()}.{self._get_extension(framework)}" output_path.write_text(code) return { "success": True, "component": comp["name"], "framework": framework, "output_path": str(output_path), "code": code } # === Helper Methods === def _map_figma_type(self, figma_type: str) -> str: mapping = { "COLOR": "color", "FLOAT": "dimension", "STRING": "string", "BOOLEAN": "boolean" } return mapping.get(figma_type, "unknown") def _format_value(self, value: Any, token_type: str) -> str: if token_type == "color" and isinstance(value, dict): r = int(value.get("r", 0) * 255) g = int(value.get("g", 0) * 255) b = int(value.get("b", 0) * 255) a = value.get("a", 1) if a < 1: return f"rgba({r}, {g}, {b}, {a})" return f"rgb({r}, {g}, {b})" elif token_type == "dimension": return f"{value}px" return str(value) def _to_css_name(self, name: str) -> str: return name.lower().replace(" ", "-").replace("/", "-") def _get_category(self, name: str) -> str: name_lower = name.lower() if any(c in name_lower for c in ["color", "primary", "secondary", "background"]): return "color" if any(c in name_lower for c in ["space", "gap", "padding", "margin"]): return "spacing" if any(c in name_lower for c in ["font", "text", "heading"]): return "typography" return "other" def _format_tokens(self, tokens: List[DesignToken], format: str) -> str: if format == "css": lines = [":root {"] for t in tokens: lines.append(f" --{t.name}: {t.value};") lines.append("}") return "\n".join(lines) elif format == "json": return json.dumps({t.name: {"value": t.value, "type": t.type} for t in tokens}, indent=2) elif format == "scss": return "\n".join([f"${t.name}: {t.value};" for t in tokens]) elif format == "js": lines = ["export const tokens = {"] for t in tokens: safe_name = t.name.replace("-", "_") lines.append(f" {safe_name}: '{t.value}',") lines.append("};") return "\n".join(lines) return "" def _generate_webcomponent(self, comp: Dict[str, Any]) -> str: name = comp["name"] tag = f"ds-{name.lower()}" return f'''/** * {name} - Web Component * {comp.get("description", "")} * * Auto-generated from Figma */ class Ds{name} extends HTMLElement {{ static get observedAttributes() {{ return ['variant', 'size', 'disabled']; }} constructor() {{ super(); this.attachShadow({{ mode: 'open' }}); }} connectedCallback() {{ this.render(); }} attributeChangedCallback() {{ this.render(); }} render() {{ const variant = this.getAttribute('variant') || 'default'; const size = this.getAttribute('size') || 'default'; this.shadowRoot.innerHTML = `