diff --git a/.gitignore b/.gitignore index 36ebc23..ecf31e1 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,8 @@ out/ # Keep the local debug helper itself in Git; only its generated captures stay local. .debug-server/data/ .debug-server/server.log +.mcp-server/venv/ +.mcp-server/__pycache__/ # ======================== # Editor & IDE diff --git a/.mcp-server/mcp_server.py b/.mcp-server/mcp_server.py new file mode 100644 index 0000000..1841348 --- /dev/null +++ b/.mcp-server/mcp_server.py @@ -0,0 +1,560 @@ +#!/usr/bin/env python3 +"""Local MCP server for the FigmaToCode plugin. + +Bridges an MCP client (e.g. Claude Desktop) to whatever is currently +selected in Figma. The FigmaToCode plugin's Settings > MCP Connect panel +pushes the live selection to this process over a WebSocket; this script +re-exposes the last snapshot it received as MCP tools over stdio, and can +write real files (code, decoded image assets, the ground-truth preview PNG) +to disk — something the plugin's own browser-sandboxed UI can't do itself. + +NOTE: this file is also embedded verbatim in ui.html's Settings > MCP +Connect panel (as the Copy/Download source for marketplace users who never +cloned this repo) — keep both copies in sync when editing. + +Setup: + pip install mcp websockets + python3 mcp_server.py + +Then, in Figma: FigmaToCode > Settings > MCP Connect > Connect. +And point your MCP client at this script over stdio, e.g. in Claude +Desktop's claude_desktop_config.json: + "figma-to-code": { + "command": "python3", + "args": ["/absolute/path/to/mcp_server.py"] + } +""" + +import asyncio +import base64 +import json +import os +import threading + +import websockets + +# mcp 2.x renamed FastMCP to MCPServer (different import path, same .tool()/ +# .run() shape used below) — support whichever the user's `pip install mcp` +# actually resolved to instead of pinning a version. +try: + from mcp.server.fastmcp import FastMCP as MCPServer +except ImportError: + from mcp.server.mcpserver import MCPServer + +WS_PORT = 8788 + +# The most recent selection and whole-page-scan payloads pushed by the +# plugin's ui.html over the WebSocket below. Only one Figma tab is ever +# expected to connect at a time, so a single shared value per kind (not a +# per-connection cache) is enough. +_latest_selection = None +_latest_page_overview = None + + +async def _handle_plugin_connection(websocket): + global _latest_selection, _latest_page_overview + async for message in websocket: + try: + payload = json.loads(message) + except ValueError: + continue + if payload.get("type") == "pageOverview": + _latest_page_overview = payload + else: + _latest_selection = payload + + +def _run_websocket_bridge(): + # Own event loop in a background thread so it never blocks the MCP + # server's stdio event loop started from __main__ below. + async def main(): + # websockets' 1 MiB default max_size drops any selection whose + # payload (preview PNG + per-node CSS/HTML/Dart + image assets, all + # base64) runs bigger than that — trivially easy with just a couple + # of image-heavy layers selected. Unbounded is fine here: this only + # ever accepts connections from localhost. + async with websockets.serve(_handle_plugin_connection, "localhost", WS_PORT, max_size=None): + await asyncio.Future() + + asyncio.run(main()) + + +# Read by the MCP client as up-front, standing context for this whole +# server — unlike a tool docstring, which the client only sees once it has +# already decided to call that tool. Without this, a client with no other +# signal tends to fall back to asking the user for a screenshot or a written +# description instead of calling these tools, even though the *exact* code, +# layout data, and real assets are one tool call away. +INSTRUCTIONS = """\ +This server gives you the live selection from a Figma plugin (FigmaToCode) — \ +real generated code, design tokens, and image assets, not a picture to \ +reverse-engineer. If you're about to ask the user for a screenshot, a design \ +description, or "what should this look like" — stop and call \ +list_selected_nodes() first. If it returns nodes, use this server instead of \ +guessing. + +Planning across a whole app (multiple screens), not just one selection: \ +call list_page_screens() first. It's the closest thing to a screenshot of \ +the whole file without pulling one — every top-level frame's name, position \ +and size, grouped by Figma Section where the designer used one (a \ +Section's name is usually the flow/feature it groups, e.g. "Onboarding" or \ +"Checkout"). Use it to decide what exists, how screens relate spatially, \ +and what order to tackle them in, before following the per-selection \ +workflow below for whichever one you pick. Unlike the selection tools, this \ +requires the user to have clicked Settings > MCP Connect > Scan page at \ +least once — it isn't sent automatically, since it exposes the file's whole \ +structure rather than just what's selected. + +Standard workflow: +1. list_selected_nodes() — see what's selected, by index. +2. get_node_metadata(index) — width/height/warnings. Compare width/height \ +against common device viewports (e.g. ~375-430 wide, ~700-930 tall) to tell \ +a full-screen layout from an isolated component/card — a node sized like a \ +phone screen should become the screen's root container, not something \ +nested inside one. +3. get_design_tokens() / get_design_export(format) — pull real colors, \ +type scale, and spacing before inventing any values of your own. +4. export_node(index, output_dir, output) or export_selection(output_dir, \ +output) — write ready-to-use code plus a real assets/ folder (images \ +already rewritten to real file paths, never left as inline base64) and a \ +preview.png. Prefer this over get_node_code when you're actually building \ +something, not just inspecting. get_selection_code(indices, output) is the \ +lighter-weight option for inspecting several layers at once without writing \ +files — it still strips inline base64 to an assets/ placeholder path, so it \ +stays safe to use even with a small context window. +5. get_prototype_html() — when 2+ frames were selected together, this is \ +the ground truth for navigation between screens (which reactions/links go \ +where) — use it instead of guessing screen flow, and to see which layers \ +Figma's own prototype keeps fixed across screens (a strong signal for a \ +persistent bottom navigation bar or header). + +Things Figma's fixed-pixel export does NOT encode, so infer them instead of \ +copying pixels blindly: +- Scrolling: nothing marks a layer as scrollable. A tall stack of content \ +inside a phone-sized frame almost always means the frame's body scrolls \ +and any nav bar / tab bar pinned to the same edge across every screen in \ +get_prototype_html should stay fixed (position: sticky/fixed) instead of \ +scrolling with it. +- Full-bleed vs. centered: a node whose width/height matches a device \ +viewport should fill the screen (100vw/100dvh or equivalent); don't leave \ +it boxed in a fixed-pixel container sized to the Figma frame. +- Responsive: request output="responsive_css"/"responsive_html"/\ +"responsive_dart" from get_node_code (or export_node/export_selection with \ +a *_dart output for Flutter) for a fluid, Fill/Hug/Constraints-based layout \ +instead of the fixed-pixel default — use this whenever the target is a real \ +app/site meant to run at more than one exact size, not a pixel-perfect \ +match of the Figma canvas. + +The rule for everything visual — theme, color, type, spacing, reusable \ +components, assets — is: it comes from Figma via these tools, always, on \ +both web and mobile. Never invent a color/font-size/spacing value, and \ +never hand-roll a component Figma already defines once and reuses. \ +Everything else (business logic, state, data fetching, routing beyond what \ +get_prototype_html encodes, backend integration) is normal engineering \ +judgment — this server has no opinion on it. + +Theme, once, shared by every platform you're building for: +- Call get_design_tokens() and get_design_export() for the *whole* \ +selection before writing any component, not per-node — a theme built once \ +and imported everywhere keeps web and mobile visually identical, instead of \ +each screen/component silently drifting from the last. +- get_design_export(format="css") → CSS custom properties, for a web \ +target (or feed those values into your own Tailwind config / styled-\ +components theme / CSS-in-JS tokens object — same values, whichever your \ +stack uses). get_design_export(format="flutter") → a ThemeData + pubspec \ +font snippet, for a Flutter/mobile target. Same underlying tokens, just two \ +renderings — use both if you're building both platforms from one selection. +- Afterwards, every hex color, font-size, and spacing value in the code you \ +write should trace back to one of these tokens (a CSS var / Theme property \ +/ Dart constant) — not a bare literal typed by hand. If a value doesn't \ +appear in get_design_tokens() at all, that's a real signal it's a one-off \ +override, not a token — keep it inline rather than inventing a fake token \ +for it. + +Reusable components: +- get_design_tokens()["componentLibrary"] lists every Figma component used \ +in the selection with a usageCount. usageCount > 1 means: build it once (a \ +React/Vue/web component, a Flutter widget — whatever your stack's reuse \ +unit is) and reuse it everywhere it appears, instead of copy-pasting the \ +markup/widget tree at each occurrence. +- get_design_export(format="components") returns ready-made Dart widget \ +scaffolds for that same component library — a starting point for the \ +Flutter side; port the same componentization to whatever you're building \ +for web instead of re-deriving it from scratch. + +Assets, on every platform: always go through export_node/export_selection \ +(never ship get_node_code's raw output as-is if it contains images) so \ +every image lands as a real file under assets/ with the code already \ +pointing at it — never inline base64 in anything you actually ship. + +Verify before calling it done, every time: +1. Compare your result against the ground-truth render: export_node/\ +export_selection already writes preview.png; open it (or save_preview_image \ +for a single layer) and check your generated UI actually matches it — \ +layout, spacing, colors — not just "looks plausible". +2. Re-check for stray hardcoded values: grep the code you wrote for hex \ +colors / raw px-or-pt sizes that aren't one of get_design_tokens()'s \ +values — anything left over either belongs in the theme or is a genuine \ +one-off, decide which, don't leave it unexamined. +3. Confirm no leftover inline base64 made it into shipped code — only \ +export_node/export_selection's rewritten output, never get_node_code's raw \ +html/dart, should end up in a component that uses images. +4. Re-read get_node_metadata(index).warnings for every node you used and \ +either address what they flag (a flattened layer, a missing font, etc.) or \ +consciously decide it doesn't matter here — don't silently drop them. +""" + +mcp = MCPServer("figma-to-code", instructions=INSTRUCTIONS) + + +def _selection_or_raise(): + if not _latest_selection or not _latest_selection.get("nodes"): + raise ValueError( + "No selection available yet. In Figma, open the FigmaToCode plugin, " + "select a layer, and toggle Settings > MCP Connect > Connect." + ) + return _latest_selection + + +def _node_or_raise(index: int) -> dict: + nodes = _selection_or_raise()["nodes"] + if index < 0 or index >= len(nodes): + raise ValueError(f"index {index} out of range: selection has {len(nodes)} node(s)") + return nodes[index] + + +# code/output keys, by the `output` string a caller passes in. +_CODE_FIELDS = { + "css": lambda n: "\n".join(f"{key}: {value};" for key, value in n["css"].items()), + "html": lambda n: n["html"], + "dart": lambda n: n["dart"], + "inline_svg_html": lambda n: n["inlineSvgHtml"], + "responsive_css": lambda n: "\n".join(f"{key}: {value};" for key, value in n["responsive"]["css"].items()), + "responsive_html": lambda n: n["responsive"]["html"], + "responsive_dart": lambda n: n["responsive"]["dart"], +} + +# Per-output asset lists and the file extension code should be saved under. +_ASSET_FIELDS = {"html": "htmlAssets", "inline_svg_html": "htmlAssets", "dart": "dartAssets"} +_FILE_EXT = { + "css": "css", + "html": "html", + "dart": "dart", + "inline_svg_html": "html", + "responsive_css": "css", + "responsive_html": "html", + "responsive_dart": "dart", +} + + +def _safe_dir(output_dir: str) -> str: + path = os.path.abspath(os.path.expanduser(output_dir)) + os.makedirs(path, exist_ok=True) + return path + + +def _get_assets(node: dict, output: str) -> list: + key = _ASSET_FIELDS.get(output) + return node.get(key, []) if key else [] + + +def _write_assets(assets: list, assets_dir: str) -> list: + if not assets: + return [] + os.makedirs(assets_dir, exist_ok=True) + written = [] + for asset in assets: + # basename() strips any accidental path segments out of a filename + # that ultimately came from a Figma layer name. + filename = os.path.basename(asset["filename"]) + file_path = os.path.join(assets_dir, filename) + with open(file_path, "wb") as f: + f.write(base64.b64decode(asset["base64"])) + written.append(file_path) + return written + + +# Mirrors rewriteHtmlWithAssetPaths/rewriteDartWithAssetPaths in ui.html +# exactly, so code exported here matches what the plugin's own "Download +# images + copy code" button produces — real files, not inline base64. The +# data URI (or, for Dart, the whole Image.memory/MemoryImage call) is unique +# per asset, so a literal string replace is exact and needs no regex. +def _rewrite_html_with_asset_paths(html: str, assets: list) -> str: + result = html + for asset in assets: + result = result.replace(asset["dataUri"], f"assets/{asset['filename']}") + return result + + +def _rewrite_dart_with_asset_paths(dart: str, assets: list) -> str: + result = dart + for asset in assets: + if asset.get("fillOnly"): + old_provider = f"MemoryImage(base64Decode('{asset['base64']}'))" + new_provider = f"AssetImage('assets/{asset['filename']}')" + result = result.replace(old_provider, new_provider) + continue + old_call = ( + f"Image.memory(base64Decode('{asset['base64']}'), width: {asset['width']}, " + f"height: {asset['height']}, fit: BoxFit.fill, gaplessPlayback: true)" + ) + new_call = ( + f"Image.asset('assets/{asset['filename']}', width: {asset['width']}, " + f"height: {asset['height']}, fit: BoxFit.fill)" + ) + result = result.replace(old_call, new_call) + if "base64Decode(" not in result: + result = result.replace("import 'dart:convert';\n", "") + return result + + +def _rewrite_code_with_asset_paths(code: str, output: str, assets: list) -> str: + if output == "dart": + return _rewrite_dart_with_asset_paths(code, assets) + return _rewrite_html_with_asset_paths(code, assets) + + +@mcp.tool() +def list_page_screens() -> dict: + """Return a lightweight map of every top-level frame on the current + Figma page, grouped by Figma Section where the designer used one — name, + position (x/y), size (width/height), and visibility only, no generated + code. Use this before diving into any one screen, to see how many + screens the whole app has, which ones are grouped together (a Section's + name is usually the flow/feature it groups), and how they're laid out on + the canvas. Requires the user to have clicked Settings > MCP Connect > + Scan page at least once — it isn't pushed automatically like the + selection is, since it exposes the file's whole structure rather than + just what's selected.""" + if not _latest_page_overview: + raise ValueError( + "No page overview yet. In Figma, open the FigmaToCode plugin and " + "click Settings > MCP Connect > Scan page." + ) + return { + "pageName": _latest_page_overview["pageName"], + "sections": _latest_page_overview["sections"], + "ungroupedScreens": _latest_page_overview["ungroupedScreens"], + } + + +@mcp.tool() +def list_selected_nodes() -> list: + """List every currently-selected layer's index, name, and type. Call this + first to see what's selected before calling other tools by index.""" + selection = _selection_or_raise() + return [ + {"index": i, "name": node["nodeName"], "type": node["nodeType"]} + for i, node in enumerate(selection["nodes"]) + ] + + +@mcp.tool() +def get_node_metadata(index: int = 0) -> dict: + """Return a selected layer's size and any generation warnings, without the + full generated code — use this to reason about layout before pulling code.""" + node = _node_or_raise(index) + return { + "nodeId": node["nodeId"], + "nodeName": node["nodeName"], + "nodeType": node["nodeType"], + "width": node["width"], + "height": node["height"], + "warnings": node["warnings"], + } + + +@mcp.tool() +def get_node_code(index: int = 0, output: str = "css") -> str: + """Return generated code for one selected layer. `output` is one of: css, + html, dart, inline_svg_html, responsive_css, responsive_html, + responsive_dart. `index` picks which selected layer (see + list_selected_nodes). The non-responsive variants match the fixed-pixel + size captured from Figma; the responsive_* variants are the fluid, + Fill/Hug/Constraints-based layout instead.""" + node = _node_or_raise(index) + getter = _CODE_FIELDS.get(output) + if getter is None: + raise ValueError(f"output must be one of: {', '.join(_CODE_FIELDS)}") + return getter(node) + + +@mcp.tool() +def get_selection_code(indices: list = None, output: str = "html") -> list: + """Return generated code for several (or, by default, all) selected + layers in one call, with any image assets replaced by an + `assets/` placeholder path instead of inline base64 — safe to + use with a low-context client, unlike raw get_node_code on an + image-heavy selection. Each result also lists the assets that code + references (filename + mimeType, no bytes); fetch the real files + afterward with export_node_assets or export_node if you need them. + `output` accepts the same values as get_node_code. `indices` (default: + every selected node) picks which layers to include — see + list_selected_nodes.""" + nodes = _selection_or_raise()["nodes"] + if indices is None: + indices = list(range(len(nodes))) + if output not in _CODE_FIELDS: + raise ValueError(f"output must be one of: {', '.join(_CODE_FIELDS)}") + getter = _CODE_FIELDS[output] + # Responsive variants reuse the same top-level asset lists as their + # fixed-pixel counterpart — ui.html's own copy/download buttons do the + # same lookup (see rewriteHtmlWithAssetPaths call sites). + asset_key = _ASSET_FIELDS.get(output.replace("responsive_", "", 1)) + results = [] + for i in indices: + node = _node_or_raise(i) + code = getter(node) + assets = node.get(asset_key, []) if asset_key else [] + if assets: + code = _rewrite_code_with_asset_paths(code, "dart" if "dart" in output else "html", assets) + results.append({ + "index": i, + "name": node["nodeName"], + "code": code, + "assets": [{"filename": a["filename"], "mimeType": a["mimeType"]} for a in assets], + }) + return results + + +@mcp.tool() +def list_node_assets(index: int = 0, output: str = "html") -> list: + """List the image assets a selected layer's generated code references + (filename + MIME type, no image bytes) — use export_node_assets to + actually save them as files.""" + node = _node_or_raise(index) + key = _ASSET_FIELDS.get(output) + if key is None: + raise ValueError(f"output must be one of: {', '.join(sorted(set(_ASSET_FIELDS)))}") + return [{"filename": a["filename"], "mimeType": a["mimeType"]} for a in node.get(key, [])] + + +@mcp.tool() +def export_node_assets(index: int, output_dir: str, output: str = "html") -> list: + """Decode a selected layer's image assets and write them as real files + under output_dir (created if missing). Returns the absolute paths written.""" + node = _node_or_raise(index) + return _write_assets(_get_assets(node, output), _safe_dir(output_dir)) + + +@mcp.tool() +def export_node(index: int, output_dir: str, output: str = "html") -> dict: + """Export one selected layer as a ready-to-use folder under output_dir: + a code file (html or dart) with every image it uses already rewritten to + point at real files in an assets/ subfolder — not left as inline base64 — + plus preview.png, the ground-truth Figma render. Use this for "pull this + one component, images and all" requests; use export_selection instead for + the whole current selection at once.""" + node = _node_or_raise(index) + if output not in ("html", "dart"): + raise ValueError("output must be 'html' or 'dart'") + node_dir = _safe_dir(output_dir) + assets = _get_assets(node, output) + code = _rewrite_code_with_asset_paths(node[output], output, assets) + code_path = os.path.join(node_dir, f"code.{_FILE_EXT[output]}") + with open(code_path, "w") as f: + f.write(code) + written_assets = _write_assets(assets, os.path.join(node_dir, "assets")) + preview_path = None + if node.get("previewImage"): + _, _, b64 = node["previewImage"].partition(",") + preview_path = os.path.join(node_dir, "preview.png") + with open(preview_path, "wb") as f: + f.write(base64.b64decode(b64)) + return {"code": code_path, "assets": written_assets, "preview": preview_path} + + +@mcp.tool() +def save_preview_image(index: int, output_dir: str, filename: str = "preview.png") -> str: + """Save the ground-truth Figma render (the same PNG the plugin's Preview + tab compares generated code against) for a selected layer to output_dir. + Returns the absolute path written.""" + node = _node_or_raise(index) + uri = node.get("previewImage") + if not uri: + raise ValueError("This node has no preview image (export may have failed).") + _, _, b64 = uri.partition(",") + path = os.path.join(_safe_dir(output_dir), os.path.basename(filename)) + with open(path, "wb") as f: + f.write(base64.b64decode(b64)) + return path + + +@mcp.tool() +def get_design_tokens() -> dict: + """Return the raw design system collected from the current selection: + Figma variables, styles, colors, gradients, typography, radii, shadows, + spacing, fonts, and the component library — everything needed to build a + consistent theme, not just one layer's code.""" + selection = _selection_or_raise() + ds = selection["designSystem"] + return {k: v for k, v in ds.items() if k != "exports"} + + +@mcp.tool() +def get_design_export(format: str = "css") -> str: + """Return a ready-to-use export built from the current selection's design + system. `format` is one of: css (CSS custom properties + token rules), + flutter (ThemeData + pubspec font snippet), components (Dart component + scaffolds), json (design tokens as JSON).""" + selection = _selection_or_raise() + exports = selection["designSystem"]["exports"] + if format not in exports: + raise ValueError(f"format must be one of: {', '.join(exports)}") + return exports[format] + + +@mcp.tool() +def get_prototype_html() -> str: + """Return the bundled, clickable multi-frame HTML prototype (Navigate/ + Overlay reactions become real click/hover behavior, Smart Animate + transitions tween matching layers) — only available when 2+ top-level + frames were selected together in Figma.""" + selection = _selection_or_raise() + prototype = selection.get("prototype") + if not prototype or not prototype.get("html"): + raise ValueError( + "No prototype available — select 2 or more top-level frames together in Figma " + "(with MCP Connect on) and try again." + ) + return prototype["html"] + + +@mcp.tool() +def export_selection(output_dir: str, output: str = "html") -> dict: + """One-shot export of the entire current selection to real files under + output_dir: one subfolder per selected layer (code + assets/ + preview.png + — see export_node), plus the design tokens export and prototype.html if + the selection qualifies for one. `output` is 'html' or 'dart'. This is the + fastest way to pull a whole selection down to build from — call + list_selected_nodes first if you only want one specific layer, via + export_node instead.""" + selection = _selection_or_raise() + root = _safe_dir(output_dir) + written = {"root": root, "nodes": [], "designTokens": None, "prototype": None} + + for i, node in enumerate(selection["nodes"]): + node_dir = os.path.join(root, f"{i:02d}_{node['nodeName'] or 'node'}") + node_export = export_node(i, node_dir, output) + written["nodes"].append({"name": node["nodeName"], **node_export}) + + tokens_format = "flutter" if output == "dart" else "css" + tokens_ext = "dart" if tokens_format == "flutter" else "css" + tokens_path = os.path.join(root, f"design-tokens.{tokens_ext}") + with open(tokens_path, "w") as f: + f.write(selection["designSystem"]["exports"][tokens_format]) + written["designTokens"] = tokens_path + + prototype = selection.get("prototype") + if prototype and prototype.get("html"): + prototype_path = os.path.join(root, "prototype.html") + with open(prototype_path, "w") as f: + f.write(prototype["html"]) + written["prototype"] = prototype_path + + return written + + +if __name__ == "__main__": + threading.Thread(target=_run_websocket_bridge, daemon=True).start() + mcp.run(transport="stdio") diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7f5c9e4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,68 @@ +# CLAUDE.md + +Guidance for working in this repo. See [README.md](README.md) for user-facing +docs (features, usage, project structure). + +## What this is + +A Figma plugin (no build step, no bundler). `code.js` runs in the Figma +plugin sandbox and has access to the `figma` global; `ui.html` is the plugin +panel (iframe) and only ever talks to `code.js` via `postMessage`. There is +no server component except the optional local debug HTTP server in +`.debug-server/` (git-ignored, not part of the shipped plugin). + +## Core invariant: don't break the fixed-pixel converter + +The CSS/HTML/Dart extraction (`extractCss`, `generateHtmlTree`, +`generateDartForNode`, and everything they call) reproduces the Figma +selection at its exact captured pixel size, verified against a real Figma +render in the Preview tab. This is the thing users trust most about the +plugin — new features must be **additive**, never a rewrite of this path: + +- New output modes (e.g. Responsive/fluid) are opt-in via an extra parameter + that defaults to today's exact behavior, so every existing call site + (including tests) stays byte-identical unless it explicitly opts in. +- Prefer post-processing a copy of the CSS object these functions already + return over threading new logic into them directly. + +## Tests + +``` +node --test tests/code-regression.test.js +``` + +Loads `code.js` into a `vm` sandbox with a minimal fake `figma` global +(including fake variables/styles/component-instance APIs) and asserts +directly against the exported functions — no Figma installation needed. CI +runs this on every push/PR (`.github/workflows/test.yml`). + +When adding a feature, extend this file's fixtures rather than creating a +new test file, and keep the existing assertions passing unmodified — that's +the proof the default path didn't regress. + +## Conventions + +- Plain functions, no classes. Small, single-purpose helpers (see the + `PRIMARY_AXIS_ALIGN_CSS`-style constant tables and `getBorderRadiusCss`-style + helpers near the top of `code.js`) composed by the top-level + `extractCss`/`generateDartForNode`/`generateHtmlTree`. +- Comments explain *why*, not what — a hidden Figma API quirk, a + browser/Flutter constraint workaround, a non-obvious invariant. Don't + narrate what the code already says. +- No new npm dependencies — this ships as two plain files Figma loads + directly, and `tests/` runs on bare Node with no `node_modules`. + +## Known gaps (see README's "Known limitations" for the user-facing version) + +- Assets (rasterized icons, rotated groups) are inlined as base64 today, not + exported as separate files. +- Dart-side GRID children don't participate in Responsive mode yet. +- The Prototype tab's Smart Animate diffing (`diffFramesForSmartAnimate`) + matches layers between two frames by name + sibling occurrence order, same + as Figma's own heuristic — duplicate/renamed layers can match the wrong + node or fall back to a cross-dissolve cut instead of a tween. +- The opt-in "Inline SVG icons" HTML mode (`inlineSvgHtml`) only applies to + vector-only-subtree assets (real SVG bytes) — image-fill/rotated-container + assets are still raster PNGs and always stay a background-image, and the + variant is only computed for the fixed-pixel geometry, not combined with + Responsive. diff --git a/README.md b/README.md index 7f721b1..dbdbbe0 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,82 @@ python3 .debug-server/debug-server.py Dumps land in `.debug-server/data/latest.json` / `latest.png` (git-ignored). +### Local MCP server + +Settings → **MCP Connect** runs a local [MCP](https://modelcontextprotocol.io) +server so an AI client (e.g. Claude Desktop) can pull whatever it needs from +the current Figma selection — generated CSS/HTML/Dart, design tokens, the +clickable prototype bundle, and real image/preview files written straight to +disk (not left as inline base64). It stays off by default and everything +stays on your own machine. + +1. **Get the script** — either use `.mcp-server/mcp_server.py` from this repo, + or, if you only installed the plugin from Community (no repo checkout), + copy/download it straight from the plugin's Settings → MCP Connect panel. +2. **Create a virtual environment next to the script and install into it** + (plain `pip install` fails on modern macOS/Homebrew Python with an + "externally-managed-environment" error — a venv sidesteps that, and keeps + these two packages out of your system Python entirely): + + ``` + cd .mcp-server + python3 -m venv venv + source venv/bin/activate # Windows: venv\Scripts\activate + python3 -m ensurepip --upgrade # skip if `pip --version` already works + pip install mcp websockets + ``` + + That `ensurepip` line is only needed if `pip install` above errors with + `command not found: pip` — some Python builds don't bootstrap pip into a + new venv automatically. If `ensurepip` itself then fails too (some + Homebrew Python builds strip pip's bundled installer wheel to shrink the + bottle), fall back to fetching the real installer instead: + + ``` + curl -sS https://bootstrap.pypa.io/get-pip.py -o get-pip.py + python3 get-pip.py + pip install mcp websockets + ``` + + Then, each time you want the server running: + + ``` + source venv/bin/activate + python3 mcp_server.py + ``` + + Leave that process running — it holds the live bridge to the plugin and + answers the MCP client's tool calls. +3. **In Figma**, open the plugin, select something, and toggle + Settings → MCP Connect → **Connect**. Every new selection streams to the + server automatically from then on. +4. **Point your MCP client at the script over stdio.** MCP clients spawn the + process themselves — they don't inherit an activated venv from your shell + — so `command` must point at the venv's own Python executable, not the + system `python3`. For Claude Desktop, add this to + `claude_desktop_config.json` (using the absolute paths to wherever you + saved the script): + + ```json + { + "mcpServers": { + "figma-to-code": { + "command": "/absolute/path/to/.mcp-server/venv/bin/python3", + "args": ["/absolute/path/to/.mcp-server/mcp_server.py"] + } + } + } + ``` + + Restart Claude Desktop, and it can then call `list_selected_nodes`, + `get_node_code`, `export_node` (code + real asset files + preview PNG for + one layer), `export_selection` (the whole selection at once), + `get_design_tokens`/`get_design_export`, and `get_prototype_html`. + +Like the debug server, this needs Python 3 on your machine and is meant for +developers/power users wiring the plugin into an AI workflow — most +plugin users will never need it. + ## Project structure ``` @@ -84,6 +160,8 @@ ui.html Plugin UI (tabs, toggles, copy buttons) — postMessage'd tests/ Node-runnable regression suite (see below) .debug-server/ Optional local debug HTTP server (see above); generated JSON, PNG captures, and logs stay git-ignored +.mcp-server/ Optional local MCP server (see above) bridging the plugin's + live selection to an MCP client like Claude Desktop ``` ## Development diff --git a/code.js b/code.js index fcc61af..b347a02 100644 --- a/code.js +++ b/code.js @@ -198,29 +198,45 @@ async function describeInstance(node) { const set = main && main.parent && main.parent.type === 'COMPONENT_SET' ? main.parent : null; let variantProperties = {}; - const rawVariants = node.variantProperties || (main && main.variantProperties) || null; - if (rawVariants) { - Object.keys(rawVariants).forEach((key) => { - if (rawVariants[key] !== null && rawVariants[key] !== undefined) { - variantProperties[key] = String(rawVariants[key]); - } - }); - } else if (main && set) { - variantProperties = parseVariantName(main.name); + try { + // Figma itself throws reading `.variantProperties` ("Component set for + // node has existing errors") when the backing component set has an + // editor-side validation error (e.g. a duplicate variant combination) — + // unrelated to anything this plugin controls. Fall back to parsing the + // visible name instead of failing the whole selection over it. + const rawVariants = node.variantProperties || (main && main.variantProperties) || null; + if (rawVariants) { + Object.keys(rawVariants).forEach((key) => { + if (rawVariants[key] !== null && rawVariants[key] !== undefined) { + variantProperties[key] = String(rawVariants[key]); + } + }); + } else if (main && set) { + variantProperties = parseVariantName(main.name); + } + } catch (e) { + variantProperties = main ? parseVariantName(main.name) : {}; } const properties = {}; - if (node.componentProperties) { - Object.keys(node.componentProperties).forEach((key) => { - const prop = node.componentProperties[key]; - if (!prop) return; - // Figma suffixes property keys with a unique id ("Label#123:0") — the part - // before the "#" is the name the designer sees. - properties[key.split('#')[0]] = { - type: prop.type, - value: prop.type === 'INSTANCE_SWAP' ? String(prop.value) : prop.value, - }; - }); + try { + // Same Figma-side quirk as the variantProperties read above — throws for + // an instance of a component set that already has an editor-side error, + // regardless of what this plugin does. + if (node.componentProperties) { + Object.keys(node.componentProperties).forEach((key) => { + const prop = node.componentProperties[key]; + if (!prop) return; + // Figma suffixes property keys with a unique id ("Label#123:0") — the + // part before the "#" is the name the designer sees. + properties[key.split('#')[0]] = { + type: prop.type, + value: prop.type === 'INSTANCE_SWAP' ? String(prop.value) : prop.value, + }; + }); + } + } catch (e) { + // leave `properties` empty — better than losing the whole selection over it } const componentName = set ? set.name : main ? main.name : node.name; @@ -2681,6 +2697,45 @@ function imageMimeType(bytes) { return 'image/png'; } +// Figma's plugin sandbox can't be assumed to expose a global TextDecoder (the +// test harness's bare vm context confirmably doesn't), so decode the SVG +// export's UTF-8 bytes by hand rather than depend on a host API. +function utf8BytesToString(bytes) { + let result = ''; + let i = 0; + while (i < bytes.length) { + const byte1 = bytes[i++]; + if (byte1 < 0x80) { + result += String.fromCharCode(byte1); + } else if (byte1 >= 0xc0 && byte1 < 0xe0 && i < bytes.length) { + const byte2 = bytes[i++]; + result += String.fromCharCode(((byte1 & 0x1f) << 6) | (byte2 & 0x3f)); + } else if (byte1 >= 0xe0 && byte1 < 0xf0 && i + 1 < bytes.length) { + const byte2 = bytes[i++]; + const byte3 = bytes[i++]; + result += String.fromCharCode(((byte1 & 0x0f) << 12) | ((byte2 & 0x3f) << 6) | (byte3 & 0x3f)); + } else if (byte1 >= 0xf0 && i + 2 < bytes.length) { + const byte2 = bytes[i++]; + const byte3 = bytes[i++]; + const byte4 = bytes[i++]; + const codepoint = + ((byte1 & 0x07) << 18) | ((byte2 & 0x3f) << 12) | ((byte3 & 0x3f) << 6) | (byte4 & 0x3f); + const surrogate = codepoint - 0x10000; + result += String.fromCharCode(0xd800 + (surrogate >> 10), 0xdc00 + (surrogate & 0x3ff)); + } else { + result += String.fromCharCode(byte1); // malformed byte — best-effort passthrough + } + } + return result; +} + +// Forces the exported SVG to fill whatever box the wrapping element already +// has (from extractCss), rather than trusting its own width/height/viewBox +// attributes to line up with that box exactly. +function sizeInlineSvg(svgMarkup) { + return svgMarkup.replace(/)/, ' wants ms. + transitionDuration: + transition && typeof transition.duration === 'number' ? Math.round(transition.duration * 1000) : null, + transitionEasing: transition && transition.easing ? transition.easing.type : null, + }; +} + +function extractReactions(node) { + if (!node.reactions || node.reactions.length === 0) return []; + const out = []; + node.reactions.forEach((reaction) => { + // Older API shape: `reaction.action` (singular). Newer: `reaction.actions` + // (array). Handle both so this doesn't silently go blind on either version. + const actions = reaction.actions || (reaction.action ? [reaction.action] : []); + actions.forEach((action) => { + const normalized = normalizeReactionAction(reaction.trigger, action); + if (normalized) out.push(normalized); + }); + }); + return out; +} + +function collectReactionsForTree(root, map) { + const result = map || new Map(); + const reactions = extractReactions(root); + if (reactions.length > 0) result.set(root.id, reactions); + if (root.children) { + root.children.forEach((child) => collectReactionsForTree(child, result)); + } + return result; +} + +// A node can carry several reactions (one per trigger). Only the first one +// with a destination drives the exported bundle's click/hover wiring — this +// export targets navigate/hover/overlay, not arbitrary reaction stacking. +function reactionAttrsHtml(reactions) { + if (!reactions || reactions.length === 0) return ''; + const primary = reactions.find((r) => r.destinationId) || null; + if (!primary) return ''; + const parts = [ + ` data-reaction-trigger="${escapeAttr(primary.trigger)}"`, + ` data-reaction-target="frame-${escapeAttr(primary.destinationId)}"`, + ]; + if (primary.navigation) parts.push(` data-reaction-nav="${escapeAttr(primary.navigation)}"`); + if (primary.transitionType) parts.push(` data-reaction-transition="${escapeAttr(primary.transitionType)}"`); + if (typeof primary.transitionDuration === 'number') { + parts.push(` data-reaction-duration="${primary.transitionDuration}"`); + } + return parts.join(''); +} + function generateHtmlTree(node, usedNames, rules, warnings, indent, assets, posOverride, flexChild, gridChild, options) { const pad = ' '.repeat(indent); const className = toClassName(node.name, node.type, usedNames); @@ -2797,9 +2928,26 @@ function generateHtmlTree(node, usedNames, rules, warnings, indent, assets, posO // the recursive call below, which is the sole place it gets threaded down. const responsive = !!(options && options.responsive); const parent = responsive ? options.parent || null : null; + + // Opt-in only: unset for every existing call site (Preview/CSS/HTML/Dart + // tabs), so this never changes today's output. Only generatePrototypeBundle + // passes a reactionMap/smartKeyMap, to attach data-reaction-*/data-smart-key + // attributes read by the bundled
by default (see + // below). Only set when generateHtml's inline-SVG variant is being built. + const inlineSvg = !!(options && options.inlineSvg); const asset = assets.get(node.id); const flattenedAsset = asset && !asset.fillOnly; let assetVisualRule = null; + let inlineSvgMarkup = null; + let inlineSvgWrapperClass = null; if (responsive && !flattenedAsset) { if (!parent) applyRootFluidCss(css, node); @@ -2872,7 +3020,29 @@ function generateHtmlTree(node, usedNames, rules, warnings, indent, assets, posO if (node.width === 0) css.width = `${round(strokeWeight)}px`; if (node.height === 0) css.height = `${round(strokeWeight)}px`; } - if (asset.differsFromNodeBounds) { + if (inlineSvg && asset.svgMarkup) { + // Real markup instead of a background-image data URI — inspectable, + // and stylable via CSS (`.className path { fill: ... }`) instead of an + // opaque raster. Only ever true for the vector-only-subtree branch of + // collectAssets (image fills/rotated containers have no svgMarkup), and + // only when generateHtml's inline-SVG variant explicitly asked for it. + if (asset.differsFromNodeBounds) { + css.position = css.position || 'relative'; + delete css.overflow; + inlineSvgWrapperClass = `${className}-svg`; + rules.push( + `.${inlineSvgWrapperClass} {\n` + + ` position: absolute;\n` + + ` left: ${asset.offsetX}px;\n` + + ` top: ${asset.offsetY}px;\n` + + ` width: ${asset.width}px;\n` + + ` height: ${asset.height}px;\n` + + ` pointer-events: none;\n` + + `}` + ); + } + inlineSvgMarkup = sizeInlineSvg(asset.svgMarkup); + } else if (asset.differsFromNodeBounds) { // Keep this element at the logical Figma size so flex/absolute layout is // unchanged, while a paint-only layer carries the larger exported // render (overflowing children, strokes, and shadows included). @@ -2944,14 +3114,24 @@ function generateHtmlTree(node, usedNames, rules, warnings, indent, assets, posO // Exported as a flattened image — don't also emit its (now-redundant) vector sub-paths. if (flattenedAsset) { - return `${pad}
`; + if (inlineSvgMarkup) { + const inner = inlineSvgWrapperClass + ? `
${inlineSvgMarkup}
` + : inlineSvgMarkup; + return `${pad}
${inner}
`; + } + return `${pad}
`; } if (node.type === 'TEXT') { - return `${pad}

${escapeHtml(node.characters)}

`; + return `${pad}

${escapeHtml(node.characters)}

`; } if (hasChildren) { const inset = borderInset(node); + const childOptions = + responsive || reactionMap || smartKeyMap || inlineSvg + ? { responsive, parent: responsive ? node : null, reactionMap, smartKeyMap, inlineSvg } + : undefined; const childrenHtml = visibleChildren .map((c) => { const childNeedsAbsolute = !isAutoLayout || c.layoutPositioning === 'ABSOLUTE'; @@ -2974,34 +3154,32 @@ function generateHtmlTree(node, usedNames, rules, warnings, indent, assets, posO childOverride, !childNeedsAbsolute && !childIsGridItem, childIsGridItem, - responsive ? { responsive: true, parent: node } : undefined + childOptions ); }) .join('\n'); - return `${pad}
\n${childrenHtml}\n${pad}
`; + return `${pad}
\n${childrenHtml}\n${pad}
`; } - return `${pad}
`; + return `${pad}
`; } // Shared by both the fixed and the fluid build below — same tree walk, same // asset map (exportAsync already ran once by the time this is called), only // the `responsive` flag differs. -function buildHtmlDocument(node, assets, responsive) { +function buildHtmlDocument(node, assets, responsive, reactionMap, smartKeyMap, inlineSvg) { const usedNames = new Set(); const rules = []; const warnings = []; - const body = generateHtmlTree( - node, - usedNames, - rules, - warnings, - 0, - assets, - null, - false, - false, - responsive ? { responsive: true } : undefined - ); + const rootOptions = + responsive || reactionMap || smartKeyMap || inlineSvg + ? { + responsive: !!responsive, + reactionMap: reactionMap || null, + smartKeyMap: smartKeyMap || null, + inlineSvg: !!inlineSvg, + } + : undefined; + const body = generateHtmlTree(node, usedNames, rules, warnings, 0, assets, null, false, false, rootOptions); // Figma sizes include the stroke (its default stroke align is inside), and //

carries a default margin. Scope the reset to the generated root so @@ -3019,16 +3197,25 @@ function buildHtmlDocument(node, assets, responsive) { }; } -async function generateHtml(node) { +// `reactionMap`/`smartKeyMap` are only ever passed by generatePrototypeBundle +// — every other call site omits them, so the fixed/fluid output here is +// unchanged by default. +async function generateHtml(node, reactionMap, smartKeyMap) { const assets = new Map(); await collectAssets(node, assets); - const fixed = buildHtmlDocument(node, assets, false); + const fixed = buildHtmlDocument(node, assets, false, reactionMap, smartKeyMap); const fluid = buildHtmlDocument(node, assets, true); + // Same fixed-pixel geometry as `fixed`, just with flattened vector icons + // kept as real inline markup instead of a background-image data URI + // — opt-in, shown only when ui.html's "Inline SVG icons" toggle is on. + // Scoped to the fixed geometry only; not combined with the fluid variant. + const inlineSvgDoc = buildHtmlDocument(node, assets, false, reactionMap, smartKeyMap, true); return { html: fixed.html, warnings: fixed.warnings, responsiveHtml: fluid.html, + inlineSvgHtml: inlineSvgDoc.html, // Named, deduped asset files — see nameAssetFiles. The ui.html "download // images" action decodes these and swaps the matching data URI in the // html string above for a real assets/ path. @@ -3036,6 +3223,291 @@ async function generateHtml(node) { }; } +// ---------- Prototype export: bundling multiple frames into one clickable file ---------- + +// Combines every selected frame's assets into a single HTML file — a +// per-node cap calibrated for one-frame-at-a-time code generation +// (MAX_CODE_NODES) isn't calibrated for that combined payload, so this gets +// its own, smaller limit. +const MAX_PROTOTYPE_FRAMES = 6; + +function frameSectionId(node) { + return `frame-${node.id}`; +} + +// Vanilla JS, not generated per-node: wires up every element the reactionMap +// attached data-reaction-* attributes to, so the exported file is clickable +// on its own with no runtime dependency. +const PROTOTYPE_BUNDLE_SCRIPT = `(function () { + // Opened as a standalone file, this script's root is \`document\` itself. + // Rendered live inside the plugin's own Prototype tab, ui.html copies it + // into a shadow root instead (same CSP workaround as the Preview tab) — + // querying the outer \`document\` from there finds nothing, so every + // listener below would silently attach to zero elements. ui.html sets + // window.__figmaPrototypeRoot to that shadow root immediately before + // (re-)running this script, specifically so it can be found here; a + // standalone open never sets it, so this just falls back to \`document\`. + var root = window.__figmaPrototypeRoot || document; + function showFrame(id) { + root.querySelectorAll('.proto-frame').forEach(function (section) { + if (!section.classList.contains('proto-overlay')) section.hidden = section.id !== id; + }); + } + function applyTransition(el, type, durationMs) { + if (!durationMs) return; + if (type === 'MOVE_IN' || type === 'MOVE_OUT' || type === 'SLIDE_IN' || type === 'SLIDE_OUT' || type === 'PUSH') { + el.style.transition = 'transform ' + durationMs + 'ms ease, opacity ' + durationMs + 'ms ease'; + } else { + el.style.transition = 'opacity ' + durationMs + 'ms ease'; + } + } + // Smart Animate: instead of an instant cut, add a "smart-to-" class to + // the *source* section first — the CSS diffFramesForSmartAnimate emitted + // reacts to that class and tweens the matched elements — then cut over to + // the destination section once the transition has had time to finish. + function handleReaction(el) { + var targetId = el.getAttribute('data-reaction-target'); + if (!targetId) return; + var target = root.getElementById(targetId); + if (!target) return; + var nav = el.getAttribute('data-reaction-nav'); + var transitionType = el.getAttribute('data-reaction-transition'); + var duration = parseInt(el.getAttribute('data-reaction-duration') || '0', 10); + var cutOver = function () { + if (nav === 'OVERLAY') { + target.classList.add('proto-overlay'); + target.hidden = false; + } else { + showFrame(targetId); + } + }; + if (transitionType === 'SMART_ANIMATE') { + var source = el.closest('.proto-frame'); + var triggerClass = 'smart-to-' + targetId.replace('frame-', ''); + if (source) { + source.classList.add(triggerClass); + setTimeout(function () { + source.classList.remove(triggerClass); + cutOver(); + }, duration || 300); + return; + } + } + applyTransition(target, transitionType, duration); + cutOver(); + } + root.querySelectorAll('[data-reaction-trigger="ON_CLICK"]').forEach(function (el) { + el.style.cursor = 'pointer'; + el.addEventListener('click', function () { handleReaction(el); }); + }); + root.querySelectorAll('[data-reaction-trigger="ON_HOVER"]').forEach(function (el) { + el.addEventListener('mouseenter', function () { handleReaction(el); }); + }); +})();`; + +// ---------- Prototype export: Smart Animate diffing ---------- +// Figma's own Smart Animate matches layers between two frames by name (falling +// back to cross-dissolve for anything unmatched) and tweens each matched +// layer's own position/size/opacity/rotation/fill. This mirrors that: reuse +// the *rendered* geometry (absoluteBoundingBox via childOffsetWithin/ +// renderedSize) the fixed-pixel converter already computes, rather than +// re-deriving layout. + +// Preorder walk so occurrence order lines up exactly with buildSmartKeyMap's +// `${name}#${index}` keys below — that's what lets a duplicate layer name +// resolve to the right specific node instead of "some node with this name". +function flattenTreeByName(root, map) { + const result = map || new Map(); + const list = result.get(root.name) || []; + list.push(root); + result.set(root.name, list); + if (root.children) root.children.forEach((child) => flattenTreeByName(child, result)); + return result; +} + +function buildSmartKeyMap(root, counts, map) { + const countMap = counts || new Map(); + const result = map || new Map(); + const occurrence = countMap.get(root.name) || 0; + countMap.set(root.name, occurrence + 1); + result.set(root.id, `${root.name}#${occurrence}`); + if (root.children) root.children.forEach((child) => buildSmartKeyMap(child, countMap, result)); + return result; +} + +function solidFillHex(node) { + const fill = getSolidFill(node); + return fill && fill.color ? rgbToHex(fill.color, fill.opacity) : null; +} + +// Figma's easing curve names, approximated with the nearest CSS keyword — +// exact bezier control points aren't exposed on every easing type. +const SMART_ANIMATE_EASING_CSS = { + EASE_IN: 'ease-in', + EASE_OUT: 'ease-out', + EASE_IN_AND_OUT: 'ease-in-out', + LINEAR: 'linear', + GENTLE: 'ease-in-out', + QUICK: 'ease-out', + BOUNCY: 'ease-out', + SLOW: 'ease-in-out', +}; + +// Diffs two *different* frames' subtrees (not two variants of the same node), +// matched by layer name + occurrence order. Returns CSS that, scoped to +// `source`'s section gaining a `smart-to-` class, tweens every +// matched-and-changed layer toward `dest`'s values; unmatched layers are left +// alone (they just cut over with the section swap — a cross-dissolve +// approximation, not a real match). +function diffFramesForSmartAnimate(source, dest, durationMs, easingType) { + const namesA = flattenTreeByName(source); + const namesB = flattenTreeByName(dest); + const duration = typeof durationMs === 'number' && durationMs > 0 ? durationMs : 300; + const easing = SMART_ANIMATE_EASING_CSS[easingType] || 'ease-in-out'; + const rules = []; + let unmatchedCount = 0; + + namesA.forEach((listA, name) => { + const listB = namesB.get(name); + if (!listB) { + unmatchedCount += listA.length; + return; + } + const count = Math.min(listA.length, listB.length); + unmatchedCount += listA.length - count; + for (let i = 0; i < count; i++) { + const nodeA = listA[i]; + const nodeB = listB[i]; + const posA = childOffsetWithin(source, nodeA); + const posB = childOffsetWithin(dest, nodeB); + const sizeA = renderedSize(nodeA); + const sizeB = renderedSize(nodeB); + const opacityA = typeof nodeA.opacity === 'number' ? nodeA.opacity : 1; + const opacityB = typeof nodeB.opacity === 'number' ? nodeB.opacity : 1; + const rotationA = nodeA.rotation || 0; + const rotationB = nodeB.rotation || 0; + const fillB = solidFillHex(nodeB); + + const changed = + round(posA.left) !== round(posB.left) || + round(posA.top) !== round(posB.top) || + round(sizeA.width) !== round(sizeB.width) || + round(sizeA.height) !== round(sizeB.height) || + opacityA !== opacityB || + rotationA !== rotationB || + fillB !== solidFillHex(nodeA); + if (!changed) continue; + + const declarations = [ + `left: ${round(posB.left)}px`, + `top: ${round(posB.top)}px`, + `width: ${round(sizeB.width)}px`, + `height: ${round(sizeB.height)}px`, + `opacity: ${opacityB}`, + ]; + if (rotationA !== rotationB) declarations.push(`transform: rotate(${round(-rotationB)}deg)`); + if (fillB) declarations.push(`background-color: ${fillB}`); + + rules.push( + `#${frameSectionId(source)}.smart-to-${dest.id} [data-smart-key="${escapeAttr(`${name}#${i}`)}"] {\n ${declarations.join(';\n ')};\n}` + ); + } + }); + + const transitionRule = + `#${frameSectionId(source)} [data-smart-key] {\n` + + ` transition: left ${duration}ms ${easing}, top ${duration}ms ${easing}, width ${duration}ms ${easing},\n` + + ` height ${duration}ms ${easing}, opacity ${duration}ms ${easing}, transform ${duration}ms ${easing},\n` + + ` background-color ${duration}ms ${easing};\n` + + `}`; + + return { + css: [transitionRule].concat(rules).join('\n\n'), + warnings: + unmatchedCount > 0 + ? [ + `Smart Animate from "${source.name}" to "${dest.name}": ${unmatchedCount} layer(s) have no name match in the destination frame and will cut over instead of tween (approximation of Figma's own name-matching).`, + ] + : [], + }; +} + +// Only called when the user's selection is multiple top-level frames — see +// buildSelectionPayload. Reuses the exact same per-frame generateHtml path +// as the single-node HTML tab, just wrapped so reactions can jump between +// the resulting sections. +async function generatePrototypeBundle(frames) { + const eligible = (frames || []).filter((node) => node && typeof node.id === 'string'); + const included = eligible.slice(0, MAX_PROTOTYPE_FRAMES); + const warnings = []; + if (eligible.length > included.length) { + warnings.push( + `Prototype export includes only the first ${MAX_PROTOTYPE_FRAMES} selected frames; ${ + eligible.length - included.length + } more were skipped.` + ); + } + + const byId = new Map(included.map((frame) => [frame.id, frame])); + const frameReactionMaps = new Map(included.map((frame) => [frame.id, collectReactionsForTree(frame)])); + + // Every (source frame -> destination frame) pair that has at least one + // SMART_ANIMATE reaction between two frames both present in this bundle. + const smartPairs = []; + const seenPairKeys = new Set(); + frameReactionMaps.forEach((reactionMap, sourceId) => { + reactionMap.forEach((reactions) => { + reactions.forEach((reaction) => { + if (reaction.transitionType !== 'SMART_ANIMATE' || !byId.has(reaction.destinationId)) return; + const key = `${sourceId}->${reaction.destinationId}`; + if (seenPairKeys.has(key)) return; + seenPairKeys.add(key); + smartPairs.push({ + source: byId.get(sourceId), + dest: byId.get(reaction.destinationId), + duration: reaction.transitionDuration, + easing: reaction.transitionEasing, + }); + }); + }); + }); + + const smartKeyFrameIds = new Set(); + smartPairs.forEach((pair) => { + smartKeyFrameIds.add(pair.source.id); + smartKeyFrameIds.add(pair.dest.id); + }); + + const sections = []; + for (let i = 0; i < included.length; i++) { + const frame = included[i]; + const reactionMap = frameReactionMaps.get(frame.id); + const smartKeyMap = smartKeyFrameIds.has(frame.id) ? buildSmartKeyMap(frame) : null; + const result = await generateHtml(frame, reactionMap, smartKeyMap); + result.warnings.forEach((w) => warnings.push(w)); + sections.push( + `
\n${result.html}\n
` + ); + } + + const smartCss = smartPairs.map((pair) => { + const diff = diffFramesForSmartAnimate(pair.source, pair.dest, pair.duration, pair.easing); + diff.warnings.forEach((w) => warnings.push(w)); + return diff.css; + }); + + const html = + `

\n${sections.join('\n')}\n
\n\n` + + `\n\n` + + ``; + + return { + html, + warnings: warnings.filter((w, i, a) => a.indexOf(w) === i), + }; +} + // ---------- Compact JSON for AI ---------- function buildCompactNode(node, bindings) { @@ -3352,6 +3824,10 @@ async function buildNodePayload(node, bindings, extraWarnings) { dart: dartResult.responsiveDart, html: htmlResult.responsiveHtml, }, + // Opt-in: same fixed-pixel geometry as `html` above, but flattened vector + // icons stay real inline markup instead of a background-image data + // URI — never used unless ui.html's "Inline SVG icons" toggle is on. + inlineSvgHtml: htmlResult.inlineSvgHtml, // Real, named, downloadable files for whatever collectAssets/ // collectFlutterAssets embedded as base64 above — the ui.html "Download // images" action decodes these and rewrites the copied code to reference @@ -3389,6 +3865,23 @@ async function buildSelectionPayload(nodes) { payloads.push(await buildNodePayload(node, bindings, fontWarnings)); } + // Opt-in only: a single-node selection (today's only supported case in the + // existing tabs) never gets this key, so `nodes`/everything else above is + // unchanged. Only a multi-frame selection is eligible for the bundled, + // clickable "Prototype" export — see generatePrototypeBundle. + // A bug in this newer, less-battle-tested path must never take the rest of + // the payload down with it — sendSelection's caller only has one try/catch + // for the *whole* payload, and a rejection there blanks every tab (ui.html + // treats any {type:'error'} the same as no selection), not just this one. + let prototype = null; + if (nodes.length > 1) { + try { + prototype = await generatePrototypeBundle(nodes); + } catch (e) { + prototype = { html: '', warnings: [`Prototype export failed: ${String(e && e.message ? e.message : e)}`] }; + } + } + return { type: 'selection', nodeName: payloads.length > 0 ? payloads[0].nodeName : '', @@ -3397,6 +3890,7 @@ async function buildSelectionPayload(nodes) { skippedNames: nodes.slice(coded.length).map((node) => node.name), nodes: payloads, designSystem, + prototype, }; } @@ -3407,18 +3901,26 @@ let selectionGeneration = 0; async function sendSelection() { const generation = ++selectionGeneration; - // Variables and styles are editable while the plugin is open, so resolve them - // fresh per selection rather than serving a rename from cache. The cache still - // does its job *within* one selection, where the same token is hit repeatedly. - variableCache.clear(); - variableCollectionCache.clear(); - figmaStyleCache.clear(); - const selection = figma.currentPage.selection; - if (selection.length === 0) { - figma.ui.postMessage({ type: 'empty' }); - return; - } + // Everything below used to start outside this try (only the + // buildSelectionPayload call itself was guarded) — any failure reading the + // selection or clearing the caches was an *unhandled* promise rejection: + // no 'error' message, no 'empty' message, nothing. ui.html would just sit + // on whatever it last rendered (or its static "Select an element..." + // placeholder if this was the first selection since the panel opened), + // giving no indication anything had gone wrong. try { + // Variables and styles are editable while the plugin is open, so resolve + // them fresh per selection rather than serving a rename from cache. The + // cache still does its job *within* one selection, where the same token + // is hit repeatedly. + variableCache.clear(); + variableCollectionCache.clear(); + figmaStyleCache.clear(); + const selection = figma.currentPage.selection; + if (selection.length === 0) { + if (generation === selectionGeneration) figma.ui.postMessage({ type: 'empty' }); + return; + } const payload = await buildSelectionPayload(selection.slice()); if (generation === selectionGeneration) { figma.ui.postMessage(payload); @@ -3432,3 +3934,56 @@ async function sendSelection() { figma.on('selectionchange', sendSelection); sendSelection(); + +// Independent of the selection-driven push above: a lightweight map of every +// top-level frame on the page (grouped by Figma Section, where the designer +// used one), for planning across a whole app instead of one selection at a +// time. Only ever sent when the UI explicitly asks for it ('scan-page'), +// never automatically — unlike the selection push, this exposes the file's +// whole structure rather than just what the user selected, so it needs an +// explicit opt-in click (Settings > MCP Connect > Scan page). +function describePageChild(node) { + return { + name: node.name, + nodeType: node.type, + x: node.x, + y: node.y, + width: node.width, + height: node.height, + visible: node.visible, + }; +} + +function buildPageOverview() { + const sections = []; + const ungroupedScreens = []; + for (const child of figma.currentPage.children) { + if (child.type === 'SECTION') { + sections.push({ + name: child.name, + x: child.x, + y: child.y, + width: child.width, + height: child.height, + screens: child.children.map(describePageChild), + }); + } else { + ungroupedScreens.push(describePageChild(child)); + } + } + return { + type: 'pageOverview', + pageName: figma.currentPage.name, + sections, + ungroupedScreens, + }; +} + +figma.ui.onmessage = (msg) => { + if (!msg || msg.type !== 'scan-page') return; + try { + figma.ui.postMessage(buildPageOverview()); + } catch (e) { + figma.ui.postMessage({ type: 'pageOverviewError', message: String(e && e.message ? e.message : e) }); + } +}; diff --git a/manifest.json b/manifest.json index c29cc60..630431f 100644 --- a/manifest.json +++ b/manifest.json @@ -7,8 +7,8 @@ "ui": "ui.html", "editorType": ["figma"], "networkAccess": { - "allowedDomains": ["http://localhost:8787"], + "allowedDomains": ["http://localhost:8787", "ws://localhost:8788"], "devAllowedDomains": ["*"], - "reasoning": "Optional local debug server on the user's own machine, used only while the Connect button is toggled on, to dump the selected layers for troubleshooting. Nothing is sent anywhere by default and no data leaves the user's own machine." + "reasoning": "Optional local servers on the user's own machine, used only while the matching Connect button in Settings is toggled on: localhost:8787 dumps the selected layers for troubleshooting, localhost:8788 is a WebSocket bridge to a local MCP server so an AI client can read the current selection. Nothing is sent anywhere by default and no data leaves the user's own machine." } } diff --git a/tests/code-regression.test.js b/tests/code-regression.test.js index ec2061b..415e04b 100644 --- a/tests/code-regression.test.js +++ b/tests/code-regression.test.js @@ -89,6 +89,13 @@ globalThis.__testApi = { generateDartForNode, generateDart, buildDesignSystem, + extractReactions, + collectReactionsForTree, + generatePrototypeBundle, + diffFramesForSmartAnimate, + buildSmartKeyMap, + utf8BytesToString, + sizeInlineSvg, };`, context, { filename: 'code.js' } @@ -117,6 +124,20 @@ async function run() { /cssFromGeneratedHtml\(htmlCode\) \|\|[\s\S]*?formatCss\(source\.css\)/ ); assert.match(uiSource, /id="exclude-base64-toggle"/); + assert.match(uiSource, /data-tab="prototype"/); + assert.match(uiSource, /id="panel-prototype"/); + assert.match(uiSource, /renderPrototype\(message\)/); + assert.match( + uiSource, + /oldScript\.replaceWith\(newScript\)/ + ); + assert.match(uiSource, /window\.__figmaPrototypeRoot = host\.shadowRoot;/); + assert.match(source, /var root = window\.__figmaPrototypeRoot \|\| document;/); + assert.match(uiSource, /id="inline-svg-toggle"/); + assert.match( + uiSource, + /if \(inlineSvgMode && !responsiveMode && payload\.inlineSvgHtml\) source\.html = payload\.inlineSvgHtml;/ + ); assert.match( uiSource, /excludeBase64[\s\S]*?rewriteHtmlWithAssetPaths\(source\.html, payload\.htmlAssets \|\| \[\]\)[\s\S]*?rewriteDartWithAssetPaths\(source\.dart, payload\.dartAssets \|\| \[\]\)/ @@ -938,6 +959,254 @@ async function run() { assert.match(anchorDart.responsiveDart, /FractionallySizedBox\(/); // SCALE assert.match(anchorDart.responsiveDart, /widthFactor: 0\.25,/); + // ---------- Prototype export: reactions ---------- + const dissolveReaction = { + trigger: { type: 'ON_CLICK' }, + action: { + type: 'NODE', + navigation: 'NAVIGATE', + destinationId: 'frame-b', + transition: { type: 'DISSOLVE', duration: 0.3, easing: { type: 'EASE_OUT' } }, + }, + }; + const buttonNode = { + id: 'button-a', + type: 'FRAME', + name: 'Button', + width: 100, + height: 40, + x: 10, + y: 10, + opacity: 1, + visible: true, + fills: [], + strokes: [], + effects: [], + absoluteBoundingBox: { x: 10, y: 10, width: 100, height: 40 }, + reactions: [dissolveReaction], + children: [], + }; + const frameANode = { + id: 'frame-a', + type: 'FRAME', + name: 'Frame A', + width: 400, + height: 300, + x: 0, + y: 0, + opacity: 1, + visible: true, + fills: [], + strokes: [], + effects: [], + absoluteBoundingBox: { x: 0, y: 0, width: 400, height: 300 }, + children: [buttonNode], + }; + + const buttonReactions = api.extractReactions(buttonNode); + assert.equal(buttonReactions.length, 1); + assert.equal(buttonReactions[0].trigger, 'ON_CLICK'); + assert.equal(buttonReactions[0].navigation, 'NAVIGATE'); + assert.equal(buttonReactions[0].destinationId, 'frame-b'); + assert.equal(buttonReactions[0].transitionType, 'DISSOLVE'); + assert.equal(buttonReactions[0].transitionDuration, 300); + assert.equal(buttonReactions[0].transitionEasing, 'EASE_OUT'); + assert.equal(api.extractReactions(frameANode).length, 0); // no reactions of its own — untouched by default + + const reactionMap = api.collectReactionsForTree(frameANode); + assert.equal(reactionMap.size, 1); + assert.equal(reactionMap.get('button-a')[0].destinationId, 'frame-b'); + + const singleFrameBundle = await api.generatePrototypeBundle([frameANode]); + assert.match(singleFrameBundle.html, /
/); + assert.match(singleFrameBundle.html, /data-reaction-trigger="ON_CLICK"/); + assert.match(singleFrameBundle.html, /data-reaction-target="frame-frame-b"/); + assert.match(singleFrameBundle.html, /data-reaction-transition="DISSOLVE"/); + assert.match(singleFrameBundle.html, /data-reaction-duration="300"/); + + // ---------- Prototype export: Smart Animate diffing ---------- + const boxInC = { + id: 'box-c', + type: 'RECTANGLE', + name: 'Box', + width: 50, + height: 50, + x: 0, + y: 0, + opacity: 1, + visible: true, + fills: [solid(1, 0, 0)], + strokes: [], + effects: [], + absoluteBoundingBox: { x: 0, y: 0, width: 50, height: 50 }, + children: [], + }; + const boxInD = { + ...boxInC, + id: 'box-d', + absoluteBoundingBox: { x: 200, y: 100, width: 50, height: 50 }, + }; + const triggerInC = { + id: 'trigger-c', + type: 'FRAME', + name: 'Trigger', + width: 20, + height: 20, + x: 0, + y: 0, + opacity: 1, + visible: true, + fills: [], + strokes: [], + effects: [], + absoluteBoundingBox: { x: 0, y: 0, width: 20, height: 20 }, + reactions: [ + { + trigger: { type: 'ON_CLICK' }, + action: { + type: 'NODE', + navigation: 'CHANGE_TO', + destinationId: 'frame-d', + transition: { type: 'SMART_ANIMATE', duration: 0.4, easing: { type: 'EASE_IN_AND_OUT' } }, + }, + }, + ], + children: [], + }; + const frameCNode = { + id: 'frame-c', + type: 'FRAME', + name: 'Frame C', + width: 400, + height: 300, + x: 0, + y: 0, + opacity: 1, + visible: true, + fills: [], + strokes: [], + effects: [], + absoluteBoundingBox: { x: 0, y: 0, width: 400, height: 300 }, + children: [triggerInC, boxInC], + }; + const frameDNode = { + id: 'frame-d', + type: 'FRAME', + name: 'Frame D', + width: 400, + height: 300, + x: 0, + y: 0, + opacity: 1, + visible: true, + fills: [], + strokes: [], + effects: [], + absoluteBoundingBox: { x: 0, y: 0, width: 400, height: 300 }, + children: [boxInD], + }; + + assert.equal(api.buildSmartKeyMap(frameCNode).get('box-c'), 'Box#0'); + + const diff = api.diffFramesForSmartAnimate(frameCNode, frameDNode, 400, 'EASE_IN_AND_OUT'); + assert.match( + diff.css, + /#frame-frame-c\.smart-to-frame-d \[data-smart-key="Box#0"\] \{\n\s*left: 200px;\n\s*top: 100px;/ + ); + assert.match(diff.css, /transition: left 400ms ease-in-out/); + // "Frame C"/"Frame D" (differently-named roots) and "Trigger" (only in C) + // have no counterpart — cross-dissolve fallback, not a tween. + assert.equal(diff.warnings.length, 1); + assert.match(diff.warnings[0], /2 layer\(s\) have no name match/); + + const smartBundle = await api.generatePrototypeBundle([frameCNode, frameDNode]); + assert.match(smartBundle.html, /
/); + assert.match(smartBundle.html, /
.*<\/div>/ + ); + assert.doesNotMatch(plainIconResult.inlineSvgHtml, /background-image/); + + // A shadow/stroke can make the exported render bigger than the node's own + // layout box — the default falls back to a ::before background trick; the + // inline-SVG variant needs its own absolutely-positioned wrapper instead + // (a pseudo-element can't hold real child markup). + const boundsIconSvg = ''; + const boundsIconNode = { + id: 'vector-bounds', + type: 'VECTOR', + name: 'badge_icon', + width: 16, + height: 16, + x: 0, + y: 0, + opacity: 1, + visible: true, + fills: [solid(0, 0, 0)], + strokes: [], + effects: [], + absoluteBoundingBox: { x: 0, y: 0, width: 16, height: 16 }, + absoluteRenderBounds: { x: -4, y: -4, width: 24, height: 24 }, + children: [], + async exportAsync() { + return stringToBytes(boundsIconSvg); + }, + }; + + const boundsIconResult = await api.generateHtml(boundsIconNode); + assert.match(boundsIconResult.html, /::before/); // default still uses the pseudo-element background trick + assert.match(boundsIconResult.inlineSvgHtml, /
/); + assert.match( + boundsIconResult.inlineSvgHtml, + /\.badge-icon-svg \{\n\s*position: absolute;\n\s*left: -4px;/ + ); + console.log('code regression tests passed'); } diff --git a/ui.html b/ui.html index a8a4e0d..3cbe8ba 100644 --- a/ui.html +++ b/ui.html @@ -117,6 +117,29 @@ padding: 6px 12px; border-bottom: 1px solid #e6e6e6; } + .subtabs { + display: flex; + border-bottom: 1px solid #e6e6e6; + margin: -12px -12px 12px -12px; + } + .subtab { + flex: 1; + padding: 8px 0; + text-align: center; + cursor: pointer; + background: none; + border: none; + font-size: 11px; + font-weight: 500; + color: #6b6b6b; + border-bottom: 2px solid transparent; + } + .subtab.active { + color: #18a0fb; + border-bottom-color: #18a0fb; + } + .subpanel { display: none; } + .subpanel.active { display: block; } .preview-label { font-weight: 600; font-size: 11px; @@ -212,6 +235,8 @@ body { background: #2c2c2c; color: #eaeaea; } .tabs { border-bottom-color: #444; } .tab { color: #9a9a9a; } + .subtabs { border-bottom-color: #444; } + .subtab { color: #9a9a9a; } pre.code-block { background: #1e1e1e; border-color: #444; color: #eaeaea; } .select-row { border-bottom-color: #444; } .select-row select { background: #1e1e1e; border-color: #444; color: #eaeaea; } @@ -235,6 +260,8 @@ + +
+
Select an element on the canvas to inspect it.
@@ -356,12 +390,658 @@
+ +
+
Prototype
+
+ Bundles every top-level frame in the current selection into one clickable HTML file — Navigate/Overlay + reactions become real click/hover behavior, and Smart Animate transitions tween matching layers by name. +
+
Select 2 or more frames to build a prototype.
+
+ + + +
+ + + +
+
+ + +
+ +
+
+ Streams every selection (including the exported preview PNG) to a local debug server for filing bug reports. + Off by default — see README.md for how to start .debug-server/debug-server.py. +
+
+ + Not connected — every selection stays local until you connect. +
+
+ +
+
+ Runs a local MCP server so an AI client (e.g. Claude Desktop) can pull whatever it needs from the current Figma + selection: generated CSS/HTML/Dart (fixed-pixel and responsive), design tokens, the clickable prototype bundle, + and real asset/preview PNG files written straight to disk — enough to build the whole thing, not just read it. + Requires Python 3 on your machine — off by default, and no data leaves it. +
+
1. Get the script
+
+ Copy or download mcp_server.py, save it anywhere, then run once in a terminal (a virtual + environment avoids the "externally-managed-environment" error plain pip install hits on modern + macOS/Homebrew Python): +
+
python3 -m venv venv
+source venv/bin/activate
+python3 -m ensurepip --upgrade
+pip install mcp websockets
+python3 mcp_server.py
+ +
+ The ensurepip line is only needed if pip install errors with + command not found: pip (some Python builds don't bootstrap pip into a new venv automatically) — + skip it if pip --version already works. If ensurepip itself then fails too (some + Homebrew Python builds strip pip's bundled installer wheel), fall back to + curl -sS https://bootstrap.pypa.io/get-pip.py -o get-pip.py && python3 get-pip.py instead, + then retry pip install mcp websockets. Next time, just + source venv/bin/activate && python3 mcp_server.py — no need to reinstall. In step 3's + MCP client config, point command at this venv's own venv/bin/python3, not the + system one. +
+
#!/usr/bin/env python3
+"""Local MCP server for the FigmaToCode plugin.
+
+Bridges an MCP client (e.g. Claude Desktop) to whatever is currently
+selected in Figma. The FigmaToCode plugin's Settings > MCP Connect panel
+pushes the live selection to this process over a WebSocket; this script
+re-exposes the last snapshot it received as MCP tools over stdio, and can
+write real files (code, decoded image assets, the ground-truth preview PNG)
+to disk — something the plugin's own browser-sandboxed UI can't do itself.
+
+Setup:
+    pip install mcp websockets
+    python3 mcp_server.py
+
+Then, in Figma: FigmaToCode > Settings > MCP Connect > Connect.
+And point your MCP client at this script over stdio, e.g. in Claude
+Desktop's claude_desktop_config.json:
+    "figma-to-code": {
+      "command": "python3",
+      "args": ["/absolute/path/to/mcp_server.py"]
+    }
+"""
+
+import asyncio
+import base64
+import json
+import os
+import threading
+
+import websockets
+
+# mcp 2.x renamed FastMCP to MCPServer (different import path, same .tool()/
+# .run() shape used below) — support whichever the user's `pip install mcp`
+# actually resolved to instead of pinning a version.
+try:
+    from mcp.server.fastmcp import FastMCP as MCPServer
+except ImportError:
+    from mcp.server.mcpserver import MCPServer
+
+WS_PORT = 8788
+
+# The most recent selection and whole-page-scan payloads pushed by the
+# plugin's ui.html over the WebSocket below. Only one Figma tab is ever
+# expected to connect at a time, so a single shared value per kind (not a
+# per-connection cache) is enough.
+_latest_selection = None
+_latest_page_overview = None
+
+
+async def _handle_plugin_connection(websocket):
+    global _latest_selection, _latest_page_overview
+    async for message in websocket:
+        try:
+            payload = json.loads(message)
+        except ValueError:
+            continue
+        if payload.get("type") == "pageOverview":
+            _latest_page_overview = payload
+        else:
+            _latest_selection = payload
+
+
+def _run_websocket_bridge():
+    # Own event loop in a background thread so it never blocks the MCP
+    # server's stdio event loop started from __main__ below.
+    async def main():
+        # websockets' 1 MiB default max_size drops any selection whose
+        # payload (preview PNG + per-node CSS/HTML/Dart + image assets, all
+        # base64) runs bigger than that — trivially easy with just a couple
+        # of image-heavy layers selected. Unbounded is fine here: this only
+        # ever accepts connections from localhost.
+        async with websockets.serve(_handle_plugin_connection, "localhost", WS_PORT, max_size=None):
+            await asyncio.Future()
+
+    asyncio.run(main())
+
+
+# Read by the MCP client as up-front, standing context for this whole
+# server — unlike a tool docstring, which the client only sees once it has
+# already decided to call that tool. Without this, a client with no other
+# signal tends to fall back to asking the user for a screenshot or a written
+# description instead of calling these tools, even though the *exact* code,
+# layout data, and real assets are one tool call away.
+INSTRUCTIONS = """\
+This server gives you the live selection from a Figma plugin (FigmaToCode) — \
+real generated code, design tokens, and image assets, not a picture to \
+reverse-engineer. If you're about to ask the user for a screenshot, a design \
+description, or "what should this look like" — stop and call \
+list_selected_nodes() first. If it returns nodes, use this server instead of \
+guessing.
+
+Planning across a whole app (multiple screens), not just one selection: \
+call list_page_screens() first. It's the closest thing to a screenshot of \
+the whole file without pulling one — every top-level frame's name, position \
+and size, grouped by Figma Section where the designer used one (a \
+Section's name is usually the flow/feature it groups, e.g. "Onboarding" or \
+"Checkout"). Use it to decide what exists, how screens relate spatially, \
+and what order to tackle them in, before following the per-selection \
+workflow below for whichever one you pick. Unlike the selection tools, this \
+requires the user to have clicked Settings > MCP Connect > Scan page at \
+least once — it isn't sent automatically, since it exposes the file's whole \
+structure rather than just what's selected.
+
+Standard workflow:
+1. list_selected_nodes() — see what's selected, by index.
+2. get_node_metadata(index) — width/height/warnings. Compare width/height \
+against common device viewports (e.g. ~375-430 wide, ~700-930 tall) to tell \
+a full-screen layout from an isolated component/card — a node sized like a \
+phone screen should become the screen's root container, not something \
+nested inside one.
+3. get_design_tokens() / get_design_export(format) — pull real colors, \
+type scale, and spacing before inventing any values of your own.
+4. export_node(index, output_dir, output) or export_selection(output_dir, \
+output) — write ready-to-use code plus a real assets/ folder (images \
+already rewritten to real file paths, never left as inline base64) and a \
+preview.png. Prefer this over get_node_code when you're actually building \
+something, not just inspecting. get_selection_code(indices, output) is the \
+lighter-weight option for inspecting several layers at once without writing \
+files — it still strips inline base64 to an assets/ placeholder path, so it \
+stays safe to use even with a small context window.
+5. get_prototype_html() — when 2+ frames were selected together, this is \
+the ground truth for navigation between screens (which reactions/links go \
+where) — use it instead of guessing screen flow, and to see which layers \
+Figma's own prototype keeps fixed across screens (a strong signal for a \
+persistent bottom navigation bar or header).
+
+Things Figma's fixed-pixel export does NOT encode, so infer them instead of \
+copying pixels blindly:
+- Scrolling: nothing marks a layer as scrollable. A tall stack of content \
+inside a phone-sized frame almost always means the frame's body scrolls \
+and any nav bar / tab bar pinned to the same edge across every screen in \
+get_prototype_html should stay fixed (position: sticky/fixed) instead of \
+scrolling with it.
+- Full-bleed vs. centered: a node whose width/height matches a device \
+viewport should fill the screen (100vw/100dvh or equivalent); don't leave \
+it boxed in a fixed-pixel container sized to the Figma frame.
+- Responsive: request output="responsive_css"/"responsive_html"/\
+"responsive_dart" from get_node_code (or export_node/export_selection with \
+a *_dart output for Flutter) for a fluid, Fill/Hug/Constraints-based layout \
+instead of the fixed-pixel default — use this whenever the target is a real \
+app/site meant to run at more than one exact size, not a pixel-perfect \
+match of the Figma canvas.
+
+The rule for everything visual — theme, color, type, spacing, reusable \
+components, assets — is: it comes from Figma via these tools, always, on \
+both web and mobile. Never invent a color/font-size/spacing value, and \
+never hand-roll a component Figma already defines once and reuses. \
+Everything else (business logic, state, data fetching, routing beyond what \
+get_prototype_html encodes, backend integration) is normal engineering \
+judgment — this server has no opinion on it.
+
+Theme, once, shared by every platform you're building for:
+- Call get_design_tokens() and get_design_export() for the *whole* \
+selection before writing any component, not per-node — a theme built once \
+and imported everywhere keeps web and mobile visually identical, instead of \
+each screen/component silently drifting from the last.
+- get_design_export(format="css") → CSS custom properties, for a web \
+target (or feed those values into your own Tailwind config / styled-\
+components theme / CSS-in-JS tokens object — same values, whichever your \
+stack uses). get_design_export(format="flutter") → a ThemeData + pubspec \
+font snippet, for a Flutter/mobile target. Same underlying tokens, just two \
+renderings — use both if you're building both platforms from one selection.
+- Afterwards, every hex color, font-size, and spacing value in the code you \
+write should trace back to one of these tokens (a CSS var / Theme property \
+/ Dart constant) — not a bare literal typed by hand. If a value doesn't \
+appear in get_design_tokens() at all, that's a real signal it's a one-off \
+override, not a token — keep it inline rather than inventing a fake token \
+for it.
+
+Reusable components:
+- get_design_tokens()["componentLibrary"] lists every Figma component used \
+in the selection with a usageCount. usageCount > 1 means: build it once (a \
+React/Vue/web component, a Flutter widget — whatever your stack's reuse \
+unit is) and reuse it everywhere it appears, instead of copy-pasting the \
+markup/widget tree at each occurrence.
+- get_design_export(format="components") returns ready-made Dart widget \
+scaffolds for that same component library — a starting point for the \
+Flutter side; port the same componentization to whatever you're building \
+for web instead of re-deriving it from scratch.
+
+Assets, on every platform: always go through export_node/export_selection \
+(never ship get_node_code's raw output as-is if it contains images) so \
+every image lands as a real file under assets/ with the code already \
+pointing at it — never inline base64 in anything you actually ship.
+
+Verify before calling it done, every time:
+1. Compare your result against the ground-truth render: export_node/\
+export_selection already writes preview.png; open it (or save_preview_image \
+for a single layer) and check your generated UI actually matches it — \
+layout, spacing, colors — not just "looks plausible".
+2. Re-check for stray hardcoded values: grep the code you wrote for hex \
+colors / raw px-or-pt sizes that aren't one of get_design_tokens()'s \
+values — anything left over either belongs in the theme or is a genuine \
+one-off, decide which, don't leave it unexamined.
+3. Confirm no leftover inline base64 made it into shipped code — only \
+export_node/export_selection's rewritten output, never get_node_code's raw \
+html/dart, should end up in a component that uses images.
+4. Re-read get_node_metadata(index).warnings for every node you used and \
+either address what they flag (a flattened layer, a missing font, etc.) or \
+consciously decide it doesn't matter here — don't silently drop them.
+"""
+
+mcp = MCPServer("figma-to-code", instructions=INSTRUCTIONS)
+
+
+def _selection_or_raise():
+    if not _latest_selection or not _latest_selection.get("nodes"):
+        raise ValueError(
+            "No selection available yet. In Figma, open the FigmaToCode plugin, "
+            "select a layer, and toggle Settings > MCP Connect > Connect."
+        )
+    return _latest_selection
+
+
+def _node_or_raise(index: int) -> dict:
+    nodes = _selection_or_raise()["nodes"]
+    if index < 0 or index >= len(nodes):
+        raise ValueError(f"index {index} out of range: selection has {len(nodes)} node(s)")
+    return nodes[index]
+
+
+# code/output keys, by the `output` string a caller passes in.
+_CODE_FIELDS = {
+    "css": lambda n: "\n".join(f"{key}: {value};" for key, value in n["css"].items()),
+    "html": lambda n: n["html"],
+    "dart": lambda n: n["dart"],
+    "inline_svg_html": lambda n: n["inlineSvgHtml"],
+    "responsive_css": lambda n: "\n".join(f"{key}: {value};" for key, value in n["responsive"]["css"].items()),
+    "responsive_html": lambda n: n["responsive"]["html"],
+    "responsive_dart": lambda n: n["responsive"]["dart"],
+}
+
+# Per-output asset lists and the file extension code should be saved under.
+_ASSET_FIELDS = {"html": "htmlAssets", "inline_svg_html": "htmlAssets", "dart": "dartAssets"}
+_FILE_EXT = {
+    "css": "css",
+    "html": "html",
+    "dart": "dart",
+    "inline_svg_html": "html",
+    "responsive_css": "css",
+    "responsive_html": "html",
+    "responsive_dart": "dart",
+}
+
+
+def _safe_dir(output_dir: str) -> str:
+    path = os.path.abspath(os.path.expanduser(output_dir))
+    os.makedirs(path, exist_ok=True)
+    return path
+
+
+def _get_assets(node: dict, output: str) -> list:
+    key = _ASSET_FIELDS.get(output)
+    return node.get(key, []) if key else []
+
+
+def _write_assets(assets: list, assets_dir: str) -> list:
+    if not assets:
+        return []
+    os.makedirs(assets_dir, exist_ok=True)
+    written = []
+    for asset in assets:
+        # basename() strips any accidental path segments out of a filename
+        # that ultimately came from a Figma layer name.
+        filename = os.path.basename(asset["filename"])
+        file_path = os.path.join(assets_dir, filename)
+        with open(file_path, "wb") as f:
+            f.write(base64.b64decode(asset["base64"]))
+        written.append(file_path)
+    return written
+
+
+# Mirrors rewriteHtmlWithAssetPaths/rewriteDartWithAssetPaths in ui.html
+# exactly, so code exported here matches what the plugin's own "Download
+# images + copy code" button produces — real files, not inline base64. The
+# data URI (or, for Dart, the whole Image.memory/MemoryImage call) is unique
+# per asset, so a literal string replace is exact and needs no regex.
+def _rewrite_html_with_asset_paths(html: str, assets: list) -> str:
+    result = html
+    for asset in assets:
+        result = result.replace(asset["dataUri"], f"assets/{asset['filename']}")
+    return result
+
+
+def _rewrite_dart_with_asset_paths(dart: str, assets: list) -> str:
+    result = dart
+    for asset in assets:
+        if asset.get("fillOnly"):
+            old_provider = f"MemoryImage(base64Decode('{asset['base64']}'))"
+            new_provider = f"AssetImage('assets/{asset['filename']}')"
+            result = result.replace(old_provider, new_provider)
+            continue
+        old_call = (
+            f"Image.memory(base64Decode('{asset['base64']}'), width: {asset['width']}, "
+            f"height: {asset['height']}, fit: BoxFit.fill, gaplessPlayback: true)"
+        )
+        new_call = (
+            f"Image.asset('assets/{asset['filename']}', width: {asset['width']}, "
+            f"height: {asset['height']}, fit: BoxFit.fill)"
+        )
+        result = result.replace(old_call, new_call)
+    if "base64Decode(" not in result:
+        result = result.replace("import 'dart:convert';\n", "")
+    return result
+
+
+def _rewrite_code_with_asset_paths(code: str, output: str, assets: list) -> str:
+    if output == "dart":
+        return _rewrite_dart_with_asset_paths(code, assets)
+    return _rewrite_html_with_asset_paths(code, assets)
+
+
+@mcp.tool()
+def list_page_screens() -> dict:
+    """Return a lightweight map of every top-level frame on the current
+    Figma page, grouped by Figma Section where the designer used one — name,
+    position (x/y), size (width/height), and visibility only, no generated
+    code. Use this before diving into any one screen, to see how many
+    screens the whole app has, which ones are grouped together (a Section's
+    name is usually the flow/feature it groups), and how they're laid out on
+    the canvas. Requires the user to have clicked Settings > MCP Connect >
+    Scan page at least once — it isn't pushed automatically like the
+    selection is, since it exposes the file's whole structure rather than
+    just what's selected."""
+    if not _latest_page_overview:
+        raise ValueError(
+            "No page overview yet. In Figma, open the FigmaToCode plugin and "
+            "click Settings > MCP Connect > Scan page."
+        )
+    return {
+        "pageName": _latest_page_overview["pageName"],
+        "sections": _latest_page_overview["sections"],
+        "ungroupedScreens": _latest_page_overview["ungroupedScreens"],
+    }
+
+
+@mcp.tool()
+def list_selected_nodes() -> list:
+    """List every currently-selected layer's index, name, and type. Call this
+    first to see what's selected before calling other tools by index."""
+    selection = _selection_or_raise()
+    return [
+        {"index": i, "name": node["nodeName"], "type": node["nodeType"]}
+        for i, node in enumerate(selection["nodes"])
+    ]
+
+
+@mcp.tool()
+def get_node_metadata(index: int = 0) -> dict:
+    """Return a selected layer's size and any generation warnings, without the
+    full generated code — use this to reason about layout before pulling code."""
+    node = _node_or_raise(index)
+    return {
+        "nodeId": node["nodeId"],
+        "nodeName": node["nodeName"],
+        "nodeType": node["nodeType"],
+        "width": node["width"],
+        "height": node["height"],
+        "warnings": node["warnings"],
+    }
+
+
+@mcp.tool()
+def get_node_code(index: int = 0, output: str = "css") -> str:
+    """Return generated code for one selected layer. `output` is one of: css,
+    html, dart, inline_svg_html, responsive_css, responsive_html,
+    responsive_dart. `index` picks which selected layer (see
+    list_selected_nodes). The non-responsive variants match the fixed-pixel
+    size captured from Figma; the responsive_* variants are the fluid,
+    Fill/Hug/Constraints-based layout instead."""
+    node = _node_or_raise(index)
+    getter = _CODE_FIELDS.get(output)
+    if getter is None:
+        raise ValueError(f"output must be one of: {', '.join(_CODE_FIELDS)}")
+    return getter(node)
+
+
+@mcp.tool()
+def get_selection_code(indices: list = None, output: str = "html") -> list:
+    """Return generated code for several (or, by default, all) selected
+    layers in one call, with any image assets replaced by an
+    `assets/<filename>` placeholder path instead of inline base64 — safe to
+    use with a low-context client, unlike raw get_node_code on an
+    image-heavy selection. Each result also lists the assets that code
+    references (filename + mimeType, no bytes); fetch the real files
+    afterward with export_node_assets or export_node if you need them.
+    `output` accepts the same values as get_node_code. `indices` (default:
+    every selected node) picks which layers to include — see
+    list_selected_nodes."""
+    nodes = _selection_or_raise()["nodes"]
+    if indices is None:
+        indices = list(range(len(nodes)))
+    if output not in _CODE_FIELDS:
+        raise ValueError(f"output must be one of: {', '.join(_CODE_FIELDS)}")
+    getter = _CODE_FIELDS[output]
+    # Responsive variants reuse the same top-level asset lists as their
+    # fixed-pixel counterpart — ui.html's own copy/download buttons do the
+    # same lookup (see rewriteHtmlWithAssetPaths call sites).
+    asset_key = _ASSET_FIELDS.get(output.replace("responsive_", "", 1))
+    results = []
+    for i in indices:
+        node = _node_or_raise(i)
+        code = getter(node)
+        assets = node.get(asset_key, []) if asset_key else []
+        if assets:
+            code = _rewrite_code_with_asset_paths(code, "dart" if "dart" in output else "html", assets)
+        results.append({
+            "index": i,
+            "name": node["nodeName"],
+            "code": code,
+            "assets": [{"filename": a["filename"], "mimeType": a["mimeType"]} for a in assets],
+        })
+    return results
+
+
+@mcp.tool()
+def list_node_assets(index: int = 0, output: str = "html") -> list:
+    """List the image assets a selected layer's generated code references
+    (filename + MIME type, no image bytes) — use export_node_assets to
+    actually save them as files."""
+    node = _node_or_raise(index)
+    key = _ASSET_FIELDS.get(output)
+    if key is None:
+        raise ValueError(f"output must be one of: {', '.join(sorted(set(_ASSET_FIELDS)))}")
+    return [{"filename": a["filename"], "mimeType": a["mimeType"]} for a in node.get(key, [])]
+
+
+@mcp.tool()
+def export_node_assets(index: int, output_dir: str, output: str = "html") -> list:
+    """Decode a selected layer's image assets and write them as real files
+    under output_dir (created if missing). Returns the absolute paths written."""
+    node = _node_or_raise(index)
+    return _write_assets(_get_assets(node, output), _safe_dir(output_dir))
+
+
+@mcp.tool()
+def export_node(index: int, output_dir: str, output: str = "html") -> dict:
+    """Export one selected layer as a ready-to-use folder under output_dir:
+    a code file (html or dart) with every image it uses already rewritten to
+    point at real files in an assets/ subfolder — not left as inline base64 —
+    plus preview.png, the ground-truth Figma render. Use this for "pull this
+    one component, images and all" requests; use export_selection instead for
+    the whole current selection at once."""
+    node = _node_or_raise(index)
+    if output not in ("html", "dart"):
+        raise ValueError("output must be 'html' or 'dart'")
+    node_dir = _safe_dir(output_dir)
+    assets = _get_assets(node, output)
+    code = _rewrite_code_with_asset_paths(node[output], output, assets)
+    code_path = os.path.join(node_dir, f"code.{_FILE_EXT[output]}")
+    with open(code_path, "w") as f:
+        f.write(code)
+    written_assets = _write_assets(assets, os.path.join(node_dir, "assets"))
+    preview_path = None
+    if node.get("previewImage"):
+        _, _, b64 = node["previewImage"].partition(",")
+        preview_path = os.path.join(node_dir, "preview.png")
+        with open(preview_path, "wb") as f:
+            f.write(base64.b64decode(b64))
+    return {"code": code_path, "assets": written_assets, "preview": preview_path}
+
+
+@mcp.tool()
+def save_preview_image(index: int, output_dir: str, filename: str = "preview.png") -> str:
+    """Save the ground-truth Figma render (the same PNG the plugin's Preview
+    tab compares generated code against) for a selected layer to output_dir.
+    Returns the absolute path written."""
+    node = _node_or_raise(index)
+    uri = node.get("previewImage")
+    if not uri:
+        raise ValueError("This node has no preview image (export may have failed).")
+    _, _, b64 = uri.partition(",")
+    path = os.path.join(_safe_dir(output_dir), os.path.basename(filename))
+    with open(path, "wb") as f:
+        f.write(base64.b64decode(b64))
+    return path
+
+
+@mcp.tool()
+def get_design_tokens() -> dict:
+    """Return the raw design system collected from the current selection:
+    Figma variables, styles, colors, gradients, typography, radii, shadows,
+    spacing, fonts, and the component library — everything needed to build a
+    consistent theme, not just one layer's code."""
+    selection = _selection_or_raise()
+    ds = selection["designSystem"]
+    return {k: v for k, v in ds.items() if k != "exports"}
+
+
+@mcp.tool()
+def get_design_export(format: str = "css") -> str:
+    """Return a ready-to-use export built from the current selection's design
+    system. `format` is one of: css (CSS custom properties + token rules),
+    flutter (ThemeData + pubspec font snippet), components (Dart component
+    scaffolds), json (design tokens as JSON)."""
+    selection = _selection_or_raise()
+    exports = selection["designSystem"]["exports"]
+    if format not in exports:
+        raise ValueError(f"format must be one of: {', '.join(exports)}")
+    return exports[format]
+
+
+@mcp.tool()
+def get_prototype_html() -> str:
+    """Return the bundled, clickable multi-frame HTML prototype (Navigate/
+    Overlay reactions become real click/hover behavior, Smart Animate
+    transitions tween matching layers) — only available when 2+ top-level
+    frames were selected together in Figma."""
+    selection = _selection_or_raise()
+    prototype = selection.get("prototype")
+    if not prototype or not prototype.get("html"):
+        raise ValueError(
+            "No prototype available — select 2 or more top-level frames together in Figma "
+            "(with MCP Connect on) and try again."
+        )
+    return prototype["html"]
+
+
+@mcp.tool()
+def export_selection(output_dir: str, output: str = "html") -> dict:
+    """One-shot export of the entire current selection to real files under
+    output_dir: one subfolder per selected layer (code + assets/ + preview.png
+    — see export_node), plus the design tokens export and prototype.html if
+    the selection qualifies for one. `output` is 'html' or 'dart'. This is the
+    fastest way to pull a whole selection down to build from — call
+    list_selected_nodes first if you only want one specific layer, via
+    export_node instead."""
+    selection = _selection_or_raise()
+    root = _safe_dir(output_dir)
+    written = {"root": root, "nodes": [], "designTokens": None, "prototype": None}
+
+    for i, node in enumerate(selection["nodes"]):
+        node_dir = os.path.join(root, f"{i:02d}_{node['nodeName'] or 'node'}")
+        node_export = export_node(i, node_dir, output)
+        written["nodes"].append({"name": node["nodeName"], **node_export})
+
+    tokens_format = "flutter" if output == "dart" else "css"
+    tokens_ext = "dart" if tokens_format == "flutter" else "css"
+    tokens_path = os.path.join(root, f"design-tokens.{tokens_ext}")
+    with open(tokens_path, "w") as f:
+        f.write(selection["designSystem"]["exports"][tokens_format])
+    written["designTokens"] = tokens_path
+
+    prototype = selection.get("prototype")
+    if prototype and prototype.get("html"):
+        prototype_path = os.path.join(root, "prototype.html")
+        with open(prototype_path, "w") as f:
+            f.write(prototype["html"])
+        written["prototype"] = prototype_path
+
+    return written
+
+
+if __name__ == "__main__":
+    threading.Thread(target=_run_websocket_bridge, daemon=True).start()
+    mcp.run(transport="stdio")
+
+ + + +
2. Connect this plugin to it
+
+ + Not connected — start mcp_server.py first, then Connect. +
+ +
3. Point your MCP client at it
+
Add it to your MCP client's config (stdio transport), e.g. Claude Desktop's claude_desktop_config.json, using the absolute path where you saved the script.
+ +
Optional: share the whole page, not just a selection
+
+ + Not scanned yet. +
+
Sends a lightweight map of every top-level frame on the current page — name, position, size, and which Figma Section (if any) groups it — so an MCP client can plan across a whole app before diving into one screen. Never sent automatically like a selection is; click this each time the page's frames change.
+