From 7ff36a069bd62a2ce594af76dc7618eca3066dc8 Mon Sep 17 00:00:00 2001 From: Daniel Steigman Date: Mon, 29 Jun 2026 20:28:45 -1000 Subject: [PATCH] Add Figma copy-paste skill --- plugins/figma/.codex-plugin/plugin.json | 2 +- plugins/figma/README.md | 3 + .../figma/skills/figma-copy-paste/SKILL.md | 22 +++ .../figma-copy-paste/agents/openai.yaml | 4 + .../figma-copy-paste/references/clipboard.md | 40 +++++ .../figma-copy-paste/references/figjam.md | 10 ++ .../figma-copy-paste/references/selection.md | 9 ++ .../references/verification.md | 27 ++++ .../references/view-only-nested.md | 12 ++ .../figma-copy-paste/scripts/compare_pngs.py | 103 ++++++++++++ .../scripts/inspect_figma_clipboard.py | 149 ++++++++++++++++++ 11 files changed, 380 insertions(+), 1 deletion(-) create mode 100644 plugins/figma/skills/figma-copy-paste/SKILL.md create mode 100644 plugins/figma/skills/figma-copy-paste/agents/openai.yaml create mode 100644 plugins/figma/skills/figma-copy-paste/references/clipboard.md create mode 100644 plugins/figma/skills/figma-copy-paste/references/figjam.md create mode 100644 plugins/figma/skills/figma-copy-paste/references/selection.md create mode 100644 plugins/figma/skills/figma-copy-paste/references/verification.md create mode 100644 plugins/figma/skills/figma-copy-paste/references/view-only-nested.md create mode 100755 plugins/figma/skills/figma-copy-paste/scripts/compare_pngs.py create mode 100755 plugins/figma/skills/figma-copy-paste/scripts/inspect_figma_clipboard.py diff --git a/plugins/figma/.codex-plugin/plugin.json b/plugins/figma/.codex-plugin/plugin.json index 49e0a7738..a06121d0e 100644 --- a/plugins/figma/.codex-plugin/plugin.json +++ b/plugins/figma/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "figma", - "version": "2.0.12", + "version": "2.0.13", "description": "Figma workflows for design implementation, Code Connect templates, and design system rule generation.", "author": { "name": "Figma", diff --git a/plugins/figma/README.md b/plugins/figma/README.md index 4fcb44474..81affda19 100644 --- a/plugins/figma/README.md +++ b/plugins/figma/README.md @@ -7,6 +7,7 @@ It currently includes these skills: - `figma-implement-design` - `figma-code-connect` +- `figma-copy-paste` - `figma-create-design-system-rules` - `figma-create-new-file` - `figma-generate-design` @@ -18,6 +19,7 @@ It currently includes these skills: - translating Figma frames and components into production-ready UI code - inspecting design context and screenshots through the connected Figma tools - creating parserless Code Connect template files for published Figma components +- copying exact, editable screens, components, and layers between Figma files - generating project-specific design system rules for Figma-to-code workflows - creating or updating full screens and design system libraries in Figma - creating new Figma or FigJam files when needed for a workflow @@ -64,6 +66,7 @@ The current skill set is focused on these workflows: - implementing designs from Figma with high visual fidelity - creating parserless Code Connect templates for published Figma components +- transferring existing Figma content between files without recreating or flattening it - generating durable project rules for future Figma-to-code work - creating or updating Figma files, screens, and design system libraries diff --git a/plugins/figma/skills/figma-copy-paste/SKILL.md b/plugins/figma/skills/figma-copy-paste/SKILL.md new file mode 100644 index 000000000..bb3da81ca --- /dev/null +++ b/plugins/figma/skills/figma-copy-paste/SKILL.md @@ -0,0 +1,22 @@ +--- +name: figma-copy-paste +description: Copy exact, editable Figma screens, frames, components, or layers from one file to another with Figma's native clipboard, then verify the pasted result. Use when the user asks to copy, paste, transfer, or duplicate existing Figma content between files without recreating or flattening it. +--- + +# Figma Copy Paste + +Use Figma's native clipboard. Use Figma MCP to identify and verify content; never reconstruct an exact-copy request. + +Computer Use is required to send the native OS-level Copy and Paste events that make exact cross-file Figma transfer reliable on macOS and Windows. + +## Workflow + +1. Identify the smallest complete source node that satisfies the request. For a vague request or oversized source canvas, follow [references/selection.md](references/selection.md). Prefer a full screen over an inner layer or surrounding multi-screen canvas. When the user supplies an exact node ID and metadata confirms it is already the requested complete node, do not substitute an ancestor. Record its file key, node ID, name, type, dimensions, metadata counts, variables, and screenshot. Name similar frames excluded from the copy. +2. Keep the source read-only. Never cut, detach, flatten, move, or republish it. For view-only sources, use MCP read tools rather than `use_figma`. +3. Inspect the destination page and record its existing top-level nodes and local assets. +4. Follow [references/clipboard.md](references/clipboard.md) to select the exact node, validate the native Figma clipboard payload, paste, and handle the watchdog and variable prompt. +5. Do not copy every main component set preemptively. Preserve linked instances; localize only components the user later chooses to edit independently. +6. Follow [references/verification.md](references/verification.md) to compare structure, bindings, and isolated renders. +7. Report the source and destination node IDs, clipboard path, variable behavior, fidelity classification, discrepancies, and optional follow-up work. + +Ask only when two materially different complete source nodes remain equally plausible. Never substitute an image, generated nodes, or a flattened export for a failed native transfer. diff --git a/plugins/figma/skills/figma-copy-paste/agents/openai.yaml b/plugins/figma/skills/figma-copy-paste/agents/openai.yaml new file mode 100644 index 000000000..6020e1246 --- /dev/null +++ b/plugins/figma/skills/figma-copy-paste/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Figma Copy Paste" + short_description: "Copy exact Figma layers across files" + default_prompt: "Use $figma-copy-paste to copy an exact frame between Figma files and verify fidelity." diff --git a/plugins/figma/skills/figma-copy-paste/references/clipboard.md b/plugins/figma/skills/figma-copy-paste/references/clipboard.md new file mode 100644 index 000000000..6d7a6a523 --- /dev/null +++ b/plugins/figma/skills/figma-copy-paste/references/clipboard.md @@ -0,0 +1,40 @@ +# Native Clipboard Transfer + +## Copy + +If the Computer plugin reports that the device is locked, stop before Copy, verify the destination is unchanged, and ask the user to unlock it. Never bypass the OS lock. + +Before Copy and Paste, confirm Computer can act on the Figma window. If it cannot, reactivate the exact Figma app or browser tab with Computer and retry once; then stop without mutating the destination if the window remains unavailable. + +For a FigJam source (`/board/`), read [figjam.md](figjam.md) before selecting the node. + +For a Design source opened in Dev Mode (`m=dev` or Inspect), switch to Design mode before selecting or copying. Dev Mode may serialize properties or a different focused node even when the pasteboard contains `figmeta`. + +1. Open the exact node deep link and use Computer to select the exact Layers-panel row, not canvas text or Dev Mode inspector content. Browser accessibility/DOM may be used when available, but is optional. +2. Re-check that the selected URL still contains the expected node ID, and that the properties panel shows the expected layer name and dimensions; names such as `Window` are not unique. +3. Focus Figma and invoke Copy through Computer's real app keypress. Browser-scoped `press` events do not count as OS-level Copy. +4. On macOS, validate the host pasteboard: + + ```bash + scripts/inspect_figma_clipboard.py \ + --expect-file-key FILE_KEY \ + --expect-node-id NODE_ID \ + --wait-seconds 5 \ + --require-match + ``` + + Run with host/escalated access if a sandboxed call sees an empty pasteboard. Browser-scoped clipboard inspectors are not authoritative. + On Windows, where this host validator is unavailable, record clipboard validation as unavailable and continue only after confirming the exact source selection and capturing the destination baseline. Treat any unexpected destination delta as failure and reset immediately. +5. If validation returns visible child text instead of `figmeta`, treat it as a focus failure. Click the exact Layers row again before retrying. Do not repeat Copy with unchanged focus. +6. If validation still fails, invoke Figma's top Edit-menu `Copy`, then poll again. Use the selected layer row's context-menu `Copy` as the final fallback and poll once more. +7. If all three attempts fail for a nested node in a view-only source, read [view-only-nested.md](view-only-nested.md). Otherwise report a safe copy failure. Do not recreate or request edit access unless the user asks. + +## Paste + +1. Focus an empty destination canvas area and invoke OS-level Paste. +2. Do not use canvas `Paste here`; it has stalled in automation. +3. Wait up to 20 seconds for `Pasting…` to resolve, a variable prompt to appear, or a new node to materialize. +4. If the paste stalls, cancel or undo, verify the destination returned to its baseline, and retry the complete copy sequence once. +5. If Figma reports unpublished variables, choose `Copy variables into this file`. Otherwise preserve remote library bindings. + +Track: `selected`, `copied`, `clipboard-validated`, `paste-started`, `variable-prompt`, `pasted`, `verified`, `reset`. Return the last state and timings on failure. diff --git a/plugins/figma/skills/figma-copy-paste/references/figjam.md b/plugins/figma/skills/figma-copy-paste/references/figjam.md new file mode 100644 index 000000000..fdeb08c11 --- /dev/null +++ b/plugins/figma/skills/figma-copy-paste/references/figjam.md @@ -0,0 +1,10 @@ +# FigJam Source + +Load the Figma FigJam skill and inspect the exact node with `get_figjam`. FigJam has one implicit page and no Design-style Layers panel. + +1. Open the exact `/board/` node deep link. +2. Select the node on the FigJam canvas through its accessible canvas object or a coordinate grounded in a fresh screenshot. +3. Confirm the URL node ID and visible selection bounds before OS-level Copy. +4. Use the normal host pasteboard validation and paste watchdog. + +When pasting into a Design file, node types may convert. For image-filled FigJam rectangles, require matching dimensions, image content, crop, corner geometry, and visual metrics rather than the same editor-specific node type. For stickies, shapes, connectors, or widgets, report any conversion explicitly and fail if editability or visible relationships are lost. diff --git a/plugins/figma/skills/figma-copy-paste/references/selection.md b/plugins/figma/skills/figma-copy-paste/references/selection.md new file mode 100644 index 000000000..ef69f3788 --- /dev/null +++ b/plugins/figma/skills/figma-copy-paste/references/selection.md @@ -0,0 +1,9 @@ +# Vague Source Selection + +Use this only when the user describes a screen or component instead of supplying its exact node, or when the supplied node is an oversized multi-screen canvas. + +1. Turn the request into two or three visible cues: distinctive text, selected navigation state, and required region or control. +2. Keep the source read-only. Use Figma's Find through Chrome to locate the rarest visible text cue, then inspect its ancestors in the Layers panel. Do not copy the search result itself unless it is the requested complete object. +3. Promote to the smallest ancestor with a complete visual boundary and plausible screen or component dimensions. Reject page canvases, multi-screen strips, clipped fragments, and frames missing any required cue. +4. Capture isolated screenshots and metadata for at most three plausible candidates. Choose the only candidate that satisfies every cue; record why the others fail. +5. If two candidates remain materially plausible, ask the user. If none match, stop without copying. diff --git a/plugins/figma/skills/figma-copy-paste/references/verification.md b/plugins/figma/skills/figma-copy-paste/references/verification.md new file mode 100644 index 000000000..70a6cb508 --- /dev/null +++ b/plugins/figma/skills/figma-copy-paste/references/verification.md @@ -0,0 +1,27 @@ +# Fidelity Verification + +Capture source and destination metadata, resolved variables, and isolated screenshots before slow analysis. + +## Structure + +- Normalize node IDs and the pasted root's page position. +- Compare root name, type, dimensions, direct children, metadata-tree type counts, visible names and geometry, instance count/status, variables, and library access. +- Do not expand remote instance interiors into a second count tree. + +## Visuals + +- Render source and destination in isolation at identical dimensions. +- Compare decoded pixels, not PNG file hashes. +- Also compare white-composited renders to separate alpha-container differences from visible differences. +- Use `scripts/compare_pngs.py` for deterministic metrics. +- If raw render sizes differ, inspect its `transparent_bounds_normalized` metrics. Use them only when the structural dimensions match and the removed rows or columns are fully transparent; report both raw bounds and normalized scores. + +Classify: + +- `exact`: decoded pixels and normalized structure match exactly. +- `native-equivalent`: white-composited similarity is at least 0.999, visible structure/counts/variables match, and differences are limited to hidden metadata or negligible float normalization. +- `failed`: any visible, layout, instance, variable, or library mismatch, or similarity below 0.999. + +Report every normalization even for `native-equivalent` so behavior can be tracked. + +If teardown is requested, save the evidence, restore and verify the destination immediately, then run offline pixel analysis. This protects cleanup from later timeouts. diff --git a/plugins/figma/skills/figma-copy-paste/references/view-only-nested.md b/plugins/figma/skills/figma-copy-paste/references/view-only-nested.md new file mode 100644 index 000000000..ae1596c1a --- /dev/null +++ b/plugins/figma/skills/figma-copy-paste/references/view-only-nested.md @@ -0,0 +1,12 @@ +# View-only Nested Layer Fallback + +Use only when a requested nested layer cannot produce native `figmeta`, but a containing top-level frame can. + +1. Record the target's hierarchy path, sibling indexes, name, type, dimensions, metadata counts, variables, and screenshot. +2. Select the nearest complete top-level containing frame and transfer it natively using the normal clipboard procedure. +3. In the editable destination, locate the requested descendant by the recorded hierarchy fingerprint. +4. Clone that descendant within the destination file and append the clone to the page. This is a native same-file clone, not reconstruction. +5. Remove the temporary containing frame. +6. Verify the retained clone directly against the original nested source node. + +Abort and undo if the descendant fingerprint is not unique or the temporary top-level frame cannot be transferred natively. diff --git a/plugins/figma/skills/figma-copy-paste/scripts/compare_pngs.py b/plugins/figma/skills/figma-copy-paste/scripts/compare_pngs.py new file mode 100755 index 000000000..e6770eca9 --- /dev/null +++ b/plugins/figma/skills/figma-copy-paste/scripts/compare_pngs.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Compare two decoded PNG renders and emit stable JSON metrics.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +import numpy as np +from PIL import Image, ImageChops, ImageStat + + +def global_ssim(left: Image.Image, right: Image.Image) -> float: + """Return a deterministic whole-image SSIM-like score in the range [-1, 1].""" + left_array = np.asarray(left, dtype=np.float64) + right_array = np.asarray(right, dtype=np.float64) + scores: list[float] = [] + c1 = (0.01 * 255) ** 2 + c2 = (0.03 * 255) ** 2 + for channel in range(left_array.shape[2]): + x = left_array[:, :, channel] + y = right_array[:, :, channel] + mean_x = float(x.mean()) + mean_y = float(y.mean()) + variance_x = float(x.var()) + variance_y = float(y.var()) + covariance = float(((x - mean_x) * (y - mean_y)).mean()) + numerator = (2 * mean_x * mean_y + c1) * (2 * covariance + c2) + denominator = (mean_x**2 + mean_y**2 + c1) * ( + variance_x + variance_y + c2 + ) + scores.append(numerator / denominator if denominator else 1.0) + return float(sum(scores) / len(scores)) + + +def metrics(left: Image.Image, right: Image.Image) -> dict[str, object]: + if left.size != right.size: + return {"same_size": False, "left_size": left.size, "right_size": right.size} + + diff = ImageChops.difference(left, right) + stat = ImageStat.Stat(diff) + pixels = ( + diff.get_flattened_data() + if hasattr(diff, "get_flattened_data") + else diff.getdata() + ) + changed = sum(1 for pixel in pixels if any(pixel)) + total = left.width * left.height + return { + "same_size": True, + "decoded_exact": changed == 0, + "changed_pixels": changed, + "changed_pixel_ratio": changed / total if total else 0, + "global_ssim": global_ssim(left, right), + "mean_absolute_error_by_channel": stat.mean, + "rms_by_channel": stat.rms, + "max_delta_by_channel": [maximum for _, maximum in stat.extrema], + } + + +def white_composite(image: Image.Image) -> Image.Image: + background = Image.new("RGBA", image.size, (255, 255, 255, 255)) + return Image.alpha_composite(background, image).convert("RGB") + + +def crop_to_visible_bounds(image: Image.Image) -> tuple[Image.Image, tuple[int, ...] | None]: + """Crop fully transparent outer padding without changing visible pixels.""" + bounds = image.getchannel("A").getbbox() + if bounds is None: + return Image.new("RGBA", (1, 1), (0, 0, 0, 0)), None + return image.crop(bounds), bounds + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("left", type=Path) + parser.add_argument("right", type=Path) + args = parser.parse_args() + + left = Image.open(args.left).convert("RGBA") + right = Image.open(args.right).convert("RGBA") + left_visible, left_bounds = crop_to_visible_bounds(left) + right_visible, right_bounds = crop_to_visible_bounds(right) + result = { + "left": str(args.left), + "right": str(args.right), + "rgba": metrics(left, right), + "white_composited": metrics(white_composite(left), white_composite(right)), + "transparent_bounds_normalized": { + "left_bounds": left_bounds, + "right_bounds": right_bounds, + "rgba": metrics(left_visible, right_visible), + "white_composited": metrics( + white_composite(left_visible), white_composite(right_visible) + ), + }, + } + print(json.dumps(result, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/plugins/figma/skills/figma-copy-paste/scripts/inspect_figma_clipboard.py b/plugins/figma/skills/figma-copy-paste/scripts/inspect_figma_clipboard.py new file mode 100755 index 000000000..f29614c2c --- /dev/null +++ b/plugins/figma/skills/figma-copy-paste/scripts/inspect_figma_clipboard.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Validate that the macOS pasteboard holds Figma HTML for an expected node.""" + +from __future__ import annotations + +import argparse +import json +import platform +import re +import subprocess +import sys +import time + + +def pbpaste(preference: str) -> bytes: + result = subprocess.run( + ["pbpaste", "-Prefer", preference], + check=False, + capture_output=True, + ) + return result.stdout if result.returncode == 0 else b"" + + +def pasteboard_types(expected_file: str, expected_node: str) -> list[dict[str, object]]: + """Inspect every macOS pasteboard type through AppKit without mutating it.""" + swift_file = json.dumps(expected_file) + swift_node = json.dumps(expected_node.replace("-", ":")) + code = f""" +import AppKit +import Foundation +let expectedFile = {swift_file} +let expectedNode = {swift_node} +let pasteboard = NSPasteboard.general +let rows: [[String: Any]] = (pasteboard.types ?? []).map {{ type in + let data = pasteboard.data(forType: type) ?? Data() + let text = String(data: data, encoding: .utf8) ?? "" + let normalized = text.replacingOccurrences(of: "-", with: ":") + let hasFigmeta = text.range(of: "figmeta", options: .caseInsensitive) != nil + let fileMatches = expectedFile.isEmpty || text.contains(expectedFile) + let nodeMatches = expectedNode.isEmpty || normalized.contains(expectedNode) + return [ + "type": type.rawValue, + "bytes": data.count, + "has_figmeta": hasFigmeta, + "file_key_matches": fileMatches, + "node_id_matches": nodeMatches, + "matches": hasFigmeta && fileMatches && nodeMatches + ] +}} +let output = try! JSONSerialization.data(withJSONObject: rows) +print(String(data: output, encoding: .utf8)!) +""" + result = subprocess.run( + ["/usr/bin/swift", "-e", code], + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + return [] + try: + value = json.loads(result.stdout) + return value if isinstance(value, list) else [] + except json.JSONDecodeError: + return [] + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--expect-file-key") + parser.add_argument("--expect-node-id") + parser.add_argument("--require-match", action="store_true") + parser.add_argument("--wait-seconds", type=float, default=0) + parser.add_argument("--poll-interval", type=float, default=0.25) + args = parser.parse_args() + + if platform.system() != "Darwin": + result = { + "supported": False, + "platform": platform.system(), + "reason": "This helper currently supports the macOS pasteboard only.", + } + print(json.dumps(result, indent=2)) + raise SystemExit(2 if args.require_match else 0) + + deadline = time.monotonic() + max(args.wait_seconds, 0) + attempts = 0 + while True: + attempts += 1 + html_bytes = pbpaste("html") + text_bytes = pbpaste("txt") + html = html_bytes.decode("utf-8", errors="replace") + normalized_html = html.replace("-", ":") + html_has_figmeta = re.search(r"figmeta", html, flags=re.IGNORECASE) is not None + html_file_matches = not args.expect_file_key or args.expect_file_key in html + expected_node = (args.expect_node_id or "").replace("-", ":") + html_node_matches = not expected_node or expected_node in normalized_html + types = pasteboard_types(args.expect_file_key or "", expected_node) + aggregate_has_figmeta = any(bool(item.get("has_figmeta")) for item in types) + aggregate_file_matches = any( + bool(item.get("file_key_matches")) for item in types + ) + aggregate_node_matches = any( + bool(item.get("node_id_matches")) for item in types + ) + custom_match = bool( + aggregate_has_figmeta + and aggregate_file_matches + and aggregate_node_matches + ) + html_match = bool( + html_bytes + and html_has_figmeta + and html_file_matches + and html_node_matches + ) + matches = bool(html_match or custom_match) + match_basis = "html" if html_match else "aggregate" if custom_match else "none" + if matches or time.monotonic() >= deadline: + break + time.sleep(max(args.poll_interval, 0.05)) + + result = { + "supported": True, + "html_bytes": len(html_bytes), + "plain_text_bytes": len(text_bytes), + "has_figmeta": bool(html_has_figmeta or aggregate_has_figmeta), + "file_key_matches": bool(html_file_matches or aggregate_file_matches), + "node_id_matches": bool(html_node_matches or aggregate_node_matches), + "matches": matches, + "attempts": attempts, + "pasteboard_types": types, + "aggregate_has_figmeta": aggregate_has_figmeta, + "aggregate_file_key_matches": aggregate_file_matches, + "aggregate_node_id_matches": aggregate_node_matches, + "match_basis": match_basis, + "direct_html": { + "has_figmeta": html_has_figmeta, + "file_key_matches": html_file_matches, + "node_id_matches": html_node_matches, + }, + } + print(json.dumps(result, indent=2)) + if args.require_match and not matches: + raise SystemExit(2) + + +if __name__ == "__main__": + main()