Files
dss/tools/figma/figma_tools.py
Bruno Sarlo d6c25cb4db Simplify code documentation, remove organism terminology
- Remove biological metaphors from docstrings (organism, sensory, genetic, nutrient, etc.)
- Simplify documentation to be minimal and structured for fast model parsing
- Complete SQLite to JSON storage migration (project_manager.py, json_store.py)
- Add Integrations and IntegrationHealth classes to json_store.py
- Add kill_port() function to server.py for port conflict handling
- All 33 tests pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 11:02:00 -03:00

883 lines
30 KiB
Python

"""
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 = `
<style>
@import '/admin-ui/css/tokens.css';
:host {{
display: inline-block;
}}
.{name.lower()} {{
/* Component styles */
}}
</style>
<div class="{name.lower()} {name.lower()}--${{variant}} {name.lower()}--${{size}}">
<slot></slot>
</div>
`;
}}
}}
customElements.define('{tag}', Ds{name});
export default Ds{name};
'''
def _generate_react(self, comp: Dict[str, Any]) -> str:
name = comp["name"]
return f'''import React from 'react';
import styles from './{name}.module.css';
/**
* {name} Component
* {comp.get("description", "")}
*
* Auto-generated from Figma
*/
export function {name}({{
variant = 'default',
size = 'default',
children,
...props
}}) {{
return (
<div
className={{`${{styles.{name.lower()}}} ${{styles[variant]}} ${{styles[size]}}`}}
{{...props}}
>
{{children}}
</div>
);
}}
export default {name};
'''
def _generate_vue(self, comp: Dict[str, Any]) -> str:
name = comp["name"]
return f'''<template>
<div :class="classes">
<slot />
</div>
</template>
<script setup>
/**
* {name} Component
* {comp.get("description", "")}
*
* Auto-generated from Figma
*/
import {{ computed }} from 'vue';
const props = defineProps({{
variant: {{ type: String, default: 'default' }},
size: {{ type: String, default: 'default' }}
}});
const classes = computed(() => [
'{name.lower()}',
`{name.lower()}--${{props.variant}}`,
`{name.lower()}--${{props.size}}`
]);
</script>
<style scoped>
.{name.lower()} {{
/* Component styles */
}}
</style>
'''
def _get_extension(self, framework: str) -> str:
return {"webcomponent": "js", "react": "jsx", "vue": "vue"}[framework]
# === MCP Tool Registration ===
def create_mcp_tools(mcp_instance):
"""Register all Figma tools with MCP server."""
suite = FigmaToolSuite()
@mcp_instance.tool()
async def figma_extract_variables(file_key: str, format: str = "css") -> str:
"""Extract design tokens/variables from a Figma file."""
result = await suite.extract_variables(file_key, format)
return json.dumps(result, indent=2)
@mcp_instance.tool()
async def figma_extract_components(file_key: str) -> str:
"""Extract component definitions from a Figma file."""
result = await suite.extract_components(file_key)
return json.dumps(result, indent=2)
@mcp_instance.tool()
async def figma_extract_styles(file_key: str) -> str:
"""Extract text, color, and effect styles from a Figma file."""
result = await suite.extract_styles(file_key)
return json.dumps(result, indent=2)
@mcp_instance.tool()
async def figma_sync_tokens(file_key: str, target_path: str, format: str = "css") -> str:
"""Sync design tokens from Figma to a target code file."""
result = await suite.sync_tokens(file_key, target_path, format)
return json.dumps(result, indent=2)
@mcp_instance.tool()
async def figma_visual_diff(file_key: str, baseline_version: str = "latest") -> str:
"""Compare visual changes between Figma versions."""
result = await suite.visual_diff(file_key, baseline_version)
return json.dumps(result, indent=2)
@mcp_instance.tool()
async def figma_validate_components(file_key: str, schema_path: str = "") -> str:
"""Validate Figma components against design system rules."""
result = await suite.validate_components(file_key, schema_path or None)
return json.dumps(result, indent=2)
@mcp_instance.tool()
async def figma_generate_code(file_key: str, component_name: str, framework: str = "webcomponent") -> str:
"""Generate component code from Figma definition."""
result = await suite.generate_code(file_key, component_name, framework)
return json.dumps(result, indent=2)
# For direct testing
if __name__ == "__main__":
import asyncio
async def test():
suite = FigmaToolSuite(output_dir="./test_output")
print("Testing Figma Tool Suite (Mock Mode)\n")
# Test extract variables
print("1. Extract Variables:")
result = await suite.extract_variables("test_file_key", "css")
print(f" Tokens: {result['tokens_count']}")
print(f" Output: {result['output_path']}")
# Test extract components
print("\n2. Extract Components:")
result = await suite.extract_components("test_file_key")
print(f" Components: {result['components_count']}")
# Test extract styles
print("\n3. Extract Styles:")
result = await suite.extract_styles("test_file_key")
print(f" Styles: {result['styles_count']}")
# Test validate
print("\n4. Validate Components:")
result = await suite.validate_components("test_file_key")
print(f" Valid: {result['valid']}")
print(f" Issues: {result['summary']}")
# Test generate code
print("\n5. Generate Code:")
result = await suite.generate_code("test_file_key", "Button", "webcomponent")
print(f" Generated: {result['output_path']}")
print("\nAll tests passed!")
asyncio.run(test())