#!/usr/bin/env python3 """ figma-extract-css.py — Extract data-driven CSS from Figma MCP get_design_context output. 避免反模式 (2026-05-13 retrospection lesson 11): - AI 估算 padding/margin 数值 → 累积误差 (v4 37px, v6-v8 12-15px) - AI approximate 重画 vector / icon → semantic 错误 - AI 把 designer annotations 渲染为 UI 元素 设计原则: - Figma 是 connected API (MCP), 不是图片 → 必从 metadata 取精确数值 - AI 不许估算 layout values, 全部从 Figma 数据来 - Annotation 节点自动过滤 (dashed stroke + 命名 convention + 浮于布局外) - Asset (logo/icon/image) 自动识别 + 生成 figma-asset-fetch.py 命令 Usage: python3 scripts/figma-extract-css.py \\ --design-context \\ [--metadata ] \\ --output-dir / \\ [--node-id 50:4190] [--file-key X] 工作流 (AI 调用): 1. mcp__claude_ai_Figma__get_design_context → save to /tmp/dc.txt 2. mcp__claude_ai_Figma__get_metadata → save to /tmp/md.xml (optional, for annotation filter) 3. python3 scripts/figma-extract-css.py \\ --design-context /tmp/dc.txt \\ --metadata /tmp/md.xml \\ --output-dir /path/to/sibling/ 4. Output: /extracted-css.css (per-element exact CSS values) /skeleton.html (HTML structure) /assets-manifest.json (assets needing figma-asset-fetch.py) /annotation-filtered.md (filtered annotations + reasons) 5. AI uses extracted-css.css as baseline (禁止重新估算 layout values) 6. AI runs figma-asset-fetch.py for each asset in manifest 7. AI 填补 colors / interactive states / complex logic 8. AI 跑 R8 v2 close-loop (visual-diff.py) """ import argparse import json import os import re import sys from dataclasses import dataclass, field from pathlib import Path from typing import Dict, List, Optional, Set, Tuple # ============================================================ # Tailwind utility class → CSS mapping # (Figma's get_design_context outputs React + Tailwind reference) # ============================================================ # Static utility classes (no value): exact lookup TAILWIND_STATIC: Dict[str, Dict[str, str]] = { # Display / box "flex": {"display": "flex"}, "block": {"display": "block"}, "inline-block": {"display": "inline-block"}, "inline-flex": {"display": "inline-flex"}, "grid": {"display": "grid"}, "inline-grid": {"display": "inline-grid"}, "contents": {"display": "contents"}, "hidden": {"display": "none"}, "content-stretch": {"align-content": "stretch"}, # Figma's content-stretch # Flex direction "flex-row": {"flex-direction": "row"}, "flex-col": {"flex-direction": "column"}, "flex-row-reverse": {"flex-direction": "row-reverse"}, "flex-col-reverse": {"flex-direction": "column-reverse"}, # Flex wrap "flex-wrap": {"flex-wrap": "wrap"}, "flex-nowrap": {"flex-wrap": "nowrap"}, # Align items "items-start": {"align-items": "flex-start"}, "items-end": {"align-items": "flex-end"}, "items-center": {"align-items": "center"}, "items-baseline": {"align-items": "baseline"}, "items-stretch": {"align-items": "stretch"}, # Justify content "justify-start": {"justify-content": "flex-start"}, "justify-end": {"justify-content": "flex-end"}, "justify-center": {"justify-content": "center"}, "justify-between": {"justify-content": "space-between"}, "justify-around": {"justify-content": "space-around"}, "justify-evenly": {"justify-content": "space-evenly"}, # Position "static": {"position": "static"}, "relative": {"position": "relative"}, "absolute": {"position": "absolute"}, "fixed": {"position": "fixed"}, "sticky": {"position": "sticky"}, # Sizing shortcuts "size-full": {"width": "100%", "height": "100%"}, "w-full": {"width": "100%"}, "h-full": {"height": "100%"}, "min-h-px": {"min-height": "1px"}, "min-h-screen": {"min-height": "100vh"}, "max-w-none": {"max-width": "none"}, # Overflow "overflow-hidden": {"overflow": "hidden"}, "overflow-clip": {"overflow": "clip"}, "overflow-visible": {"overflow": "visible"}, "overflow-auto": {"overflow": "auto"}, "overflow-scroll": {"overflow": "scroll"}, # Flex grow/shrink "flex-1": {"flex": "1 1 0%"}, "flex-auto": {"flex": "1 1 auto"}, "flex-none": {"flex": "none"}, "shrink-0": {"flex-shrink": "0"}, "shrink": {"flex-shrink": "1"}, "grow-0": {"flex-grow": "0"}, "grow": {"flex-grow": "1"}, # Font weight "font-thin": {"font-weight": "100"}, "font-light": {"font-weight": "300"}, "font-normal": {"font-weight": "400"}, "font-medium": {"font-weight": "500"}, "font-semibold": {"font-weight": "600"}, "font-bold": {"font-weight": "700"}, "font-extrabold": {"font-weight": "800"}, "font-black": {"font-weight": "900"}, # Font style "italic": {"font-style": "italic"}, "not-italic": {"font-style": "normal"}, # Text alignment "text-left": {"text-align": "left"}, "text-center": {"text-align": "center"}, "text-right": {"text-align": "right"}, "text-justify": {"text-align": "justify"}, # White space "whitespace-nowrap": {"white-space": "nowrap"}, "whitespace-pre": {"white-space": "pre"}, "whitespace-normal": {"white-space": "normal"}, # Cursor "cursor-pointer": {"cursor": "pointer"}, "cursor-default": {"cursor": "default"}, "cursor-not-allowed": {"cursor": "not-allowed"}, # Inset shortcuts "inset-0": {"inset": "0"}, "inset-x-0": {"left": "0", "right": "0"}, "inset-y-0": {"top": "0", "bottom": "0"}, # Border "border": {"border-width": "1px", "border-style": "solid"}, "rounded-none": {"border-radius": "0"}, "rounded-full": {"border-radius": "9999px"}, # Visibility "visible": {"visibility": "visible"}, "invisible": {"visibility": "hidden"}, # Box sizing "box-border": {"box-sizing": "border-box"}, "box-content": {"box-sizing": "content-box"}, # Object fit "object-cover": {"object-fit": "cover"}, "object-contain": {"object-fit": "contain"}, "object-fill": {"object-fit": "fill"}, } # Arbitrary value prefix → CSS property # Pattern: PREFIX-[VALUE] (e.g., w-[1000px], padding-[16px_140px]) TAILWIND_ARBITRARY: Dict[str, str] = { "w": "width", "h": "height", "min-w": "min-width", "min-h": "min-height", "max-w": "max-width", "max-h": "max-height", "size": "width-and-height", # special: applies to both # Padding "p": "padding", "px": "padding-inline", "py": "padding-block", "pt": "padding-top", "pr": "padding-right", "pb": "padding-bottom", "pl": "padding-left", "ps": "padding-inline-start", "pe": "padding-inline-end", # Margin "m": "margin", "mx": "margin-inline", "my": "margin-block", "mt": "margin-top", "mr": "margin-right", "mb": "margin-bottom", "ml": "margin-left", # Gap "gap": "gap", "gap-x": "column-gap", "gap-y": "row-gap", # Color "bg": "background", "text": "color", # text-[#fff] = color (but text-[16px] = font-size — handled below) "fill": "fill", "stroke": "stroke", # Border "border": "border-width", "rounded": "border-radius", # Position "top": "top", "right": "right", "bottom": "bottom", "left": "left", "inset": "inset", # Z-index "z": "z-index", # Opacity "opacity": "opacity", # Transform "rotate": "rotate", # CSS rotate property (not transform: rotate()) "scale": "scale", "translate-x": "--translate-x", # CSS variable approach for transform "translate-y": "--translate-y", # Font "font": "font-family", # font-['Helvetica:Regular',sans-serif] "leading": "line-height", "tracking": "letter-spacing", # Shadow "shadow": "box-shadow", } # ============================================================ # Annotation detection signals # ============================================================ ANNOTATION_NAME_PATTERNS = [ re.compile(r"^_annotation/", re.IGNORECASE), re.compile(r"^annotation[/-]", re.IGNORECASE), re.compile(r"\bmark\s*area\b", re.IGNORECASE), re.compile(r"\bnote[/-]", re.IGNORECASE), re.compile(r"\bspec/", re.IGNORECASE), # spec/highlight re.compile(r"\b批注\b"), # Chinese: annotation re.compile(r"\b标注\b"), # Chinese: marker re.compile(r"\bcallout\b", re.IGNORECASE), ] # ============================================================ # Data structures # ============================================================ @dataclass class FigmaNode: """In-memory representation of a JSX/Figma node.""" tag: str # 'div', 'p', 'img', 'svg', or PascalCase component name node_id: Optional[str] = None # data-node-id (e.g., "50:4190" or "I50:4197;1976:19339") name: Optional[str] = None # data-name (e.g., "Page Content") classes: List[str] = field(default_factory=list) # parsed Tailwind classes inline_style: Dict[str, str] = field(default_factory=dict) # from style={{ }} or style="..." attrs: Dict[str, str] = field(default_factory=dict) # other attrs (src, alt, etc.) text: Optional[str] = None # inner text content (for

, ) children: List["FigmaNode"] = field(default_factory=list) is_annotation: bool = False annotation_reason: Optional[str] = None def css_class_name(self) -> str: """Generate stable CSS class name from node_id + tag.""" if self.node_id: safe_id = self.node_id.replace(":", "-").replace(";", "_") return f"figma-{safe_id}" return f"figma-{self.tag}-unnamed" # ============================================================ # Tailwind class parsing # ============================================================ def parse_tailwind_class(cls: str) -> Dict[str, str]: """Convert one Tailwind class → dict of CSS property:value. Returns empty dict if not recognized. """ if not cls.strip(): return {} # Negative prefix (e.g., -translate-x-1/2, -mt-4) negative = cls.startswith("-") if negative: cls = cls[1:] # Static utility — direct lookup if cls in TAILWIND_STATIC: return dict(TAILWIND_STATIC[cls]) # Arbitrary value: PREFIX-[VALUE] m = re.match(r"^([a-z\-]+?)-\[(.+)\]$", cls) if m: prefix, value = m.group(1), m.group(2) # Tailwind uses _ for space in arbitrary values (e.g., padding-[16px_140px]) value = value.replace("_", " ") # Negative prefix → prepend - to numeric values if negative and re.match(r"^-?\d", value): value = "-" + value if not value.startswith("-") else value[1:] # Special: size-[X] → width + height if prefix == "size": return {"width": value, "height": value} # Special: text-[X] → either color (#hex / rgba) or font-size (Npx) if prefix == "text": if value.startswith("#") or value.startswith("rgb") or value.startswith("hsl"): return {"color": value} elif re.match(r"^\d+(\.\d+)?(px|rem|em|%)?$", value): return {"font-size": value} else: return {"color": value} # Special: font-[X] → font-family (Figma syntax: font-['Helvetica:Regular',sans-serif]) if prefix == "font": return {"font-family": value} # Special: rotate-[X] → transform: rotate(X) if prefix == "rotate": return {"transform": f"rotate({value})"} # Special: translate-x / translate-y → transform: translate(X, Y) if prefix == "translate-x": return {"transform": f"translateX({value})"} if prefix == "translate-y": return {"transform": f"translateY({value})"} # Standard mapping css_prop = TAILWIND_ARBITRARY.get(prefix) if css_prop: return {css_prop: value} # Unknown prefix — emit comment return {"/* tailwind-unknown */": f"{prefix}-[{value}]"} # Number-suffixed utilities (e.g., gap-4, p-2, mt-8) — Tailwind spacing scale n × 4px m = re.match(r"^([a-z\-]+?)-(\d+(?:\.\d+)?)$", cls) if m: prefix, num = m.group(1), float(m.group(2)) if prefix in TAILWIND_ARBITRARY: value = f"{num * 4}px" # Tailwind spacing scale: 1 unit = 4px if negative: value = "-" + value return {TAILWIND_ARBITRARY[prefix]: value} # Fraction (e.g., w-1/2, -translate-x-1/2) m = re.match(r"^([a-z\-]+?)-(\d+)/(\d+)$", cls) if m: prefix, num, denom = m.group(1), int(m.group(2)), int(m.group(3)) pct = num / denom * 100 if prefix == "translate-x": return {"transform": f"translateX({-pct if negative else pct}%)"} if prefix == "translate-y": return {"transform": f"translateY({-pct if negative else pct}%)"} if prefix in TAILWIND_ARBITRARY: return {TAILWIND_ARBITRARY[prefix]: f"{pct}%"} return {} def parse_class_string(class_string: str) -> Tuple[List[str], Dict[str, str]]: """Parse className string → (list of classes, dict of merged CSS). Multiple Tailwind classes are merged into one CSS dict (later classes win for same property). """ classes = class_string.strip().split() css: Dict[str, str] = {} transforms: List[str] = [] # accumulate transforms for cls in classes: result = parse_tailwind_class(cls) # Transforms compose, not overwrite if "transform" in result: transforms.append(result.pop("transform")) css.update(result) if transforms: css["transform"] = " ".join(transforms) return classes, css # ============================================================ # JSX parsing # ============================================================ # Regex to extract JSX tags (handles self-closing + paired) # This is best-effort for Figma's specific output format; not a full JSX parser. def parse_jsx_to_tree(jsx_source: str) -> Optional[FigmaNode]: """Parse Figma's React+Tailwind JSX output into FigmaNode tree. Handles Figma's typical output structure: - Multiple helper `function PascalCase()` declarations defined first - `export default function Component()` containing the root JSX - Helper components referenced as `` in root Strategy: parse ALL function declarations' return JSX. The export default becomes root; helpers are inlined into root tree (replacing `` references with the helper's actual children). This way the script captures complete structure (no info lost to component composition). """ funcs: Dict[str, FigmaNode] = {} root_name: Optional[str] = None # Find every `function NAME()` (with optional `export default`) declaration for m in re.finditer( r"(?Pexport\s+default\s+)?function\s+(?P\w+)\s*\([^)]*\)\s*\{", jsx_source, ): is_default = bool(m.group("export")) name = m.group("name") body_start = m.end() # Find this function's `return (` block return_m = re.search(r"return\s*\(", jsx_source[body_start:]) if not return_m: continue jsx_start = body_start + return_m.end() # Find function's closing brace via depth counting (bounds JSX search) depth = 1 i = body_start n = len(jsx_source) while i < n and depth > 0: ch = jsx_source[i] if ch == "{": depth += 1 elif ch == "}": depth -= 1 i += 1 func_body_end = i if jsx_start >= func_body_end: continue func_jsx = jsx_source[jsx_start:func_body_end] tree, _ = _parse_jsx_element(func_jsx, 0) if tree is None: continue funcs[name] = tree if is_default: root_name = name if not funcs: # Fallback: parse from first JSX (legacy behavior) start = jsx_source.find("<") if start < 0: return None body = _strip_trailing_js(jsx_source[start:]) tree, _ = _parse_jsx_element(body, 0) return tree # Determine root: prefer `export default`, else last declared function if root_name is None: root_name = list(funcs.keys())[-1] root = funcs.pop(root_name) # Inline helpers: replace child references with helper tree _inline_helpers(root, funcs) return root def _inline_helpers(node: FigmaNode, helpers: Dict[str, FigmaNode]) -> None: """In-place: replace PascalCase children with their helper tree's content. Preserves the INSTANCE's outer className/data attrs (positioning, size), adopts the HELPER's inner children + node_id/name (component identity). Recursive: helpers can reference other helpers. """ for child in node.children: if child.tag in helpers: helper_root = helpers[child.tag] # Adopt helper's children (the actual structure) child.children = list(helper_root.children) # Adopt helper's inline_style merged with instance's (instance wins on conflict) merged_style = dict(helper_root.inline_style) merged_style.update(child.inline_style) child.inline_style = merged_style # Adopt text if instance has none if not child.text and helper_root.text: child.text = helper_root.text # Adopt node_id/name if instance has none (component identity) if not child.node_id: child.node_id = helper_root.node_id if not child.name: child.name = helper_root.name # Tag stays as-is (still PascalCase, marks this as a component instance) # Recurse into all children (might have nested PascalCase) _inline_helpers(child, helpers) def _strip_trailing_js(body: str) -> str: """Best-effort: cut off JS code after the JSX root element.""" # We'll find the position of the root element's end by tag balancing depth = 0 in_tag = False self_closing = False i = 0 n = len(body) while i < n: c = body[i] if c == "<": if i + 1 < n and body[i + 1] == "/": depth -= 1 # find > and continue close = body.find(">", i) if close < 0: break if depth == 0: return body[: close + 1] i = close + 1 continue else: # opening tag close = body.find(">", i) if close < 0: break # check self-closing self_closing = body[close - 1] == "/" if not self_closing: depth += 1 else: if depth == 0: return body[: close + 1] i = close + 1 continue i += 1 return body # Tag opening pattern: or TAG_OPEN_RE = re.compile( r"<\s*([A-Za-z][A-Za-z0-9]*)\b" # tag name r"([^>]*?)" # attrs (non-greedy) r"(/)?\s*>", # > or /> ) def _parse_jsx_element(text: str, pos: int) -> Tuple[Optional[FigmaNode], int]: """Parse one JSX element starting at pos. Returns (node, new_pos).""" # Skip whitespace and JS expressions in braces (e.g., {imgGroup9}) while pos < len(text): if text[pos].isspace(): pos += 1 elif text[pos] == "{": # Skip {expression} depth = 1 pos += 1 while pos < len(text) and depth > 0: if text[pos] == "{": depth += 1 elif text[pos] == "}": depth -= 1 pos += 1 elif text[pos] == "<": break else: pos += 1 if pos >= len(text) or text[pos] != "<": return None, pos m = TAG_OPEN_RE.match(text, pos) if not m: return None, pos + 1 tag_name = m.group(1) attrs_str = m.group(2) or "" self_closing = m.group(3) == "/" end_of_open = m.end() node = FigmaNode(tag=tag_name) _parse_attributes(attrs_str, node) pos = end_of_open if self_closing: return node, pos # Parse children until matching close_tag = f"" while pos < len(text): # Look for closing tag at this position if text[pos : pos + len(close_tag)] == close_tag: pos += len(close_tag) break # Try to parse a child element if text[pos] == "<": child, new_pos = _parse_jsx_element(text, pos) if child: node.children.append(child) pos = new_pos continue # Otherwise treat as text content # Find next < or end next_tag = text.find("<", pos) if next_tag < 0: next_tag = len(text) text_chunk = text[pos:next_tag] # Skip pure whitespace text if text_chunk.strip(): # Strip { } around JS expressions cleaned = _clean_text_content(text_chunk) if cleaned: if node.text: node.text += " " + cleaned else: node.text = cleaned pos = next_tag return node, pos # Attribute pattern: NAME="VALUE" or NAME={VALUE} ATTR_RE = re.compile( r"([A-Za-z][A-Za-z0-9\-]*)\s*=\s*" r"(?:\"([^\"]*)\"|\{([^}]*)\})" ) def _parse_attributes(attrs_str: str, node: FigmaNode) -> None: """Extract className, data-node-id, data-name, and other attributes.""" for m in ATTR_RE.finditer(attrs_str): name = m.group(1) str_val = m.group(2) expr_val = m.group(3) value = str_val if str_val is not None else expr_val if name == "className": if str_val is not None: # Static className="..." classes, css = parse_class_string(str_val) node.classes = classes node.inline_style.update(css) elif expr_val is not None: # Dynamic className={...} — try to extract literal string fallback # Pattern: className || "...fallback..." fb = re.search(r'"([^"]*)"', expr_val) if fb: classes, css = parse_class_string(fb.group(1)) node.classes = classes node.inline_style.update(css) elif name == "data-node-id": node.node_id = value elif name == "data-name": node.name = value elif name == "src": # img src={varName} — store as var ref node.attrs["src"] = expr_val if expr_val else (str_val or "") elif name == "style": # style={{ key: value, ... }} — best-effort parse if expr_val: _parse_inline_style_object(expr_val, node) else: node.attrs[name] = value or "" def _parse_inline_style_object(expr: str, node: FigmaNode) -> None: """Best-effort parse of style={{ key: value, ... }} object literal.""" # Strip outer { } if present expr = expr.strip() if expr.startswith("{"): expr = expr[1:] if expr.endswith("}"): expr = expr[:-1] # Match: key: 'value' or key: "value" or key: value for m in re.finditer( r"([A-Za-z][A-Za-z0-9]*)\s*:\s*(?:['\"]([^'\"]+)['\"]|([^,}]+))", expr, ): key = m.group(1) val = (m.group(2) or m.group(3) or "").strip() # Convert camelCase → kebab-case css_key = re.sub(r"([A-Z])", r"-\1", key).lower() node.inline_style[css_key] = val def _clean_text_content(text: str) -> str: """Strip JS expression braces from text content like {`Quad Link 4K `}.""" text = text.strip() # Pattern: {`...`} or {"..."} or {'...'} m = re.match(r"^\{[`'\"](.*)[`'\"]\}$", text, re.DOTALL) if m: return m.group(1).strip() # Pattern: just braces {expr} — skip (not literal text) if text.startswith("{") and text.endswith("}"): return "" return text # ============================================================ # Annotation detection # ============================================================ def detect_annotations(tree: FigmaNode, metadata_xml: Optional[str] = None) -> None: """Mark annotation nodes in tree (sets is_annotation + annotation_reason).""" # Build set of node IDs with stroke-dasharray (from metadata XML if provided) dashed_ids: Set[str] = set() if metadata_xml: dashed_ids = _extract_dashed_node_ids(metadata_xml) _mark_annotations_recursive(tree, dashed_ids) def _extract_dashed_node_ids(metadata_xml: str) -> Set[str]: """Find node IDs with stroke dash patterns in Figma metadata XML.""" dashed: Set[str] = set() # Look for elements containing dash-related attributes # Figma metadata XML format varies; this is best-effort for m in re.finditer( r'id="([^"]+)"[^>]*(?:dash|strokeDashes|dashGap)', metadata_xml, ): dashed.add(m.group(1)) return dashed def _mark_annotations_recursive(node: FigmaNode, dashed_ids: Set[str]) -> None: # Check name patterns if node.name: for pat in ANNOTATION_NAME_PATTERNS: if pat.search(node.name): node.is_annotation = True node.annotation_reason = f"name matches /{pat.pattern}/" break # Check dashed stroke (from metadata) if not node.is_annotation and node.node_id and node.node_id in dashed_ids: node.is_annotation = True node.annotation_reason = "node has stroke-dasharray (Figma metadata)" # Recurse for child in node.children: _mark_annotations_recursive(child, dashed_ids) # ============================================================ # Asset extraction # ============================================================ def extract_assets(jsx_source: str, tree: FigmaNode) -> List[Dict]: """Find image/asset URLs referenced in JSX, plus icon/logo nodes. Returns list of asset dicts for assets-manifest.json. """ assets = [] # 1. Top-level const declarations: const imgX = "URL"; for m in re.finditer( r'^\s*const\s+(\w+)\s*=\s*"(https?://[^"]+)"', jsx_source, re.MULTILINE, ): var_name, url = m.group(1), m.group(2) assets.append({ "variable": var_name, "url": url, "type": "image-or-svg", "node_id": None, "node_name": None, "fetch_command": f"python3 scripts/figma-asset-fetch.py '{url}' assets/", }) # 2. Match img src refs in tree to top-level vars var_to_node = _collect_img_var_to_node(tree) for asset in assets: if asset["variable"] in var_to_node: n = var_to_node[asset["variable"]] asset["node_id"] = n.node_id asset["node_name"] = n.name # 3. Detect logo/icon nodes by name (for R10 strategy hint) logo_icon_nodes = _find_logo_icon_nodes(tree) for n in logo_icon_nodes: # Add a strategy hint entry (no specific URL; figma-asset-fetch by nodeId via use_figma) assets.append({ "variable": None, "url": None, "type": "vector-component", "node_id": n.node_id, "node_name": n.name, "strategy_hint": "single-svg (Component / Named Group with `/` namespace) — R10 case A/B", "fetch_command": f"# Figma node {n.node_id} '{n.name}': use use_figma exportAsync, or fetch all child SVG URLs and composite", }) return assets def _collect_img_var_to_node(tree: FigmaNode) -> Dict[str, FigmaNode]: """Map img src variable name → containing parent node (for context).""" mapping: Dict[str, FigmaNode] = {} _collect_recursive(tree, mapping, parent=None) return mapping def _collect_recursive(node: FigmaNode, mapping: Dict[str, FigmaNode], parent: Optional[FigmaNode]): if node.tag == "img" and node.attrs.get("src"): src = node.attrs["src"] # src can be {imgGroup9} or {imgGroup9} stripped of braces var = src.strip() if var: mapping[var] = parent or node for c in node.children: _collect_recursive(c, mapping, parent=node) def _find_logo_icon_nodes(tree: FigmaNode) -> List[FigmaNode]: """Find nodes whose name matches `Logo/`, `Icon/`, `Illustration/` etc.""" matches = [] def walk(n): if n.name: if re.match(r"^(Logo|Icon|Illustration|Symbol)/", n.name): matches.append(n) for c in n.children: walk(c) walk(tree) return matches # ============================================================ # Output generators # ============================================================ def generate_css(tree: FigmaNode, file_key: Optional[str], node_id: Optional[str]) -> str: """Build CSS file with one rule per element (using exact values).""" lines = [ "/* ============================================================", f" * Generated by figma-extract-css.py", f" * Source: fileKey={file_key or '?'} nodeId={node_id or '?'}", " * DO NOT MANUALLY EDIT layout values — re-run script if Figma changes.", " * AI must use these exact values; estimation prohibited (R10 / R8 v2).", " * ============================================================ */", "", "/* Base reset (R11 boilerplate) */", "* { box-sizing: border-box; margin: 0; padding: 0; }", "button, input, select, textarea { all: unset; font: inherit; cursor: pointer; }", "img, svg { display: block; max-width: 100%; }", "", ] _emit_css_rule(tree, lines) return "\n".join(lines) def _emit_css_rule(node: FigmaNode, lines: List[str]) -> None: if node.is_annotation: return # Skip annotations if node.inline_style or node.classes: cls = node.css_class_name() comment_parts = [] if node.name: comment_parts.append(f"name={node.name}") if node.node_id: comment_parts.append(f"node-id={node.node_id}") comment = " ".join(comment_parts) lines.append(f".{cls} {{") if comment: lines.append(f" /* {comment} */") # Filter out the meta /* tailwind-unknown */ key for prop, val in node.inline_style.items(): if prop.startswith("/*"): lines.append(f" /* unrecognized Tailwind class: {val} */") continue lines.append(f" {prop}: {val};") lines.append("}") lines.append("") for child in node.children: _emit_css_rule(child, lines) def generate_html(tree: FigmaNode) -> str: """Build HTML skeleton with class references + asset placeholders.""" lines = [ "", '', "", '', "Figma extracted — replace with task name", '', "", "", ] _emit_html(tree, lines, indent=0) lines.append("") lines.append("") return "\n".join(lines) VOID_TAGS = {"img", "input", "br", "hr", "meta", "link"} def _emit_html(node: FigmaNode, lines: List[str], indent: int) -> None: if node.is_annotation: lines.append(" " * indent + f"") return ind = " " * indent cls = node.css_class_name() tag = node.tag # Components: emit as comment placeholder if tag and tag[0].isupper(): # PascalCase = React component (e.g., ) lines.append(f"{ind}") lines.append(f'{ind}

') if node.text: lines.append(f"{ind} {node.text}") for c in node.children: _emit_html(c, lines, indent + 1) lines.append(f"{ind}
") return # img: void tag with src placeholder if tag == "img": src = node.attrs.get("src", "") src_clean = src.strip("{}").strip() # If src is a variable name, indicate it needs figma-asset-fetch if src_clean and not src_clean.startswith("http") and not src_clean.startswith("/") and not src_clean.startswith("."): placeholder = f"PLACEHOLDER_{src_clean}.svg" lines.append(f"{ind}\"\"") else: lines.append(f'{ind}') return # SVG: emit as block (children are SVG nodes — keep as-is hint) if tag == "svg": lines.append(f'{ind}') return # Other void tags if tag in VOID_TAGS: lines.append(f'{ind}<{tag} class="{cls}">') return # Regular paired tag open_tag = f'<{tag} class="{cls}"' if node.text and not node.children: lines.append(f"{ind}{open_tag}>{node.text}") else: lines.append(f"{ind}{open_tag}>") if node.text: lines.append(f"{ind} {node.text}") for c in node.children: _emit_html(c, lines, indent + 1) lines.append(f"{ind}") def generate_manifest(assets: List[Dict], file_key: Optional[str], node_id: Optional[str]) -> str: """Build assets-manifest.json.""" from datetime import datetime manifest = { "generated_by": "scripts/figma-extract-css.py", "generated_at": datetime.now().isoformat(), "source": {"fileKey": file_key, "nodeId": node_id}, "instructions": ( "For each asset, run the fetch_command. " "AI must use scripts/figma-asset-fetch.py (R8 / R10). " "vector-component entries (Logo/Icon) require composite or use_figma exportAsync." ), "assets": assets, } return json.dumps(manifest, indent=2, ensure_ascii=False) def generate_annotation_report(tree: FigmaNode) -> str: """Build annotation-filtered.md report.""" lines = [ "# Annotation Filter Report", "", "The following nodes were detected as designer annotations and FILTERED from output.", "**Human review** to confirm these are not UI elements:", "", ] found = [] _collect_annotations(tree, found) if not found: lines.append("*(No annotations detected.)*") else: lines.append("| node-id | name | reason |") lines.append("|---|---|---|") for n in found: lines.append(f"| `{n.node_id or '-'}` | {n.name or '-'} | {n.annotation_reason or '-'} |") lines.append("") lines.append("If any row above is actually a UI element (false positive), update node names in Figma:") lines.append("- Remove `_annotation/`, `mark`, `note` prefixes") lines.append("- Remove stroke dasharray on UI elements") lines.append("- Or pass `--no-annotation-filter` to script") return "\n".join(lines) def _collect_annotations(node: FigmaNode, found: List[FigmaNode]) -> None: if node.is_annotation: found.append(node) for c in node.children: _collect_annotations(c, found) # ============================================================ # CLI # ============================================================ def main() -> int: parser = argparse.ArgumentParser( description="Extract data-driven CSS from Figma get_design_context output.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__, ) parser.add_argument( "--design-context", required=True, help="Path to file containing mcp__claude_ai_Figma__get_design_context output (JSX + React+Tailwind reference).", ) parser.add_argument( "--metadata", help="Optional: path to file containing get_metadata XML output (for annotation filter via stroke-dasharray).", ) parser.add_argument( "--output-dir", required=True, help="Where to write extracted-css.css / skeleton.html / assets-manifest.json / annotation-filtered.md", ) parser.add_argument("--file-key", help="Source Figma fileKey (for output metadata).") parser.add_argument("--node-id", help="Source Figma nodeId (for output metadata).") parser.add_argument( "--no-annotation-filter", action="store_true", help="Disable annotation detection (output everything; useful if false positives).", ) args = parser.parse_args() dc_path = Path(args.design_context) if not dc_path.exists(): print(f"❌ design-context file not found: {dc_path}", file=sys.stderr) return 1 jsx_source = dc_path.read_text(encoding="utf-8") metadata_xml = None if args.metadata: md_path = Path(args.metadata) if md_path.exists(): metadata_xml = md_path.read_text(encoding="utf-8") else: print(f"⚠️ metadata file not found: {md_path} (continuing without annotation filter)", file=sys.stderr) # Parse JSX → tree tree = parse_jsx_to_tree(jsx_source) if not tree: print("❌ Failed to parse JSX (no root element found).", file=sys.stderr) return 1 # Annotation detection if not args.no_annotation_filter: detect_annotations(tree, metadata_xml) # Extract assets assets = extract_assets(jsx_source, tree) # Generate outputs out_dir = Path(args.output_dir) out_dir.mkdir(parents=True, exist_ok=True) css_path = out_dir / "extracted-css.css" html_path = out_dir / "skeleton.html" manifest_path = out_dir / "assets-manifest.json" annot_path = out_dir / "annotation-filtered.md" css_path.write_text(generate_css(tree, args.file_key, args.node_id), encoding="utf-8") html_path.write_text(generate_html(tree), encoding="utf-8") manifest_path.write_text(generate_manifest(assets, args.file_key, args.node_id), encoding="utf-8") annot_path.write_text(generate_annotation_report(tree), encoding="utf-8") # Summary css_lines = sum(1 for ln in css_path.read_text().splitlines() if ln.strip().endswith(";")) asset_count = len(assets) annot_count = sum(1 for _ in _gen_annotations(tree)) print(f"✅ Output written to {out_dir}/") print(f" - extracted-css.css ({css_lines} CSS properties)") print(f" - skeleton.html") print(f" - assets-manifest.json ({asset_count} assets)") print(f" - annotation-filtered.md ({annot_count} annotations filtered)") print() print("Next steps (AI workflow):") print(" 1. Read extracted-css.css as layout baseline (禁止估算 layout values)") print(" 2. For each asset in assets-manifest.json, run scripts/figma-asset-fetch.py") print(" 3. Read annotation-filtered.md, confirm filtered nodes are not UI") print(" 4. Compose final HTML using skeleton.html + fetched assets") print(" 5. Run R8 v2 self-check (Chrome headless + scripts/visual-diff.py)") return 0 def _gen_annotations(node): if node.is_annotation: yield node for c in node.children: yield from _gen_annotations(c) if __name__ == "__main__": sys.exit(main())