
Figma Sync
- 8 installs
- 33 repo stars
- Updated April 26, 2026
- bighardperson/computer-science-skills-collection
figma-sync is a skill that reads Figma files, extracts design tokens, generates React Native or React + Tailwind code, and diffs and pushes changes back to Figma.
About
figma-sync is a skill for bidirectional synchronization between Figma files and code. A developer uses it to pull Figma files, extract design tokens, generate React Native Expo TypeScript or React + Tailwind code, write changes back to Figma, and diff a local model against Figma for minimal patches. It ships Python scripts for pull, push, diff, and preview.
- Bidirectional Figma-to-code sync with pull, push, diff, and preview commands
- Extracts design tokens and generates React Native Expo TS or React + Tailwind code
- Diffs a local model against Figma to produce minimal patches
Figma Sync by the numbers
- 8 all-time installs (skills.sh)
- Ranked #1,481 of 1,880 Design & UI/UX skills by installs in the Skillselion catalog
- Data as of Jul 30, 2026 (Skillselion catalog sync)
figma-sync capabilities & compatibility
Requires a free Figma personal access token; Figma API usage is subject to platform rate limits.
- Capabilities
- figma pull · design token extract · code generation · figma diff
- Works with
- figma
- Use cases
- ui design · frontend
- Pricing
- Bring your own API key
What figma-sync says it does
Bidirectional Figma ↔ Code synchronization skill.
Outputs: `designModel.json`, `tokens.json`, `codePlan.json`, and generated component files.
npx skills add https://github.com/bighardperson/computer-science-skills-collection --skill figma-syncAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 8 |
|---|---|
| repo stars | ★ 33 |
| Last updated | April 26, 2026 |
| Repository | bighardperson/computer-science-skills-collection ↗ |
What it does
Pull Figma files to extract design tokens and generate React Native or React + Tailwind code, then diff and push changes back.
Who is it for?
Keeping Figma designs and React/React Native code in sync via tokens and generated components.
Skip if: Design work outside Figma or backend code generation.
When should I use this skill?
The user wants to pull from Figma, generate code from a design, diff against Figma, or push changes back.
What you get
A synced designModel.json, tokens.json, and generated component files, plus a patchSpec to push back to Figma.
- designModel.json
- tokens.json
- generated React Native or React + Tailwind components
By the numbers
- 4 CLI commands: pull, push, diff, preview
- 2 target platforms: rn-expo and web-react
- respects Figma rate limits of ~30 req/min
Files
figma-sync
Bidirectional Figma ↔ Code synchronization skill.
Setup
export FIGMA_TOKEN="your-personal-access-token"Get a token at https://www.figma.com/developers/api#access-tokens
Commands
Pull (Read + Generate Code)
python3 scripts/figma_pull.py --file-key <KEY> --platform rn-expo --output-dir ./out
python3 scripts/figma_pull.py --file-key <KEY> --node-ids 1:2,3:4 --platform web-react --output-dir ./outOutputs: designModel.json, tokens.json, codePlan.json, and generated component files.
Push (Write Back)
python3 scripts/figma_push.py --file-key <KEY> --patch-spec patch.json
python3 scripts/figma_push.py --file-key <KEY> --patch-spec patch.json --execute # actually applyDry-run by default. Pass --execute to apply changes.
Diff
python3 scripts/figma_diff.py --file-key <KEY> --local-model designModel.jsonOutputs changes and a patchSpec to sync.
Preview
python3 scripts/figma_preview.py --file-key <KEY> --operations ops.jsonShows what would change without touching anything.
Platforms
- rn-expo: React Native + Expo + TypeScript (primary)
- web-react: React + Tailwind CSS (secondary)
Rate Limits
Uses exponential backoff, ETag caching, and respects Figma's rate limits (~30 req/min). Cache stored in .figma-cache/ directory.
References
- DesignSpec Schema
- API Guide
{
"ownerId": "kn7fkntzs34mbdd2633qphqdw580tagq",
"slug": "figma-sync",
"version": "1.0.0",
"publishedAt": 1770611102933
}{
"slug": "figma-sync",
"name": "Figma Sync",
"version": "1.0.0",
"installedAt": 1776152388746,
"source": "skillhub"
}Figma REST API Guide
Authentication
Set FIGMA_TOKEN env var with a Personal Access Token or OAuth token.
Authorization: Bearer <FIGMA_TOKEN>Get a token: https://www.figma.com/developers/api#access-tokens
Endpoints Used
Read Operations
| Endpoint | Purpose |
|---|---|
GET /v1/files/{key} | Full file tree, styles, components |
GET /v1/files/{key}/nodes?ids=X,Y | Specific nodes only |
GET /v1/images/{key}?ids=X&format=png | Export node images/assets |
GET /v1/files/{key}/styles | Published styles |
GET /v1/files/{key}/components | Published components |
Base URL: https://api.figma.com
Write Operations — Limitations
The Figma REST API is read-only for file content. There is no REST endpoint to modify nodes, text, fills, or layout.
Write operations require one of: 1. Figma Plugin API — runs inside Figma desktop/web app via a plugin 2. Figma Variables REST API — can create/update variables and variable collections (limited) 3. Figma Dev Mode API — read-only, for developers
figma_push.py generates a plugin-compatible operations spec that can be consumed by a companion Figma plugin. The script documents what each operation would do and validates the patch spec, but actual mutations require the plugin bridge.
For operations that can be done via REST (variables, comments, webhooks), the script uses those endpoints directly.
Rate Limits
- ~30 requests/minute for personal tokens
- Figma returns
429withRetry-Afterheader - Strategy: exponential backoff starting at 1s, max 60s, max 5 retries
- Use
If-None-Matchwith ETags for conditional requests - Check
X-FIGMA-CACHEDresponse header
Caching
Cache directory: .figma-cache/
.figma-cache/
{fileKey}/
file.json # full file response
etag.txt # ETag for conditional requests
last_modified.txt
images/ # exported imagesError Codes
| Code | Meaning |
|---|---|
| 400 | Bad request (invalid node IDs, etc.) |
| 403 | Token invalid or no access to file |
| 404 | File or node not found |
| 429 | Rate limited — retry after delay |
| 500 | Figma server error — retry |
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "DesignSpec.v1",
"description": "Canonical design specification extracted from Figma",
"type": "object",
"properties": {
"version": { "const": "1.0.0" },
"fileKey": { "type": "string" },
"fileName": { "type": "string" },
"lastModified": { "type": "string", "format": "date-time" },
"frames": {
"type": "array",
"items": { "$ref": "#/definitions/DesignNode" }
},
"components": {
"type": "array",
"items": { "$ref": "#/definitions/DesignNode" }
},
"variants": {
"type": "array",
"items": { "$ref": "#/definitions/VariantGroup" }
},
"textStyles": {
"type": "array",
"items": { "$ref": "#/definitions/TextStyle" }
},
"colorStyles": {
"type": "array",
"items": { "$ref": "#/definitions/ColorStyle" }
},
"effectStyles": {
"type": "array",
"items": { "$ref": "#/definitions/EffectStyle" }
},
"tokens": { "$ref": "#/definitions/DesignTokens" },
"assets": {
"type": "array",
"items": { "$ref": "#/definitions/Asset" }
}
},
"required": ["version", "fileKey", "fileName", "lastModified"],
"definitions": {
"DesignNode": {
"type": "object",
"properties": {
"id": { "type": "string", "description": "Stable deterministic ID" },
"name": { "type": "string" },
"type": { "type": "string", "enum": ["FRAME", "COMPONENT", "INSTANCE", "GROUP", "TEXT", "RECTANGLE", "ELLIPSE", "VECTOR", "LINE", "BOOLEAN_OPERATION", "SECTION"] },
"figmaNodeId": { "type": "string" },
"properties": { "$ref": "#/definitions/NodeProperties" },
"children": {
"type": "array",
"items": { "$ref": "#/definitions/DesignNode" }
}
},
"required": ["id", "name", "type", "figmaNodeId"]
},
"NodeProperties": {
"type": "object",
"properties": {
"x": { "type": "number" },
"y": { "type": "number" },
"width": { "type": "number" },
"height": { "type": "number" },
"opacity": { "type": "number" },
"visible": { "type": "boolean" },
"rotation": { "type": "number" },
"cornerRadius": { "type": ["number", "null"] },
"cornerRadii": {
"type": "object",
"properties": {
"topLeft": { "type": "number" },
"topRight": { "type": "number" },
"bottomRight": { "type": "number" },
"bottomLeft": { "type": "number" }
}
},
"fills": { "type": "array", "items": { "$ref": "#/definitions/Paint" } },
"strokes": { "type": "array", "items": { "$ref": "#/definitions/Paint" } },
"strokeWeight": { "type": "number" },
"effects": { "type": "array", "items": { "$ref": "#/definitions/Effect" } },
"constraints": { "$ref": "#/definitions/Constraints" },
"autoLayout": { "$ref": "#/definitions/AutoLayout" },
"textContent": { "type": "string" },
"textStyle": { "$ref": "#/definitions/TextStyleProps" },
"componentPropertyDefinitions": { "type": "object" }
}
},
"Paint": {
"type": "object",
"properties": {
"type": { "type": "string", "enum": ["SOLID", "GRADIENT_LINEAR", "GRADIENT_RADIAL", "GRADIENT_ANGULAR", "GRADIENT_DIAMOND", "IMAGE"] },
"color": { "$ref": "#/definitions/RGBA" },
"opacity": { "type": "number" },
"visible": { "type": "boolean" }
}
},
"RGBA": {
"type": "object",
"properties": {
"r": { "type": "number" },
"g": { "type": "number" },
"b": { "type": "number" },
"a": { "type": "number" }
},
"required": ["r", "g", "b", "a"]
},
"Effect": {
"type": "object",
"properties": {
"type": { "type": "string", "enum": ["DROP_SHADOW", "INNER_SHADOW", "LAYER_BLUR", "BACKGROUND_BLUR"] },
"visible": { "type": "boolean" },
"radius": { "type": "number" },
"color": { "$ref": "#/definitions/RGBA" },
"offset": {
"type": "object",
"properties": { "x": { "type": "number" }, "y": { "type": "number" } }
},
"spread": { "type": "number" }
}
},
"Constraints": {
"type": "object",
"properties": {
"horizontal": { "type": "string", "enum": ["LEFT", "RIGHT", "CENTER", "LEFT_RIGHT", "SCALE"] },
"vertical": { "type": "string", "enum": ["TOP", "BOTTOM", "CENTER", "TOP_BOTTOM", "SCALE"] }
}
},
"AutoLayout": {
"type": "object",
"properties": {
"mode": { "type": "string", "enum": ["HORIZONTAL", "VERTICAL", "WRAP"] },
"paddingTop": { "type": "number" },
"paddingRight": { "type": "number" },
"paddingBottom": { "type": "number" },
"paddingLeft": { "type": "number" },
"itemSpacing": { "type": "number" },
"counterAxisSpacing": { "type": "number" },
"primaryAxisAlignItems": { "type": "string", "enum": ["MIN", "CENTER", "MAX", "SPACE_BETWEEN"] },
"counterAxisAlignItems": { "type": "string", "enum": ["MIN", "CENTER", "MAX", "BASELINE"] },
"primaryAxisSizingMode": { "type": "string", "enum": ["FIXED", "AUTO"] },
"counterAxisSizingMode": { "type": "string", "enum": ["FIXED", "AUTO"] }
}
},
"TextStyleProps": {
"type": "object",
"properties": {
"fontFamily": { "type": "string" },
"fontWeight": { "type": "number" },
"fontSize": { "type": "number" },
"lineHeight": { "type": ["number", "string"] },
"letterSpacing": { "type": "number" },
"textAlignHorizontal": { "type": "string" },
"textAlignVertical": { "type": "string" },
"textDecoration": { "type": "string" },
"textCase": { "type": "string" }
}
},
"TextStyle": {
"type": "object",
"properties": {
"id": { "type": "string" },
"name": { "type": "string" },
"figmaStyleId": { "type": "string" },
"properties": { "$ref": "#/definitions/TextStyleProps" }
},
"required": ["id", "name"]
},
"ColorStyle": {
"type": "object",
"properties": {
"id": { "type": "string" },
"name": { "type": "string" },
"figmaStyleId": { "type": "string" },
"color": { "$ref": "#/definitions/RGBA" }
},
"required": ["id", "name", "color"]
},
"EffectStyle": {
"type": "object",
"properties": {
"id": { "type": "string" },
"name": { "type": "string" },
"figmaStyleId": { "type": "string" },
"effects": { "type": "array", "items": { "$ref": "#/definitions/Effect" } }
},
"required": ["id", "name", "effects"]
},
"DesignTokens": {
"type": "object",
"properties": {
"colors": { "type": "object", "additionalProperties": { "type": "string" } },
"typography": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"fontFamily": { "type": "string" },
"fontWeight": { "type": "number" },
"fontSize": { "type": "number" },
"lineHeight": { "type": ["number", "string"] },
"letterSpacing": { "type": "number" }
}
}
},
"spacing": { "type": "object", "additionalProperties": { "type": "number" } },
"radii": { "type": "object", "additionalProperties": { "type": "number" } },
"shadows": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"x": { "type": "number" },
"y": { "type": "number" },
"blur": { "type": "number" },
"spread": { "type": "number" },
"color": { "type": "string" }
}
}
}
}
},
"VariantGroup": {
"type": "object",
"properties": {
"id": { "type": "string" },
"name": { "type": "string" },
"figmaNodeId": { "type": "string" },
"properties": { "type": "object" },
"variants": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "string" },
"name": { "type": "string" },
"figmaNodeId": { "type": "string" },
"propertyValues": { "type": "object" }
}
}
}
},
"required": ["id", "name", "variants"]
},
"Asset": {
"type": "object",
"properties": {
"id": { "type": "string" },
"name": { "type": "string" },
"figmaNodeId": { "type": "string" },
"exportSettings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"format": { "type": "string", "enum": ["PNG", "SVG", "JPG", "PDF"] },
"scale": { "type": "number" }
}
}
},
"url": { "type": "string" }
},
"required": ["id", "name", "figmaNodeId"]
}
}
}
"""Shared utilities for figma-sync scripts."""
import hashlib
import json
import logging
import os
import re
import sys
import time
from pathlib import Path
import requests
logger = logging.getLogger("figma-sync")
FIGMA_API = "https://api.figma.com"
CACHE_DIR = Path(".figma-cache")
MAX_RETRIES = 5
INITIAL_BACKOFF = 1.0
def get_token() -> str:
token = os.environ.get("FIGMA_TOKEN", "")
if not token:
logger.error("FIGMA_TOKEN environment variable not set")
sys.exit(1)
return token
def headers() -> dict:
return {"X-Figma-Token": get_token(), "Content-Type": "application/json"}
def cache_path(file_key: str) -> Path:
p = CACHE_DIR / file_key
p.mkdir(parents=True, exist_ok=True)
return p
def api_get(path: str, file_key: str = "", use_cache: bool = True, params: dict = None) -> dict:
"""GET from Figma API with retry, backoff, and ETag caching."""
url = f"{FIGMA_API}{path}"
hdrs = headers()
etag_file = None
cache_file = None
if use_cache and file_key:
cp = cache_path(file_key)
safe = path.replace("/", "_").strip("_")
cache_file = cp / f"{safe}.json"
etag_file = cp / f"{safe}.etag"
if etag_file.exists():
hdrs["If-None-Match"] = etag_file.read_text().strip()
backoff = INITIAL_BACKOFF
for attempt in range(MAX_RETRIES):
try:
resp = requests.get(url, headers=hdrs, params=params, timeout=30)
if resp.status_code == 304 and cache_file and cache_file.exists():
logger.debug("Cache hit (304) for %s", path)
return json.loads(cache_file.read_text())
if resp.status_code == 429:
retry_after = float(resp.headers.get("Retry-After", backoff))
logger.warning("Rate limited, retrying in %.1fs", retry_after)
time.sleep(retry_after)
backoff = min(backoff * 2, 60)
continue
resp.raise_for_status()
data = resp.json()
if cache_file:
cache_file.write_text(json.dumps(data, sort_keys=True))
if etag_file and "ETag" in resp.headers:
etag_file.write_text(resp.headers["ETag"])
return data
except requests.exceptions.RequestException as e:
if attempt == MAX_RETRIES - 1:
logger.error("API request failed after %d retries: %s", MAX_RETRIES, e)
raise
logger.warning("Request failed (attempt %d): %s, retrying in %.1fs", attempt + 1, e, backoff)
time.sleep(backoff)
backoff = min(backoff * 2, 60)
return {}
def stable_id(name: str, figma_id: str) -> str:
"""Generate a stable deterministic ID from name + figma node ID."""
raw = f"{name}::{figma_id}"
return hashlib.sha256(raw.encode()).hexdigest()[:12]
def to_camel_case(name: str) -> str:
"""Convert a Figma layer name to CamelCase component name."""
cleaned = re.sub(r"[^a-zA-Z0-9\s_-]", "", name)
parts = re.split(r"[\s_-]+", cleaned)
result = "".join(p.capitalize() for p in parts if p)
if not result:
return "Component"
if result[0].isdigit():
result = "Screen" + result
return result
def to_kebab_case(name: str) -> str:
"""Convert name to kebab-case filename."""
cleaned = re.sub(r"[^a-zA-Z0-9\s_-]", "", name)
parts = re.split(r"[\s_-]+", cleaned)
return "-".join(p.lower() for p in parts if p)
def rgba_to_hex(color: dict) -> str:
"""Convert Figma RGBA (0-1 floats) to hex string."""
r = int(color.get("r", 0) * 255)
g = int(color.get("g", 0) * 255)
b = int(color.get("b", 0) * 255)
a = color.get("a", 1.0)
if a < 1.0:
return f"rgba({r}, {g}, {b}, {a:.2f})"
return f"#{r:02x}{g:02x}{b:02x}"
def rgba_to_rn(color: dict) -> str:
"""Convert Figma RGBA to React Native color string."""
return rgba_to_hex(color)
def write_json(path: Path, data: dict):
"""Write JSON with sorted keys for deterministic output."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, indent=2, sort_keys=True, default=str) + "\n")
logger.info("Wrote %s", path)
def setup_logging(verbose: bool = False):
level = logging.DEBUG if verbose else logging.INFO
logging.basicConfig(
level=level,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%H:%M:%S",
)
#!/usr/bin/env python3
"""Diff local DesignSpec model against current Figma file state."""
import argparse
import json
import logging
from pathlib import Path
from figma_common import api_get, setup_logging, stable_id, write_json
from figma_pull import normalize_node, extract_styles, extract_tokens
logger = logging.getLogger("figma-sync.diff")
def diff_properties(local_props: dict, remote_props: dict, path: str) -> list:
"""Compare two property dicts and return list of changes."""
changes = []
all_keys = sorted(set(list(local_props.keys()) + list(remote_props.keys())))
for key in all_keys:
local_val = local_props.get(key)
remote_val = remote_props.get(key)
if local_val == remote_val:
continue
if local_val is None:
changes.append({
"type": "added_remote",
"path": f"{path}.{key}",
"remoteValue": remote_val,
})
elif remote_val is None:
changes.append({
"type": "removed_remote",
"path": f"{path}.{key}",
"localValue": local_val,
})
else:
changes.append({
"type": "modified",
"path": f"{path}.{key}",
"localValue": local_val,
"remoteValue": remote_val,
})
return changes
def diff_nodes(local_node: dict, remote_node: dict, path: str = "") -> list:
"""Recursively diff two DesignSpec nodes."""
changes = []
node_path = f"{path}/{local_node.get('name', '?')}"
# Compare properties
local_props = local_node.get("properties", {})
remote_props = remote_node.get("properties", {})
changes.extend(diff_properties(local_props, remote_props, node_path))
# Compare children by figmaNodeId
local_children = {c["figmaNodeId"]: c for c in local_node.get("children", [])}
remote_children = {c["figmaNodeId"]: c for c in remote_node.get("children", [])}
all_child_ids = sorted(set(list(local_children.keys()) + list(remote_children.keys())))
for cid in all_child_ids:
lc = local_children.get(cid)
rc = remote_children.get(cid)
if lc and rc:
changes.extend(diff_nodes(lc, rc, node_path))
elif lc and not rc:
changes.append({
"type": "removed_remote",
"path": f"{node_path}/{lc['name']}",
"description": f"Node {lc['name']} ({cid}) exists locally but not in Figma",
})
elif rc and not lc:
changes.append({
"type": "added_remote",
"path": f"{node_path}/{rc['name']}",
"description": f"Node {rc['name']} ({cid}) exists in Figma but not locally",
})
return changes
def changes_to_patch_spec(changes: list) -> dict:
"""Convert a list of changes into a minimal patch spec."""
operations = []
for change in changes:
if change["type"] == "modified":
path_parts = change["path"].rsplit(".", 1)
if len(path_parts) == 2:
prop_name = path_parts[1]
if prop_name == "textContent":
operations.append({
"type": "setText",
"nodeId": _extract_node_id(change["path"]),
"value": change["remoteValue"],
})
elif prop_name == "fills":
operations.append({
"type": "setFill",
"nodeId": _extract_node_id(change["path"]),
"value": change["remoteValue"],
})
return {"operations": operations}
def _extract_node_id(path: str) -> str:
"""Best-effort extraction of a node identifier from a diff path."""
parts = path.split("/")
return parts[-1].split(".")[0] if parts else "unknown"
def diff(file_key: str, local_model_path: str, output_dir: str = "./out"):
out = Path(output_dir)
out.mkdir(parents=True, exist_ok=True)
# Load local model
local_model = json.loads(Path(local_model_path).read_text())
logger.info("Loaded local model: %s (%s)", local_model.get("fileName"), local_model.get("lastModified"))
# Fetch current remote state
logger.info("Fetching current file %s from Figma", file_key)
data = api_get(f"/v1/files/{file_key}", file_key=file_key)
document = data.get("document", {})
# Normalize remote
remote_frames = []
remote_components = []
for page in document.get("children", []):
for child in page.get("children", []):
if not isinstance(child, dict):
continue
normalized = normalize_node(child)
if child.get("type") in ("COMPONENT", "COMPONENT_SET"):
remote_components.append(normalized)
else:
remote_frames.append(normalized)
# Diff frames
all_changes = []
local_frames = {f["figmaNodeId"]: f for f in local_model.get("frames", [])}
remote_frames_map = {f["figmaNodeId"]: f for f in remote_frames}
for fid in sorted(set(list(local_frames.keys()) + list(remote_frames_map.keys()))):
lf = local_frames.get(fid)
rf = remote_frames_map.get(fid)
if lf and rf:
all_changes.extend(diff_nodes(lf, rf))
elif lf and not rf:
all_changes.append({"type": "removed_remote", "path": f"/{lf['name']}", "description": f"Frame removed from Figma"})
elif rf and not lf:
all_changes.append({"type": "added_remote", "path": f"/{rf['name']}", "description": f"New frame in Figma"})
# Diff components
local_comps = {c["figmaNodeId"]: c for c in local_model.get("components", [])}
remote_comps_map = {c["figmaNodeId"]: c for c in remote_components}
for cid in sorted(set(list(local_comps.keys()) + list(remote_comps_map.keys()))):
lc = local_comps.get(cid)
rc = remote_comps_map.get(cid)
if lc and rc:
all_changes.extend(diff_nodes(lc, rc))
elif lc and not rc:
all_changes.append({"type": "removed_remote", "path": f"/{lc['name']}", "description": "Component removed from Figma"})
elif rc and not lc:
all_changes.append({"type": "added_remote", "path": f"/{rc['name']}", "description": "New component in Figma"})
# Diff tokens
local_tokens = local_model.get("tokens", {})
remote_tokens = extract_tokens({"frames": remote_frames, "components": remote_components})
token_changes = diff_properties(local_tokens, remote_tokens, "/tokens")
all_changes.extend(token_changes)
# Generate patch spec
patch_spec = changes_to_patch_spec(all_changes)
result = {
"fileKey": file_key,
"localLastModified": local_model.get("lastModified", ""),
"remoteLastModified": data.get("lastModified", ""),
"totalChanges": len(all_changes),
"changes": all_changes,
"patchSpec": patch_spec,
}
write_json(out / "diffResult.json", result)
write_json(out / "patchSpec.json", patch_spec)
logger.info("Diff complete: %d changes found", len(all_changes))
return result
def main():
parser = argparse.ArgumentParser(description="Diff local model against Figma")
parser.add_argument("--file-key", required=True, help="Figma file key")
parser.add_argument("--local-model", required=True, help="Path to local designModel.json")
parser.add_argument("--output-dir", default="./out")
parser.add_argument("--verbose", "-v", action="store_true")
args = parser.parse_args()
setup_logging(args.verbose)
diff(args.file_key, args.local_model, args.output_dir)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Preview what Figma operations would change (dry-run summary)."""
import argparse
import json
import logging
from pathlib import Path
from figma_common import api_get, setup_logging, write_json
from figma_push import validate_patch_spec, describe_operation, fetch_current_state, _summarize_node
logger = logging.getLogger("figma-sync.preview")
def preview(file_key: str, operations_path: str, output_dir: str = "./out"):
out = Path(output_dir)
out.mkdir(parents=True, exist_ok=True)
# Load operations
ops_data = json.loads(Path(operations_path).read_text())
if isinstance(ops_data, list):
patch_spec = {"operations": ops_data}
else:
patch_spec = ops_data
ops, warnings = validate_patch_spec(patch_spec)
# Fetch current state of affected nodes
node_ids = list(set(op["nodeId"] for op in ops if op.get("nodeId")))
current_state = fetch_current_state(file_key, node_ids) if node_ids else {}
preview_items = []
for i, op in enumerate(ops):
item = {
"index": i,
"type": op.get("type"),
"description": describe_operation(op),
"nodeId": op.get("nodeId"),
"requiresPlugin": True, # All node mutations need plugin
}
# Add current state if available
nid = op.get("nodeId")
if nid and nid in current_state:
item["currentState"] = _summarize_node(current_state[nid])
# Show what would change
if op.get("type") == "setText":
item["change"] = {
"property": "characters",
"currentValue": current_state.get(nid, {}).get("document", {}).get("characters"),
"newValue": op.get("value"),
}
elif op.get("type") == "setFill":
item["change"] = {
"property": "fills",
"newValue": op.get("value"),
}
elif op.get("type") == "setAutoLayout":
item["change"] = {
"property": "layoutMode",
"newValue": op.get("value"),
}
elif op.get("type") in ("createComponent", "createFrame"):
item["change"] = {
"action": "create",
"name": op.get("name"),
"properties": op.get("properties", {}),
}
preview_items.append(item)
result = {
"fileKey": file_key,
"totalOperations": len(ops),
"operationsRequiringPlugin": len([p for p in preview_items if p.get("requiresPlugin")]),
"warnings": warnings,
"preview": preview_items,
"summary": _build_summary(preview_items),
}
write_json(out / "preview.json", result)
# Print human-readable summary
print(f"\n{'='*60}")
print(f"PREVIEW: {len(ops)} operations on file {file_key}")
print(f"{'='*60}")
for item in preview_items:
status = "🔌 Plugin" if item.get("requiresPlugin") else "🌐 REST"
print(f" [{status}] {item['description']}")
if item.get("change"):
change = item["change"]
if "currentValue" in change and change["currentValue"]:
print(f" Current: {str(change['currentValue'])[:60]}")
if "newValue" in change:
print(f" New: {str(change['newValue'])[:60]}")
if warnings:
print(f"\n⚠️ Warnings:")
for w in warnings:
print(f" - {w}")
print(f"{'='*60}\n")
logger.info("Preview complete: %d operations", len(ops))
return result
def _build_summary(items: list) -> dict:
"""Build a summary of operation types."""
by_type = {}
for item in items:
t = item.get("type", "unknown")
by_type[t] = by_type.get(t, 0) + 1
return {"byType": by_type}
def main():
parser = argparse.ArgumentParser(description="Preview Figma operations (dry-run)")
parser.add_argument("--file-key", required=True, help="Figma file key")
parser.add_argument("--operations", required=True, help="Path to operations JSON")
parser.add_argument("--output-dir", default="./out")
parser.add_argument("--verbose", "-v", action="store_true")
args = parser.parse_args()
setup_logging(args.verbose)
preview(args.file_key, args.operations, args.output_dir)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Pull Figma file data, extract design tokens, and generate code."""
import argparse
import json
import logging
import re
import sys
from pathlib import Path
import requests
from figma_common import (
api_get, get_token, rgba_to_hex, rgba_to_rn, setup_logging, stable_id,
to_camel_case, to_kebab_case, write_json, FIGMA_API,
)
logger = logging.getLogger("figma-sync.pull")
# ---------------------------------------------------------------------------
# Figma tree normalization
# ---------------------------------------------------------------------------
def normalize_node(node: dict, depth: int = 0) -> dict:
"""Convert a Figma API node into our DesignSpec DesignNode."""
ntype = node.get("type", "UNKNOWN")
name = node.get("name", "Unnamed")
fid = node.get("id", "")
sid = stable_id(name, fid)
props = {}
bbox = node.get("absoluteBoundingBox") or node.get("size") or {}
if bbox:
props["x"] = bbox.get("x", 0)
props["y"] = bbox.get("y", 0)
props["width"] = bbox.get("width", 0)
props["height"] = bbox.get("height", 0)
props["visible"] = node.get("visible", True)
props["opacity"] = node.get("opacity", 1.0)
props["rotation"] = node.get("rotation", 0)
props["layoutPositioning"] = node.get("layoutPositioning", "AUTO")
props["layoutSizingHorizontal"] = node.get("layoutSizingHorizontal", "FIXED")
props["layoutSizingVertical"] = node.get("layoutSizingVertical", "FIXED")
if "cornerRadius" in node:
props["cornerRadius"] = node["cornerRadius"]
if "rectangleCornerRadii" in node:
radii = node["rectangleCornerRadii"]
props["cornerRadii"] = {
"topLeft": radii[0], "topRight": radii[1],
"bottomRight": radii[2], "bottomLeft": radii[3],
}
if "fills" in node:
props["fills"] = normalize_paints(node["fills"])
if "strokes" in node:
props["strokes"] = normalize_paints(node["strokes"])
if "strokeWeight" in node:
props["strokeWeight"] = node["strokeWeight"]
if "effects" in node:
props["effects"] = normalize_effects(node["effects"])
if "constraints" in node:
props["constraints"] = node["constraints"]
if "clipsContent" in node:
props["clipsContent"] = node["clipsContent"]
# Auto-layout
if node.get("layoutMode"):
props["autoLayout"] = {
"mode": node["layoutMode"],
"paddingTop": node.get("paddingTop", 0),
"paddingRight": node.get("paddingRight", 0),
"paddingBottom": node.get("paddingBottom", 0),
"paddingLeft": node.get("paddingLeft", 0),
"itemSpacing": node.get("itemSpacing", 0),
"counterAxisSpacing": node.get("counterAxisSpacing", 0),
"primaryAxisAlignItems": node.get("primaryAxisAlignItems", "MIN"),
"counterAxisAlignItems": node.get("counterAxisAlignItems", "MIN"),
"primaryAxisSizingMode": node.get("primaryAxisSizingMode", "AUTO"),
"counterAxisSizingMode": node.get("counterAxisSizingMode", "AUTO"),
}
# Text
if ntype == "TEXT":
props["textContent"] = node.get("characters", "")
style = node.get("style", {})
props["textStyle"] = {
"fontFamily": style.get("fontFamily", ""),
"fontWeight": style.get("fontWeight", 400),
"fontSize": style.get("fontSize", 16),
"lineHeight": style.get("lineHeightPx", "auto"),
"letterSpacing": style.get("letterSpacing", 0),
"textAlignHorizontal": style.get("textAlignHorizontal", "LEFT"),
"textAlignVertical": style.get("textAlignVertical", "TOP"),
"textDecoration": style.get("textDecoration", "NONE"),
"textCase": style.get("textCase", "ORIGINAL"),
}
if "componentPropertyDefinitions" in node:
props["componentPropertyDefinitions"] = node["componentPropertyDefinitions"]
children = []
for child in node.get("children", []):
children.append(normalize_node(child, depth + 1))
result = {
"id": sid,
"name": name,
"type": ntype,
"figmaNodeId": fid,
"properties": props,
}
if children:
result["children"] = children
return result
def normalize_paints(paints: list) -> list:
result = []
for p in paints:
paint = {"type": p.get("type", "SOLID"), "visible": p.get("visible", True)}
if "color" in p:
paint["color"] = p["color"]
paint["opacity"] = p.get("opacity", 1.0)
if "imageRef" in p:
paint["imageRef"] = p["imageRef"]
if "scaleMode" in p:
paint["scaleMode"] = p["scaleMode"]
result.append(paint)
return result
def normalize_effects(effects: list) -> list:
result = []
for e in effects:
eff = {
"type": e.get("type", "DROP_SHADOW"),
"visible": e.get("visible", True),
"radius": e.get("radius", 0),
}
if "color" in e:
eff["color"] = e["color"]
if "offset" in e:
eff["offset"] = e["offset"]
if "spread" in e:
eff["spread"] = e["spread"]
result.append(eff)
return result
# ---------------------------------------------------------------------------
# Style & token extraction
# ---------------------------------------------------------------------------
def extract_styles(file_data: dict) -> dict:
text_styles, color_styles, effect_styles = [], [], []
styles = file_data.get("styles", {})
for style_id, style_meta in sorted(styles.items()):
stype = style_meta.get("styleType", "")
entry = {
"id": stable_id(style_meta.get("name", ""), style_id),
"name": style_meta.get("name", ""),
"figmaStyleId": style_id,
}
if stype == "TEXT":
text_styles.append(entry)
elif stype == "FILL":
color_styles.append(entry)
elif stype == "EFFECT":
effect_styles.append(entry)
return {"textStyles": text_styles, "colorStyles": color_styles, "effectStyles": effect_styles}
def extract_tokens(design_model: dict) -> dict:
colors, typography, shadows = {}, {}, {}
spacing, radii = set(), set()
def walk(node):
props = node.get("properties", {})
for fill in props.get("fills", []):
if fill.get("color") and fill.get("visible", True):
hex_val = rgba_to_hex(fill["color"])
colors[to_kebab_case(f"color-{node['name']}")] = hex_val
ts = props.get("textStyle")
if ts and ts.get("fontFamily"):
typography[to_kebab_case(f"type-{node['name']}")] = {
"fontFamily": ts["fontFamily"], "fontWeight": ts.get("fontWeight", 400),
"fontSize": ts.get("fontSize", 16), "lineHeight": ts.get("lineHeight", "auto"),
"letterSpacing": ts.get("letterSpacing", 0),
}
al = props.get("autoLayout")
if al:
for key in ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft", "itemSpacing"]:
val = al.get(key, 0)
if val > 0:
spacing.add(val)
cr = props.get("cornerRadius")
if cr and cr > 0:
radii.add(cr)
for v in (props.get("cornerRadii") or {}).values():
if v > 0:
radii.add(v)
for eff in props.get("effects", []):
if eff.get("type") in ("DROP_SHADOW", "INNER_SHADOW") and eff.get("visible", True):
sname = to_kebab_case(f"shadow-{node['name']}")
shadows[sname] = {
"x": (eff.get("offset") or {}).get("x", 0),
"y": (eff.get("offset") or {}).get("y", 0),
"blur": eff.get("radius", 0),
"spread": eff.get("spread", 0),
"color": rgba_to_hex(eff["color"]) if "color" in eff else "rgba(0,0,0,0.25)",
}
for child in node.get("children", []):
walk(child)
for frame in design_model.get("frames", []):
walk(frame)
for comp in design_model.get("components", []):
walk(comp)
return {
"colors": dict(sorted(colors.items())),
"typography": dict(sorted(typography.items())),
"spacing": {f"spacing-{int(v)}": v for v in sorted(spacing)},
"radii": {f"radius-{int(v)}": v for v in sorted(radii)},
"shadows": dict(sorted(shadows.items())),
}
# ---------------------------------------------------------------------------
# Variant extraction
# ---------------------------------------------------------------------------
def extract_variants(file_data: dict) -> list:
variants = []
for cs_id, cs_meta in sorted(file_data.get("componentSets", {}).items()):
variants.append({
"id": stable_id(cs_meta.get("name", ""), cs_id),
"name": cs_meta.get("name", ""),
"figmaNodeId": cs_id,
"properties": {},
"variants": [],
})
return variants
# ---------------------------------------------------------------------------
# Code generation helpers
# ---------------------------------------------------------------------------
def _figma_color(color: dict, fill_opacity: float = 1.0) -> str:
"""Convert Figma color dict to rgba string."""
r = round(color.get("r", 0) * 255)
g = round(color.get("g", 0) * 255)
b = round(color.get("b", 0) * 255)
a = color.get("a", 1.0) * fill_opacity
if a >= 1.0:
return f"#{r:02x}{g:02x}{b:02x}"
return f"rgba({r}, {g}, {b}, {a:.2f})"
def _style_key(name: str, used: set) -> str:
"""Make a unique camelCase style key."""
camel = to_camel_case(name)
if not camel:
camel = "Item"
key = camel[0].lower() + camel[1:]
# Sanitize: only allow valid JS identifier chars
key = re.sub(r"[^a-zA-Z0-9]", "", key)
if not key or key[0].isdigit():
key = "s" + key
base = key
i = 2
while key in used:
key = f"{base}{i}"
i += 1
used.add(key)
return key
def _get_bg_color(props: dict) -> str | None:
"""Get background color from fills."""
for fill in props.get("fills", []):
if not fill.get("visible", True):
continue
if fill.get("type") == "SOLID" and fill.get("color"):
return _figma_color(fill["color"], fill.get("opacity", 1.0))
if fill.get("type") in ("GRADIENT_LINEAR", "GRADIENT_RADIAL") and fill.get("color"):
return _figma_color(fill["color"], fill.get("opacity", 1.0))
return None
def _has_image_fill(props: dict) -> str | None:
"""Return imageRef if node has an image fill."""
for fill in props.get("fills", []):
if fill.get("type") == "IMAGE" and fill.get("visible", True) and fill.get("imageRef"):
return fill["imageRef"]
return None
def _get_scale_mode(props: dict) -> str:
for fill in props.get("fills", []):
if fill.get("type") == "IMAGE":
mode = fill.get("scaleMode", "FILL")
return "contain" if mode == "FIT" else "cover"
return "cover"
def _get_stroke_styles(props: dict) -> dict:
styles = {}
sw = props.get("strokeWeight", 0)
if sw and sw > 0:
for s in props.get("strokes", []):
if s.get("visible", True) and s.get("color"):
styles["borderWidth"] = sw
styles["borderColor"] = f'"{_figma_color(s["color"], s.get("opacity", 1.0))}"'
break
return styles
def _get_shadow_styles(props: dict) -> dict:
styles = {}
for eff in props.get("effects", []):
if eff.get("type") == "DROP_SHADOW" and eff.get("visible", True):
c = eff.get("color", {})
ox = (eff.get("offset") or {}).get("x", 0)
oy = (eff.get("offset") or {}).get("y", 0)
r = eff.get("radius", 0)
a = c.get("a", 0.25)
styles["shadowColor"] = f'"rgba({round(c.get("r",0)*255)}, {round(c.get("g",0)*255)}, {round(c.get("b",0)*255)}, 1)"'
styles["shadowOffset"] = f'{{ width: {ox}, height: {oy} }}'
styles["shadowOpacity"] = round(a, 2)
styles["shadowRadius"] = round(r / 2, 1)
styles["elevation"] = max(1, round(r / 2))
break
return styles
def _border_radius_styles(props: dict) -> dict:
styles = {}
radii = props.get("cornerRadii")
cr = props.get("cornerRadius")
if radii:
if radii["topLeft"] == radii["topRight"] == radii["bottomRight"] == radii["bottomLeft"]:
if radii["topLeft"] > 0:
styles["borderRadius"] = radii["topLeft"]
else:
for k, rn_k in [("topLeft", "borderTopLeftRadius"), ("topRight", "borderTopRightRadius"),
("bottomRight", "borderBottomRightRadius"), ("bottomLeft", "borderBottomLeftRadius")]:
if radii[k] > 0:
styles[rn_k] = radii[k]
elif cr and cr > 0:
styles["borderRadius"] = cr
return styles
# ---------------------------------------------------------------------------
# Recursive RN component generation
# ---------------------------------------------------------------------------
class RNGenerator:
"""Generates a React Native component from a normalized Figma node tree."""
def __init__(self, file_key: str):
self.file_key = file_key
self.style_defs: dict[str, dict] = {} # style_key -> style dict
self.style_keys = set()
self.image_nodes: dict[str, dict] = {} # imageRef -> {node_id, style_key, scale_mode}
self.needs_image = False
self.needs_scroll = False
self.needs_text = False
def generate(self, root_node: dict) -> str:
comp_name = to_camel_case(root_node["name"])
if not comp_name:
comp_name = "Screen"
# Generate JSX tree
jsx = self._render_node(root_node, parent_has_autolayout=False, parent_bbox=None, is_root=True, depth=2)
# Build imports
rn_imports = ["View", "StyleSheet"]
if self.needs_text:
rn_imports.insert(1, "Text")
if self.needs_image:
rn_imports.insert(1, "Image")
if self.needs_scroll:
rn_imports.append("ScrollView")
rn_imports_str = ", ".join(rn_imports)
# Build styles
style_lines = []
for key, sdict in self.style_defs.items():
entries = []
for sk, sv in sdict.items():
if isinstance(sv, str) and not sv.startswith('"') and not sv.startswith("'") and not sv.startswith("{"):
entries.append(f' {sk}: "{sv}"')
elif isinstance(sv, str) and (sv.startswith("{") or sv.startswith('"')):
entries.append(f" {sk}: {sv}")
elif isinstance(sv, bool):
entries.append(f" {sk}: {'true' if sv else 'false'}")
elif isinstance(sv, float):
# Clean float
if sv == int(sv):
entries.append(f" {sk}: {int(sv)}")
else:
entries.append(f" {sk}: {sv}")
else:
entries.append(f" {sk}: {sv}")
style_lines.append(f" {key}: {{\n" + ",\n".join(entries) + ",\n }")
styles_str = ",\n".join(style_lines)
# Determine if content is tall enough to need ScrollView
root_props = root_node.get("properties", {})
root_h = root_props.get("height", 0)
wrap_scroll = root_h > 900
self.needs_scroll = wrap_scroll
# Rebuild imports if scroll needed
if wrap_scroll and "ScrollView" not in rn_imports:
rn_imports.append("ScrollView")
rn_imports_str = ", ".join(rn_imports)
indent = " "
if wrap_scroll:
body = f'{indent}<ScrollView style={{styles.root}} contentContainerStyle={{{{ flexGrow: 1 }}}}>\n{jsx}\n{indent}</ScrollView>'
else:
body = jsx
return f'''import React from "react";
import {{ {rn_imports_str} }} from "react-native";
export const {comp_name}: React.FC = () => {{
return (
{body}
);
}};
const styles = StyleSheet.create({{
{styles_str}
}});
export default {comp_name};
'''
def _render_node(self, node: dict, parent_has_autolayout: bool, parent_bbox: dict | None,
is_root: bool, depth: int) -> str:
ntype = node.get("type", "UNKNOWN")
props = node.get("properties", {})
# Skip invisible nodes
if not props.get("visible", True):
return ""
# Skip complex vector types
if ntype in ("VECTOR", "BOOLEAN_OPERATION"):
# TODO: Support VECTOR/BOOLEAN_OPERATION nodes (SVG paths)
return ""
if ntype == "TEXT":
return self._render_text(node, parent_has_autolayout, parent_bbox, depth)
# FRAME, GROUP, RECTANGLE, ELLIPSE, INSTANCE, COMPONENT, LINE, etc → View
if ntype == "LINE":
return self._render_line(node, parent_has_autolayout, parent_bbox, depth)
# Check for image fill
image_ref = _has_image_fill(props)
if image_ref:
return self._render_image(node, image_ref, parent_has_autolayout, parent_bbox, depth)
# Regular container (View)
return self._render_view(node, parent_has_autolayout, parent_bbox, is_root, depth)
def _build_layout_style(self, node: dict, parent_has_autolayout: bool, parent_bbox: dict | None,
is_root: bool = False) -> dict:
"""Build the style dict for a node's layout properties."""
props = node.get("properties", {})
ntype = node.get("type", "UNKNOWN")
style = {}
has_al = "autoLayout" in props
al = props.get("autoLayout")
# --- Sizing ---
sizing_h = props.get("layoutSizingHorizontal", "FIXED")
sizing_v = props.get("layoutSizingVertical", "FIXED")
w = props.get("width", 0)
h = props.get("height", 0)
if parent_has_autolayout:
if sizing_h == "FILL":
style["flex"] = 1
elif sizing_h == "FIXED" and w:
style["width"] = round(w)
# HUG = no explicit width
if sizing_v == "FILL" and "flex" not in style:
style["flex"] = 1
elif sizing_v == "FIXED" and h:
style["height"] = round(h)
else:
if is_root:
if w:
style["width"] = round(w)
if h:
style["height"] = round(h)
else:
# Absolute positioning relative to parent
style["position"] = '"absolute"'
if parent_bbox and props.get("x") is not None:
style["left"] = round(props["x"] - parent_bbox.get("x", 0))
style["top"] = round(props["y"] - parent_bbox.get("y", 0))
if w:
style["width"] = round(w)
if h:
style["height"] = round(h)
# Override: layoutPositioning ABSOLUTE inside auto-layout parent
if parent_has_autolayout and props.get("layoutPositioning") == "ABSOLUTE":
style["position"] = '"absolute"'
if parent_bbox and props.get("x") is not None:
style["left"] = round(props["x"] - parent_bbox.get("x", 0))
style["top"] = round(props["y"] - parent_bbox.get("y", 0))
if w:
style["width"] = round(w)
if h:
style["height"] = round(h)
# --- Auto-layout (flexbox) ---
if has_al and al:
mode = al.get("mode", "VERTICAL")
if mode == "HORIZONTAL":
style["flexDirection"] = '"row"'
else:
style["flexDirection"] = '"column"'
justify_map = {"MIN": '"flex-start"', "CENTER": '"center"', "MAX": '"flex-end"',
"SPACE_BETWEEN": '"space-between"'}
align_map = {"MIN": '"flex-start"', "CENTER": '"center"', "MAX": '"flex-end"'}
j = justify_map.get(al.get("primaryAxisAlignItems", "MIN"))
if j and j != '"flex-start"':
style["justifyContent"] = j
a = align_map.get(al.get("counterAxisAlignItems", "MIN"))
if a and a != '"flex-start"':
style["alignItems"] = a
gap = al.get("itemSpacing", 0)
if gap > 0:
style["gap"] = gap
pt, pr, pb, pl = al.get("paddingTop", 0), al.get("paddingRight", 0), al.get("paddingBottom", 0), al.get("paddingLeft", 0)
if pt == pr == pb == pl and pt > 0:
style["padding"] = pt
else:
if pt > 0: style["paddingTop"] = pt
if pr > 0: style["paddingRight"] = pr
if pb > 0: style["paddingBottom"] = pb
if pl > 0: style["paddingLeft"] = pl
return style
def _build_visual_style(self, props: dict, ntype: str) -> dict:
"""Build visual styles (colors, borders, shadows, opacity)."""
style = {}
bg = _get_bg_color(props)
if bg:
style["backgroundColor"] = f'"{bg}"'
style.update({k: (f'"{v}"' if isinstance(v, str) and not v.startswith('"') else v)
for k, v in _border_radius_styles(props).items()})
if ntype == "ELLIPSE":
w = props.get("width", 0)
if w:
style["borderRadius"] = round(w / 2)
style.update(_get_stroke_styles(props))
style.update(_get_shadow_styles(props))
opacity = props.get("opacity", 1.0)
if opacity < 1.0:
style["opacity"] = round(opacity, 2)
if props.get("clipsContent"):
style["overflow"] = '"hidden"'
return style
def _render_view(self, node: dict, parent_has_autolayout: bool, parent_bbox: dict | None,
is_root: bool, depth: int) -> str:
props = node.get("properties", {})
ntype = node.get("type", "UNKNOWN")
has_al = "autoLayout" in props
# Build style
layout_style = self._build_layout_style(node, parent_has_autolayout, parent_bbox, is_root)
visual_style = self._build_visual_style(props, ntype)
combined = {**layout_style, **visual_style}
style_key = _style_key(node["name"], self.style_keys)
if is_root:
style_key = "root"
self.style_keys.add("root")
self.style_defs[style_key] = combined
# Render children
children_jsx = []
child_bbox = {"x": props.get("x", 0), "y": props.get("y", 0)}
for child in node.get("children", []):
child_jsx = self._render_node(child, parent_has_autolayout=has_al,
parent_bbox=child_bbox, is_root=False, depth=depth + 1)
if child_jsx:
children_jsx.append(child_jsx)
pad = " " * depth
if children_jsx:
inner = "\n".join(children_jsx)
return f'{pad}<View style={{styles.{style_key}}}>\n{inner}\n{pad}</View>'
else:
return f'{pad}<View style={{styles.{style_key}}} />'
def _render_text(self, node: dict, parent_has_autolayout: bool, parent_bbox: dict | None, depth: int) -> str:
self.needs_text = True
props = node.get("properties", {})
ts = props.get("textStyle", {})
text = props.get("textContent", "")
style = {}
# Layout
sizing_h = props.get("layoutSizingHorizontal", "FIXED")
sizing_v = props.get("layoutSizingVertical", "FIXED")
w = props.get("width", 0)
if parent_has_autolayout:
if sizing_h == "FILL":
style["flex"] = 1
elif sizing_h == "FIXED" and w:
style["width"] = round(w)
else:
style["position"] = '"absolute"'
if parent_bbox and props.get("x") is not None:
style["left"] = round(props["x"] - parent_bbox.get("x", 0))
style["top"] = round(props["y"] - parent_bbox.get("y", 0))
if w:
style["width"] = round(w)
if parent_has_autolayout and props.get("layoutPositioning") == "ABSOLUTE":
style["position"] = '"absolute"'
if parent_bbox and props.get("x") is not None:
style["left"] = round(props["x"] - parent_bbox.get("x", 0))
style["top"] = round(props["y"] - parent_bbox.get("y", 0))
# Text styles
if ts.get("fontSize"):
style["fontSize"] = ts["fontSize"]
fw = ts.get("fontWeight", 400)
if fw and fw != 400:
fw_map = {100: '"100"', 200: '"200"', 300: '"300"', 500: '"500"',
600: '"600"', 700: '"bold"', 800: '"800"', 900: '"900"'}
style["fontWeight"] = fw_map.get(fw, f'"{fw}"')
if ts.get("fontFamily"):
style["fontFamily"] = f'"{ts["fontFamily"]}"'
align_map = {"LEFT": '"left"', "CENTER": '"center"', "RIGHT": '"right"', "JUSTIFIED": '"justify"'}
ta = align_map.get(ts.get("textAlignHorizontal", "LEFT"))
if ta and ta != '"left"':
style["textAlign"] = ta
lh = ts.get("lineHeight")
if lh and lh != "auto" and isinstance(lh, (int, float)) and lh > 0:
style["lineHeight"] = round(lh, 1)
ls = ts.get("letterSpacing", 0)
if ls and ls != 0:
style["letterSpacing"] = round(ls, 2)
# Text color from fills
for fill in props.get("fills", []):
if fill.get("visible", True) and fill.get("color"):
style["color"] = f'"{_figma_color(fill["color"], fill.get("opacity", 1.0))}"'
break
opacity = props.get("opacity", 1.0)
if opacity < 1.0:
style["opacity"] = round(opacity, 2)
td = ts.get("textDecoration", "NONE")
if td == "UNDERLINE":
style["textDecorationLine"] = '"underline"'
elif td == "STRIKETHROUGH":
style["textDecorationLine"] = '"line-through"'
tc = ts.get("textCase", "ORIGINAL")
if tc == "UPPER":
style["textTransform"] = '"uppercase"'
elif tc == "LOWER":
style["textTransform"] = '"lowercase"'
elif tc == "TITLE":
style["textTransform"] = '"capitalize"'
style_key = _style_key(node["name"], self.style_keys)
self.style_defs[style_key] = style
# Escape text for JSX
escaped = text.replace("{", "{").replace("}", "}").replace("<", "<").replace(">", ">")
# For multi-line, keep as-is (React Native Text handles newlines)
pad = " " * depth
if "\n" in escaped:
return f'{pad}<Text style={{styles.{style_key}}}>{{\n{pad} `{text}`\n{pad}}}</Text>'
return f'{pad}<Text style={{styles.{style_key}}}>{escaped}</Text>'
def _render_line(self, node: dict, parent_has_autolayout: bool, parent_bbox: dict | None, depth: int) -> str:
props = node.get("properties", {})
style = {}
w = props.get("width", 0)
if parent_has_autolayout:
style["alignSelf"] = '"stretch"'
else:
style["position"] = '"absolute"'
if parent_bbox and props.get("x") is not None:
style["left"] = round(props["x"] - parent_bbox.get("x", 0))
style["top"] = round(props["y"] - parent_bbox.get("y", 0))
if w:
style["width"] = round(w)
style["height"] = 1
bg = _get_bg_color(props)
if bg:
style["backgroundColor"] = f'"{bg}"'
else:
# Use stroke color for lines
for s in props.get("strokes", []):
if s.get("visible", True) and s.get("color"):
style["backgroundColor"] = f'"{_figma_color(s["color"], s.get("opacity", 1.0))}"'
break
style_key = _style_key(node["name"], self.style_keys)
self.style_defs[style_key] = style
pad = " " * depth
return f'{pad}<View style={{styles.{style_key}}} />'
def _render_image(self, node: dict, image_ref: str, parent_has_autolayout: bool,
parent_bbox: dict | None, depth: int) -> str:
self.needs_image = True
props = node.get("properties", {})
style = {}
w = props.get("width", 0)
h = props.get("height", 0)
if parent_has_autolayout:
sizing_h = props.get("layoutSizingHorizontal", "FIXED")
if sizing_h == "FILL":
style["flex"] = 1
elif w:
style["width"] = round(w)
if h:
style["height"] = round(h)
else:
style["position"] = '"absolute"'
if parent_bbox and props.get("x") is not None:
style["left"] = round(props["x"] - parent_bbox.get("x", 0))
style["top"] = round(props["y"] - parent_bbox.get("y", 0))
if w:
style["width"] = round(w)
if h:
style["height"] = round(h)
style.update({k: (f'"{v}"' if isinstance(v, str) and not v.startswith('"') else v)
for k, v in _border_radius_styles(props).items()})
style_key = _style_key(node["name"], self.style_keys)
self.style_defs[style_key] = style
safe_name = re.sub(r"[^a-zA-Z0-9]", "_", node["name"]).strip("_").lower()
scale_mode = _get_scale_mode(props)
self.image_nodes[image_ref] = {
"node_id": node["figmaNodeId"],
"style_key": style_key,
"safe_name": safe_name,
"scale_mode": scale_mode,
}
pad = " " * depth
return f'{pad}<Image source={{require("../assets/{safe_name}.png")}} style={{styles.{style_key}}} resizeMode="{scale_mode}" />'
def collect_image_refs(self) -> dict:
"""Return {imageRef: {node_id, safe_name}} for downloading."""
return self.image_nodes
# ---------------------------------------------------------------------------
# Image downloading
# ---------------------------------------------------------------------------
def download_images(file_key: str, image_nodes: dict, output_dir: Path):
"""Download image fills from Figma and save to assets/."""
if not image_nodes:
return
assets_dir = output_dir / "assets"
assets_dir.mkdir(parents=True, exist_ok=True)
# Collect node IDs that have image fills
node_ids = [info["node_id"] for info in image_nodes.values()]
if not node_ids:
return
ids_str = ",".join(node_ids)
logger.info("Exporting %d images from Figma...", len(node_ids))
try:
resp = api_get(f"/v1/images/{file_key}", file_key=file_key,
params={"ids": ids_str, "format": "png", "scale": "2"},
use_cache=False)
images = resp.get("images", {})
hdrs = {"X-Figma-Token": get_token()}
for ref, info in image_nodes.items():
url = images.get(info["node_id"])
if url:
try:
img_resp = requests.get(url, timeout=30)
img_resp.raise_for_status()
out_path = assets_dir / f"{info['safe_name']}.png"
out_path.write_bytes(img_resp.content)
logger.info("Saved image: %s", out_path)
except Exception as e:
logger.warning("Failed to download image %s: %s", info["safe_name"], e)
except Exception as e:
logger.warning("Failed to export images: %s", e)
# ---------------------------------------------------------------------------
# Code generation (top-level)
# ---------------------------------------------------------------------------
def generate_code_plan(design_model: dict, platform: str) -> dict:
plan = {"platform": platform, "components": []}
for comp in design_model.get("components", []):
comp_name = to_camel_case(comp["name"])
plan["components"].append({
"componentName": comp_name,
"figmaNodeId": comp["figmaNodeId"],
"filePath": f"components/{comp_name}.tsx",
"stableId": comp["id"],
})
for frame in design_model.get("frames", []):
comp_name = to_camel_case(frame["name"])
if platform == "rn-expo":
plan["components"].append({
"componentName": comp_name,
"figmaNodeId": frame["figmaNodeId"],
"filePath": f"screens/{comp_name}Screen.tsx",
"stableId": frame["id"],
})
else:
plan["components"].append({
"componentName": comp_name,
"figmaNodeId": frame["figmaNodeId"],
"filePath": f"pages/{comp_name}Page.tsx",
"stableId": frame["id"],
})
return plan
def generate_rn_component(node: dict, tokens: dict, file_key: str = "") -> tuple[str, dict]:
"""Generate a React Native Expo TypeScript component. Returns (code, image_refs)."""
gen = RNGenerator(file_key)
code = gen.generate(node)
return code, gen.collect_image_refs()
def generate_web_component(node: dict, tokens: dict) -> str:
"""Generate a React + Tailwind component (basic, unchanged)."""
comp_name = to_camel_case(node["name"])
return f'''import React from "react";
export const {comp_name}: React.FC = () => {{
return <div>{{/* TODO: web component */}}</div>;
}};
export default {comp_name};
'''
# ---------------------------------------------------------------------------
# Asset extraction
# ---------------------------------------------------------------------------
def extract_assets(file_data: dict) -> list:
assets = []
for comp_id, comp_meta in sorted(file_data.get("components", {}).items()):
assets.append({
"id": stable_id(comp_meta.get("name", ""), comp_id),
"name": comp_meta.get("name", ""),
"figmaNodeId": comp_id,
"exportSettings": [{"format": "PNG", "scale": 2}],
})
return assets
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def pull(file_key: str, node_ids: list = None, platform: str = "rn-expo", output_dir: str = "./out"):
out = Path(output_dir)
out.mkdir(parents=True, exist_ok=True)
# Fetch file data
if node_ids:
ids_str = ",".join(node_ids)
logger.info("Fetching nodes %s from file %s", ids_str, file_key)
data = api_get(f"/v1/files/{file_key}/nodes", file_key=file_key, params={"ids": ids_str})
nodes_data = data.get("nodes", {})
children = []
for nid, ninfo in sorted(nodes_data.items()):
doc = ninfo.get("document")
if doc:
children.append(doc)
file_data = data
document = {"children": children, "type": "DOCUMENT", "name": "Partial", "id": "0:0"}
else:
logger.info("Fetching full file %s", file_key)
data = api_get(f"/v1/files/{file_key}", file_key=file_key)
file_data = data
document = data.get("document", {})
file_name = data.get("name", file_key)
last_modified = data.get("lastModified", "")
# Normalize tree
frames = []
components = []
if node_ids:
# When fetching specific nodes, each document IS the frame itself
for page in document.get("children", []):
if not isinstance(page, dict):
continue
normalized = normalize_node(page)
ctype = page.get("type", "")
if ctype in ("COMPONENT", "COMPONENT_SET"):
components.append(normalized)
else:
frames.append(normalized)
else:
for page in document.get("children", []):
for child in page.get("children", []) if page.get("children") else []:
if not isinstance(child, dict):
continue
normalized = normalize_node(child)
ctype = child.get("type", "")
if ctype in ("COMPONENT", "COMPONENT_SET"):
components.append(normalized)
else:
frames.append(normalized)
# Extract styles
styles = extract_styles(file_data)
variants = extract_variants(file_data)
assets = extract_assets(file_data)
design_model = {
"version": "1.0.0",
"fileKey": file_key,
"fileName": file_name,
"lastModified": last_modified,
"frames": frames,
"components": components,
"variants": variants,
"textStyles": styles["textStyles"],
"colorStyles": styles["colorStyles"],
"effectStyles": styles["effectStyles"],
"assets": assets,
}
tokens = extract_tokens(design_model)
design_model["tokens"] = tokens
write_json(out / "designModel.json", design_model)
write_json(out / "tokens.json", tokens)
code_plan = generate_code_plan(design_model, platform)
write_json(out / "codePlan.json", code_plan)
# Generate component files
all_image_refs = {}
for entry in code_plan["components"]:
node = None
for n in components + frames:
if n["figmaNodeId"] == entry["figmaNodeId"]:
node = n
break
if node:
if platform == "rn-expo":
code, img_refs = generate_rn_component(node, tokens, file_key)
all_image_refs.update(img_refs)
else:
code = generate_web_component(node, tokens)
file_path = out / entry["filePath"]
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(code)
logger.info("Generated %s", file_path)
# Download images
if all_image_refs:
download_images(file_key, all_image_refs, out)
logger.info("Pull complete. Output in %s", out)
return design_model
def main():
parser = argparse.ArgumentParser(description="Pull Figma file and generate code")
parser.add_argument("--file-key", required=True, help="Figma file key")
parser.add_argument("--node-ids", help="Comma-separated node IDs (optional)")
parser.add_argument("--platform", choices=["rn-expo", "web-react"], default="rn-expo")
parser.add_argument("--output-dir", default="./out")
parser.add_argument("--verbose", "-v", action="store_true")
args = parser.parse_args()
setup_logging(args.verbose)
node_ids = args.node_ids.split(",") if args.node_ids else None
pull(args.file_key, node_ids, args.platform, args.output_dir)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Push changes to Figma. Generates plugin-compatible operation specs.
IMPORTANT: The Figma REST API is read-only for file content. This script:
1. Validates operations against the current file state
2. Generates a plugin-compatible operations spec
3. For REST-supported operations (variables, comments), executes directly
4. Logs all operations for audit trail
Actual node mutations require the figma-sync companion plugin running in Figma.
"""
import argparse
import json
import logging
import sys
from datetime import datetime, timezone
from pathlib import Path
from figma_common import api_get, setup_logging, write_json
logger = logging.getLogger("figma-sync.push")
SUPPORTED_OPS = {
"setText", "setFill", "setAutoLayout", "createComponent",
"updateVariant", "createFrame", "updateStyle",
}
# Operations that can be done via REST API
REST_OPS = set() # Currently none for node mutations — all require plugin
def validate_operation(op: dict) -> list:
"""Validate a single operation. Returns list of warnings."""
warnings = []
op_type = op.get("type", "")
if op_type not in SUPPORTED_OPS:
warnings.append(f"Unknown operation type: {op_type}")
if not op.get("nodeId") and op_type not in ("createComponent", "createFrame"):
warnings.append(f"Operation {op_type} missing nodeId")
return warnings
def validate_patch_spec(patch_spec: dict) -> tuple:
"""Validate entire patch spec. Returns (valid_ops, all_warnings)."""
ops = patch_spec.get("operations", [])
valid = []
all_warnings = []
for i, op in enumerate(ops):
warnings = validate_operation(op)
if warnings:
for w in warnings:
all_warnings.append(f"Op[{i}]: {w}")
valid.append(op)
return valid, all_warnings
def fetch_current_state(file_key: str, node_ids: list) -> dict:
"""Fetch current state of target nodes for before/after comparison."""
if not node_ids:
return {}
ids_str = ",".join(node_ids)
try:
data = api_get(f"/v1/files/{file_key}/nodes", file_key=file_key, params={"ids": ids_str})
return data.get("nodes", {})
except Exception as e:
logger.warning("Could not fetch current state: %s", e)
return {}
def describe_operation(op: dict) -> str:
"""Human-readable description of an operation."""
op_type = op.get("type", "unknown")
node_id = op.get("nodeId", "N/A")
if op_type == "setText":
return f"Set text on {node_id} to: {op.get('value', '')[:50]}"
elif op_type == "setFill":
return f"Set fill on {node_id} to: {op.get('value', {})}"
elif op_type == "setAutoLayout":
mode = op.get("value", {}).get("mode", "?")
return f"Set auto-layout on {node_id} to {mode}"
elif op_type == "createComponent":
return f"Create component: {op.get('name', 'unnamed')}"
elif op_type == "updateVariant":
return f"Update variant on {node_id}: {op.get('value', {})}"
elif op_type == "createFrame":
return f"Create frame: {op.get('name', 'unnamed')}"
elif op_type == "updateStyle":
return f"Update style on {node_id}: {op.get('styleType', '?')}"
return f"{op_type} on {node_id}"
def push(file_key: str, patch_spec_path: str, execute: bool = False, output_dir: str = "./out"):
out = Path(output_dir)
out.mkdir(parents=True, exist_ok=True)
# Load patch spec
patch_spec = json.loads(Path(patch_spec_path).read_text())
ops, warnings = validate_patch_spec(patch_spec)
# Gather node IDs for state comparison
node_ids = [op["nodeId"] for op in ops if op.get("nodeId")]
before_state = fetch_current_state(file_key, list(set(node_ids))) if node_ids else {}
results = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"fileKey": file_key,
"dryRun": not execute,
"totalOperations": len(ops),
"appliedOps": [],
"failures": [],
"warnings": warnings,
"changelog": [],
}
for i, op in enumerate(ops):
description = describe_operation(op)
entry = {
"index": i,
"type": op.get("type"),
"nodeId": op.get("nodeId"),
"description": description,
}
if execute:
if op.get("type") in REST_OPS:
# Execute REST-capable operations
try:
# Currently no node-mutation REST ops exist
entry["status"] = "applied"
results["appliedOps"].append(entry)
except Exception as e:
entry["status"] = "failed"
entry["error"] = str(e)
results["failures"].append(entry)
else:
# Plugin-required operation
entry["status"] = "pending_plugin"
entry["note"] = "Requires figma-sync companion plugin in Figma"
results["appliedOps"].append(entry)
else:
entry["status"] = "dry_run"
results["appliedOps"].append(entry)
results["changelog"].append({
"timestamp": datetime.now(timezone.utc).isoformat(),
"operation": description,
"status": entry["status"],
})
# Generate plugin-compatible spec
plugin_spec = {
"version": "1.0.0",
"fileKey": file_key,
"operations": ops,
"generatedAt": datetime.now(timezone.utc).isoformat(),
}
write_json(out / "pluginSpec.json", plugin_spec)
# Before/after summary
if before_state:
results["beforeState"] = {nid: _summarize_node(ndata) for nid, ndata in before_state.items()}
write_json(out / "pushResult.json", results)
# Summary
applied = len([o for o in results["appliedOps"] if o["status"] != "failed"])
failed = len(results["failures"])
mode = "EXECUTE" if execute else "DRY RUN"
logger.info("[%s] %d operations processed, %d applied, %d failed, %d warnings",
mode, len(ops), applied, failed, len(warnings))
if not execute:
logger.info("To apply changes, run with --execute flag")
logger.info("Plugin spec written to %s/pluginSpec.json — load in Figma plugin to apply", out)
return results
def _summarize_node(node_data: dict) -> dict:
"""Create a brief summary of a node for before/after comparison."""
doc = node_data.get("document", {})
return {
"name": doc.get("name"),
"type": doc.get("type"),
"characters": doc.get("characters"),
"fills": len(doc.get("fills", [])),
"children": len(doc.get("children", [])),
}
def main():
parser = argparse.ArgumentParser(description="Push changes to Figma")
parser.add_argument("--file-key", required=True, help="Figma file key")
parser.add_argument("--patch-spec", required=True, help="Path to patch spec JSON")
parser.add_argument("--execute", action="store_true", help="Actually apply (default is dry-run)")
parser.add_argument("--output-dir", default="./out")
parser.add_argument("--verbose", "-v", action="store_true")
args = parser.parse_args()
setup_logging(args.verbose)
push(args.file_key, args.patch_spec, args.execute, args.output_dir)
if __name__ == "__main__":
main()
Related skills
FAQ
What does figma-sync generate?
Pulling outputs designModel.json, tokens.json, codePlan.json, and generated component files for React Native Expo TS or React + Tailwind.
Is push destructive?
No; figma_push.py is dry-run by default and only applies changes when you pass the --execute flag.