From 66a30b72a3d6d7fcd42ff3ab9a71775ed78b97f1 Mon Sep 17 00:00:00 2001 From: Ashwin Mathews Date: Fri, 20 Mar 2026 17:39:00 -0700 Subject: [PATCH 1/3] add new figma skills ported --- .../skills/figma-create-new-file/SKILL.md | 69 + .../figma-create-new-file/maintainers.yml | 1 + .../skills/figma-generate-design/SKILL.md | 299 + .../figma-generate-design/maintainers.yml | 1 + .../skills/figma-generate-library/SKILL.md | 315 + .../figma-generate-library/maintainers.yml | 3 + .../references/code-connect-setup.md | 260 + .../references/component-creation.md | 1014 ++ .../references/discovery-phase.md | 518 + .../references/documentation-creation.md | 834 ++ .../references/error-recovery.md | 540 + .../references/naming-conventions.md | 527 + .../references/token-creation.md | 962 ++ .../scripts/bindVariablesToComponent.js | 110 + .../scripts/cleanupOrphans.js | 127 + .../scripts/createComponentWithVariants.js | 148 + .../scripts/createDocumentationPage.js | 139 + .../scripts/createSemanticTokens.js | 108 + .../scripts/createVariableCollection.js | 49 + .../scripts/inspectFileStructure.js | 121 + .../scripts/rehydrateState.js | 92 + .../scripts/validateCreation.js | 83 + plugins/figma/skills/figma-use/SKILL.md | 247 + .../figma/skills/figma-use/maintainers.yml | 1 + .../figma-use/references/api-reference.md | 301 + .../figma-use/references/common-patterns.md | 512 + .../references/component-patterns.md | 488 + .../references/effect-style-patterns.md | 123 + .../skills/figma-use/references/gotchas.md | 599 + .../figma-use/references/maintainers.yml | 12 + .../references/plugin-api-patterns.md | 513 + .../references/plugin-api-standalone.d.ts | 11293 ++++++++++++++++ .../references/plugin-api-standalone.index.md | 441 + .../references/text-style-patterns.md | 203 + .../references/validation-and-recovery.md | 109 + .../figma-use/references/variable-patterns.md | 354 + .../maintainers.yml | 9 + .../wwds-components--creating.md | 17 + .../wwds-components--using.md | 17 + .../wwds-components.md | 50 + .../wwds-effect-styles.md | 52 + .../wwds-text-styles.md | 90 + .../wwds-variables--creating.md | 13 + .../wwds-variables--using.md | 13 + .../wwds-variables.md | 64 + .../working-with-design-systems/wwds.md | 41 + 46 files changed, 21882 insertions(+) create mode 100644 plugins/figma/skills/figma-create-new-file/SKILL.md create mode 100644 plugins/figma/skills/figma-create-new-file/maintainers.yml create mode 100644 plugins/figma/skills/figma-generate-design/SKILL.md create mode 100644 plugins/figma/skills/figma-generate-design/maintainers.yml create mode 100644 plugins/figma/skills/figma-generate-library/SKILL.md create mode 100644 plugins/figma/skills/figma-generate-library/maintainers.yml create mode 100644 plugins/figma/skills/figma-generate-library/references/code-connect-setup.md create mode 100644 plugins/figma/skills/figma-generate-library/references/component-creation.md create mode 100644 plugins/figma/skills/figma-generate-library/references/discovery-phase.md create mode 100644 plugins/figma/skills/figma-generate-library/references/documentation-creation.md create mode 100644 plugins/figma/skills/figma-generate-library/references/error-recovery.md create mode 100644 plugins/figma/skills/figma-generate-library/references/naming-conventions.md create mode 100644 plugins/figma/skills/figma-generate-library/references/token-creation.md create mode 100644 plugins/figma/skills/figma-generate-library/scripts/bindVariablesToComponent.js create mode 100644 plugins/figma/skills/figma-generate-library/scripts/cleanupOrphans.js create mode 100644 plugins/figma/skills/figma-generate-library/scripts/createComponentWithVariants.js create mode 100644 plugins/figma/skills/figma-generate-library/scripts/createDocumentationPage.js create mode 100644 plugins/figma/skills/figma-generate-library/scripts/createSemanticTokens.js create mode 100644 plugins/figma/skills/figma-generate-library/scripts/createVariableCollection.js create mode 100644 plugins/figma/skills/figma-generate-library/scripts/inspectFileStructure.js create mode 100644 plugins/figma/skills/figma-generate-library/scripts/rehydrateState.js create mode 100644 plugins/figma/skills/figma-generate-library/scripts/validateCreation.js create mode 100644 plugins/figma/skills/figma-use/SKILL.md create mode 100644 plugins/figma/skills/figma-use/maintainers.yml create mode 100644 plugins/figma/skills/figma-use/references/api-reference.md create mode 100644 plugins/figma/skills/figma-use/references/common-patterns.md create mode 100644 plugins/figma/skills/figma-use/references/component-patterns.md create mode 100644 plugins/figma/skills/figma-use/references/effect-style-patterns.md create mode 100644 plugins/figma/skills/figma-use/references/gotchas.md create mode 100644 plugins/figma/skills/figma-use/references/maintainers.yml create mode 100644 plugins/figma/skills/figma-use/references/plugin-api-patterns.md create mode 100644 plugins/figma/skills/figma-use/references/plugin-api-standalone.d.ts create mode 100644 plugins/figma/skills/figma-use/references/plugin-api-standalone.index.md create mode 100644 plugins/figma/skills/figma-use/references/text-style-patterns.md create mode 100644 plugins/figma/skills/figma-use/references/validation-and-recovery.md create mode 100644 plugins/figma/skills/figma-use/references/variable-patterns.md create mode 100644 plugins/figma/skills/figma-use/references/working-with-design-systems/maintainers.yml create mode 100644 plugins/figma/skills/figma-use/references/working-with-design-systems/wwds-components--creating.md create mode 100644 plugins/figma/skills/figma-use/references/working-with-design-systems/wwds-components--using.md create mode 100644 plugins/figma/skills/figma-use/references/working-with-design-systems/wwds-components.md create mode 100644 plugins/figma/skills/figma-use/references/working-with-design-systems/wwds-effect-styles.md create mode 100644 plugins/figma/skills/figma-use/references/working-with-design-systems/wwds-text-styles.md create mode 100644 plugins/figma/skills/figma-use/references/working-with-design-systems/wwds-variables--creating.md create mode 100644 plugins/figma/skills/figma-use/references/working-with-design-systems/wwds-variables--using.md create mode 100644 plugins/figma/skills/figma-use/references/working-with-design-systems/wwds-variables.md create mode 100644 plugins/figma/skills/figma-use/references/working-with-design-systems/wwds.md diff --git a/plugins/figma/skills/figma-create-new-file/SKILL.md b/plugins/figma/skills/figma-create-new-file/SKILL.md new file mode 100644 index 00000000..73fa2ee4 --- /dev/null +++ b/plugins/figma/skills/figma-create-new-file/SKILL.md @@ -0,0 +1,69 @@ +--- +name: figma-create-new-file +description: Create a new blank Figma file. Use when the user wants to create a new Figma design or FigJam file, or when you need a new file before calling use_figma. Handles plan resolution via whoami if needed. Usage — /figma-create-new-file [editorType] [fileName] (e.g. /figma-create-new-file figjam My Whiteboard) +disable-model-invocation: true +--- + +# create_new_file — Create a New Figma File + +Use the `create_new_file` MCP tool to create a new blank Figma file in the user's drafts folder. This is typically used before `use_figma` when you need a fresh file to work with. + +## Skill Arguments + +This skill accepts optional arguments: `/figma-create-new-file [editorType] [fileName]` + +- **editorType**: `design` (default) or `figjam` +- **fileName**: Name for the new file (defaults to "Untitled") + +Examples: +- `/figma-create-new-file` — creates a design file named "Untitled" +- `/figma-create-new-file figjam My Whiteboard` — creates a FigJam file named "My Whiteboard" +- `/figma-create-new-file design My New Design` — creates a design file named "My New Design" + +Parse the arguments from the skill invocation. If editorType is not provided, default to `"design"`. If fileName is not provided, default to `"Untitled"`. + +## Workflow + +### Step 1: Resolve the planKey + +The `create_new_file` tool requires a `planKey` parameter. Follow this decision tree: + +1. **User already provided a planKey** (e.g. from a previous `whoami` call or in their prompt) → use it directly, skip to Step 2. + +2. **No planKey available** → call the `whoami` tool. The response contains a `plans` array. Each plan has a `key`, `name`, `seat`, and `tier`. + + - **Single plan**: use its `key` field automatically. + - **Multiple plans**: ask the user which team or organization they want to create the file in, then use the corresponding plan's `key`. + +### Step 2: Call create_new_file + +Call the `create_new_file` tool with: + +| Parameter | Required | Description | +|-------------|----------|-------------| +| `planKey` | Yes | The plan key from Step 1 | +| `fileName` | Yes | Name for the new file | +| `editorType`| Yes | `"design"` or `"figjam"` | + +Example: +```json +{ + "planKey": "team:123456", + "fileName": "My New Design", + "editorType": "design" +} +``` + +### Step 3: Use the result + +The tool returns: +- `file_key` — the key of the newly created file +- `file_url` — a direct URL to open the file in Figma + +Use the `file_key` for subsequent tool calls like `use_figma`. + +## Important Notes + +- The file is created in the user's **drafts folder** for the selected plan. +- Only `"design"` and `"figjam"` editor types are supported. +- If `use_figma` is your next step, load the `figma-use` skill before calling it. diff --git a/plugins/figma/skills/figma-create-new-file/maintainers.yml b/plugins/figma/skills/figma-create-new-file/maintainers.yml new file mode 100644 index 00000000..262e692e --- /dev/null +++ b/plugins/figma/skills/figma-create-new-file/maintainers.yml @@ -0,0 +1 @@ +SKILL.md: mcp_server diff --git a/plugins/figma/skills/figma-generate-design/SKILL.md b/plugins/figma/skills/figma-generate-design/SKILL.md new file mode 100644 index 00000000..93ccb8e4 --- /dev/null +++ b/plugins/figma/skills/figma-generate-design/SKILL.md @@ -0,0 +1,299 @@ +--- +name: figma-generate-design +description: "Use this skill alongside figma-use when the task involves translating an application page, view, or multi-section layout into Figma. Triggers: 'write to Figma', 'create in Figma from code', 'push page to Figma', 'take this app/page and build it in Figma', 'create a screen', 'build a landing page in Figma', 'update the Figma screen to match code'. This is the preferred workflow skill whenever the user wants to build or update a full page, screen, or view in Figma from code or a description. Discovers design system components via search_design_system, imports them with importComponentByKeyAsync/importComponentSetByKeyAsync, and assembles screens incrementally section-by-section." +--- + +# Build / Update Screens from Design System Components + +Use this skill to create or update full-page screens in Figma by **reusing published design system components** rather than drawing primitives. The key insight: the Figma file likely has a published design system with components that correspond 1:1 to the UI components in the codebase (buttons, inputs, navigation elements, cards, accordions, etc.). Find and use those instead of drawing boxes. + +**MANDATORY**: You MUST also load [figma-use](../figma-use/SKILL.md) before any `use_figma` call. That skill contains critical rules (closePlugin, try/catch wrapper, color ranges, font loading, etc.) that apply to every script you write. + +**Always pass `skillNames: "figma-generate-design"` when calling `use_figma` as part of this skill.** This is a logging parameter — it does not affect execution. + +## Skill Boundaries + +- Use this skill when the deliverable is a **Figma screen** (new or updated) composed of design system component instances. +- If the user wants to generate **code from a Figma design**, switch to [implement-design](../implement-design/SKILL.md). +- If the user wants to create **new reusable components or variants**, use [figma-use](../figma-use/SKILL.md) directly. +- If the user wants to write **Code Connect mappings**, switch to [code-connect-components](../code-connect-components/SKILL.md). + +## Prerequisites + +- Figma MCP server (`figma` or `figma-staging`) must be connected +- The target Figma file must have a published design system with components (or access to a team library) +- User should provide either: + - A Figma file URL / file key to work in + - Or context about which file to target (the agent can discover pages) +- Source code or description of the screen to build/update + +## Parallel Workflow with generate_figma_design (Web Apps Only) + +When building a screen from a **web app** that can be rendered in a browser, the best results come from running both approaches in parallel: + +1. **In parallel:** + - Start building the screen using this skill's workflow (use_figma + design system components) + - Run `generate_figma_design` to capture a pixel-perfect screenshot of the running web app +2. **Once both complete:** Update the use_figma output to match the pixel-perfect layout from the `generate_figma_design` capture. The capture provides the exact spacing, sizing, and visual treatment to aim for, while your use_figma output has proper component instances linked to the design system. +3. **Once confirmed looking good:** Delete the `generate_figma_design` output — it was only used as a visual reference. + +This combines the best of both: `generate_figma_design` gives pixel-perfect layout accuracy, while use_figma gives proper design system component instances that stay linked and updatable. + +**This workflow only applies to web apps** where `generate_figma_design` can capture the running page. For non-web apps (iOS, Android, etc.) or when updating existing screens, use the standard workflow below. + +## Required Workflow + +**Follow these steps in order. Do not skip steps.** + +### Step 1: Understand the Screen + +Before touching Figma, understand what you're building: + +1. If building from code, read the relevant source files to understand the page structure, sections, and which components are used. +2. Identify the major sections of the screen (e.g., Header, Hero, Content Panels, Pricing Grid, FAQ Accordion, Footer). +3. For each section, list the UI components involved (buttons, inputs, cards, navigation pills, accordions, etc.). + +### Step 2: Discover Design System Components + +**Preferred: inspect existing screens first.** If the target file already contains screens using the same design system, skip `search_design_system` and inspect existing instances directly. `search_design_system` returns results from many unrelated libraries and is slow to filter. A single `use_figma` call that walks an existing frame's instances gives you an exact, authoritative component map in seconds: + +```js +(async () => { + try { + const frame = figma.currentPage.findOne(n => n.name === "Existing Screen"); + const uniqueSets = new Map(); + frame.findAll(n => n.type === "INSTANCE").forEach(inst => { + const mc = inst.mainComponent; + const cs = mc?.parent?.type === "COMPONENT_SET" ? mc.parent : null; + const key = cs ? cs.key : mc?.key; + const name = cs ? cs.name : mc?.name; + if (key && !uniqueSets.has(key)) { + uniqueSets.set(key, { name, key, isSet: !!cs, sampleVariant: mc.name }); + } + }); + figma.closePlugin(JSON.stringify([...uniqueSets.values()])); + } catch(e) { figma.closePluginWithFailure(e.toString()); } +})() +``` + +Only fall back to `search_design_system` when the file has no existing screens to reference. When using it, **search broadly** — try multiple terms and synonyms to maximize coverage (e.g., "button", "input", "nav", "card", "accordion", "header", "footer", "tag", "avatar", "toggle", "icon", etc.). + +For each result, note: +- **Component key** — needed for `importComponentByKeyAsync` or `importComponentSetByKeyAsync` +- **Variant properties** — what variants exist (e.g., `variant=primary`, `size=medium`, `state=default`) +- **Whether it's a component or component set** — determines which import function to use + +Build a **component map** before writing any scripts. **Include component properties** — you need to know which TEXT properties each component exposes so you can use `setProperties()` for text overrides later. Create a temporary instance, read its `componentProperties` (and those of nested instances), then remove the temp instance: + +```js +(async () => { + try { + const compSet = await figma.importComponentSetByKeyAsync("COMPONENT_SET_KEY"); + const tempInstance = compSet.defaultVariant.createInstance(); + + // Read top-level properties + const props = tempInstance.componentProperties; + + // Read nested instance properties + const nested = tempInstance.findAll(n => n.type === "INSTANCE").map(ni => ({ + name: ni.name, + properties: ni.componentProperties + })); + + tempInstance.remove(); // clean up + + figma.closePlugin(JSON.stringify({ props, nested })); + } catch(e) { figma.closePluginWithFailure(e.toString()); } +})() +``` + +Example component map with property info: + +``` +Component Map: +- Button → key: "abc123", type: COMPONENT_SET + Properties: { "Label#2:0": TEXT, "Has Icon#4:64": BOOLEAN } +- PricingCard → key: "ghi789", type: COMPONENT_SET + Properties: { "Device": VARIANT, "Variant": VARIANT } + Nested "Text Heading" has: { "Text#2104:5": TEXT } + Nested "Button" has: { "Label#2:0": TEXT } +``` + +### Step 3: Create the Page Wrapper Frame First + +**Do NOT build sections as top-level page children and reparent them later** — moving nodes across `use_figma` calls with `appendChild()` silently fails and produces orphaned frames. Instead, create the wrapper first, then build each section directly inside it. + +Create the page wrapper in its own `use_figma` call. Position it away from existing content and return its ID: + +```js +(async () => { + try { + // Find clear space + let maxX = 0; + for (const child of figma.currentPage.children) { + maxX = Math.max(maxX, child.x + child.width); + } + + const wrapper = figma.createFrame(); + wrapper.name = "Homepage"; + wrapper.layoutMode = "VERTICAL"; + wrapper.primaryAxisAlignItems = "CENTER"; + wrapper.counterAxisAlignItems = "CENTER"; + wrapper.resize(1440, 100); + wrapper.layoutSizingHorizontal = "FIXED"; + wrapper.layoutSizingVertical = "HUG"; + wrapper.x = maxX + 200; + wrapper.y = 0; + + figma.closePlugin(JSON.stringify({ + success: true, + wrapperId: wrapper.id + })); + } catch(e) { figma.closePluginWithFailure(e.toString()); } +})() +``` + +### Step 4: Build Each Section Inside the Wrapper + +**This is the most important step.** Build one section at a time, each in its own `use_figma` call. At the start of each script, fetch the wrapper by ID and append new content directly to it. + +```js +(async () => { + try { + const createdNodeIds = []; + const wrapper = await figma.getNodeByIdAsync("WRAPPER_ID_FROM_STEP_3"); + + // Import design system components by key + const buttonSet = await figma.importComponentSetByKeyAsync("BUTTON_SET_KEY"); + const primaryButton = buttonSet.children.find(c => + c.type === "COMPONENT" && c.name.includes("variant=primary") + ) || buttonSet.defaultVariant; + + // Build section frame + const section = figma.createFrame(); + section.name = "Header"; + section.layoutMode = "HORIZONTAL"; + // ... configure layout ... + + // Create instances inside the section + const btnInstance = primaryButton.createInstance(); + section.appendChild(btnInstance); + createdNodeIds.push(btnInstance.id); + + // Append section to wrapper + wrapper.appendChild(section); + section.layoutSizingHorizontal = "FILL"; // AFTER appending + + createdNodeIds.push(section.id); + figma.closePlugin(JSON.stringify({ success: true, createdNodeIds })); + } catch(e) { + figma.closePluginWithFailure(e.toString()); + } +})() +``` + +After each section, validate with `get_screenshot` before moving on. Look closely for cropped/clipped text (line heights cutting off content) and overlapping elements — these are the most common issues and easy to miss at a glance. + +#### Override instance text with setProperties() + +Component instances ship with placeholder text ("Title", "Heading", "Button"). Use the component property keys you discovered in Step 2 to override them with `setProperties()` — this is more reliable than direct `node.characters` manipulation. See [component-patterns.md](../figma-use/references/component-patterns.md#overriding-text-in-a-component-instance) for the full pattern. + +For nested instances that expose their own TEXT properties, call `setProperties()` on the nested instance: + +```js +const nestedHeading = cardInstance.findOne(n => n.type === "INSTANCE" && n.name === "Text Heading"); +if (nestedHeading) { + nestedHeading.setProperties({ "Text#2104:5": "Actual heading from source code" }); +} +``` + +Only fall back to direct `node.characters` for text that is NOT managed by any component property. + +#### Read source code defaults carefully + +When translating code components to Figma instances, check the component's default prop values in the source code, not just what's explicitly passed. For example, `` with no variant prop — check the component definition to find `variant = "primary"` as the default. Selecting the wrong variant (e.g., Neutral instead of Primary) produces a visually incorrect result that's easy to miss. + +#### What to build manually vs. import + +| Build manually (frames, text, rectangles) | Import from design system | +|-------------------------------------------|--------------------------| +| Page wrapper frame | Any component found via `search_design_system` | +| Section container frames | (buttons, cards, inputs, nav elements, etc.) | +| Layout grids (rows, columns) | | +| Spacing / divider frames | | +| Section background fills | | +| One-off text (headings, body copy) | | + +### Step 5: Validate the Full Screen + +After composing all sections, call `get_screenshot` on the full page frame and compare against the source. Fix any issues with targeted `use_figma` calls — don't rebuild the entire screen. + +**Screenshot individual sections, not just the full page.** A full-page screenshot at reduced resolution hides text truncation, wrong colors, and placeholder text that hasn't been overridden. Take a screenshot of each section by node ID to catch: +- **Cropped/clipped text** — line heights or frame sizing cutting off descenders, ascenders, or entire lines +- **Overlapping content** — elements stacking on top of each other due to incorrect sizing or missing auto-layout +- Placeholder text still showing ("Title", "Heading", "Button") +- Truncated content from layout sizing bugs +- Wrong component variants (e.g., Neutral vs Primary button) + +### Step 6: Updating an Existing Screen + +When updating rather than creating from scratch: + +1. Use `get_metadata` to inspect the existing screen structure. +2. Identify which sections need updating and which can stay. +3. For each section that needs changes: + - Locate the existing nodes by ID or name + - Swap component instances if the design system component changed + - Update text content, variant properties, or layout as needed + - Remove deprecated sections + - Add new sections +4. Validate with `get_screenshot` after each modification. + +```js +// Example: Swap a button variant in an existing screen +(async () => { + try { + const existingButton = await figma.getNodeByIdAsync("EXISTING_BUTTON_INSTANCE_ID"); + if (existingButton && existingButton.type === "INSTANCE") { + // Import the updated component + const buttonSet = await figma.importComponentSetByKeyAsync("BUTTON_SET_KEY"); + const newVariant = buttonSet.children.find(c => + c.name.includes("variant=primary") && c.name.includes("size=lg") + ) || buttonSet.defaultVariant; + existingButton.swapComponent(newVariant); + } + figma.closePlugin(JSON.stringify({ success: true, mutatedNodeIds: [existingButton.id] })); + } catch(e) { + figma.closePluginWithFailure(e.toString()); + } +})() +``` + +## Reference Docs + +For detailed API patterns and gotchas, load these from the [figma-use](../figma-use/SKILL.md) references as needed: + +- [component-patterns.md](../figma-use/references/component-patterns.md) — importing by key, finding variants, setProperties, text overrides, working with instances +- [gotchas.md](../figma-use/references/gotchas.md) — layout pitfalls (HUG/FILL interactions, counterAxisAlignItems, sizing order), paint/color issues, page context resets + +## Error Recovery + +Follow the error recovery process from [figma-use](../figma-use/SKILL.md#6-error-recovery--self-correction): + +1. **STOP** on error — do not retry immediately. +2. Call `get_metadata` to inspect partial state. +3. Clean up orphaned nodes before retrying. +4. Fix the script based on inspection results. +5. Retry only after cleanup. + +Because this skill works incrementally (one section per call), errors are naturally scoped to a single section. If a section fails, only that section needs cleanup — previous sections remain intact. + +## Best Practices + +- **Always search before building.** The design system likely has what you need. Manual construction should be the exception, not the rule. +- **Search broadly.** Try synonyms and partial terms. A "NavigationPill" might be found under "pill", "nav", "tab", or "chip". +- **Prefer component instances over manual builds.** Instances stay linked to the source component and update automatically when the design system evolves. +- **Work section by section.** Never build more than one major section per `use_figma` call. +- **Return node IDs from every call.** You'll need them to compose sections and for error recovery. +- **Validate visually after each section.** Use `get_screenshot` to catch issues early. +- **Match existing conventions.** If the file already has screens, match their naming, sizing, and layout patterns. diff --git a/plugins/figma/skills/figma-generate-design/maintainers.yml b/plugins/figma/skills/figma-generate-design/maintainers.yml new file mode 100644 index 00000000..262e692e --- /dev/null +++ b/plugins/figma/skills/figma-generate-design/maintainers.yml @@ -0,0 +1 @@ +SKILL.md: mcp_server diff --git a/plugins/figma/skills/figma-generate-library/SKILL.md b/plugins/figma/skills/figma-generate-library/SKILL.md new file mode 100644 index 00000000..72cd7762 --- /dev/null +++ b/plugins/figma/skills/figma-generate-library/SKILL.md @@ -0,0 +1,315 @@ +--- +name: figma-generate-library +description: "Build or update a professional-grade design system in Figma from a codebase. Use when the user wants to create variables/tokens, build component libraries, set up theming (light/dark modes), document foundations, or reconcile gaps between code and Figma. This skill teaches WHAT to build and in WHAT ORDER — it complements the `figma-use` skill which teaches HOW to call the Plugin API. Both skills should be loaded together." +disable-model-invocation: true +--- + +# Design System Builder — Figma MCP Skill + +Build professional-grade design systems in Figma that match code. This skill orchestrates multi-phase workflows across 20–100+ `use_figma` calls, enforcing quality patterns from real-world design systems (Material 3, Polaris, Figma UI3, Simple DS). + +**Prerequisites**: The `figma-use` skill MUST also be loaded for every `use_figma` call. It provides Plugin API syntax rules (closePlugin contract, page reset, ID return, font loading, color range). This skill provides design system domain knowledge and workflow orchestration. + +**Always pass `skillNames: "figma-generate-library"` when calling `use_figma` as part of this skill.** This is a logging parameter — it does not affect execution. + +--- + +## 1. The One Rule That Matters Most + +**This is NEVER a one-shot task.** Building a design system requires 20–100+ `use_figma` calls across multiple phases, with mandatory user checkpoints between them. Any attempt to create everything in one call WILL produce broken, incomplete, or unrecoverable results. Break every operation to the smallest useful unit, validate, get feedback, proceed. + +--- + +## 2. Mandatory Workflow + +Every design system build follows this phase order. Skipping or reordering phases causes structural failures that are expensive to undo. + +``` +Phase 0: DISCOVERY (always first — no use_figma writes yet) + 0a. Analyze codebase → extract tokens, components, naming conventions + 0b. Inspect Figma file → pages, variables, components, styles, existing conventions + 0c. Search subscribed libraries → use search_design_system for reusable assets + 0d. Lock v1 scope → agree on exact token set + component list before any creation + 0e. Map code → Figma → resolve conflicts (code and Figma disagree = ask user) + ✋ USER CHECKPOINT: present full plan, await explicit approval + +Phase 1: FOUNDATIONS (tokens first — always before components) + 1a. Create variable collections and modes + 1b. Create primitive variables (raw values, 1 mode) + 1c. Create semantic variables (aliased to primitives, mode-aware) + 1d. Set scopes on ALL variables + 1e. Set code syntax on ALL variables + 1f. Create effect styles (shadows) and text styles (typography) + → Exit criteria: every token from the agreed plan exists, all scopes set, all code syntax set + ✋ USER CHECKPOINT: show variable summary, await approval + +Phase 2: FILE STRUCTURE (before components) + 2a. Create page skeleton: Cover → Getting Started → Foundations → --- → Components → --- → Utilities + 2b. Create foundations documentation pages (color swatches, type specimens, spacing bars) + → Exit criteria: all planned pages exist, foundations docs are navigable + ✋ USER CHECKPOINT: show page list + screenshot, await approval + +Phase 3: COMPONENTS (one at a time — never batch) + For EACH component (in dependency order: atoms before molecules): + 3a. Create dedicated page + 3b. Build base component with auto-layout + full variable bindings + 3c. Create all variant combinations (combineAsVariants + grid layout) + 3d. Add component properties (TEXT, BOOLEAN, INSTANCE_SWAP) + 3e. Link properties to child nodes + 3f. Add page documentation (title, description, usage notes) + 3g. Validate: get_metadata (structure) + get_screenshot (visual) + 3h. Optional: lightweight Code Connect mapping while context is fresh + → Exit criteria: variant count correct, all bindings verified, screenshot looks right + ✋ USER CHECKPOINT per component: show screenshot, await approval before next component + +Phase 4: INTEGRATION + QA (final pass) + 4a. Finalize all Code Connect mappings + 4b. Accessibility audit (contrast, min touch targets, focus visibility) + 4c. Naming audit (no duplicates, no unnamed nodes, consistent casing) + 4d. Unresolved bindings audit (no hardcoded fills/strokes remaining) + 4e. Final review screenshots of every page + ✋ USER CHECKPOINT: complete sign-off +``` + +--- + +## 3. Critical Rules + +**Plugin API basics** (from use_figma skill — enforced here too): +- Every script: `(async () => { try { ... figma.closePlugin(JSON.stringify({...})) } catch(e) { figma.closePluginWithFailure(e.toString()) } })()` +- Return ALL created/mutated node IDs in every `closePlugin` call +- Page context resets each call — always `await figma.setCurrentPageAsync(page)` at start +- `figma.notify()` throws — never use it +- Colors are 0–1 range, not 0–255 +- Font MUST be loaded before any text write: `await figma.loadFontAsync({family, style})` + +**Design system rules**: +1. **Variables BEFORE components** — components bind to variables. No token = no component. +2. **Inspect before creating** — run read-only `use_figma` to discover existing conventions. Match them. +3. **One page per component** *(default)* — exception: tightly related families (e.g., Input + helpers) may share a page with clear section separation. +4. **Bind visual properties to variables** *(default)* — fills, strokes, padding, radius, gap. Exceptions: intentionally fixed geometry (icon pixel-grid sizes, static dividers). +5. **Scopes on every variable** — NEVER leave as `ALL_SCOPES`. Background: `FRAME_FILL, SHAPE_FILL`. Text: `TEXT_FILL`. Border: `STROKE_COLOR`. Spacing: `GAP`. Radii: `CORNER_RADIUS`. Primitives: `[]` (hidden). +6. **Code syntax on every variable** — WEB syntax MUST use the `var()` wrapper: `var(--color-bg-primary)`, not `--color-bg-primary`. Use the actual CSS variable name from the codebase. ANDROID/iOS do NOT use a wrapper. +7. **Alias semantics to primitives** — `{ type: 'VARIABLE_ALIAS', id: primitiveVar.id }`. Never duplicate raw values in semantic layer. +8. **Position variants after combineAsVariants** — they stack at (0,0). Manually grid-layout + resize. +9. **INSTANCE_SWAP for icons** — never create a variant per icon. Cap variant matrices: if Size × Style × State > 30 combinations, split into sub-component. +10. **Deterministic naming** — use `setPluginData('dsb_key', uniqueKey)` on every created node for idempotent cleanup and resumability. +11. **No destructive cleanup** — cleanup scripts identify nodes via `pluginData` tag, not name-prefix matching. +12. **Validate before proceeding** — never build on unvalidated work. `get_metadata` after every create, `get_screenshot` after each component. +13. **NEVER parallelize `use_figma` calls** — Figma state mutations must be strictly sequential. Even if your tool supports parallel calls, never run two use_figma calls simultaneously. +14. **Never hallucinate Node IDs** — always read IDs from the state ledger returned by previous calls. Never reconstruct or guess an ID from memory. +15. **Use the helper scripts** — embed scripts from `scripts/` into your use_figma calls. Don't write 200-line inline scripts from scratch. +16. **Explicit phase approval** — at each checkpoint, name the next phase explicitly. "looks good" is not approval to proceed to Phase 3 if you asked about Phase 1. + +--- + +## 4. State Management (Required for Long Workflows) + +**Important**: `setPluginData` works on scene nodes (pages, frames, components) only. Variables and variable collections do NOT support `setPluginData`. Use different idempotency strategies per entity type: + +| Entity type | Idempotency key | How to check existence | +|-------------|----------------|----------------------| +| Scene nodes (pages, frames, components) | `setPluginData('dsb_run_id', ...)` + `setPluginData('dsb_key', ...)` | Query by pluginData tag | +| Variables | Name within collection | `getLocalVariables().find(v => v.name === name && v.variableCollectionId === collId)` | +| Styles | Name | `getLocalTextStyles().find(s => s.name === name)` | + +Tag every created **scene node** immediately after creation: +```javascript +node.setPluginData('dsb_run_id', RUN_ID); // identifies this build run +node.setPluginData('dsb_phase', 'phase3'); // which phase created it +node.setPluginData('dsb_key', 'component/button');// unique logical key +``` + +**State persistence**: Do NOT rely solely on conversation context for the state ledger. Write it to disk: +``` +/tmp/dsb-state-{RUN_ID}.json +``` +Re-read this file at the start of every turn. In long workflows, conversation context will be truncated — the file is the source of truth. + +Maintain a state ledger tracking: +```json +{ + "runId": "ds-build-2024-001", + "phase": "phase3", + "step": "component-button", + "entities": { + "collections": { "primitives": "id:...", "color": "id:..." }, + "variables": { "color/bg/primary": "id:...", "spacing/sm": "id:..." }, + "pages": { "Cover": "id:...", "Button": "id:..." }, + "components": { "Button": "id:..." } + }, + "pendingValidations": ["Button:screenshot"], + "completedSteps": ["phase0", "phase1", "phase2", "component-avatar"] +} +``` + +**Idempotency check** before every create: query by name + pluginData key. If exists, skip or update — never duplicate. + +**Resume protocol**: at session start or after context truncation, run `rehydrateState.js` (see §11) to scan all nodes for `dsb_run_id` tags and reconstruct the `{key → id}` map. Then re-read the state file from disk if available. + +**Continuation prompt** (give this to the user when resuming in a new chat): +> "I'm continuing a design system build. Run ID: {RUN_ID}. Load the figma-generate-library skill and resume from the last completed step." + +--- + +## 5. search_design_system — Reuse Decision Matrix + +Search FIRST in Phase 0, then again immediately before each component creation. + +``` +search_design_system({ query, fileKey, includeComponents: true, includeVariables: true, includeStyles: true }) +``` + +**Reuse if** all of these are true: +- Component property API matches your needs (same variant axes, compatible types) +- Token binding model is compatible (uses same or aliasable variables) +- Naming conventions match the target file +- Component is editable (not locked in a remote library you don't own) + +**Rebuild if** any of these: +- API incompatibility (different property names, wrong variant model) +- Token model incompatible (hardcoded values, different variable schema) +- Ownership issue (can't modify the library) + +**Wrap if** visual match but API incompatible: +- Import the library component as a nested instance inside a new wrapper component +- Expose a clean API on the wrapper + +**Three-way priority**: local existing → subscribed library import → create new. + +--- + +## 6. User Checkpoints + +Mandatory. Design decisions require human judgment. + +| After | Required artifacts | Ask | +|-------|-------------------|-----| +| Discovery + scope lock | Token list, component list, gap analysis | "Here's my plan. Approve before I create anything?" | +| Foundations | Variable summary (N collections, M vars, K modes), style list | "All tokens created. Review before file structure?" | +| File structure | Page list + screenshot | "Pages set up. Review before components?" | +| Each component | get_screenshot of component page | "Here's [Component] with N variants. Correct?" | +| Each conflict (code ≠ Figma) | Show both versions | "Code says X, Figma has Y. Which wins?" | +| Final QA | Per-page screenshots + audit report | "Complete. Sign off?" | + +**If user rejects**: fix before moving on. Never build on rejected work. + +--- + +## 7. Naming Conventions + +Match existing file conventions. If starting fresh: + +**Variables** (slash-separated): +``` +color/bg/primary color/text/secondary color/border/default +spacing/xs spacing/sm spacing/md spacing/lg spacing/xl spacing/2xl +radius/none radius/sm radius/md radius/lg radius/full +typography/body/font-size typography/heading/line-height +``` + +**Primitives**: `blue/50` → `blue/900`, `gray/50` → `gray/900` + +**Component names**: `Button`, `Input`, `Card`, `Avatar`, `Badge`, `Checkbox`, `Toggle` + +**Variant names**: `Property=Value, Property=Value` — e.g., `Size=Medium, Style=Primary, State=Default` + +**Page separators**: `---` (most common) or `——— COMPONENTS ———` + +> Full naming reference: [naming-conventions.md](references/naming-conventions.md) + +--- + +## 8. Token Architecture + +| Complexity | Pattern | +|-----------|---------| +| < 50 tokens | Single collection, 2 modes (Light/Dark) | +| 50–200 tokens | **Standard**: Primitives (1 mode) + Color semantic (Light/Dark) + Spacing (1 mode) + Typography (1 mode) | +| 200+ tokens | **Advanced**: Multiple semantic collections, 4–8 modes (Light/Dark × Contrast × Brand). See M3 pattern in [token-creation.md](references/token-creation.md) | + +Standard pattern (recommended starting point): +``` +Collection: "Primitives" modes: ["Value"] + blue/500 = #3B82F6, gray/900 = #111827, ... + +Collection: "Color" modes: ["Light", "Dark"] + color/bg/primary → Light: alias Primitives/white, Dark: alias Primitives/gray-900 + color/text/primary → Light: alias Primitives/gray-900, Dark: alias Primitives/white + +Collection: "Spacing" modes: ["Value"] + spacing/xs = 4, spacing/sm = 8, spacing/md = 16, ... +``` + +--- + +## 9. Per-Phase Anti-Patterns + +**Phase 0 anti-patterns:** +- ❌ Starting to create anything before scope is locked with user +- ❌ Ignoring existing file conventions and imposing new ones +- ❌ Skipping `search_design_system` before planning component creation + +**Phase 1 anti-patterns:** +- ❌ Using `ALL_SCOPES` on any variable +- ❌ Duplicating raw values in semantic layer instead of aliasing +- ❌ Not setting code syntax (breaks Dev Mode and round-tripping) +- ❌ Creating component tokens before agreeing on token taxonomy + +**Phase 2 anti-patterns:** +- ❌ Skipping the cover page or foundations docs +- ❌ Putting multiple unrelated components on one page + +**Phase 3 anti-patterns:** +- ❌ Creating components before foundations exist +- ❌ Hardcoding any fill/stroke/spacing/radius value in a component +- ❌ Creating a variant per icon (use INSTANCE_SWAP instead) +- ❌ Not positioning variants after combineAsVariants (they all stack at 0,0) +- ❌ Building variant matrix > 30 without splitting (variant explosion) +- ❌ Importing remote components then immediately detaching them + +**General anti-patterns:** +- ❌ Retrying a failed script without cleanup first +- ❌ Using name-prefix matching for cleanup (deletes user-owned nodes) +- ❌ Building on unvalidated work from the previous step +- ❌ Skipping user checkpoints to "save time" +- ❌ Parallelizing use_figma calls (always sequential) +- ❌ Guessing/hallucinating node IDs from memory (always read from state ledger) +- ❌ Writing massive inline scripts instead of using the provided helper scripts +- ❌ Starting Phase 3 because the user said "build the button" without completing Phases 0-2 + +--- + +## 10. Reference Docs + +Load on demand — each reference is authoritative for its phase: + +Use your file reading tool to read these docs when needed. Do not assume their contents from the filename. + +| Doc | Phase | Required / Optional | Load when | +|-----|-------|---------------------|-----------| +| [discovery-phase.md](references/discovery-phase.md) | 0 | **Required** | Starting any build — codebase analysis + Figma inspection | +| [token-creation.md](references/token-creation.md) | 1 | **Required** | Creating variables, collections, modes, styles | +| [documentation-creation.md](references/documentation-creation.md) | 2 | Required | Creating cover page, foundations docs, swatches | +| [component-creation.md](references/component-creation.md) | 3 | **Required** | Creating any component or variant | +| [code-connect-setup.md](references/code-connect-setup.md) | 3–4 | Required | Setting up Code Connect or variable code syntax | +| [naming-conventions.md](references/naming-conventions.md) | Any | Optional | Naming anything — variables, pages, variants, styles | +| [error-recovery.md](references/error-recovery.md) | Any | **Required on error** | Script fails, partial state exists, need cleanup/retry | + +--- + +## 11. Scripts + +Reusable Plugin API helper functions. Embed in `use_figma` calls: + +| Script | Purpose | +|--------|---------| +| [inspectFileStructure.js](scripts/inspectFileStructure.js) | Discover all pages, components, variables, styles; returns full inventory | +| [createVariableCollection.js](scripts/createVariableCollection.js) | Create a named collection with modes; returns `{collectionId, modeIds}` | +| [createSemanticTokens.js](scripts/createSemanticTokens.js) | Create aliased semantic variables from a token map | +| [createComponentWithVariants.js](scripts/createComponentWithVariants.js) | Build a component set from a variant matrix; handles grid layout | +| [bindVariablesToComponent.js](scripts/bindVariablesToComponent.js) | Bind design tokens to all component visual properties | +| [createDocumentationPage.js](scripts/createDocumentationPage.js) | Create a page with title + description + section structure | +| [validateCreation.js](scripts/validateCreation.js) | Verify created nodes match expected counts, names, structure | +| [cleanupOrphans.js](scripts/cleanupOrphans.js) | Remove nodes tagged with a given `dsb_run_id` (pluginData-safe cleanup) | +| [rehydrateState.js](scripts/rehydrateState.js) | Scan file for all `dsb_*` pluginData tags; returns full `{key → nodeId}` map for state reconstruction | diff --git a/plugins/figma/skills/figma-generate-library/maintainers.yml b/plugins/figma/skills/figma-generate-library/maintainers.yml new file mode 100644 index 00000000..a572caf6 --- /dev/null +++ b/plugins/figma/skills/figma-generate-library/maintainers.yml @@ -0,0 +1,3 @@ +SKILL.md: mcp_server +references: mcp_server +scripts: mcp_server diff --git a/plugins/figma/skills/figma-generate-library/references/code-connect-setup.md b/plugins/figma/skills/figma-generate-library/references/code-connect-setup.md new file mode 100644 index 00000000..3ac49647 --- /dev/null +++ b/plugins/figma/skills/figma-generate-library/references/code-connect-setup.md @@ -0,0 +1,260 @@ +> Part of the [figma-generate-library skill](../SKILL.md). + +# Code Connect Setup Reference + +This reference covers all Code Connect tooling available to the figma-generate-library agent: the `add_code_connect_map` tool, `get_code_connect_map` for verification, `send_code_connect_mappings` for bulk application, variable code syntax, framework labels, and the decision of when to map per-component vs. in a final pass. + +--- + +## 1. What Code Connect Does + +Code Connect links a Figma component node to its code implementation so that: + +- **Dev Mode** shows a real code snippet (from your codebase) instead of an auto-generated approximation when a developer inspects a component. +- **MCP `get_design_context`** returns `componentName`, `source`, and a rendered snippet alongside design tokens, enabling accurate AI-assisted code generation. +- **`search_design_system`** can return code references alongside Figma component metadata. + +--- + +## 2. The Three MCP Tools + +### 2a. add_code_connect_map — single mapping + +Maps one Figma node to one code component. + +**Parameters:** + +| Parameter | Type | Required | Notes | +|-----------|------|----------|-------| +| `nodeId` | string | Yes (remote) / Optional (desktop) | Format `123:456`. Must be a published component or component set. | +| `fileKey` | string | Yes (remote) | The Figma file key. | +| `source` | string | Yes | Path in the codebase (e.g. `src/components/Button.tsx`) or a URL. | +| `componentName` | string | Yes | The code component name (e.g. `Button`). | +| `label` | enum | Yes | Framework label — see Section 4 for valid values. | +| `template` | string | Optional | Executable JS template code. Providing this creates a **figmadoc** (template) mapping instead of a simple **component_browser** mapping. Requires the `pixie_mcp_enable_writing_code_connect_templates` feature flag. | +| `templateDataJson` | string | Optional | JSON string with optional fields: `isParserless`, `imports`, `nestable`, `props`. | + +**Two mapping tiers:** + +1. **Simple mapping (component_browser):** Only `source`, `componentName`, and `label` provided. Associates the Figma component with a code path + name. Dev Mode generates a basic JSX snippet from Figma prop names. This is the default — use it first. + +2. **Template mapping (figmadoc):** `template` is also provided. The template is executed in a sandboxed QuickJS environment and dynamically renders the snippet based on the actual instance's property values. Use this when precise prop-level Code Connect is required by the user. + +**Common error codes:** + +| Error | Meaning | Fix | +|-------|---------|-----| +| `CODE_CONNECT_MAPPING_ALREADY_EXISTS` | Component is already mapped | Disconnect existing mapping in Figma UI first | +| `CODE_CONNECT_ASSET_NOT_FOUND` | Published component not found | Ensure the component is published to the library | +| `CODE_CONNECT_INSUFFICIENT_PERMISSIONS` | No edit access | Request edit permission on the file | +| `CODE_CONNECT_NO_LIBRARY_FOUND` | File is not published as a library | Publish the file as a Figma library first | + +**Usage example:** + +``` +Tool: add_code_connect_map +Args: { + nodeId: "123:456", + fileKey: "abc123", + source: "src/components/Button.tsx", + componentName: "Button", + label: "React" +} +``` + +--- + +### 2b. get_code_connect_map — verification + +Retrieves the current Code Connect mapping for a node. Use this immediately after `add_code_connect_map` to confirm the mapping was saved, and before `send_code_connect_mappings` to audit existing state. + +**Parameters:** + +| Parameter | Type | Required | Notes | +|-----------|------|----------|-------| +| `nodeId` | string | Optional | The node to check. Omit to get all mappings in the file. | +| `fileKey` | string | Yes (remote) | The Figma file key. | +| `codeConnectLabel` | string | Optional | Filter results to a specific framework label. | + +**Returns:** A map of `nodeId -> { componentName, source, label, snippet, snippetImports }`. + +**How to verify:** + +``` +1. Call add_code_connect_map with the node. +2. Immediately call get_code_connect_map(nodeId, fileKey). +3. Confirm the returned object has the expected componentName and source. +4. If the mapping is missing, check for error codes from step 1. +``` + +--- + +### 2c. send_code_connect_mappings — bulk application + +Applies multiple Code Connect mappings in one call. Use after `get_code_connect_suggestions` returns a batch of unmapped components, or when doing a final-pass bulk mapping at the end of Phase 4. + +**Parameters:** + +| Parameter | Type | Required | Notes | +|-----------|------|----------|-------| +| `nodeId` | string | Optional | Context node for design fallback if mappings array is empty. | +| `fileKey` | string | Yes (remote) | The Figma file key. | +| `mappings` | array | Yes | Array of mapping objects. | + +**Each mapping object:** + +| Field | Type | Required | Notes | +|-------|------|----------|-------| +| `nodeId` | string | Yes | The Figma node identifier. | +| `componentName` | string | Yes | Code component name. | +| `source` | string | Yes | Path in the codebase. | +| `label` | enum | Yes | Framework label. | +| `template` | string | Optional | JS template code for figmadoc mapping. | +| `templateDataJson` | string | Optional | JSON template metadata. | + +**Behavior:** + +- All mappings are processed in parallel via POSTs to the backend. +- If any mapping fails, errors are reported per mapping — the rest succeed. +- On full success, `get_design_context` is called for the nodes and fresh design context is returned. + +**Bulk workflow:** + +``` +1. Collect all {nodeId, componentName, source, label} pairs. +2. Call send_code_connect_mappings({ fileKey, mappings: [...all pairs...] }). +3. Review reported errors and call add_code_connect_map individually for any failures. +4. Call get_code_connect_map on a sample of nodes to spot-check. +``` + +--- + +## 3. Variable Code Syntax (Token Round-Tripping) + +Setting code syntax on variables creates the bidirectional link between Figma tokens and the codebase token system. This is what enables Dev Mode to show `var(--color-bg-primary)` next to a design value instead of a raw hex. + +**The three platforms:** + +```javascript +// In use_figma: +variable.setVariableCodeSyntax('WEB', 'var(--color-bg-primary)'); +variable.setVariableCodeSyntax('ANDROID', 'Theme.colorBgPrimary'); +variable.setVariableCodeSyntax('iOS', 'Color.bgPrimary'); +``` + +- `WEB` — used for CSS custom properties, design token JSON, and any web framework. +- `ANDROID` — used for Jetpack Compose theme references and Android resource names. +- `iOS` — used for SwiftUI Color extensions and UIKit color methods. + +**Derivation rules (in priority order):** + +1. **Best:** Use the exact token name from the codebase. Search the codebase for CSS custom properties (`--`), Swift color extensions, or Kotlin theme references and use those exact strings. +2. **Good:** Derive from the Figma variable name with a consistent transformation: replace `/` and spaces with `-`, prefix with `var(--` and suffix with `)`. + - Example: `color/bg/primary` → `var(--color-bg-primary)` +3. **Avoid:** Guessing or inventing names that don't exist in the codebase. + +**Consistency rule:** The transformation must be uniform. If you use `var(--color-bg-primary)` for one variable, use the same `var(--{path-with-hyphens})` pattern for all variables in that collection. + +**WEB syntax bulk example:** + +```javascript +// In use_figma — set WEB code syntax on all variables in a collection +const collections = figma.variables.getLocalVariableCollections(); +for (const coll of collections) { + if (coll.name !== 'Color') continue; + for (const varId of coll.variableIds) { + const v = figma.variables.getVariableById(varId); + if (!v) continue; + // Derive: "color/bg/primary" → "var(--color-bg-primary)" + const cssName = 'var(--' + v.name.toLowerCase().replace(/\//g, '-').replace(/\s+/g, '-') + ')'; + v.setVariableCodeSyntax('WEB', cssName); + } +} +``` + +--- + +## 4. Framework Labels + +The following labels are valid for all Code Connect MCP operations. Use the label that matches your codebase framework. + +| Label | Use for | +|-------|---------| +| `React` | React / JSX / TSX components | +| `Web Components` | Native Web Components, Lit, FAST | +| `Vue` | Vue 2 and Vue 3 SFCs | +| `Svelte` | Svelte components | +| `Storybook` | Storybook stories with Code Connect integration | +| `Javascript` | Plain JavaScript, framework-agnostic | +| `Swift` | Swift / UIKit | +| `Swift UIKit` | UIKit specifically | +| `Objective-C UIKit` | Objective-C with UIKit | +| `SwiftUI` | SwiftUI view components | +| `Compose` | Jetpack Compose (Android) | +| `Java` | Java Android components | +| `Kotlin` | Kotlin Android (non-Compose) | +| `Android XML Layout` | Android XML layout files | +| `Flutter` | Flutter / Dart widgets | +| `Markdown` | Documentation or MDX components | + +**HTML note:** The label `HTML` is used by the Code Connect CLI's HTML parser (for Angular, Vue, and Web Components without a framework-specific parser), but the MCP tools use `Web Components` or `Vue` directly. Check the codebase framework before selecting. + +--- + +## 5. Per-Component vs. Final-Pass Strategy + +### Per-component (preferred for new builds) + +Map Code Connect immediately after creating a component, while the context is fresh (Phase 3, step 3h in the SKILL.md workflow): + +**Advantages:** +- The node ID is already in hand from the creation script. +- You know exactly which code component this Figma component corresponds to (you just designed it to match). +- Errors surface early, before building dependent components. + +**When to use:** Any time you create a Figma component that has a clear 1:1 match with an existing code component. + +### Final pass (for bulk mapping at Phase 4) + +Collect all unmapped components and map them in one `send_code_connect_mappings` call: + +**Advantages:** +- One bulk call instead of N individual calls. +- Can use `get_code_connect_suggestions` to discover unmapped components automatically. +- Better for importing existing Figma files where you didn't control creation. + +**When to use:** Retrofitting Code Connect onto an existing file, or when the codebase mapping requires research that is better done after all components are created. + +### Hybrid (recommended for large systems) + +- Map atoms (Button, Input, Badge, Avatar) **per-component** during Phase 3. +- Map molecules and organisms in a **final pass** during Phase 4 after all atoms are mapped, since molecule snippets reference atom Code Connect IDs. + +--- + +## 6. Verification in Dev Mode + +After mapping: + +1. Open the Figma file in the browser or desktop app. +2. Switch to Dev Mode (the `` icon in the toolbar). +3. Select a component instance (not the main component — an instance placed on a page). +4. In the Inspect panel, the code snippet should show the Code Connect output instead of auto-generated code. +5. If the snippet is missing or shows `[auto-generated]`, run `get_code_connect_map` via MCP to confirm the mapping exists, then check that the component is published. + +**Via MCP (faster during agent workflows):** + +``` +get_code_connect_map(nodeId: "", fileKey: "") +``` + +The response should include `componentName`, `source`, `label`, and a non-empty `snippet`. + +--- + +## 7. Important Constraints + +- **Published components only:** `add_code_connect_map` requires the component to be published to a library. If the file is not yet published, the mapping will fail with `CODE_CONNECT_NO_LIBRARY_FOUND`. +- **One mapping per label per node:** A node can have multiple mappings (one per framework label), but only one per label. Attempting to add a second React mapping to the same node returns `CODE_CONNECT_MAPPING_ALREADY_EXISTS`. +- **Template mappings are gated:** The `template` parameter requires the `pixie_mcp_enable_writing_code_connect_templates` feature flag. Use simple mappings unless the user explicitly requests template-level Code Connect. +- **Start simple, escalate:** Always begin with simple mappings (`source` + `componentName` + `label`). Add `template` only if the user needs precise prop-level snippet rendering. diff --git a/plugins/figma/skills/figma-generate-library/references/component-creation.md b/plugins/figma/skills/figma-generate-library/references/component-creation.md new file mode 100644 index 00000000..bde17547 --- /dev/null +++ b/plugins/figma/skills/figma-generate-library/references/component-creation.md @@ -0,0 +1,1014 @@ +> Part of the [figma-generate-library skill](../SKILL.md). + +# Component Creation Reference + +Complete guide for Phase 3: building components with variant matrices, variable bindings, component properties, and documentation. + +--- + +## 1. Component Architecture + +### Dependency Ordering: Atoms Before Molecules + +Always build in dependency order. A molecule that contains an atom instance cannot exist until the atom is published. Suggested ordering: + +``` +Tier 0 (atoms): Icon, Avatar, Badge, Spinner +Tier 1 (molecules): Button, Checkbox, Toggle, Input, Select +Tier 2 (organisms): Card, Dialog, Menu, Navigation, Form +``` + +If a component embeds an instance of another component, the embedded component must be created first. Build your dependency graph during Phase 0 and encode the creation order in the plan. + +### Building Blocks Sub-Components (M3 Pattern) + +For complex components with independent sub-element state machines, extract the sub-element into its own component set prefixed with `Building Blocks/` (public) or `.Building Blocks/` (hidden from assets panel). The dot-prefix is a Figma convention for suppressing a component from the public assets panel. + +**When to use Building Blocks:** +- The sub-element has its own variant axes (state, selection) that would cause combinatorial explosion in the parent +- The sub-element repeats (nav items, table cells, calendar cells, segmented button segments) +- The sub-element has different variant axes than the parent + +**Example (M3 Segmented Button):** +``` +Building Blocks/Segmented button/Button segment (start) [27 variants: Config × State × Selected] +Building Blocks/Segmented button/Button segment (middle) [27 variants] +Building Blocks/Segmented button/Button segment (end) [27 variants] + +Segmented button [16 variants: Segments=2-5 × Density=0/-1/-2/-3] + Each variant contains instances of the appropriate Building Block segment components. +``` + +The parent manages composition and configuration; the Building Block manages its own interaction states. + +### Private Components (`__` Prefix) + +Use the `__` prefix for internal helper components that should not appear in the team library (Shop Minis pattern). Use `_` for documentation-only components (UI3 pattern). + +``` +__asset // private icon/asset holder +_Label/Direction // documentation annotation helper +``` + +--- + +## 2. Creating the Component Page + +Each component lives on its own dedicated page (one page per component is the default). The page contains: a documentation frame at top-left and the component set positioned to its right or below. + +```javascript +(async () => { + try { + // Create or find the component page + let page = figma.root.children.find(p => p.name === 'Button'); + if (!page) { + page = figma.createPage(); + page.name = 'Button'; + } + await figma.setCurrentPageAsync(page); + + // Documentation frame — positioned at (40, 40) + const docFrame = figma.createFrame(); + docFrame.name = 'Button / Documentation'; + docFrame.x = 40; + docFrame.y = 40; + docFrame.resize(600, 400); + docFrame.fills = [{ type: 'SOLID', color: { r: 1, g: 1, b: 1 } }]; + docFrame.layoutMode = 'VERTICAL'; + docFrame.primaryAxisSizingMode = 'AUTO'; + docFrame.counterAxisSizingMode = 'FIXED'; + docFrame.paddingTop = 40; + docFrame.paddingBottom = 40; + docFrame.paddingLeft = 40; + docFrame.paddingRight = 40; + docFrame.itemSpacing = 16; + + // Title text node + await figma.loadFontAsync({ family: 'Inter', style: 'Bold' }); + const title = figma.createText(); + title.fontName = { family: 'Inter', style: 'Bold' }; + title.fontSize = 32; + title.characters = 'Button'; + docFrame.appendChild(title); + + // Description text node + await figma.loadFontAsync({ family: 'Inter', style: 'Regular' }); + const desc = figma.createText(); + desc.fontName = { family: 'Inter', style: 'Regular' }; + desc.fontSize = 14; + desc.characters = 'Buttons allow users to take actions and make choices with a single tap.'; + docFrame.appendChild(desc); + + // Tag docFrame with pluginData for idempotency + docFrame.setPluginData('dsb_run_id', RUN_ID); + docFrame.setPluginData('dsb_key', 'doc/button'); + + figma.closePlugin(JSON.stringify({ docFrameId: docFrame.id, pageId: page.id })); + } catch (e) { figma.closePluginWithFailure(e.toString()); } +})(); +``` + +--- + +## 3. Base Component: Auto-Layout, Child Nodes, Variable Bindings + +The base component is the template from which all variants are cloned. It must have: +1. Auto-layout (not manual positioning) +2. All child nodes present +3. ALL visual properties bound to variables (no hardcoded values) + +### Complete Button Base Component Example + +```javascript +(async () => { + try { + const RUN_ID = 'ds-build-2024-001'; // replace with your actual run ID + await figma.setCurrentPageAsync( + figma.root.children.find(p => p.name === 'Button') + ); + + // Rehydrate variables from IDs stored in state ledger + const bgVar = await figma.variables.getVariableByIdAsync('VAR_ID_color_bg_primary'); + const textVar = await figma.variables.getVariableByIdAsync('VAR_ID_color_text_on_primary'); + const paddingVar = await figma.variables.getVariableByIdAsync('VAR_ID_spacing_md'); + const radiusVar = await figma.variables.getVariableByIdAsync('VAR_ID_radius_md'); + const gapVar = await figma.variables.getVariableByIdAsync('VAR_ID_spacing_sm'); + + // --- Base component frame --- + const comp = figma.createComponent(); + comp.name = 'Size=Medium, Style=Primary, State=Default'; + comp.layoutMode = 'HORIZONTAL'; + comp.primaryAxisSizingMode = 'AUTO'; + comp.counterAxisSizingMode = 'AUTO'; + comp.counterAxisAlignItems = 'CENTER'; + comp.primaryAxisAlignItems = 'CENTER'; + + // Padding — bound to spacing variables + comp.setBoundVariable('paddingTop', paddingVar); + comp.setBoundVariable('paddingBottom', paddingVar); + comp.setBoundVariable('paddingLeft', paddingVar); + comp.setBoundVariable('paddingRight', paddingVar); + comp.setBoundVariable('itemSpacing', gapVar); + + // Corner radius — bound to radius variable + comp.setBoundVariable('topLeftRadius', radiusVar); + comp.setBoundVariable('topRightRadius', radiusVar); + comp.setBoundVariable('bottomLeftRadius', radiusVar); + comp.setBoundVariable('bottomRightRadius', radiusVar); + + // Background fill — bound to color variable + const bgPaint = figma.variables.setBoundVariableForPaint( + { type: 'SOLID', color: { r: 0, g: 0, b: 0 } }, + 'color', + bgVar + ); + comp.fills = [bgPaint]; + + // --- Label text node --- + await figma.loadFontAsync({ family: 'Inter', style: 'Medium' }); + const label = figma.createText(); + label.name = 'label'; + label.fontName = { family: 'Inter', style: 'Medium' }; + label.fontSize = 14; + label.characters = 'Button'; + label.layoutSizingHorizontal = 'HUG'; + label.layoutSizingVertical = 'HUG'; + + // Text fill — bound to color variable + const textPaint = figma.variables.setBoundVariableForPaint( + { type: 'SOLID', color: { r: 1, g: 1, b: 1 } }, + 'color', + textVar + ); + label.fills = [textPaint]; + comp.appendChild(label); + + // --- Icon placeholder (Rectangle for now — will be INSTANCE_SWAP) --- + const iconBox = figma.createFrame(); + iconBox.name = 'icon'; + iconBox.resize(16, 16); + iconBox.fills = []; + iconBox.layoutSizingHorizontal = 'FIXED'; + iconBox.layoutSizingVertical = 'FIXED'; + comp.appendChild(iconBox); + + // Tag for idempotency + comp.setPluginData('dsb_run_id', RUN_ID); + comp.setPluginData('dsb_phase', 'phase3'); + comp.setPluginData('dsb_key', 'component/button/base'); + + figma.closePlugin(JSON.stringify({ baseCompId: comp.id })); + } catch (e) { figma.closePluginWithFailure(e.toString()); } +})(); +``` + +**ALL of these must be variable-bound (never hardcoded):** + +| Property | Variable type | API method | +|---|---|---| +| Fill color | COLOR | `setBoundVariableForPaint(..., 'color', var)` | +| Stroke color | COLOR | `setBoundVariableForPaint(..., 'color', var)` | +| Text fill | COLOR | `setBoundVariableForPaint(..., 'color', var)` | +| Padding (all 4 sides) | FLOAT | `comp.setBoundVariable('paddingTop', var)` | +| Gap / itemSpacing | FLOAT | `comp.setBoundVariable('itemSpacing', var)` | +| Corner radius (all 4) | FLOAT | `comp.setBoundVariable('topLeftRadius', var)` etc. | +| Stroke weight | FLOAT | `comp.setBoundVariable('strokeWeight', var)` | + +--- + +## 4. Variant Matrix + +### Defining Axes + +For each component, identify its variant axes before writing any code. Standard axes: + +``` +Button: + Size → [Small, Medium, Large] + Style → [Primary, Secondary, Outline, Ghost] + State → [Default, Hover, Focused, Pressed, Disabled] + Total = 3 × 4 × 5 = 60 combinations — exceeds 30 limit → split by Style +``` + +### The 30-Combination Cap and Split Strategy + +When the product of all variant axes exceeds 30 combinations, split the matrix. Options: + +1. **Split by a primary axis**: Create separate component sets, one per Style (Primary Button, Secondary Button, etc.) +2. **Use INSTANCE_SWAP**: Remove a visual axis (like Icon) from the variant matrix entirely and expose it as an INSTANCE_SWAP property instead +3. **Use Building Blocks**: Extract sub-elements with their own state axes into Building Block component sets + +For Button with Size × State = 15 combinations, add Style as a variant axis only if Style ≤ 2 options (15 × 2 = 30). For more Styles, split. + +### Creating All Variants with use_figma + +Build each variant by cloning the base component and adjusting the variable bindings that differ per variant. Pass in the base component ID from the previous call's state. + +```javascript +(async () => { + try { + const RUN_ID = 'ds-build-2024-001'; + const BASE_COMP_ID = 'BASE_ID_FROM_STATE'; // from state ledger + + await figma.setCurrentPageAsync( + figma.root.children.find(p => p.name === 'Button') + ); + + const base = await figma.getNodeByIdAsync(BASE_COMP_ID); + + // Variable IDs from state ledger + const vars = { + // Primary style + bg_primary: await figma.variables.getVariableByIdAsync('VAR_ID_color_bg_primary'), + text_primary: await figma.variables.getVariableByIdAsync('VAR_ID_color_text_on_primary'), + // Secondary style + bg_secondary: await figma.variables.getVariableByIdAsync('VAR_ID_color_bg_secondary'), + text_secondary: await figma.variables.getVariableByIdAsync('VAR_ID_color_text_secondary'), + // Disabled + bg_disabled: await figma.variables.getVariableByIdAsync('VAR_ID_color_bg_disabled'), + text_disabled: await figma.variables.getVariableByIdAsync('VAR_ID_color_text_disabled'), + // Sizes + padding_sm: await figma.variables.getVariableByIdAsync('VAR_ID_spacing_sm'), + padding_md: await figma.variables.getVariableByIdAsync('VAR_ID_spacing_md'), + padding_lg: await figma.variables.getVariableByIdAsync('VAR_ID_spacing_lg'), + }; + + const axes = { + Size: ['Small', 'Medium', 'Large'], + Style: ['Primary', 'Secondary'], + State: ['Default', 'Hover', 'Disabled'], + }; + + const paddingBySize = { Small: vars.padding_sm, Medium: vars.padding_md, Large: vars.padding_lg }; + + const components = []; + + for (const size of axes.Size) { + for (const style of axes.Style) { + for (const state of axes.State) { + const clone = base.clone(); + clone.name = `Size=${size}, Style=${style}, State=${state}`; + + // Bind padding by size + clone.setBoundVariable('paddingTop', paddingBySize[size]); + clone.setBoundVariable('paddingBottom', paddingBySize[size]); + clone.setBoundVariable('paddingLeft', paddingBySize[size]); + clone.setBoundVariable('paddingRight', paddingBySize[size]); + + // Bind fill by style + state + const isDisabled = state === 'Disabled'; + const bgVar = isDisabled ? vars.bg_disabled : (style === 'Primary' ? vars.bg_primary : vars.bg_secondary); + const txtVar = isDisabled ? vars.text_disabled : (style === 'Primary' ? vars.text_primary : vars.text_secondary); + + const bgPaint = figma.variables.setBoundVariableForPaint( + { type: 'SOLID', color: { r: 0, g: 0, b: 0 } }, 'color', bgVar + ); + clone.fills = [bgPaint]; + + const labelNode = clone.findOne(n => n.name === 'label'); + const textPaint = figma.variables.setBoundVariableForPaint( + { type: 'SOLID', color: { r: 1, g: 1, b: 1 } }, 'color', txtVar + ); + labelNode.fills = [textPaint]; + + clone.setPluginData('dsb_run_id', RUN_ID); + clone.setPluginData('dsb_key', `component/button/variant/${size}/${style}/${state}`); + + components.push(clone); + } + } + } + + figma.closePlugin(JSON.stringify({ variantIds: components.map(c => c.id) })); + } catch (e) { figma.closePluginWithFailure(e.toString()); } +})(); +``` + +--- + +## 5. `combineAsVariants` + Grid Layout + +After all variant components exist, combine them into a ComponentSet and position them in a grid. This MUST be a separate `use_figma` call — you must pass in all variant IDs from the previous call's return value. + +### Grid Design Conventions + +Professional design systems lay out variants in a readable grid where: +- **Columns** = the property users interact with most (typically **State**: Default, Hover, Focused, Pressed, Disabled) +- **Rows** = structural axes grouped together (typically **Size × Style**, where Size varies fastest) +- **Gap** = 16–40px between variants (20px is a safe default; match existing file if one exists) +- **Padding** = 40px around the grid inside the ComponentSet frame + +``` +Visual structure: + Default Hover Focused Pressed Disabled + ┌──────────────────────────────────────────────────────────────────┐ + │ Small/Primary [comp] [comp] [comp] [comp] [comp] │ + │ Small/Secondary [comp] [comp] [comp] [comp] [comp] │ + │ Medium/Primary [comp] [comp] [comp] [comp] [comp] │ + │ Medium/Secondary[comp] [comp] [comp] [comp] [comp] │ + │ Large/Primary [comp] [comp] [comp] [comp] [comp] │ + │ Large/Secondary [comp] [comp] [comp] [comp] [comp] │ + └──────────────────────────────────────────────────────────────────┘ +``` + +**Why State on columns?** State is the axis designers scan horizontally to verify interaction consistency. Size/Style define the "identity" of each row. This matches how professional design systems (M3, Polaris, Simple DS) organize their grids. + +### Adding Row/Column Header Labels + +After laying out the grid, add text labels OUTSIDE the ComponentSet to help navigation. These are siblings of the ComponentSet on the page — not children of it: + +```javascript +// Add column headers above the component set +const colLabels = ['Default', 'Hover', 'Focused', 'Pressed', 'Disabled']; +await figma.loadFontAsync({ family: 'Inter', style: 'Medium' }); +for (let i = 0; i < colLabels.length; i++) { + const label = figma.createText(); + label.fontName = { family: 'Inter', style: 'Medium' }; + label.characters = colLabels[i]; + label.fontSize = 11; + label.fills = [{ type: 'SOLID', color: { r: 0.5, g: 0.5, b: 0.5 } }]; + label.x = cs.x + padding + i * (childWidth + gap); + label.y = cs.y - 20; +} + +// Add row headers to the left of the component set +const rowLabels = ['Small / Primary', 'Small / Secondary', 'Med / Primary', ...]; +for (let i = 0; i < rowLabels.length; i++) { + const label = figma.createText(); + label.fontName = { family: 'Inter', style: 'Medium' }; + label.characters = rowLabels[i]; + label.fontSize = 11; + label.fills = [{ type: 'SOLID', color: { r: 0.5, g: 0.5, b: 0.5 } }]; + label.x = cs.x - 120; + label.y = cs.y + padding + i * (childHeight + gap) + childHeight / 2 - 6; +} +``` + +**Note:** These labels are documentation aids, not part of the component itself. They help designers navigate the variant grid. + +### Grid layout code + +```javascript +(async () => { + try { + const VARIANT_IDS = ['ID1', 'ID2', '...']; // from state ledger + const PAGE_ID = 'PAGE_ID'; // from state ledger + + await figma.setCurrentPageAsync(await figma.getNodeByIdAsync(PAGE_ID)); + + // Collect component nodes + const components = await Promise.all( + VARIANT_IDS.map(id => figma.getNodeByIdAsync(id)) + ); + + // Combine as variants + const cs = figma.combineAsVariants(components, figma.currentPage); + cs.name = 'Button'; + + // Grid layout: position each variant based on its property values + // Determine column axis (State) and row axes (Size × Style) + const axes = { + Size: ['Small', 'Medium', 'Large'], + Style: ['Primary', 'Secondary'], + State: ['Default', 'Hover', 'Disabled'], + }; + const COL_AXIS = 'State'; // columns + const ROW_AXES = ['Size', 'Style']; // rows (Size changes fastest) + + const gap = 16; + const padding = 40; + + // Measure child dimensions (all should be same height within Size tier) + // Use the first child as reference for column width + const childWidth = 120; // approximate; refine after first screenshot + const childHeight = 40; + + cs.children.forEach(child => { + const props = {}; + child.name.split(', ').forEach(part => { + const [k, v] = part.split('='); + props[k] = v; + }); + + const colIdx = axes[COL_AXIS].indexOf(props[COL_AXIS]); + // Row = Size index * number of styles + Style index + const rowIdx = axes.Size.indexOf(props.Size) * axes.Style.length + + axes.Style.indexOf(props.Style); + + child.x = padding + colIdx * (childWidth + gap); + child.y = padding + rowIdx * (childHeight + gap); + }); + + // Resize component set to fit all children + padding + let maxX = 0, maxY = 0; + for (const child of cs.children) { + maxX = Math.max(maxX, child.x + child.width); + maxY = Math.max(maxY, child.y + child.height); + } + cs.resizeWithoutConstraints(maxX + padding, maxY + padding); + + // Style the component set frame + cs.fills = [{ type: 'SOLID', color: { r: 0.95, g: 0.95, b: 0.98 } }]; + cs.cornerRadius = 8; + + // Position component set on page (to the right of doc frame) + cs.x = 680; + cs.y = 40; + + cs.setPluginData('dsb_run_id', 'ds-build-2024-001'); + cs.setPluginData('dsb_key', 'componentset/button'); + + figma.closePlugin(JSON.stringify({ componentSetId: cs.id })); + } catch (e) { figma.closePluginWithFailure(e.toString()); } +})(); +``` + +**Critical rules for combineAsVariants:** +- `components` must be a non-empty array containing ONLY `ComponentNode` objects (not frames, not groups) +- After combining, children are placed at (0,0) and overlap — you MUST manually position them +- `resizeWithoutConstraints` is required after positioning to make the component set frame fit its contents +- There is no `figma.createComponentSet()` — you cannot create an empty component set + +--- + +## 6. Component Properties + +Add TEXT, BOOLEAN, and INSTANCE_SWAP properties to the ComponentSet (not to individual variants). The return value of `addComponentProperty` is the actual property key (it gets a `#id:id` suffix appended) — save this key and use it immediately when setting `componentPropertyReferences`. + +### TEXT Properties + +Expose editable text in instances: + +```javascript +// On the ComponentSetNode (cs): +const labelKey = cs.addComponentProperty('Label', 'TEXT', 'Button'); +// labelKey is now something like "Label#0:1" + +// Wire to the label child in each variant: +for (const child of cs.children) { + const labelNode = child.findOne(n => n.name === 'label'); + if (labelNode) { + labelNode.componentPropertyReferences = { characters: labelKey }; + } +} +``` + +### BOOLEAN Properties + +Toggle child node visibility: + +```javascript +const showIconKey = cs.addComponentProperty('Show Icon', 'BOOLEAN', true); + +for (const child of cs.children) { + const iconNode = child.findOne(n => n.name === 'icon'); + if (iconNode) { + iconNode.componentPropertyReferences = { visible: showIconKey }; + } +} +``` + +### INSTANCE_SWAP Properties + +Allow swapping a nested component instance (e.g., swap the icon): + +```javascript +// defaultIconCompId is the ID of the default icon component (from state ledger) +const iconKey = cs.addComponentProperty('Icon', 'INSTANCE_SWAP', DEFAULT_ICON_COMP_ID); + +for (const child of cs.children) { + const iconSlot = child.findOne(n => n.name === 'icon'); + if (iconSlot && iconSlot.type === 'INSTANCE') { + iconSlot.componentPropertyReferences = { mainComponent: iconKey }; + } +} +``` + +**Use INSTANCE_SWAP instead of creating a variant per icon.** Never add "Icon=ChevronRight, Icon=ChevronLeft, ..." as VARIANT axes — that causes combinatorial explosion. One INSTANCE_SWAP property covers all icons. + +### Creating Icon Components for INSTANCE_SWAP + +INSTANCE_SWAP needs a real Component ID as its default value. Before wiring INSTANCE_SWAP, you need at least one icon component. Here's how to create icons from SVG: + +```javascript +(async () => { + try { + // Create a simple icon component from SVG + const svgNode = figma.createNodeFromSvg( + '' + + '' + + '' + ); + + // Wrap in a component + const iconComp = figma.createComponent(); + iconComp.name = 'Icon/ChevronRight'; + iconComp.resize(24, 24); + iconComp.clipsContent = true; + + // Move SVG children into the component + for (const child of [...svgNode.children]) { + iconComp.appendChild(child); + } + svgNode.remove(); + + // Bind the icon fill to a color variable (so it respects themes) + // Find vector children and bind their fills + iconComp.findAll(n => n.type === 'VECTOR').forEach(vec => { + // For stroke-based icons: + if (vec.strokes.length > 0) { + const strokePaint = figma.variables.setBoundVariableForPaint( + { type: 'SOLID', color: { r: 0, g: 0, b: 0 } }, 'color', iconColorVar + ); + vec.strokes = [strokePaint]; + } + }); + + iconComp.setPluginData('dsb_run_id', RUN_ID); + iconComp.setPluginData('dsb_key', 'icon/chevron-right'); + + figma.closePlugin(JSON.stringify({ iconCompId: iconComp.id })); + } catch (e) { figma.closePluginWithFailure(e.toString()); } +})(); +``` + +**Then use the returned `iconCompId` as the default value for INSTANCE_SWAP:** +```javascript +const iconKey = cs.addComponentProperty('Icon', 'INSTANCE_SWAP', ICON_COMP_ID); +``` + +**Constraining swap options with `preferredValues`:** +After adding the INSTANCE_SWAP property, you can optionally limit which components appear in the swap picker: +```javascript +// Get the property definitions to find the exact key +const props = cs.componentPropertyDefinitions; +const iconPropKey = Object.keys(props).find(k => k.startsWith('Icon')); + +// Set preferred values (array of component keys or instance IDs) +cs.editComponentProperty(iconPropKey, { + preferredValues: [ + { type: 'COMPONENT', key: chevronRightComp.key }, + { type: 'COMPONENT', key: chevronLeftComp.key }, + { type: 'COMPONENT', key: closeComp.key }, + ], +}); +``` + +**Icon library tip:** Create all icon components on a dedicated `Icons` page before building any UI components. Then reference their IDs when wiring INSTANCE_SWAP properties. + +### `componentPropertyReferences` mapping + +The `componentPropertyReferences` object maps a node's own property to a component property key: + +| Node property | Component property type | Used for | +|---|---|---| +| `characters` | TEXT | Editable text content | +| `visible` | BOOLEAN | Show/hide toggle | +| `mainComponent` | INSTANCE_SWAP | Swap nested instances | + +--- + +## 7. `pluginData` Tagging for Idempotency + +Tag EVERY created node immediately after creation. This enables safe cleanup, resumability, and idempotency checks. + +```javascript +// After creating any node: +node.setPluginData('dsb_run_id', RUN_ID); // identifies the build run +node.setPluginData('dsb_phase', 'phase3'); // which phase created it +node.setPluginData('dsb_key', KEY); // unique logical key for this entity + +// Reading back: +const runId = node.getPluginData('dsb_run_id'); // '' if not set +const key = node.getPluginData('dsb_key'); +``` + +**Key naming convention:** use `/`-separated logical paths that mirror the entity hierarchy: +``` +'component/button/base' +'component/button/variant/Medium/Primary/Default' +'componentset/button' +'doc/button' +'page/button' +``` + +**Idempotency check before creating:** before creating a node, scan the current page for an existing node with the same `dsb_key`: + +```javascript +const existing = figma.currentPage.findAll(n => + n.getPluginData('dsb_key') === 'componentset/button' +); +if (existing.length > 0) { + // Skip creation — already done. Return existing node's ID. + figma.closePlugin(JSON.stringify({ componentSetId: existing[0].id })); + return; +} +``` + +--- + +## 8. Documentation + +### Page title + description frame + +The documentation frame (see Section 2) should contain: +1. Component name as a large title (32px+ Bold) +2. 1–3 sentence description of what the component is and when to use it +3. Spec notes (sizes, spacing values, accessibility notes) + +### Component `description` property + +Set the description on the ComponentSet — it appears in the Figma properties panel and is exported as documentation: + +```javascript +cs.description = 'Buttons allow users to take actions and make choices. Use Primary for the highest-emphasis action on a page.'; +``` + +### `documentationLinks` + +Link to external documentation (Storybook, design spec, tokens reference): + +```javascript +cs.documentationLinks = [ + { uri: 'https://your-storybook.com/button' } +]; +``` + +### Node names and organization + +- ComponentSet: plain component name — `'Button'` +- Individual variants: `'Property=Value, Property=Value'` format (match the file's existing casing) +- Child nodes: semantic names — `'label'`, `'icon'`, `'container'`, `'state-layer'` +- Documentation frames: `'ComponentName / Documentation'` + +--- + +## 9. Validation + +Always validate after creating or modifying a component before proceeding to the next one. + +### `get_metadata` structural checks + +After creating the component set, call `get_metadata` on the ComponentSet node and verify: +- `variantGroupProperties` lists the expected axes with the correct value arrays +- `componentPropertyDefinitions` contains the expected TEXT/BOOLEAN/INSTANCE_SWAP properties +- `children.length` equals the expected variant count (e.g., 18 for 3×2×3) +- No children are named `'Component 1'` (unnamed components are a sign of a bug) + +### `get_screenshot` — Visual Validation (Critical) + +`get_screenshot` returns an **image** of the specified node. Call it on the **component page node** (not the component set) to see the full page including documentation and grid labels. + +``` +Tool: get_screenshot +Args: { nodeId: "PAGE_NODE_ID", fileKey: "FILE_KEY" } +``` + +**How to use the screenshot:** + +1. **Display it to the user** — this is the primary purpose. Show the screenshot as part of the user checkpoint: "Here's the Button component. Does it look right?" +2. **Analyze it yourself** — if you have vision capabilities, check the visual checklist below. If you don't (text-only agent), fall back to structural validation only via `get_metadata` and describe what you created textually. + +**Visual validation checklist** (check each item when viewing the screenshot): + +| # | Check | What "good" looks like | What "broken" looks like | +|---|-------|----------------------|------------------------| +| 1 | **Grid layout** | Variants in neat rows and columns with consistent spacing | All variants piled at top-left (0,0 stacking bug) | +| 2 | **Color fills** | Components show distinct, correct colors per style variant | All components are black or same color (variable binding failed) | +| 3 | **Size differentiation** | Small variants are visibly smaller than Large variants | All variants are the same size (height/padding not bound to variables) | +| 4 | **Text readability** | Labels are visible with correct font and color | Text is invisible (white on white), missing, or shows "undefined" | +| 5 | **Spacing/padding** | Interior padding visible, components aren't "shrink-wrapped" | Components look cramped or have no visible internal space | +| 6 | **State differentiation** | Hover/Pressed variants have visible color differences from Default | All states look identical (state-specific fills not applied) | +| 7 | **Disabled state** | Lower opacity or muted colors compared to active states | Disabled looks identical to Default | +| 8 | **Documentation frame** | Title + description text visible above or beside the component grid | No documentation, or it overlaps the component set | +| 9 | **Grid labels** | Row/column headers visible around the component set (if added) | Labels overlap the grid or are missing | +| 10 | **Component set boundary** | Gray background frame wraps all variants with even padding | Frame is too small (variants clipped) or way too large | + +**Screenshot → diagnosis → fix mapping:** + +| Screenshot shows | Diagnosis | Fix script | +|-----------------|-----------|------------| +| All variants stacked top-left | Grid layout wasn't applied after `combineAsVariants` | Re-run the grid layout script (§5) | +| Everything black/same color | Variable bindings failed or variables don't have values for the active mode | Re-run variable binding, check mode values | +| No text visible | Font wasn't loaded, or text fill is same color as background | Check `loadFontAsync` was called; bind text fill to `color/text/*` variable | +| Variants all same size | Padding/height not bound to size variables | Re-run `bindVariablesToComponent` with size-specific tokens | +| Component set frame tiny | `resizeWithoutConstraints` wasn't called or used wrong dimensions | Re-calculate bounds from children and resize | +| Doc frame overlaps components | Component set positioned at same x,y as doc frame | Move component set: `cs.x = docFrame.x + docFrame.width + 60` | + +**When visual analysis isn't available:** +If your model can't process images (text-only mode), validate structurally instead: +1. Call `get_metadata` on the component set — verify child count, property definitions, variant names +2. Run an `use_figma` that samples key properties: +```javascript +(async () => { + try { + const cs = await figma.getNodeByIdAsync(CS_ID); + const sample = cs.children.slice(0, 3).map(c => ({ + name: c.name, + width: c.width, height: c.height, + x: c.x, y: c.y, + fills: c.fills?.map(f => f.type === 'SOLID' ? + { r: f.color.r.toFixed(2), g: f.color.g.toFixed(2), b: f.color.b.toFixed(2), boundVar: f.boundVariables?.color?.id } : f.type + ), + })); + figma.closePlugin(JSON.stringify({ sampleVariants: sample, totalChildren: cs.children.length })); + } catch (e) { figma.closePluginWithFailure(e.toString()); } +})(); +``` +This gives you positions (grid working?), dimensions (size differentiation?), and fill info (bindings working?) without needing vision. + +**When to take a screenshot:** +- After EVERY completed component (mandatory — part of the user checkpoint) +- After creating the foundations documentation page +- After final QA (screenshot every page) +- Do NOT screenshot after every intermediate step (wastes tool calls) + +### Common issues + +| Symptom | Likely cause | Fix | +|---|---|---| +| All variants stacked at (0,0) | `combineAsVariants` was called but children were never repositioned | Re-run grid layout script | +| Variants show wrong colors | Variable bindings applied after `combineAsVariants` instead of before | Rebind on component set children | +| Variant count wrong | Clone loop indexing error | Print `components.map(c => c.name)` before combining | +| BOOLEAN property has no effect | `componentPropertyReferences` was set on the component set frame, not on the child node | Find the actual child node and set references there | +| INSTANCE_SWAP shows no swap option | Default value was not a valid component ID | Pass a real existing component ID as `defaultValue` | +| `combineAsVariants` throws | At least one node in the array is not a `ComponentNode` | Filter array: `nodes.filter(n => n.type === 'COMPONENT')` | +| `addComponentProperty` returns unexpected key | Expected — the key gets a `#id:id` suffix | Save the returned value immediately: `const key = cs.addComponentProperty(...)` | + +--- + +## 10. Complete Worked Example: Button Component + +This shows the full sequence of `use_figma` calls for a Button component, including state passing between calls. Replace `RUN_ID` and variable IDs with your actual values from the state ledger. + +### Call 1: Create the component page + +**Goal:** Create (or find) the Button page. +**State input:** None +**State output:** `{ pageId }` + +```javascript +(async () => { + try { + let page = figma.root.children.find(p => p.name === 'Button'); + if (!page) { page = figma.createPage(); page.name = 'Button'; } + page.setPluginData('dsb_run_id', 'ds-build-2024-001'); + page.setPluginData('dsb_key', 'page/button'); + figma.closePlugin(JSON.stringify({ pageId: page.id })); + } catch (e) { figma.closePluginWithFailure(e.toString()); } +})(); +``` + +### Call 2: Create documentation frame + +**Goal:** Add title + description frame. +**State input:** `{ pageId }` +**State output:** `{ docFrameId }` + +```javascript +(async () => { + try { + const PAGE_ID = 'PAGE_ID_FROM_STATE'; + const page = await figma.getNodeByIdAsync(PAGE_ID); + await figma.setCurrentPageAsync(page); + + // Idempotency check + const existing = page.findAll(n => n.getPluginData('dsb_key') === 'doc/button'); + if (existing.length > 0) { + figma.closePlugin(JSON.stringify({ docFrameId: existing[0].id })); + return; + } + + await figma.loadFontAsync({ family: 'Inter', style: 'Bold' }); + await figma.loadFontAsync({ family: 'Inter', style: 'Regular' }); + + const docFrame = figma.createFrame(); + docFrame.name = 'Button / Documentation'; + docFrame.x = 40; docFrame.y = 40; + docFrame.layoutMode = 'VERTICAL'; + docFrame.primaryAxisSizingMode = 'AUTO'; + docFrame.counterAxisSizingMode = 'FIXED'; + docFrame.resize(560, 100); + docFrame.paddingTop = 40; docFrame.paddingBottom = 40; + docFrame.paddingLeft = 40; docFrame.paddingRight = 40; + docFrame.itemSpacing = 16; + docFrame.fills = [{ type: 'SOLID', color: { r: 1, g: 1, b: 1 } }]; + + const title = figma.createText(); + title.fontName = { family: 'Inter', style: 'Bold' }; + title.fontSize = 32; + title.characters = 'Button'; + docFrame.appendChild(title); + + const desc = figma.createText(); + desc.fontName = { family: 'Inter', style: 'Regular' }; + desc.fontSize = 14; + desc.characters = 'Buttons allow users to take actions with a single tap. Use Primary for the highest-emphasis action on a page, Secondary for supporting actions.'; + desc.layoutSizingHorizontal = 'FILL'; + docFrame.appendChild(desc); + + docFrame.setPluginData('dsb_run_id', 'ds-build-2024-001'); + docFrame.setPluginData('dsb_key', 'doc/button'); + + figma.closePlugin(JSON.stringify({ docFrameId: docFrame.id })); + } catch (e) { figma.closePluginWithFailure(e.toString()); } +})(); +``` + +### Call 3: Create base component + +**Goal:** Create the base component with auto-layout and all variable bindings. +**State input:** `{ pageId }` + variable IDs from Phase 1 +**State output:** `{ baseCompId }` + +*(See Section 3 for full code — substituting the actual variable IDs from the state ledger.)* + +### Call 4: Create all variants + +**Goal:** Clone base and produce all 18 variants (3 Size × 2 Style × 3 State). +**State input:** `{ pageId, baseCompId }` + variable IDs +**State output:** `{ variantIds: ['id1', 'id2', ..., 'id18'] }` + +```javascript +(async () => { + try { + const RUN_ID = 'ds-build-2024-001'; + const BASE_ID = 'BASE_COMP_ID_FROM_STATE'; + const PAGE_ID = 'PAGE_ID_FROM_STATE'; + // Variable IDs from state ledger: + const VAR = { + bg_primary: 'VAR_ID_1', + text_primary: 'VAR_ID_2', + bg_secondary: 'VAR_ID_3', + text_secondary: 'VAR_ID_4', + bg_disabled: 'VAR_ID_5', + text_disabled: 'VAR_ID_6', + padding_sm: 'VAR_ID_7', + padding_md: 'VAR_ID_8', + padding_lg: 'VAR_ID_9', + }; + + const page = await figma.getNodeByIdAsync(PAGE_ID); + await figma.setCurrentPageAsync(page); + + const base = await figma.getNodeByIdAsync(BASE_ID); + + // Load all variables + const vars = {}; + for (const [k, v] of Object.entries(VAR)) { + vars[k] = await figma.variables.getVariableByIdAsync(v); + } + + const axes = { + Size: ['Small', 'Medium', 'Large'], + Style: ['Primary', 'Secondary'], + State: ['Default', 'Hover', 'Disabled'], + }; + const paddingMap = { Small: vars.padding_sm, Medium: vars.padding_md, Large: vars.padding_lg }; + + const components = []; + for (const size of axes.Size) { + for (const style of axes.Style) { + for (const state of axes.State) { + const clone = base.clone(); + clone.name = `Size=${size}, Style=${style}, State=${state}`; + + clone.setBoundVariable('paddingTop', paddingMap[size]); + clone.setBoundVariable('paddingBottom', paddingMap[size]); + clone.setBoundVariable('paddingLeft', paddingMap[size]); + clone.setBoundVariable('paddingRight', paddingMap[size]); + + const isDisabled = state === 'Disabled'; + const bgV = isDisabled ? vars.bg_disabled : (style === 'Primary' ? vars.bg_primary : vars.bg_secondary); + const txV = isDisabled ? vars.text_disabled : (style === 'Primary' ? vars.text_primary : vars.text_secondary); + + clone.fills = [figma.variables.setBoundVariableForPaint( + { type: 'SOLID', color: { r: 0, g: 0, b: 0 } }, 'color', bgV + )]; + + const labelNode = clone.findOne(n => n.name === 'label'); + labelNode.fills = [figma.variables.setBoundVariableForPaint( + { type: 'SOLID', color: { r: 1, g: 1, b: 1 } }, 'color', txV + )]; + + clone.setPluginData('dsb_run_id', RUN_ID); + clone.setPluginData('dsb_key', `component/button/variant/${size}/${style}/${state}`); + components.push(clone); + } + } + } + + figma.closePlugin(JSON.stringify({ variantIds: components.map(c => c.id) })); + } catch (e) { figma.closePluginWithFailure(e.toString()); } +})(); +``` + +### Call 5: combineAsVariants + grid layout + +**Goal:** Combine all 18 variants into a ComponentSet and lay them out in a grid. +**State input:** `{ pageId, variantIds }` (18 IDs) +**State output:** `{ componentSetId }` + +*(See Section 5 for full code.)* + +### Call 6: Add component properties + +**Goal:** Add TEXT, BOOLEAN, INSTANCE_SWAP properties and wire them to child nodes. +**State input:** `{ pageId, componentSetId }` +**State output:** `{ componentSetId, properties: { labelKey, showIconKey, iconKey } }` + +```javascript +(async () => { + try { + const CS_ID = 'CS_ID_FROM_STATE'; + const DEFAULT_ICON_ID = 'ICON_COMP_ID_FROM_STATE'; + const page = figma.root.children.find(p => p.name === 'Button'); + await figma.setCurrentPageAsync(page); + + const cs = await figma.getNodeByIdAsync(CS_ID); + cs.description = 'Buttons allow users to take actions and make choices with a single tap.'; + cs.documentationLinks = [{ uri: 'https://your-storybook.com/button' }]; + + // Add properties — save returned keys + const labelKey = cs.addComponentProperty('Label', 'TEXT', 'Button'); + const showIconKey = cs.addComponentProperty('Show Icon', 'BOOLEAN', true); + const iconKey = cs.addComponentProperty('Icon', 'INSTANCE_SWAP', DEFAULT_ICON_ID); + + // Wire to children + for (const child of cs.children) { + const labelNode = child.findOne(n => n.name === 'label'); + if (labelNode) labelNode.componentPropertyReferences = { characters: labelKey }; + + const iconNode = child.findOne(n => n.name === 'icon'); + if (iconNode) { + iconNode.componentPropertyReferences = { + visible: showIconKey, + ...(iconNode.type === 'INSTANCE' ? { mainComponent: iconKey } : {}), + }; + } + } + + figma.closePlugin(JSON.stringify({ + componentSetId: cs.id, + properties: { labelKey, showIconKey, iconKey }, + })); + } catch (e) { figma.closePluginWithFailure(e.toString()); } +})(); +``` + +### Call 7: Validate with get_metadata + +**Goal:** Structural check — variant count, properties, axes. +**Action:** Call `get_metadata` on the ComponentSet node ID (from state). Verify in the result: +- `children.length === 18` +- `variantGroupProperties` has `Size`, `Style`, `State` keys with correct value arrays +- `componentPropertyDefinitions` has `Label`, `Show Icon`, `Icon` entries + +### Call 8: Validate with get_screenshot + +**Goal:** Visual check — layout, colors, text. +**Action:** Call `get_screenshot` on the Button page. Inspect the screenshot. If variants are stacked, re-run Call 5. If colors look wrong, inspect variable bindings. + +### Checkpoint + +After Call 8: show the screenshot to the user. Ask: "Here's the Button component with 18 variants. Does this look correct?" Do not proceed to the next component until the user approves. diff --git a/plugins/figma/skills/figma-generate-library/references/discovery-phase.md b/plugins/figma/skills/figma-generate-library/references/discovery-phase.md new file mode 100644 index 00000000..29da0473 --- /dev/null +++ b/plugins/figma/skills/figma-generate-library/references/discovery-phase.md @@ -0,0 +1,518 @@ +> Part of the [figma-generate-library skill](../SKILL.md). + +# Discovery Phase Reference + +This document covers everything needed for Phase 0 of a design system build: analyzing the codebase for tokens, inspecting the Figma file for existing conventions, searching subscribed libraries, building the plan, and resolving conflicts before any write operations begin. + +--- + +## 1. Codebase Analysis — Finding Token Sources + +### Search Priority Order + +Look for token sources in this order. Stop as soon as you find a definitive source; multiple formats can coexist: + +1. Design token files: `*.tokens.json`, `tokens/*.json`, `src/tokens/**` +2. CSS variable files: `variables.css`, `tokens.css`, `theme.css`, `global.css` +3. Tailwind config: `tailwind.config.js`, `tailwind.config.ts` +4. CSS-in-JS theme objects: `theme.ts`, `createTheme`, `ThemeProvider` +5. Platform-specific: iOS Asset catalogs (`.xcassets`), Android `themes.xml`, `colors.xml` + +### CSS Custom Properties (Most Common for Web) + +**What to search for:** + +``` +:root { ... } +@theme { ... } ← Tailwind v4 +--color-*, --spacing-*, --radius-*, --shadow-*, --font-* +``` + +**Pattern:** `/--[\w-]+:\s*[^;]+/g` + +**Common file locations:** `src/styles/tokens.css`, `src/styles/variables.css`, `src/theme/*.css` + +**Extraction and naming translation:** + +| CSS Property | Figma Variable Name | Figma Type | WEB Code Syntax | +|---|---|---|---| +| `--color-bg-primary: #fff` | `color/bg/primary` | COLOR | `var(--color-bg-primary)` | +| `--color-text-secondary: #757575` | `color/text/secondary` | COLOR | `var(--color-text-secondary)` | +| `--spacing-sm: 8px` | `spacing/sm` | FLOAT | `var(--spacing-sm)` | +| `--radius-md: 8px` | `radius/md` | FLOAT | `var(--radius-md)` | +| `--font-body: "Inter"` | `typography/body/font-family` | STRING | `var(--font-body)` | + +**Naming rule:** Replace hyphens with slashes at category boundaries. Keep hyphens within the final path segment: `--color-bg-primary` → `color/bg/primary`, but `--color-bg-primary-hover` → `color/bg/primary-hover`. + +**Always store the original CSS variable name** as the code syntax value — never derive it from the Figma variable name. If the codebase uses `--sds-color-background-brand-default`, use exactly that string in `setVariableCodeSyntax('WEB', '--sds-color-background-brand-default')`. + +### Tailwind Configuration + +**What to look for in `tailwind.config.js` or `tailwind.config.ts`:** + +```javascript +// theme.extend.colors → Figma color variables +{ primary: { DEFAULT: '#3366FF', light: '#6699FF', dark: '#0033CC' } } +// → color/primary/default, color/primary/light, color/primary/dark + +// theme.extend.spacing → Figma FLOAT variables +{ 'xs': '4px', 'sm': '8px', 'md': '16px' } +// → spacing/xs = 4, spacing/sm = 8, spacing/md = 16 + +// theme.extend.borderRadius → Figma FLOAT variables +{ 'sm': '4px', 'md': '8px', 'lg': '16px' } +// → radius/sm = 4, radius/md = 8, radius/lg = 16 +``` + +Tailwind utility class names (`bg-blue-500`, `p-4`) are not tokens — extract values from the config object, not the class names. + +### Design Token Community Group (DTCG) Format + +**Pattern:** `*.tokens.json` or `tokens/*.json`. Find source files, not generated outputs from Style Dictionary or Tokens Studio. + +```json +{ + "color": { + "bg": { + "primary": { "$type": "color", "$value": "#ffffff" }, + "secondary": { "$type": "color", "$value": "#f5f5f5" } + } + }, + "spacing": { + "sm": { "$type": "dimension", "$value": "8px" } + } +} +``` + +Nested keys map to slash-separated Figma names: `color.bg.primary` → `color/bg/primary`. + +### CSS-in-JS / Theme Objects + +**What to search for:** `createTheme`, `ThemeProvider`, `theme = {}`, styled-components, Emotion, Stitches, vanilla-extract + +```typescript +// theme.colors.bg.primary → Figma variable: color/bg/primary +// theme.spacing.sm → Figma variable: spacing/sm +// Multiple theme objects (lightTheme, darkTheme) → modes in the same collection +``` + +### iOS Token Sources + +```swift +// Asset catalog colors in .xcassets/Colors.xcassets +// extension Color { static let bgPrimary = Color("bg-primary") } +// Look for traitCollection.userInterfaceStyle for dark mode detection +``` + +### Android Token Sources + +```kotlin +// res/values/colors.xml #3366FF +// res/values-night/colors.xml (dark mode overrides) +// MaterialTheme.colorScheme.primary in Compose +// val Primary = Color(0xFF3366FF) +``` + +### Detecting Dark Mode + +| Platform | Signal | +|---|---| +| Web (CSS) | `@media (prefers-color-scheme: dark)`, `.dark { }`, `[data-theme="dark"]` | +| Web (Tailwind) | `darkMode: 'class'` or `darkMode: 'media'` in config | +| Web (JS) | Separate `darkTheme` object alongside `lightTheme` | +| iOS | `Color(uiColor:)` with `traitCollection.userInterfaceStyle`, dual-appearance asset catalog | +| Android | `themes.xml` with `Theme.*.Night`, `isSystemInDarkTheme()` in Compose, `values-night/` folder | + +**Figma mapping:** If dark mode exists → minimum 2 modes (Light/Dark) in the semantic color collection. Primitive collections stay single-mode. + +### Shadow/Elevation Extraction + +Shadows cannot be Figma variables — they become **Effect Styles**. + +```css +/* Look for: box-shadow, --shadow-* */ +--shadow-sm: 0 1px 2px rgba(0,0,0,0.05); +--shadow-md: 0 4px 6px -1px rgba(0,0,0,0.10); +--shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.10); +``` + +CSS `0 4px 6px -1px rgba(0,0,0,0.1)` → Figma: +``` +{ type: "DROP_SHADOW", offset: {x:0, y:4}, radius: 6, spread: -1, color: {r:0, g:0, b:0, a:0.1} } +``` + +### Typography Extraction + +| Code token | Maps to | +|---|---| +| `font-size: 16px` | FLOAT variable (scope `FONT_SIZE`) or Text Style `fontSize` | +| `line-height: 1.5` | Text Style `lineHeight: {value: 24, unit: "PIXELS"}` | +| `font-weight: 600` | Text Style `fontName: {family: "Inter", style: "Semi Bold"}` | +| `letter-spacing: -0.02em` | Text Style `letterSpacing: {value: -2, unit: "PERCENT"}` | +| `font-family: "Inter"` | STRING variable (scope `FONT_FAMILY`) or Text Style `fontName.family` | + +Composite text styles (all properties bundled) → Figma Text Styles. Individual properties → Figma variables with appropriate scopes. + +### Component Extraction + +For each component, extract: + +1. **Name** → Figma component set name +2. **Union-type props** → VARIANT properties +3. **String content props** → TEXT properties +4. **Boolean props** → BOOLEAN properties (and VARIANT State when combined with interaction states) +5. **Child/slot props** → INSTANCE_SWAP properties + +```typescript +// React example: +interface ButtonProps { + size: 'sm' | 'md' | 'lg'; // → VARIANT: Size = sm|md|lg + variant: 'primary' | 'secondary'; // → VARIANT: Style = primary|secondary + disabled?: boolean; // → VARIANT: State (combine: default|hover|pressed|disabled) + label: string; // → TEXT: Label + icon?: ReactNode; // → INSTANCE_SWAP: Icon + BOOLEAN: Show Icon +} +// → Component Set "Button", variant count: 3 sizes × 2 styles × 4 states = 24 +``` + +--- + +## 2. Figma File Inspection + +Run these `use_figma` snippets at the start of every build. All are read-only and safe to run before any user checkpoint. + +### List All Pages + +```javascript +(async () => { + try { + const pages = figma.root.children.map((p, i) => ({ + index: i, + name: p.name, + id: p.id, + childCount: p.children.length + })); + figma.closePlugin(JSON.stringify({ pages })); + } catch(e) { figma.closePluginWithFailure(e.toString()); } +})(); +``` + +Interpret: note page names for naming convention (are they PascalCase? sentence case?), count separator pages (`---`), identify existing component pages vs foundations pages. + +### List Variable Collections With Modes + +```javascript +(async () => { + try { + const collections = await figma.variables.getLocalVariableCollectionsAsync(); + const result = collections.map(c => ({ + id: c.id, + name: c.name, + modes: c.modes, // [{modeId, name}, ...] + variableCount: c.variableIds.length, + defaultModeId: c.defaultModeId + })); + figma.closePlugin(JSON.stringify({ collections: result })); + } catch(e) { figma.closePluginWithFailure(e.toString()); } +})(); +``` + +Interpret: identify existing primitive/semantic split, note mode names (do they use "Light/Dark" or "SDS Light/SDS Dark"?), count variables to understand scope. + +### List Variables in a Collection (with names, types, scopes, and sample values) + +```javascript +(async () => { + try { + const collections = await figma.variables.getLocalVariableCollectionsAsync(); + const targetName = "Color"; // change to the collection you want to inspect + const coll = collections.find(c => c.name === targetName); + if (!coll) { figma.closePlugin(JSON.stringify({ error: `Collection "${targetName}" not found` })); return; } + + const allVars = await figma.variables.getLocalVariablesAsync(); + const vars = allVars.filter(v => v.variableCollectionId === coll.id); + + const result = vars.map(v => ({ + id: v.id, + name: v.name, + resolvedType: v.resolvedType, + scopes: v.scopes, + codeSyntax: v.codeSyntax, + // First mode value only, for a sample + sampleValue: v.valuesByMode[coll.defaultModeId] + })); + + figma.closePlugin(JSON.stringify({ collection: coll.name, variableCount: result.length, variables: result })); + } catch(e) { figma.closePluginWithFailure(e.toString()); } +})(); +``` + +Interpret: check if variables use `ALL_SCOPES` (bad), check naming convention (slash-separated hierarchy?), check if code syntax is set, identify alias chains. + +### List Component Sets with Properties + +```javascript +(async () => { + try { + await figma.setCurrentPageAsync(figma.currentPage); // ensures page context + const componentSets = figma.currentPage.findAll(n => n.type === 'COMPONENT_SET'); + const result = componentSets.map(cs => ({ + id: cs.id, + name: cs.name, + variantCount: cs.children.length, + properties: Object.entries(cs.componentPropertyDefinitions).map(([key, def]) => ({ + name: key, + type: def.type, + variantOptions: def.variantOptions || null, + defaultValue: def.defaultValue + })) + })); + figma.closePlugin(JSON.stringify({ componentSets: result, count: result.length })); + } catch(e) { figma.closePluginWithFailure(e.toString()); } +})(); +``` + +Note: to search ALL pages, iterate `figma.root.children` and `setCurrentPageAsync` for each. + +### List All Styles + +```javascript +(async () => { + try { + const [textStyles, effectStyles, paintStyles] = await Promise.all([ + figma.getLocalTextStylesAsync(), + figma.getLocalEffectStylesAsync(), + figma.getLocalPaintStylesAsync() + ]); + + figma.closePlugin(JSON.stringify({ + textStyles: textStyles.map(s => ({ id: s.id, name: s.name, fontSize: s.fontSize, fontName: s.fontName })), + effectStyles: effectStyles.map(s => ({ id: s.id, name: s.name, effectCount: s.effects.length })), + paintStyles: paintStyles.map(s => ({ id: s.id, name: s.name })), + counts: { text: textStyles.length, effect: effectStyles.length, paint: paintStyles.length } + })); + } catch(e) { figma.closePluginWithFailure(e.toString()); } +})(); +``` + +### Check Naming Conventions on an Existing Component + +```javascript +(async () => { + try { + // Replace with the node ID of an existing component to analyze + const node = await figma.getNodeByIdAsync("YOUR_NODE_ID"); + if (!node) { figma.closePlugin(JSON.stringify({ error: "Node not found" })); return; } + + // Check fills for variable bindings + const fillInfo = []; + if ('fills' in node && Array.isArray(node.fills)) { + for (const fill of node.fills) { + if (fill.type === 'SOLID' && fill.boundVariables?.color) { + fillInfo.push({ type: 'variable_alias', id: fill.boundVariables.color.id }); + } else if (fill.type === 'SOLID') { + fillInfo.push({ type: 'hardcoded', r: fill.color.r, g: fill.color.g, b: fill.color.b }); + } + } + } + + figma.closePlugin(JSON.stringify({ + name: node.name, + type: node.type, + fills: fillInfo, + pluginData: node.getPluginData('dsb_key') || null + })); + } catch(e) { figma.closePluginWithFailure(e.toString()); } +})(); +``` + +--- + +## 3. Using search_design_system + +### What It Searches + +`search_design_system` runs three parallel searches against **subscribed design libraries** for the given file: + +1. **Components** — published library components, searched by name/description via a recommendation engine (relevance-ranked, not exact match) +2. **Variables** — design tokens (colors, spacing, etc.) across subscribed libraries +3. **Styles** — paint styles, text styles, and effect styles + +Only libraries the file has subscribed to are searched. If results are empty, the file may not be subscribed to any design system libraries. + +### Input + +``` +search_design_system({ + query: "button", // required — text query + fileKey: "abc123", // required — your file key + includeComponents: true, // default true + includeVariables: true, // default true + includeStyles: true // default true +}) +``` + +### What It Returns + +```json +{ + "components": [ + { + "name": "Button", + "libraryName": "Design System", + "assetType": "component_set", + "componentKey": "abc123def", + "description": "Primary action button" + } + ], + "variables": [ + { + "name": "colors/primary/500", + "variableType": "COLOR", + "variableSetKey": "set1key", + "key": "var1key", + "scopes": ["FILL_COLOR"], + "variableCollectionName": "Colors" + } + ], + "styles": [ + { + "name": "Heading/H1", + "styleType": "TEXT", + "key": "style1key" + } + ] +} +``` + +### How to Interpret Results + +**Components:** The `componentKey` can be used in `use_figma` to import the component: +```javascript +const component = await figma.importComponentByKeyAsync("abc123def"); +// or for component sets: +const componentSet = await figma.importComponentSetByKeyAsync("abc123def"); +``` + +**Variables:** The `variableSetKey` is the collection key. The `key` is the variable key. Use these to understand what naming conventions are in use, and what tokens are available to alias from. + +**Styles:** The `key` is usable with `figma.importStyleByKeyAsync(key)` to import into the current file. + +### When to Search + +- **Phase 0, step 0c**: Search broadly (`query: "button"`, `query: "color"`, `query: "spacing"`) before planning anything. This establishes the reuse baseline. +- **Immediately before each component creation**: Search for the specific component name before writing any `use_figma` creation code. + +**Reuse decision:** + +| Condition | Decision | +|---|---| +| Found component with matching variant API, same token model | Import and reuse | +| Found component but wrong variant properties or hardcoded values | Rebuild | +| Found component that matches visually but API is incompatible | Wrap: nest as instance inside a new wrapper component | + +--- + +## 4. Building the Plan + +After codebase analysis and Figma inspection, produce a mapping table and present it to the user. + +### Token → Variable Mapping Table + +For each token found in code, record: + +| Code Token | CSS Name | Raw Value | Figma Collection | Figma Variable Name | Figma Type | Mode(s) | +|---|---|---|---|---|---|---| +| `theme.colors.blue[500]` | `--color-blue-500` | `#3B82F6` | Primitives | `blue/500` | COLOR | Value | +| `theme.colors.bg.primary` | `--color-bg-primary` | (light: blue/50, dark: gray/900) | Color | `color/bg/primary` | COLOR | Light, Dark | +| `theme.spacing.sm` | `--spacing-sm` | `8px` | Spacing | `spacing/sm` | FLOAT | Value | +| `theme.radii.md` | `--radius-md` | `8px` | Spacing | `radius/md` | FLOAT | Value | +| `theme.shadows.md` | `--shadow-md` | `0 4px 6px rgba(0,0,0,0.1)` | — | — | Effect Style | — | + +### Component → Component Set Mapping Table + +| Code Component | Props → Variant Axes | Variant Count | Figma Page | Reuse? | +|---|---|---|---|---| +| `Button` | size (sm/md/lg) × variant (primary/secondary) × state (default/hover/disabled) | 18 | Buttons | Search first | +| `Avatar` | size (sm/md/lg) × type (image/initials/icon) | 9 | Avatars | Search first | + +### Gap Identification + +Compare what was found in code vs what already exists in Figma: + +- **New:** tokens or components that exist in code but not in Figma → create +- **Existing:** tokens or components already in Figma with matching names → verify scope/code-syntax, skip or update +- **Conflict:** same name, different value → escalate to user (see section 5) +- **Figma-only:** exists in Figma but not in code → flag for user, likely skip + +### User-Facing Checkpoint Message Template + +Present this message before proceeding. Never begin Phase 1 without explicit user approval. + +``` +Here's what I found and what I plan to build: + +CODEBASE ANALYSIS + Colors: {N} primitives ({families}), {M} semantic tokens ({light/dark if applicable}) + Spacing: {N} tokens ({range}) + Typography: {N} text styles, {M} individual scale tokens + Shadows: {N} levels → will become Effect Styles + Components: {list of component names} + +EXISTING FIGMA FILE + Collections: {N} existing collections + Variables: {M} existing variables + Styles: {K} text, {L} effect, {J} paint styles + Components: {list} + +PLAN + New collections: {list with mode counts} + New variables: ~{N} ({breakdown by collection}) + New styles: {N} text, {M} effect + New components: {list} + Libraries to search before each component: {list} + +GAPS / CONFLICTS NEEDING DECISIONS + ⚠ {conflict description} — Code says X, Figma already has Y. Which wins? + +WHAT I WON'T BUILD (and why) + - {item}: already exists in Figma with matching conventions + - {item}: not supported as a Figma variable (e.g. z-index, animation timing) + +Shall I proceed? +``` + +--- + +## 5. Conflict Resolution — When Code and Figma Disagree + +When the same token/component exists in both code and Figma but with different values, names, or structures, **always ask the user**. Never silently pick one. + +### Decision Framework + +| Scenario | Ask the user | +|---|---| +| Same CSS name, different hex value (e.g., `--color-accent` is `#3366FF` in code but `#5B7FFF` in Figma) | "Code says `#3366FF`, Figma currently has `#5B7FFF` for `color/accent/default`. Which is correct?" | +| Same component name, different variant axes (code has `size: sm/md/lg`, Figma has `Size: Small/Large`) | "Code uses 3 sizes (sm/md/lg) but Figma has 2 (Small/Large). Should I add Medium, or rename to match code?" | +| Code has a semantic token with no primitive layer; Figma already has a fully-layered system | "The codebase uses a flat single-layer token model. The Figma file uses a primitive/semantic split. Should I match the Figma architecture or the code architecture?" | +| Figma variable exists but has `ALL_SCOPES` (incorrect per best practice) | "I found `color/bg/primary` already exists but it uses ALL_SCOPES. I recommend changing it to `FRAME_FILL, SHAPE_FILL`. May I update the scope?" | +| Code uses camelCase (`backgroundColor`), Figma uses slash-separated (`color/bg/default`) | "The codebase uses camelCase naming. The Figma file uses slash-separated hierarchy. For new variables, should I use slash-separated (Figma standard) and map via code syntax?" | + +### Code Wins + +Default to code as the source of truth for: +- Hex values (code is the live production value) +- Token naming (the CSS variable names become code syntax) +- Mode values (light/dark split comes from code) + +### Figma Wins + +Default to Figma as the source of truth for: +- Collection architecture (if a well-structured system already exists, extend it rather than replace it) +- Variable naming hierarchy (if designers are already using the system with specific names) +- Page structure (match the existing page organization pattern) + +### Neither: Negotiate + +When neither is clearly correct, propose a resolution and ask: +> "I'd suggest [option]. This way both the code token name and the Figma naming convention are preserved. Does that work?" diff --git a/plugins/figma/skills/figma-generate-library/references/documentation-creation.md b/plugins/figma/skills/figma-generate-library/references/documentation-creation.md new file mode 100644 index 00000000..fb798ef6 --- /dev/null +++ b/plugins/figma/skills/figma-generate-library/references/documentation-creation.md @@ -0,0 +1,834 @@ +> Part of the [figma-generate-library skill](../SKILL.md). + +# Documentation Creation Reference + +This reference covers Phase 2 of the design system build: the cover page, foundations documentation page (color swatches, type specimens, spacing bars, shadow cards, radius demo), page layout dimensions, and inline component documentation. Every code block is complete `use_figma`-ready JavaScript (helper-function form — no IIFE wrapper, no `closePlugin` — meant to be embedded in a larger script). + +--- + +## 1. Cover Page + +The cover page is always the first page in the file. It is a branded title card that sets context for anyone opening the file. + +### What to include + +- File/system name as a large heading (48–72px) +- Version string or date +- Brief tagline (1 sentence) +- Optional: color block background using the primary brand color variable + +### Cover page dimensions + +The cover frame should be **1440 × 900px** — this matches the default Figma canvas and looks correct in the page thumbnail. + +### use_figma for cover page + +```javascript +async function createCoverPage(systemName, tagline, version, primaryColorVar) { + // primaryColorVar: a Figma Variable object for the brand primary fill + const page = figma.createPage(); + page.name = 'Cover'; + await figma.setCurrentPageAsync(page); + + await figma.loadFontAsync({ family: 'Inter', style: 'Bold' }); + await figma.loadFontAsync({ family: 'Inter', style: 'Regular' }); + await figma.loadFontAsync({ family: 'Inter', style: 'Medium' }); + + const frame = figma.createFrame(); + frame.name = 'Cover'; + frame.resize(1440, 900); + frame.x = 0; + frame.y = 0; + frame.layoutMode = 'VERTICAL'; + frame.primaryAxisAlignItems = 'CENTER'; + frame.counterAxisAlignItems = 'CENTER'; + frame.itemSpacing = 16; + frame.paddingTop = 0; + frame.paddingBottom = 0; + frame.paddingLeft = 0; + frame.paddingRight = 0; + + // Background: bind to primary variable if provided, else solid dark + if (primaryColorVar) { + const bgPaint = figma.variables.setBoundVariableForPaint( + { type: 'SOLID', color: { r: 0.05, g: 0.05, b: 0.05 } }, + 'color', + primaryColorVar + ); + frame.fills = [bgPaint]; + } else { + frame.fills = [{ type: 'SOLID', color: { r: 0.06, g: 0.06, b: 0.07 } }]; + } + page.appendChild(frame); + + // System name heading + const title = figma.createText(); + title.fontName = { family: 'Inter', style: 'Bold' }; + title.characters = systemName; + title.fontSize = 64; + title.fills = [{ type: 'SOLID', color: { r: 1, g: 1, b: 1 } }]; + title.textAlignHorizontal = 'CENTER'; + frame.appendChild(title); + + // Tagline + const tag = figma.createText(); + tag.fontName = { family: 'Inter', style: 'Regular' }; + tag.characters = tagline; + tag.fontSize = 20; + tag.fills = [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 0.7 } }]; + tag.textAlignHorizontal = 'CENTER'; + frame.appendChild(tag); + + // Version + const ver = figma.createText(); + ver.fontName = { family: 'Inter', style: 'Medium' }; + ver.characters = version; + ver.fontSize = 13; + ver.fills = [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 0.45 } }]; + ver.textAlignHorizontal = 'CENTER'; + frame.appendChild(ver); + + return { page, frameId: frame.id }; +} +``` + +--- + +## 2. Foundations Page + +The Foundations page is always placed **before any component pages**. It visually documents the design tokens — colors, typography, spacing, shadows, and border radii — so designers and engineers can see available primitives at a glance. + +### Page layout dimensions + +The outer documentation frame should be **1440px wide**. Sections stack vertically with **64–100px gaps** between them. Each section frame fills the full 1440px width and hugs its content vertically. + +### Full Foundations page skeleton + +```javascript +async function createFoundationsPage() { + const page = figma.createPage(); + page.name = 'Foundations'; + await figma.setCurrentPageAsync(page); + + await figma.loadFontAsync({ family: 'Inter', style: 'Bold' }); + await figma.loadFontAsync({ family: 'Inter', style: 'Medium' }); + await figma.loadFontAsync({ family: 'Inter', style: 'Regular' }); + + // Root scroll frame + const root = figma.createFrame(); + root.name = 'Foundations'; + root.layoutMode = 'VERTICAL'; + root.primaryAxisAlignItems = 'MIN'; + root.counterAxisAlignItems = 'MIN'; + root.itemSpacing = 80; + root.paddingTop = 80; + root.paddingBottom = 120; + root.paddingLeft = 80; + root.paddingRight = 80; + root.layoutSizingHorizontal = 'FIXED'; + root.layoutSizingVertical = 'HUG'; + root.resize(1440, 1); + root.fills = [{ type: 'SOLID', color: { r: 1, g: 1, b: 1 } }]; + page.appendChild(root); + + return { page, root }; +} +``` + +--- + +## 3. Color Swatches (bound to variables) + +Color swatches must be **bound to actual Figma variables** — never hardcode hex values in swatch fills. This keeps documentation in sync automatically when variable values change. + +### Single color swatch + +```javascript +/** + * Creates a single color swatch card (rectangle + variable name label). + * The swatch rectangle fill is bound to the provided variable. + * + * @param {FrameNode} parent - The auto-layout row to append to. + * @param {string} varName - Display name (e.g. "color/bg/primary"). + * @param {Variable} variable - The Figma Variable object to bind to. + * @returns {FrameNode} The swatch frame. + */ +async function createColorSwatch(parent, varName, variable) { + await figma.loadFontAsync({ family: 'Inter', style: 'Regular' }); + + const swatchFrame = figma.createFrame(); + swatchFrame.name = `Swatch/${varName}`; + swatchFrame.layoutMode = 'VERTICAL'; + swatchFrame.primaryAxisAlignItems = 'MIN'; + swatchFrame.counterAxisAlignItems = 'MIN'; + swatchFrame.itemSpacing = 6; + swatchFrame.layoutSizingHorizontal = 'FIXED'; + swatchFrame.layoutSizingVertical = 'HUG'; + swatchFrame.resize(88, 1); + swatchFrame.fills = []; + + // Color rectangle — bound to variable + const rect = figma.createRectangle(); + rect.resize(88, 88); + rect.cornerRadius = 8; + const paint = figma.variables.setBoundVariableForPaint( + { type: 'SOLID', color: { r: 0.5, g: 0.5, b: 0.5 } }, + 'color', + variable + ); + rect.fills = [paint]; + swatchFrame.appendChild(rect); + + // Name label + const label = figma.createText(); + label.fontName = { family: 'Inter', style: 'Regular' }; + label.characters = varName.split('/').pop(); // show leaf name only + label.fontSize = 10; + label.fills = [{ type: 'SOLID', color: { r: 0.35, g: 0.35, b: 0.35 } }]; + label.layoutSizingHorizontal = 'FILL'; + swatchFrame.appendChild(label); + + // Full path tooltip label (smaller, lighter) + const pathLabel = figma.createText(); + pathLabel.fontName = { family: 'Inter', style: 'Regular' }; + pathLabel.characters = varName; + pathLabel.fontSize = 9; + pathLabel.fills = [{ type: 'SOLID', color: { r: 0.6, g: 0.6, b: 0.6 } }]; + pathLabel.layoutSizingHorizontal = 'FILL'; + swatchFrame.appendChild(pathLabel); + + parent.appendChild(swatchFrame); + return swatchFrame; +} +``` + +### Color section builder (primitives row + semantic grid) + +```javascript +/** + * Creates a complete color documentation section with a section heading, + * a row of primitive swatches, and a grid of semantic swatches. + * + * @param {FrameNode} root - The root vertical stack frame. + * @param {Variable[]} primitiveVars - Variables from the Primitives collection. + * @param {Variable[]} semanticVars - Variables from the semantic Color collection. + */ +async function createColorSection(root, primitiveVars, semanticVars) { + await figma.loadFontAsync({ family: 'Inter', style: 'Bold' }); + await figma.loadFontAsync({ family: 'Inter', style: 'Regular' }); + + // Section container + const section = figma.createFrame(); + section.name = 'Section/Colors'; + section.layoutMode = 'VERTICAL'; + section.itemSpacing = 24; + section.layoutSizingHorizontal = 'FILL'; + section.layoutSizingVertical = 'HUG'; + section.fills = []; + root.appendChild(section); + + // Section heading + const heading = figma.createText(); + heading.fontName = { family: 'Inter', style: 'Bold' }; + heading.characters = 'Colors'; + heading.fontSize = 32; + heading.fills = [{ type: 'SOLID', color: { r: 0.07, g: 0.07, b: 0.07 } }]; + section.appendChild(heading); + + // Description + const desc = figma.createText(); + desc.fontName = { family: 'Inter', style: 'Regular' }; + desc.characters = 'Primitive color palette and semantic color tokens. Semantic tokens reference primitives — always use semantic tokens in components.'; + desc.fontSize = 14; + desc.fills = [{ type: 'SOLID', color: { r: 0.4, g: 0.4, b: 0.4 } }]; + desc.layoutSizingHorizontal = 'FILL'; + section.appendChild(desc); + + // Primitive swatches row + const primLabel = figma.createText(); + primLabel.fontName = { family: 'Inter', style: 'Bold' }; + primLabel.characters = 'Primitives'; + primLabel.fontSize = 13; + primLabel.fills = [{ type: 'SOLID', color: { r: 0.55, g: 0.55, b: 0.55 } }]; + section.appendChild(primLabel); + + const primRow = figma.createFrame(); + primRow.name = 'Primitives/Row'; + primRow.layoutMode = 'HORIZONTAL'; + primRow.itemSpacing = 12; + primRow.layoutSizingHorizontal = 'FILL'; + primRow.layoutSizingVertical = 'HUG'; + primRow.fills = []; + primRow.layoutWrap = 'WRAP'; + section.appendChild(primRow); + + for (const v of primitiveVars) { + await createColorSwatch(primRow, v.name, v); + } + + // Semantic swatches grid + if (semanticVars.length > 0) { + const semLabel = figma.createText(); + semLabel.fontName = { family: 'Inter', style: 'Bold' }; + semLabel.characters = 'Semantic'; + semLabel.fontSize = 13; + semLabel.fills = [{ type: 'SOLID', color: { r: 0.55, g: 0.55, b: 0.55 } }]; + section.appendChild(semLabel); + + const semRow = figma.createFrame(); + semRow.name = 'Semantic/Row'; + semRow.layoutMode = 'HORIZONTAL'; + semRow.itemSpacing = 12; + semRow.layoutSizingHorizontal = 'FILL'; + semRow.layoutSizingVertical = 'HUG'; + semRow.fills = []; + semRow.layoutWrap = 'WRAP'; + section.appendChild(semRow); + + for (const v of semanticVars) { + await createColorSwatch(semRow, v.name, v); + } + } + + return section; +} +``` + +--- + +## 4. Type Specimens + +Typography specimens show each text style rendered at its actual size with a sample string, the style name, and its specifications. + +### Single type specimen row + +```javascript +/** + * Creates a single type specimen row: style name (small label) + sample text + + * specification line (family · style · size · line-height). + * + * @param {FrameNode} parent - The parent vertical stack. + * @param {string} styleName - The text style name (e.g. "Display Large"). + * @param {string} fontFamily - Font family (e.g. "Inter"). + * @param {string} fontStyle - Font style (e.g. "Bold"). + * @param {number} fontSize - Font size in pixels. + * @param {number} lineHeight - Line height in pixels. + * @returns {FrameNode} The specimen row frame. + */ +async function createTypeSpecimen(parent, styleName, fontFamily, fontStyle, fontSize, lineHeight) { + await figma.loadFontAsync({ family: fontFamily, style: fontStyle }); + await figma.loadFontAsync({ family: 'Inter', style: 'Medium' }); + await figma.loadFontAsync({ family: 'Inter', style: 'Regular' }); + + const row = figma.createFrame(); + row.name = `Type/${styleName}`; + row.layoutMode = 'VERTICAL'; + row.itemSpacing = 6; + row.paddingTop = 16; + row.paddingBottom = 16; + row.layoutSizingHorizontal = 'FILL'; + row.layoutSizingVertical = 'HUG'; + row.fills = []; + parent.appendChild(row); + + // Style name label (small, muted) + const nameText = figma.createText(); + nameText.fontName = { family: 'Inter', style: 'Medium' }; + nameText.characters = styleName; + nameText.fontSize = 11; + nameText.fills = [{ type: 'SOLID', color: { r: 0.55, g: 0.55, b: 0.55 } }]; + nameText.layoutSizingHorizontal = 'FILL'; + row.appendChild(nameText); + + // Sample text rendered in the actual style + const specimen = figma.createText(); + specimen.fontName = { family: fontFamily, style: fontStyle }; + specimen.characters = 'The quick brown fox jumps over the lazy dog'; + specimen.fontSize = fontSize; + specimen.lineHeight = { value: lineHeight, unit: 'PIXELS' }; + specimen.fills = [{ type: 'SOLID', color: { r: 0.07, g: 0.07, b: 0.07 } }]; + specimen.layoutSizingHorizontal = 'FILL'; + row.appendChild(specimen); + + // Specification line + const specs = figma.createText(); + specs.fontName = { family: 'Inter', style: 'Regular' }; + specs.characters = `${fontFamily} ${fontStyle} · ${fontSize}px · ${lineHeight}px line height`; + specs.fontSize = 11; + specs.fills = [{ type: 'SOLID', color: { r: 0.65, g: 0.65, b: 0.65 } }]; + specs.layoutSizingHorizontal = 'FILL'; + row.appendChild(specs); + + // Divider line + const divider = figma.createRectangle(); + divider.resize(1280, 1); + divider.fills = [{ type: 'SOLID', color: { r: 0.9, g: 0.9, b: 0.9 } }]; + divider.layoutSizingHorizontal = 'FILL'; + row.appendChild(divider); + + return row; +} +``` + +### Typography section builder + +```javascript +/** + * Creates a complete typography documentation section. + * Pass an array of style definitions; the function renders one specimen per entry. + * + * @param {FrameNode} root - Root vertical stack. + * @param {Array<{name, family, style, size, lineHeight}>} typeStyles - Style definitions. + */ +async function createTypographySection(root, typeStyles) { + await figma.loadFontAsync({ family: 'Inter', style: 'Bold' }); + + const section = figma.createFrame(); + section.name = 'Section/Typography'; + section.layoutMode = 'VERTICAL'; + section.itemSpacing = 0; + section.layoutSizingHorizontal = 'FILL'; + section.layoutSizingVertical = 'HUG'; + section.fills = []; + root.appendChild(section); + + const heading = figma.createText(); + heading.fontName = { family: 'Inter', style: 'Bold' }; + heading.characters = 'Typography'; + heading.fontSize = 32; + heading.fills = [{ type: 'SOLID', color: { r: 0.07, g: 0.07, b: 0.07 } }]; + section.appendChild(heading); + + for (const ts of typeStyles) { + await createTypeSpecimen(section, ts.name, ts.family, ts.style, ts.size, ts.lineHeight); + } + + return section; +} +``` + +--- + +## 5. Spacing Bars + +Spacing bars show each spacing token as a filled rectangle whose width equals the spacing value. Shorter bars for small values, longer bars for large values — the visual encoding is immediate. + +### Spacing bar row + +```javascript +/** + * Creates a single spacing bar: a colored rectangle sized to the spacing value, + * with a label showing name + pixel value + code syntax. + * + * @param {FrameNode} parent - Parent vertical stack. + * @param {string} name - Token name (e.g. "spacing/sm"). + * @param {number} value - Spacing value in pixels. + * @param {Variable} variable - Figma Variable to bind the width to. + * @param {string} codeSyntax - CSS variable string (e.g. "var(--spacing-sm)"). + */ +async function createSpacingBar(parent, name, value, variable, codeSyntax) { + await figma.loadFontAsync({ family: 'Inter', style: 'Regular' }); + + const row = figma.createFrame(); + row.name = `Spacing/${name}`; + row.layoutMode = 'HORIZONTAL'; + row.counterAxisAlignItems = 'CENTER'; + row.itemSpacing = 16; + row.layoutSizingHorizontal = 'FILL'; + row.layoutSizingVertical = 'HUG'; + row.fills = []; + parent.appendChild(row); + + // The bar rectangle — width bound to spacing variable + const bar = figma.createRectangle(); + bar.resize(value, 16); + bar.cornerRadius = 3; + bar.fills = [{ type: 'SOLID', color: { r: 0.22, g: 0.47, b: 0.98 } }]; + // Bind width to the spacing variable so it reflects the actual token value + if (variable) { + bar.setBoundVariable('width', variable); + } + row.appendChild(bar); + + // Label: "spacing/sm 8px var(--spacing-sm)" + const label = figma.createText(); + label.fontName = { family: 'Inter', style: 'Regular' }; + label.characters = `${name} ${value}px ${codeSyntax}`; + label.fontSize = 12; + label.fills = [{ type: 'SOLID', color: { r: 0.35, g: 0.35, b: 0.35 } }]; + row.appendChild(label); + + return row; +} +``` + +### Spacing section builder + +```javascript +/** + * Creates the full spacing documentation section. + * + * @param {FrameNode} root - Root vertical stack. + * @param {Array<{name, value, variable, codeSyntax}>} spacingTokens - Token definitions. + */ +async function createSpacingSection(root, spacingTokens) { + await figma.loadFontAsync({ family: 'Inter', style: 'Bold' }); + + const section = figma.createFrame(); + section.name = 'Section/Spacing'; + section.layoutMode = 'VERTICAL'; + section.itemSpacing = 12; + section.layoutSizingHorizontal = 'FILL'; + section.layoutSizingVertical = 'HUG'; + section.fills = []; + root.appendChild(section); + + const heading = figma.createText(); + heading.fontName = { family: 'Inter', style: 'Bold' }; + heading.characters = 'Spacing'; + heading.fontSize = 32; + heading.fills = [{ type: 'SOLID', color: { r: 0.07, g: 0.07, b: 0.07 } }]; + section.appendChild(heading); + + for (const tok of spacingTokens) { + await createSpacingBar(section, tok.name, tok.value, tok.variable, tok.codeSyntax); + } + + return section; +} +``` + +--- + +## 6. Shadow Cards (Elevation) + +Elevation documentation shows cards with progressively stronger drop shadows, labeled with name and effect parameters. + +### Single shadow card + +```javascript +/** + * Creates a shadow card: a white rectangle with a drop shadow effect, + * labeled with the elevation name and shadow parameters. + * + * @param {FrameNode} parent - The horizontal row to append to. + * @param {string} name - Elevation name (e.g. "Shadow/Medium"). + * @param {DropShadowEffect[]} effects - Array of Figma effect objects. + */ +async function createShadowCard(parent, name, effects) { + await figma.loadFontAsync({ family: 'Inter', style: 'Regular' }); + await figma.loadFontAsync({ family: 'Inter', style: 'Medium' }); + + const card = figma.createFrame(); + card.name = `ShadowCard/${name}`; + card.layoutMode = 'VERTICAL'; + card.primaryAxisAlignItems = 'CENTER'; + card.counterAxisAlignItems = 'CENTER'; + card.itemSpacing = 8; + card.paddingTop = 16; + card.paddingBottom = 16; + card.resize(120, 120); + card.cornerRadius = 8; + card.fills = [{ type: 'SOLID', color: { r: 1, g: 1, b: 1 } }]; + card.effects = effects; + parent.appendChild(card); + + // Elevation name + const nameLabel = figma.createText(); + nameLabel.fontName = { family: 'Inter', style: 'Medium' }; + nameLabel.characters = name.split('/').pop(); + nameLabel.fontSize = 12; + nameLabel.textAlignHorizontal = 'CENTER'; + nameLabel.fills = [{ type: 'SOLID', color: { r: 0.2, g: 0.2, b: 0.2 } }]; + card.appendChild(nameLabel); + + // Effect parameters as small text + if (effects.length > 0) { + const e = effects[0]; + if (e.type === 'DROP_SHADOW') { + const params = figma.createText(); + params.fontName = { family: 'Inter', style: 'Regular' }; + params.characters = `x:${e.offset.x} y:${e.offset.y}\nblur:${e.radius}`; + params.fontSize = 10; + params.textAlignHorizontal = 'CENTER'; + params.fills = [{ type: 'SOLID', color: { r: 0.55, g: 0.55, b: 0.55 } }]; + card.appendChild(params); + } + } + + return card; +} +``` + +### Shadow section builder + +```javascript +/** + * Creates the full elevation/shadow documentation section. + * + * @param {FrameNode} root - Root vertical stack. + * @param {Array<{name, effects}>} shadowTokens - Shadow definitions. + */ +async function createShadowSection(root, shadowTokens) { + await figma.loadFontAsync({ family: 'Inter', style: 'Bold' }); + + const section = figma.createFrame(); + section.name = 'Section/Elevation'; + section.layoutMode = 'VERTICAL'; + section.itemSpacing = 24; + section.layoutSizingHorizontal = 'FILL'; + section.layoutSizingVertical = 'HUG'; + section.fills = []; + root.appendChild(section); + + const heading = figma.createText(); + heading.fontName = { family: 'Inter', style: 'Bold' }; + heading.characters = 'Elevation'; + heading.fontSize = 32; + heading.fills = [{ type: 'SOLID', color: { r: 0.07, g: 0.07, b: 0.07 } }]; + section.appendChild(heading); + + // Cards row — extra top padding so shadows are visible + const row = figma.createFrame(); + row.name = 'Elevation/Row'; + row.layoutMode = 'HORIZONTAL'; + row.itemSpacing = 32; + row.paddingTop = 24; + row.paddingBottom = 40; + row.layoutSizingHorizontal = 'FILL'; + row.layoutSizingVertical = 'HUG'; + row.fills = [{ type: 'SOLID', color: { r: 0.97, g: 0.97, b: 0.97 } }]; + row.cornerRadius = 8; + row.paddingLeft = 24; + row.paddingRight = 24; + section.appendChild(row); + + for (const tok of shadowTokens) { + await createShadowCard(row, tok.name, tok.effects); + } + + return section; +} +``` + +--- + +## 7. Border Radius Demo + +Border radius documentation shows rectangles at each corner radius value, labeled with the token name and pixel value. + +### Single radius card + +```javascript +/** + * Creates a single border radius card: a square with corner radius applied, + * labeled with the token name and pixel value. + * + * @param {FrameNode} parent - The horizontal row to append to. + * @param {string} name - Token name (e.g. "radius/md"). + * @param {number} value - Corner radius in pixels (0 for none, 9999 for full). + * @param {Variable} [variable] - Optional Figma Variable to bind corner radius. + */ +async function createRadiusCard(parent, name, value, variable) { + await figma.loadFontAsync({ family: 'Inter', style: 'Regular' }); + await figma.loadFontAsync({ family: 'Inter', style: 'Medium' }); + + const wrapper = figma.createFrame(); + wrapper.name = `Radius/${name}`; + wrapper.layoutMode = 'VERTICAL'; + wrapper.primaryAxisAlignItems = 'CENTER'; + wrapper.counterAxisAlignItems = 'CENTER'; + wrapper.itemSpacing = 8; + wrapper.fills = []; + wrapper.layoutSizingHorizontal = 'FIXED'; + wrapper.layoutSizingVertical = 'HUG'; + wrapper.resize(96, 1); + parent.appendChild(wrapper); + + const rect = figma.createRectangle(); + rect.resize(72, 72); + rect.fills = [{ type: 'SOLID', color: { r: 0.22, g: 0.47, b: 0.98, a: 0.15 } }]; + rect.strokes = [{ type: 'SOLID', color: { r: 0.22, g: 0.47, b: 0.98 } }]; + rect.strokeWeight = 1.5; + + // Cap display value — 9999 is how Figma represents "full/pill" + const displayRadius = Math.min(value, 36); + rect.cornerRadius = displayRadius; + + // Bind to variable if provided + if (variable) { + rect.setBoundVariable('cornerRadius', variable); + } + wrapper.appendChild(rect); + + const nameLabel = figma.createText(); + nameLabel.fontName = { family: 'Inter', style: 'Medium' }; + nameLabel.characters = name.split('/').pop(); + nameLabel.fontSize = 11; + nameLabel.textAlignHorizontal = 'CENTER'; + nameLabel.fills = [{ type: 'SOLID', color: { r: 0.2, g: 0.2, b: 0.2 } }]; + wrapper.appendChild(nameLabel); + + const valueLabel = figma.createText(); + valueLabel.fontName = { family: 'Inter', style: 'Regular' }; + valueLabel.characters = value >= 9999 ? 'full' : `${value}px`; + valueLabel.fontSize = 10; + valueLabel.textAlignHorizontal = 'CENTER'; + valueLabel.fills = [{ type: 'SOLID', color: { r: 0.55, g: 0.55, b: 0.55 } }]; + wrapper.appendChild(valueLabel); + + return wrapper; +} +``` + +### Radius section builder + +```javascript +/** + * Creates the full border radius documentation section. + * + * @param {FrameNode} root - Root vertical stack. + * @param {Array<{name, value, variable}>} radiusTokens - Radius token definitions. + */ +async function createRadiusSection(root, radiusTokens) { + await figma.loadFontAsync({ family: 'Inter', style: 'Bold' }); + + const section = figma.createFrame(); + section.name = 'Section/Radius'; + section.layoutMode = 'VERTICAL'; + section.itemSpacing = 24; + section.layoutSizingHorizontal = 'FILL'; + section.layoutSizingVertical = 'HUG'; + section.fills = []; + root.appendChild(section); + + const heading = figma.createText(); + heading.fontName = { family: 'Inter', style: 'Bold' }; + heading.characters = 'Border Radius'; + heading.fontSize = 32; + heading.fills = [{ type: 'SOLID', color: { r: 0.07, g: 0.07, b: 0.07 } }]; + section.appendChild(heading); + + const row = figma.createFrame(); + row.name = 'Radius/Row'; + row.layoutMode = 'HORIZONTAL'; + row.itemSpacing = 24; + row.paddingTop = 24; + row.paddingBottom = 24; + row.paddingLeft = 24; + row.paddingRight = 24; + row.layoutSizingHorizontal = 'FILL'; + row.layoutSizingVertical = 'HUG'; + row.fills = [{ type: 'SOLID', color: { r: 0.97, g: 0.97, b: 0.97 } }]; + row.cornerRadius = 8; + section.appendChild(row); + + for (const tok of radiusTokens) { + await createRadiusCard(row, tok.name, tok.value, tok.variable); + } + + return section; +} +``` + +--- + +## 8. Documentation Alongside Components + +Each component page should include a documentation frame directly on the canvas, placed to the left of the component set. This keeps docs and the component in sync without requiring a separate file. + +### Component page documentation frame + +```javascript +/** + * Creates the documentation frame for a component page: title, description, + * and usage notes, positioned at x=0 with the component set to its right. + * + * @param {PageNode} page - The component page (must already be current). + * @param {string} componentName - The component name. + * @param {string} description - What the component does and when to use it. + * @param {string[]} usageNotes - Bullet points for usage guidance. + * @returns {FrameNode} The documentation frame. + */ +async function createComponentDocFrame(page, componentName, description, usageNotes) { + await figma.loadFontAsync({ family: 'Inter', style: 'Bold' }); + await figma.loadFontAsync({ family: 'Inter', style: 'Regular' }); + + const doc = figma.createFrame(); + doc.name = '_Doc'; + doc.layoutMode = 'VERTICAL'; + doc.itemSpacing = 16; + doc.paddingTop = 40; + doc.paddingBottom = 40; + doc.paddingLeft = 40; + doc.paddingRight = 40; + doc.layoutSizingHorizontal = 'FIXED'; + doc.layoutSizingVertical = 'HUG'; + doc.resize(360, 1); + doc.fills = []; + doc.x = 0; + doc.y = 0; + page.appendChild(doc); + + // Component name — large heading + const title = figma.createText(); + title.fontName = { family: 'Inter', style: 'Bold' }; + title.characters = componentName; + title.fontSize = 28; + title.fills = [{ type: 'SOLID', color: { r: 0.07, g: 0.07, b: 0.07 } }]; + title.layoutSizingHorizontal = 'FILL'; + doc.appendChild(title); + + // Description + const descText = figma.createText(); + descText.fontName = { family: 'Inter', style: 'Regular' }; + descText.characters = description; + descText.fontSize = 13; + descText.lineHeight = { value: 20, unit: 'PIXELS' }; + descText.fills = [{ type: 'SOLID', color: { r: 0.35, g: 0.35, b: 0.35 } }]; + descText.layoutSizingHorizontal = 'FILL'; + doc.appendChild(descText); + + // Divider + const divider = figma.createRectangle(); + divider.resize(280, 1); + divider.fills = [{ type: 'SOLID', color: { r: 0.88, g: 0.88, b: 0.88 } }]; + divider.layoutSizingHorizontal = 'FILL'; + doc.appendChild(divider); + + // Usage notes + if (usageNotes.length > 0) { + const usageHeading = figma.createText(); + usageHeading.fontName = { family: 'Inter', style: 'Bold' }; + usageHeading.characters = 'Usage'; + usageHeading.fontSize = 13; + usageHeading.fills = [{ type: 'SOLID', color: { r: 0.07, g: 0.07, b: 0.07 } }]; + doc.appendChild(usageHeading); + + for (const note of usageNotes) { + const noteText = figma.createText(); + noteText.fontName = { family: 'Inter', style: 'Regular' }; + noteText.characters = `• ${note}`; + noteText.fontSize = 12; + noteText.lineHeight = { value: 18, unit: 'PIXELS' }; + noteText.fills = [{ type: 'SOLID', color: { r: 0.4, g: 0.4, b: 0.4 } }]; + noteText.layoutSizingHorizontal = 'FILL'; + doc.appendChild(noteText); + } + } + + return doc; +} +``` + +--- + +## 9. Critical Rules + +1. **Bind swatches to variables** — use `setBoundVariableForPaint` for color fills, `setBoundVariable('width', ...)` for spacing bars, and `setBoundVariable('cornerRadius', ...)` for radius cards. Never hardcode values that have corresponding variables. +2. **Foundations page comes before component pages** — always insert it between the file structure separators and the first component page. +3. **Show both primitive and semantic layers** — if the system has a Primitives collection and a semantic Color collection, document both on the Foundations page with clear section labels. +4. **Page frame width = 1440px** — this is the convention across Simple DS, Polaris, and Material 3. Use it unless you detect a different existing convention via `get_metadata`. +5. **Section spacing = 64–80px** — the gap between color / typography / spacing / shadow / radius sections should be at minimum 64px so the page is scannable. +6. **Match existing page style** — if the target file uses emoji page name prefixes or a decorative separator style, carry that through to the Foundations page name. +7. **Include code syntax in labels** — where variables have code syntax set, display the CSS variable name in the swatch/bar label so developers can copy it directly. diff --git a/plugins/figma/skills/figma-generate-library/references/error-recovery.md b/plugins/figma/skills/figma-generate-library/references/error-recovery.md new file mode 100644 index 00000000..8c5aab70 --- /dev/null +++ b/plugins/figma/skills/figma-generate-library/references/error-recovery.md @@ -0,0 +1,540 @@ +> Part of the [figma-generate-library skill](../SKILL.md). + +# Error Recovery Reference + +Protocol for handling failures, partial state, and incomplete runs across a 20–100+ call design system build. + +--- + +## 1. Core Protocol: STOP → Inspect → Identify → Clean → Fix → Retry + +**Never retry a failed script without cleanup first.** A failed script may have created partial state — frames, components, or variables that are half-built. Retrying on top of partial state compounds the problem and can make recovery impossible. + +The mandatory recovery sequence: + +``` +1. STOP — Do not run any more use_figma writes. +2. INSPECT — Call get_metadata on the current page. Optionally call get_screenshot. +3. IDENTIFY — Find artifacts from the failed attempt using dsb_run_id pluginData tags. +4. CLEAN — Run a targeted cleanup script to remove orphaned nodes (pluginData-based, never name-based). +5. VERIFY — Run get_metadata again to confirm cleanup was complete. +6. FIX — Correct the script that failed. +7. RETRY — Re-run the corrected script from the last clean checkpoint. +8. PERSIST — Update the state ledger with the outcome. +``` + +Do not skip step 4 even if the failure seems minor. Partial frames and components accumulate and cause confusing results in later steps. + +--- + +## 2. `pluginData`-Based Cleanup: Why Name Matching is Dangerous + +### Why name-prefix matching fails + +A cleanup script that deletes "all nodes whose name starts with `Button`" will also delete nodes the user may have created manually with that name, or nodes from a previous approved phase. Name-based cleanup has no way to distinguish "orphan from a failed attempt" from "intentional user node." + +Furthermore, variant names (`Size=Medium, Style=Primary, State=Default`) do not have consistent prefixes that are safe to target without also hitting legitimate nodes. + +### How `setPluginData` / `getPluginData` works + +`pluginData` is a key-value store attached to individual nodes. It persists across sessions and is invisible to the user in the Figma UI. Only plugins with the same `pluginId` can read/write data scoped to that plugin. Use three keys: + +```javascript +node.setPluginData('dsb_run_id', 'ds-build-2024-001'); // identifies the build run +node.setPluginData('dsb_phase', 'phase3'); // which phase created this node +node.setPluginData('dsb_key', 'componentset/button');// unique logical key + +// Reading: +const runId = node.getPluginData('dsb_run_id'); // returns '' if never set +const key = node.getPluginData('dsb_key'); +``` + +`getPluginData` returns `''` (empty string, not null) for unset keys. Always check for `!== ''`. + +**Tag every created node immediately after creation** — before any further operations that might fail. If a failure happens between `createComponent()` and the tagging line, the node will be an untagged orphan. To minimize this window, tag in the same statement sequence as creation: + +```javascript +const comp = figma.createComponent(); +comp.setPluginData('dsb_run_id', RUN_ID); // tag immediately +comp.setPluginData('dsb_key', key); // tag immediately +// ... then do the rest of the setup +``` + +### Complete `cleanupOrphans` script using `dsb_run_id` + +This script finds all nodes tagged with a given `dsb_run_id` and optionally a `dsb_phase` filter, then removes them. Run it on the specific page where the failure occurred. + +```javascript +(async () => { + try { + const TARGET_RUN_ID = 'ds-build-2024-001'; // run ID to clean + const TARGET_PHASE = 'phase3'; // optionally filter by phase ('' = all phases) + const PAGE_NAME = 'Button'; // page to clean (or null for all pages) + + const pagesToSearch = PAGE_NAME + ? [figma.root.children.find(p => p.name === PAGE_NAME)].filter(Boolean) + : figma.root.children; + + const removed = []; + const skipped = []; + + for (const page of pagesToSearch) { + await figma.setCurrentPageAsync(page); + + const orphans = page.findAll(node => { + const runId = node.getPluginData('dsb_run_id'); + if (runId !== TARGET_RUN_ID) return false; + if (TARGET_PHASE && node.getPluginData('dsb_phase') !== TARGET_PHASE) return false; + return true; + }); + + // Remove leaf-first to avoid removing parents before children + // Sort by depth (deepest first) to avoid double-remove errors + const sorted = orphans.slice().sort((a, b) => { + let depthA = 0, depthB = 0; + let n = a; while (n.parent) { depthA++; n = n.parent; } + n = b; while (n.parent) { depthB++; n = n.parent; } + return depthB - depthA; + }); + + for (const node of sorted) { + try { + if (node.removed) continue; // already removed (was a child of removed parent) + node.remove(); + removed.push({ id: node.id, name: node.name, key: node.getPluginData('dsb_key') }); + } catch (e) { + skipped.push({ id: node.id, name: node.name, error: e.message }); + } + } + } + + figma.closePlugin(JSON.stringify({ removed: removed.length, skipped: skipped.length, details: removed })); + } catch (e) { figma.closePluginWithFailure(e.toString()); } +})(); +``` + +After running cleanup, call `get_metadata` on the target page to confirm the orphaned nodes are gone before retrying. + +--- + +## 3. Idempotency Patterns: Check-Before-Create + +Run an idempotency check at the start of every create operation. If the entity already exists (tagged with the expected `dsb_key`), skip creation and return the existing ID. + +### Check-before-create for a variable collection + +```javascript +(async () => { + try { + const KEY = 'collection/color'; + const RUN_ID = 'ds-build-2024-001'; + const COLLECTION_NAME = 'Color'; + + // Check: does a collection tagged with this key already exist? + const allCollections = await figma.variables.getLocalVariableCollectionsAsync(); + // Variables/collections support pluginData too — check by name as fallback + // Note: VariableCollection pluginData is set via collection.setPluginData(...) + const existing = allCollections.find(c => + c.getPluginData('dsb_key') === KEY + ); + + if (existing) { + figma.closePlugin(JSON.stringify({ + collectionId: existing.id, + modeIds: existing.modes.map(m => ({ name: m.name, id: m.modeId })), + alreadyExisted: true, + })); + return; + } + + // Create fresh + const collection = figma.variables.createVariableCollection(COLLECTION_NAME); + collection.setPluginData('dsb_run_id', RUN_ID); + collection.setPluginData('dsb_key', KEY); + + // Rename default mode, add second mode + collection.renameMode(collection.modes[0].modeId, 'Light'); + const darkModeId = collection.addMode('Dark'); + + figma.closePlugin(JSON.stringify({ + collectionId: collection.id, + modeIds: [ + { name: 'Light', id: collection.modes[0].modeId }, + { name: 'Dark', id: darkModeId }, + ], + })); + } catch (e) { figma.closePluginWithFailure(e.toString()); } +})(); +``` + +### Check-before-create for a page + +```javascript +(async () => { + try { + const KEY = 'page/button'; + const PAGE_NAME = 'Button'; + const RUN_ID = 'ds-build-2024-001'; + + // Check by pluginData key first, then by name as fallback + let page = figma.root.children.find(p => p.getPluginData('dsb_key') === KEY); + if (!page) { + page = figma.root.children.find(p => p.name === PAGE_NAME); + } + + if (page) { + // Ensure it's tagged if it was found by name only + if (!page.getPluginData('dsb_key')) { + page.setPluginData('dsb_run_id', RUN_ID); + page.setPluginData('dsb_key', KEY); + } + figma.closePlugin(JSON.stringify({ pageId: page.id, alreadyExisted: true })); + return; + } + + page = figma.createPage(); + page.name = PAGE_NAME; + page.setPluginData('dsb_run_id', RUN_ID); + page.setPluginData('dsb_key', KEY); + + figma.closePlugin(JSON.stringify({ pageId: page.id, alreadyExisted: false })); + } catch (e) { figma.closePluginWithFailure(e.toString()); } +})(); +``` + +### Check-before-create for a component set + +```javascript +(async () => { + try { + const KEY = 'componentset/button'; + const PAGE_ID = 'PAGE_ID_FROM_STATE'; + const RUN_ID = 'ds-build-2024-001'; + + const page = await figma.getNodeByIdAsync(PAGE_ID); + await figma.setCurrentPageAsync(page); + + const existing = page.findAll(n => + n.type === 'COMPONENT_SET' && n.getPluginData('dsb_key') === KEY + ); + + if (existing.length > 0) { + figma.closePlugin(JSON.stringify({ + componentSetId: existing[0].id, + alreadyExisted: true, + })); + return; + } + + // ... proceed with creation + figma.closePlugin(JSON.stringify({ componentSetId: null, alreadyExisted: false })); + } catch (e) { figma.closePluginWithFailure(e.toString()); } +})(); +``` + +--- + +## 4. State Ledger + +### JSON Schema + +Maintain a state ledger in your context (not in the Figma file) across calls. This is your source of truth for node IDs, completed steps, and pending validations. + +```json +{ + "runId": "ds-build-2024-001", + "phase": "phase3", + "step": "component-button/combine-variants", + "completedSteps": [ + "phase0", + "phase1/collections", + "phase1/primitives", + "phase1/semantics", + "phase2/pages", + "phase2/foundations-docs", + "phase3/component-avatar", + "phase3/component-icon" + ], + "entities": { + "collections": { + "primitives": "VariableCollectionId:1234:5678", + "color": "VariableCollectionId:1234:5679", + "spacing": "VariableCollectionId:1234:5680" + }, + "variables": { + "color/bg/primary": "VariableId:2345:1", + "color/bg/secondary": "VariableId:2345:2", + "color/bg/disabled": "VariableId:2345:3", + "color/text/on-primary": "VariableId:2345:4", + "color/text/on-secondary": "VariableId:2345:5", + "color/text/disabled": "VariableId:2345:6", + "spacing/sm": "VariableId:2345:7", + "spacing/md": "VariableId:2345:8", + "spacing/lg": "VariableId:2345:9", + "radius/md": "VariableId:2345:10" + }, + "modes": { + "color/light": "2345:1", + "color/dark": "2345:2" + }, + "pages": { + "Cover": "0:1", + "Foundations": "0:2", + "Button": "0:3" + }, + "components": { + "Icon": "3456:1", + "Avatar": "3456:2", + "Button": "3456:3" + }, + "componentSets": { + "Button": "4567:1" + } + }, + "pendingValidations": [ + "Button:metadata", + "Button:screenshot" + ], + "userCheckpoints": { + "phase0": "approved-2024-01-15", + "phase1": "approved-2024-01-15", + "phase2": "approved-2024-01-15", + "component-avatar": "approved-2024-01-15" + } +} +``` + +### Persisting between calls + +After every successful `use_figma` call: +1. Extract all IDs from the `closePlugin` return value +2. Add them to the appropriate `entities` section of the ledger +3. Add the completed step to `completedSteps` +4. Remove from `pendingValidations` if this call validated something +5. Update `phase` and `step` to the current position + +### Rehydrating at session start + +If a conversation is interrupted and resumed, read the state ledger and verify key entities still exist: + +```javascript +(async () => { + try { + // Verify that critical nodes from the ledger still exist + const toVerify = { + 'color-collection': 'VariableCollectionId:1234:5679', + 'button-page': '0:3', + 'button-componentset': '4567:1', + }; + + const results = {}; + for (const [label, id] of Object.entries(toVerify)) { + const node = await figma.getNodeByIdAsync(id) + .catch(() => null); + results[label] = node ? { found: true, name: node.name } : { found: false }; + } + + figma.closePlugin(JSON.stringify(results)); + } catch (e) { figma.closePluginWithFailure(e.toString()); } +})(); +``` + +If any entity is missing, treat the phase that created it as incomplete and re-run from that checkpoint. + +--- + +## 5. Resume Protocol + +### Step 1: Inspect the file for `dsb_run_id` tags + +```javascript +(async () => { + try { + const TARGET_RUN_ID = 'ds-build-2024-001'; + const inventory = { pages: [], variables: [], componentSets: [], frames: [] }; + + // Scan pages + for (const page of figma.root.children) { + if (page.getPluginData('dsb_run_id') === TARGET_RUN_ID) { + inventory.pages.push({ id: page.id, name: page.name, key: page.getPluginData('dsb_key') }); + } + } + + // Scan variables + const allVars = await figma.variables.getLocalVariablesAsync(); + for (const v of allVars) { + if (v.getPluginData('dsb_run_id') === TARGET_RUN_ID) { + inventory.variables.push({ id: v.id, name: v.name, key: v.getPluginData('dsb_key') }); + } + } + + // Scan all component sets and frames on each page + for (const page of figma.root.children) { + await figma.setCurrentPageAsync(page); + const nodes = page.findAll(n => n.getPluginData('dsb_run_id') === TARGET_RUN_ID); + for (const n of nodes) { + if (n.type === 'COMPONENT_SET') { + inventory.componentSets.push({ id: n.id, name: n.name, key: n.getPluginData('dsb_key') }); + } else if (n.type === 'FRAME') { + inventory.frames.push({ id: n.id, name: n.name, key: n.getPluginData('dsb_key') }); + } + } + } + + figma.closePlugin(JSON.stringify(inventory)); + } catch (e) { figma.closePluginWithFailure(e.toString()); } +})(); +``` + +### Step 2: Reconstruct state from inventory + +Map the inventory keys back to the state ledger schema. For each entity found with a `dsb_key`, add its ID to the appropriate section. Mark the corresponding step as `completedSteps`. + +Example mapping: +``` +key: 'collection/color' → entities.collections.color +key: 'variable/color/bg/primary' → entities.variables['color/bg/primary'] +key: 'page/button' → entities.pages.Button +key: 'componentset/button' → entities.componentSets.Button +``` + +### Step 3: Identify the resume point + +The resume point is the first step in the workflow that is NOT in `completedSteps`. If the inventory shows the Button component set exists but the pending validations list shows `'Button:screenshot'`, the resume point is the screenshot validation call, not re-creation. + +Use the checkpoint table from the workflow to determine which phase to continue from: + +``` +Phase 0 complete: all planned pages listed in entities.pages +Phase 1 complete: all planned variables listed in entities.variables with correct scopes +Phase 2 complete: all structural pages + foundations doc frames present +Phase 3 complete (per component): componentSet exists + no pending validations + user checkpoint recorded +``` + +--- + +## 6. Failure Taxonomy + +### Recoverable Errors + +These can be fixed and retried without affecting already-created entities: + +| Category | Examples | Recovery | +|---|---|---| +| Layout errors | Variants stacked at (0,0), wrong padding values | Re-run the positioning step only | +| Naming issues | Typo in variant name, wrong casing | Find nodes by `dsb_key`, update `name` property | +| Missing property wiring | `componentPropertyReferences` not set | Find component set by ID, re-run the property wiring step | +| Variable binding omission | A fill was hardcoded instead of bound | Find nodes by `dsb_key`, re-bind the fill | +| Wrong variable bound | Bound to wrong variable ID | Re-bind with correct variable ID | +| Text not visible | Font not loaded before text write | Re-run text creation with `loadFontAsync` first | +| Partial variant creation | Only 12 of 18 variants created before timeout | Run cleanup for the partial set, re-run full variant creation | + +### Structural Corruption (Requires Rollback or Restart) + +These errors leave the file in a state where continuing forward is unreliable: + +| Category | Examples | Recovery | +|---|---|---| +| Component cycle | A component instance was accidentally nested inside itself | Full cleanup of the affected component, restart that component from Call 1 | +| combineAsVariants with non-components | Mixed node types passed to combineAsVariants, causing unexpected merges | Remove the malformed component set, re-run from variant creation | +| Variable collection ID drift | Collection was deleted and re-created, old IDs in state ledger are stale | Re-run Phase 1 completely; update all IDs in state ledger | +| Page deletion | A page was deleted after component sets were created on it | Treat as Phase 2 incomplete; re-create the page + re-run affected component creations | +| Mode limit exceeded | `addMode` threw because the plan is Starter or Professional | Redesign variable collection architecture to fit mode limits, restart Phase 1 | + +**Recovery from structural corruption**: run `cleanupOrphans` for the entire run ID, then restart from the affected phase. Do NOT attempt to patch corrupted structure in-place. + +--- + +## 7. Common Error Table + +| Error message | Likely cause | Fix | +|---|---|---| +| `"Cannot create component from node"` | Tried to call `createComponentFromNode` on a node inside a component | Create a fresh component instead: `figma.createComponent()` | +| `"in addMode: Limited to N modes only"` | Plan mode limit hit (Starter=1, Professional=4) | Redesign to use fewer modes or upgrade plan | +| `"setCurrentPageAsync: page does not exist"` | Page was deleted or wrong ID | Re-create the page using the idempotency pattern | +| `"Cannot read properties of null"` | `getNodeByIdAsync` returned null — node was deleted | Run the resume protocol to find what exists, update state ledger | +| `"Expected nodes to be component nodes"` | Passed a non-ComponentNode to `combineAsVariants` | Filter the array: `nodes.filter(n => n.type === 'COMPONENT')` | +| `"in createVariable: Cannot create variable"` | Collection was deleted or ID is wrong | Verify collection exists with `getVariableCollectionByIdAsync` | +| `"font not loaded"` | Called a text property setter without `loadFontAsync` first | Add `await figma.loadFontAsync({ family, style })` before the text operation | +| `"Cannot set properties of a read-only array"` | Tried to mutate fills/strokes in-place | Clone first: `const fills = JSON.parse(JSON.stringify(node.fills))` | +| `"Expected RGBA color"` | Color value out of 0–1 range | Divide RGB 0–255 values by 255: `{ r: 65/255, g: 85/255, b: 143/255 }` | +| `"Cannot add children to a non-parent node"` | Tried to append a child to a leaf node (text, rect) | Ensure the parent is a FrameNode, ComponentNode, or GroupNode | +| `"in combineAsVariants: nodes must be in the same parent"` | Components are on different pages | Move all components to the same page before combining | +| `"Script exceeded time limit"` | Loop creating too many nodes in one call | Split the work: create N/2 variants per call | +| Component set deletes itself | Tried to create a component set with no children | `combineAsVariants` requires at least 1 node — always pass 1+ | +| `addComponentProperty` returns unexpected name | This is normal — `BOOLEAN`/`TEXT`/`INSTANCE_SWAP` get `#id:id` suffix | Save the returned key immediately and use that, not the input name | + +--- + +## 8. Per-Phase Recovery Guidance + +### Phase 1 fails mid-execution (variable creation) + +Symptoms: partial variable collections exist; some variables are missing; some have wrong values. + +Recovery steps: +1. Run inspection script to find all variables tagged with your `dsb_run_id` +2. For each variable with `dsb_key` matching the plan, verify its `valuesByMode` and `scopes` are correct +3. If a variable is malformed, call `variable.remove()` and recreate it +4. If the collection itself is malformed, remove the entire collection and recreate from scratch +5. Do NOT proceed to Phase 2 until ALL planned variables exist with correct scopes and code syntax + +**The most common Phase 1 failure:** running out of time in a single `use_figma` call when creating many variables. Fix: batch variable creation — create at most 20–30 variables per call. + +### Phase 2 fails mid-execution (page/file structure) + +Symptoms: some pages exist, others are missing; foundations doc frames are incomplete. + +Recovery steps: +1. Identify which pages were successfully created (check for `dsb_key` tags) +2. Mark remaining pages as pending and create them in subsequent calls +3. If a foundations doc frame is malformed, run `cleanupOrphans` for `dsb_phase: 'phase2'` on that page, then recreate + +Phase 2 failures rarely require Phase 1 rollback unless the page structure itself is corrupted (which is unusual). + +### Phase 3 fails mid-execution (component creation) + +This is the most common failure mode in long builds. Handle by component: + +``` +If failure in Call 1 (page creation): + → Idempotency check will handle on retry. Safe to re-run. + +If failure in Call 2 (doc frame): + → cleanupOrphans for dsb_key='doc/{component}', then re-run. + +If failure in Call 3 (base component): + → Remove the partial base component node, re-run from Call 3. + +If failure in Call 4 (variant creation): + → cleanupOrphans for dsb_phase='phase3' on the component page (scoped by page). + → Re-run from Call 3 (base) or Call 4 if base was successfully tagged. + +If failure in Call 5 (combineAsVariants + layout): + → Remove the malformed component set. + → Remove all variant ComponentNodes for this component (by dsb_key pattern). + → Re-run from Call 3. + +If failure in Call 6 (component properties): + → The component set already exists and is structurally sound. + → Re-run Call 6 only — addComponentProperty is safe to retry if + you first check componentPropertyDefinitions for existing properties. + → Idempotency check: if 'Label' property already exists, skip addComponentProperty. +``` + +**Idempotency for component properties (Call 6 retry):** + +```javascript +const existingDefs = cs.componentPropertyDefinitions; +const labelKey = existingDefs['Label'] + ? Object.keys(existingDefs).find(k => k.startsWith('Label')) + : cs.addComponentProperty('Label', 'TEXT', 'Button'); +``` + +### Phase 4 fails mid-execution (QA / Code Connect) + +Phase 4 is non-destructive. Failures here do not corrupt Phase 3 work. Common failures: + +- **Accessibility audit finds contrast failures:** do not attempt auto-fix. Report the specific variable IDs and token names that fail, then ask the user which value to update. +- **Naming audit finds duplicates:** list all duplicates with their `dsb_key` values, ask user which to keep, then remove the duplicates. +- **Code Connect mapping fails:** treat as incomplete, not broken. Continue and leave as pending. diff --git a/plugins/figma/skills/figma-generate-library/references/naming-conventions.md b/plugins/figma/skills/figma-generate-library/references/naming-conventions.md new file mode 100644 index 00000000..3405e37c --- /dev/null +++ b/plugins/figma/skills/figma-generate-library/references/naming-conventions.md @@ -0,0 +1,527 @@ +> Part of the [figma-generate-library skill](../SKILL.md). + +# Naming Conventions Reference + +This reference documents every naming convention used in the figma-generate-library workflow. Cover all naming decisions in order: variables, components, pages, variants, styles, separators, status indicators. The last section explains when to match an existing file's conventions vs. using the defaults here. + +--- + +## 1. Variable Naming + +### Slash hierarchy (the universal pattern) + +All Figma variables use slash-separated paths. The slash creates visual grouping in the Variables panel and maps directly to the token hierarchy in code. + +``` +{category}/{subcategory}/{role} +``` + +Real examples from Simple DS and Material 3: + +``` +color/bg/primary +color/bg/secondary +color/text/primary +color/text/muted +color/border/default +color/border/focus +color/feedback/error +color/feedback/success +spacing/xs +spacing/sm +spacing/md +spacing/lg +spacing/xl +spacing/2xl +radius/none +radius/sm +radius/md +radius/lg +radius/full +typography/body/font-size +typography/body/line-height +typography/heading/font-size +typography/heading/font-weight +``` + +### Primitives collection + +Primitive variables hold raw values and are **not** exposed to consumers (scope = `[]`). They use a flat `{family}/{step}` format matching the color scale convention from Simple DS: + +``` +blue/50 +blue/100 +blue/200 +... +blue/900 +gray/50 +gray/100 +... +gray/900 +red/500 +green/500 +``` + +Step numbers follow the convention of the target codebase. If the codebase uses `100–900`, use that. If it uses `50–950`, use that. If there is no codebase convention, use `100–900` in increments of 100. + +### Semantic collection + +Semantic variables alias primitives. They use the role-based `{category}/{role}` or `{category}/{subcategory}/{role}` pattern: + +``` +color/bg/primary → alias: primitives/white (light), primitives/gray/900 (dark) +color/bg/secondary → alias: primitives/gray/100 (light), primitives/gray/800 (dark) +color/text/primary → alias: primitives/gray/900 (light), primitives/white (dark) +color/text/secondary → alias: primitives/gray/600 (light), primitives/gray/400 (dark) +color/border/default → alias: primitives/gray/200 (light), primitives/gray/700 (dark) +``` + +**Rule:** Semantic variables must never hold raw hex values — they always alias a primitive. If you need a new color value, create the primitive first, then create the semantic alias. + +### Casing + +**Default:** Use **lowercase** with forward slashes: `color/bg/primary`, `spacing/2xl`. + +**When to deviate:** +- If the existing file uses PascalCase (e.g., Material 3 uses `Schemes/Primary`) — match it. +- If the design team prefers PascalCase for readability in the Variables panel — acceptable as long as the code syntax is separately defined and uses the platform-correct case. +- Mode names can use spaces and mixed case (e.g., `SDS Light`, `Mode 1 → Light`) — these are labels, not identifiers. + +**Never:** camelCase inside variable names (`colorBgPrimary` as a Figma name is wrong — that belongs in Android code syntax only). Never use spaces inside a path segment: `color/bg primary` is wrong; `color/bg/primary` is correct. + +**Key distinction:** The casing rule applies to *Figma variable names*. Code syntax names follow *platform conventions* regardless of the Figma name case — see §9 for the full picture. + +--- + +## 2. Component Naming + +### Main components: PascalCase, no prefix + +Published components intended for library consumers use plain PascalCase names: + +``` +Button +Input +Checkbox +Toggle +Avatar +Badge +Card +Dialog +Tooltip +Banner +``` + +Do not use a namespace prefix for public components (e.g., do not name them `DS/Button` or `sds-Button`). Slashes in component names create nested grouping in the Assets panel, which is correct for sub-components but not for top-level public components. + +### Sub-components: underscore prefix + slash namespace + +Internal sub-components that are NOT meant for library consumers use the `_` prefix. This hides them from the Assets panel by default and signals to other designers that they should not be used directly. + +``` +_Button/Slot (internal icon slot for Button) +_Input/Indicator (internal state indicator for Input) +_Badge/Dot (internal dot sub-component of Badge) +_Parts/Avatar.Status (UI3 pattern: _Parts/{ParentName}.{SubPart}) +_Slider/Handle (UI3 pattern: _{ParentName}/{SubPart}) +``` + +Pattern rules: +- Use `_` prefix for ALL internal sub-components — no exceptions. +- Use slash namespacing to group sub-components under their parent: `_Button/IconSlot`. +- For sub-components shared by multiple parents, use `_Parts/{ComponentName}.{SubPart}`. + +### Private documentation components + +Components used only for internal documentation (not for production use) use the `.` prefix: + +``` +.ExampleCard +.GuidelineHeader +.DemoFrame +``` + +This hides them from consumers while keeping them accessible on the canvas. + +--- + +## 3. Page Naming + +Five reference design systems use three distinct naming patterns. Choose one pattern and apply it consistently across all pages in the file. + +### Pattern 1: Plain names (Simple DS, Material 3, Polaris) + +The most common pattern. Clean, readable, no decoration. + +``` +Cover +--- +Foundations +Icons +--- +Accordion +Avatars +Buttons +Cards +Dialog +Inputs +Menu +--- +Utilities +Component Playground +``` + +Use this pattern when starting from scratch or when the target file already uses this style. + +### Pattern 2: Emoji prefix + status (UI3 Library) + +The most expressive pattern. The page name encodes asset type, design status, and code readiness. + +Anatomy: `[Asset Type Emoji] [Optional FPL Label] [Status Circle] Component Name [Code Status Bracket]` + +| Segment | Values | +|---------|--------| +| Asset type | Component pages use the C-flag emoji; pattern pages use the P-flag emoji | +| Design status | Green circle = Ready, Yellow circle = WIP, Red circle = Do not use | +| Code status | (none) = Ready in code, `[beta]` = Beta, `[future]` = Not yet built | + +Examples: +``` +Overview +Status Key +--- +FPL COMPONENTS (go/fpl) +[C-flag] FPL [Green] Buttons +[C-flag] FPL [Green] Inputs +[C-flag] FPL [Yellow] Popovers [future] +--- +UI3 COMPONENTS +[C-flag] [Green] Comments +--- +PATTERNS +[P-flag] [Green] Editor / Layers +--- +[Book] Cover +[Headstone] Deprecated +``` + +Use this pattern only when building a large, multi-team design system where lifecycle tracking is needed, or when the target file already uses it. + +### Pattern 3: Emoji prefix (Shop Minis) + +A lighter version of the UI3 pattern without status circles. + +``` +📔 Cover +ℹ️ About +🚀 Getting started +——— THEME ——— +Color +Typography +Spacing +——— COMPONENTS ——— +Button +Input +Card +``` + +Use this pattern when the target file already uses emoji prefixes but does not need lifecycle tracking. + +### Universal rules (all patterns) + +- **Cover** is always first. +- **Separator pages** come before and after each logical section. +- **Foundation/token pages** always come before component pages. +- **Utility and internal pages** always come last. +- Pick one convention and do not mix patterns within a file. + +--- + +## 4. Variant Naming + +### Property=Value format + +All component variant properties and their values use `Property=Value` format in the Figma component set: + +``` +Size=Small, Style=Primary, State=Default +Size=Medium, Style=Secondary, State=Hover +Size=Large, Style=Ghost, State=Disabled +``` + +Actual property names match code prop names where possible: + +| Figma Property | Code Prop Equivalent | +|---------------|---------------------| +| `Size` | `size` | +| `Style` / `Variant` | `variant` | +| `State` | Typically controlled by `:hover`, `:focus`, `:disabled` in CSS, but `state` in some systems | +| `Type` | `type` | +| `Disabled` | `disabled` (boolean) | +| `Icon` | `icon` (boolean or instance swap) | + +### Property value casing + +Property values use **Title Case** in Figma (to be readable in the Variants panel), mapping to lowercase in code: + +| Figma value | Code value | +|-------------|-----------| +| `Small` | `"small"` / `"sm"` | +| `Medium` | `"medium"` / `"md"` | +| `Large` | `"large"` / `"lg"` | +| `Primary` | `"primary"` | +| `Disabled` | `disabled` (boolean prop) | +| `Default` | *(typically the absent/unset case)* | + +### Boolean properties + +Boolean component properties in Figma use `true` / `false` as values (Figma's native boolean), not `Yes` / `No` or `On` / `Off`. + +--- + +## 5. Style Naming (Text and Effect Styles) + +### Text styles: category/name + +``` +Display/Large +Display/Medium +Display/Small +Heading/1 +Heading/2 +Heading/3 +Body/Large +Body/Medium +Body/Small +Label/Large +Label/Small +Code/Inline +``` + +The category segment maps to the typographic role. Use the same category names as the codebase's typography scale where possible. + +### Effect styles (shadows): category/name + +``` +Shadow/None +Shadow/Subtle +Shadow/Medium +Shadow/Strong +Shadow/Overlay +Elevation/0 +Elevation/1 +Elevation/2 +Elevation/3 +Elevation/4 +Elevation/5 +``` + +Use `Shadow/` for named semantic shadows. Use `Elevation/N` for Material Design-style numbered elevation levels. + +--- + +## 6. Separator Pages + +Separator pages are empty pages whose sole purpose is to create visual breaks in the Figma page panel. Two conventions: + +| Convention | Example | Used by | +|------------|---------|---------| +| Three dashes | `---` | Simple DS, UI3, Polaris, Material 3 | +| Decorated text | `——— COMPONENTS ———` | Shop Minis | + +The three-dash convention (`---`) is the most common and the default for new files. Use it unless the target file uses the decorated-text style. + +**Where to place separators:** + +``` +Cover +--- ← after cover +Foundations +Icons +--- ← before components +[component pages] +--- ← before utilities +Utilities +``` + +--- + +## 7. Status Indicators (UI3 Emoji System) + +The UI3 Library uses colored circle emojis in page names to communicate design readiness at a glance. This system is optional but powerful for large teams. + +| Emoji | Meaning | When to use | +|-------|---------|-------------| +| Green circle | Ready / Approved | Design is stable, reviewed, and safe to use | +| Yellow circle | WIP / In Progress | Design is being actively worked on, may change | +| Red circle | Do not use | Not ready, do not reference; may be deprecated | + +Code readiness is communicated via brackets appended to the component name: + +| Bracket | Meaning | +|---------|---------| +| (none) | Component is implemented in code and stable | +| `[beta]` | Component is in code but not yet stable (~3 weeks from ready) | +| `[future]` | Not yet implemented in code | + +**Documentation status (within component pages):** + +If building a UI3-style system, each documentation frame gets a status banner with one of these labels: + +- `APPROVED` — fully vetted +- `READY FOR REVIEW` — awaiting sign-off +- `WORK IN PROGRESS` — actively being designed +- `NEEDS UPDATE` — outdated, requires revision +- `DO NOT REFERENCE` — should not be used + +This system is only recommended for large, multi-team systems where lifecycle tracking provides real value. For smaller systems, skip the emoji status indicators and use plain page names. + +--- + +## 8. When to Match Existing vs. Use Defaults + +**Always inspect before naming anything.** Run `get_metadata` or `inspectFileStructure` to discover existing conventions before creating any pages or variables. + +### Match the existing file when: + +- The file already has pages with a consistent naming pattern (emoji prefixes, separator style, casing). +- The file already has variable collections with an established naming scheme. +- The file was started by a design team and carries intentional decisions. +- Any existing component names use a specific pattern (PascalCase, kebab-case, namespace prefixes). + +### Use the defaults from this document when: + +- Starting a brand-new Figma file with no existing content. +- The existing conventions are inconsistent (mix of styles = no convention to match). +- The user explicitly asks for a fresh design system following best practices. + +### When code and Figma disagree: + +If the codebase uses `button-primary` but Figma has a component named `Button`, do not rename the Figma component. Instead: +- Keep the Figma name as `Button` (PascalCase, human-readable). +- Set variable code syntax to match the exact CSS token name from the codebase. +- Set Code Connect source path to the actual code file and use the exact code component name. + +**The rule:** Figma names are for designers; code syntax and Code Connect source paths carry the exact code identifiers. These two identity systems operate in parallel. + +--- + +## 9. Figma Variable Names vs Code Names — The Full Picture + +This is one of the most misunderstood areas. Figma names and code names follow **different conventions on purpose** — they serve different audiences and live in different environments. + +### Why they differ + +| | Figma variable name | Code syntax (WEB) | +|---|---|---| +| **Audience** | Designers in the Variables panel | Developers in CSS/Swift/Kotlin | +| **Separator** | `/` (slash) — creates visual grouping in Figma UI | `-` (hyphen) — required by CSS custom property syntax | +| **Case** | lowercase (or PascalCase for display — see below) | kebab-case for CSS; camelCase for JS/Android | +| **Depth** | 2–4 levels | Flat for CSS; dot-notation for JS | +| **Namespace** | Implicit (by collection) | Explicit prefix (`--p-`, `--md-`, `--cds-`) | + +### The transformation + +``` +Figma variable name Code syntax (WEB) +────────────────── ───────────────── +color/bg/primary → var(--color-bg-primary) +spacing/xs → var(--spacing-xs) +radius/md → var(--radius-md) +typography/body/font-size → var(--typography-body-font-size) + +Pattern: replace "/" with "-", wrap in var(--) + +**CRITICAL: The `var()` wrapper is REQUIRED for WEB code syntax.** Figma expects the full CSS function syntax — not just the property name. If you set `--color-bg-primary` (without `var()`), Dev Mode will show raw hex values instead of the variable reference. Always set `var(--color-bg-primary)`. +``` + +``` +Figma variable name Code syntax (ANDROID) +────────────────── ───────────────────── +color/bg/primary → colorBgPrimary +spacing/xs → spacingXs +radius/md → radiusMd + +Pattern: replace "/" with "", capitalize each word after first +``` + +``` +Figma variable name Code syntax (iOS) +────────────────── ───────────────── +color/bg/primary → Color.bgPrimary +spacing/xs → Spacing.xs +radius/md → Radius.md + +Pattern: first segment becomes class name, remainder becomes property (camelCase) +``` + +### Real-world examples from the 5 reference files + +| File | Figma variable name | WEB code syntax | ANDROID code syntax | +|------|--------------------|-----------------|--------------------| +| Simple DS | `color/bg/primary` | `var(--color-bg-primary)` | `colorBgPrimary` | +| Simple DS | `spacing/sm` | `var(--spacing-sm)` | `spacingSm` | +| Material 3 | `Schemes/Primary` | `var(--md-sys-color-primary)` | `colorPrimary` | +| Material 3 | `Corner/Extra-small` | `var(--md-sys-shape-corner-extra-small)` | `shapeCornerExtraSmall` | +| Polaris | `color/bg/surface` | `var(--p-color-bg-surface)` | — | + +**Key observation from Material 3:** The Figma name `Schemes/Primary` uses PascalCase with a space, but the WEB code syntax is `var(--md-sys-color-primary)` — entirely kebab-case with a vendor prefix `md-sys-`. The Figma name and the code syntax bear almost no resemblance. This is intentional and common in mature design systems. + +### Casing in Figma: lowercase is default, PascalCase is valid for display + +The guideline to use lowercase is a default, not a universal rule. Evidence from real files: + +| File | Figma case | Code output case | Why | +|------|-----------|------------------|-----| +| Simple DS | `color/bg/primary` (lowercase) | `var(--color-bg-primary)` | Direct mapping — simple | +| Material 3 | `Schemes/Primary` (PascalCase) | `var(--md-sys-color-primary)` | PascalCase reads better in Variables panel; code name is independently defined | +| Polaris | `color/bg/surface` (lowercase) | `var(--p-color-bg-surface)` | Direct mapping with vendor prefix | + +**Rule:** Use lowercase when the Figma name will map directly to the CSS name. Use PascalCase (or match existing file) when the design system has human-readable variable names that are distinct from the technical code names. + +### When the codebase doesn't use CSS custom properties + +Some JavaScript-first systems (Chakra, Ant Design, MUI) don't use CSS `var(--...)` at all. Their tokens live in JS theme objects: + +``` +Chakra: colors.gray[500] → JS: theme.colors.gray[500] +Ant: colorPrimary → JS: token.colorPrimary +MUI: palette.primary.main → JS: theme.palette.primary.main +``` + +In these cases, set WEB code syntax to the JS property path rather than a CSS variable: +```javascript +// For a JS-object-based system like Chakra: +v.setVariableCodeSyntax('WEB', 'colors.gray.500'); + +// For Ant Design: +v.setVariableCodeSyntax('WEB', 'colorPrimary'); +``` + +### Hierarchy depth: match the codebase + +The number of slash levels should mirror the codebase's nesting depth: + +| Codebase pattern | Figma depth | Example | +|-----------------|------------|---------| +| `--primary` (flat) | 1–2 levels | `color/primary` | +| `--color-bg-surface` (3-part) | 3 levels | `color/bg/surface` | +| `--md-sys-color-primary` (vendor + 3-part) | 3 levels (vendor prefix goes in code syntax only) | `color/primary` | +| `theme.palette.primary.main` (4-part) | 3–4 levels | `color/palette/primary/main` | + +**Important:** Vendor prefixes (`--p-`, `--md-sys-`, `--cds-`) belong in the **code syntax**, not the Figma variable name. The Figma name `color/bg/surface` + code syntax `var(--p-color-bg-surface)` is the correct pattern. + +### Action at discovery time + +During Phase 0 discovery, capture both sides of the mapping explicitly: + +``` +For each token found in the codebase: + CSS variable: --sds-color-background-brand-default + Figma name: color/bg/brand/default (slash hierarchy, no vendor prefix) + WEB syntax: var(--sds-color-background-brand-default) (exact CSS name) + ANDROID syntax: sdsColorBackgroundBrandDefault (camelCase) + iOS syntax: Color.backgroundBrandDefault (dot-notation) +``` + +Store this mapping in the state ledger. Use it when calling `setVariableCodeSyntax` in Phase 1. Never derive the code syntax from the Figma name if you have the original CSS variable name — always use the original. diff --git a/plugins/figma/skills/figma-generate-library/references/token-creation.md b/plugins/figma/skills/figma-generate-library/references/token-creation.md new file mode 100644 index 00000000..b6440343 --- /dev/null +++ b/plugins/figma/skills/figma-generate-library/references/token-creation.md @@ -0,0 +1,962 @@ +> Part of the [figma-generate-library skill](../SKILL.md). + +# Token Creation Reference + +This document covers Phase 1: creating variable collections, modes, primitives, semantic aliases, scopes, code syntax, styles, and validation. All code is copy-paste ready for `use_figma`. + +--- + +## 1. Collection Architecture + +Choose the pattern that matches your token count and complexity: + +### Simple Pattern (< 50 tokens) + +One collection, 2 modes. Appropriate for small projects or brand kits. + +``` +Collection: "Tokens" modes: ["Light", "Dark"] + color/bg/primary → Light: #FFFFFF, Dark: #1A1A1A + spacing/sm = 8 +``` + +### Standard Pattern (50–200 tokens) — Recommended Starting Point + +Separate primitives from semantics. The real-world reference is Figma's Simple Design System (SDS): 7 collections, 368 variables, light/dark modes on semantic colors, single-mode primitives. + +``` +Collection: "Primitives" modes: ["Value"] ← raw hex values, no modes + blue/500 = #3B82F6 + gray/900 = #111827 + white/1000 = #FFFFFF + +Collection: "Color" modes: ["Light", "Dark"] ← aliases to Primitives + color/bg/primary → Light: alias Primitives/white/1000, Dark: alias Primitives/gray/900 + color/text/primary → Light: alias Primitives/gray/900, Dark: alias Primitives/white/1000 + +Collection: "Spacing" modes: ["Value"] + spacing/xs = 4, spacing/sm = 8, spacing/md = 16, spacing/lg = 24, spacing/xl = 32 + +Collection: "Typography Primitives" modes: ["Value"] + family/sans = "Inter", scale/01 = 12, scale/02 = 14, scale/03 = 16, weight/regular = 400 + +Collection: "Typography" modes: ["Value"] ← aliases to Typography Primitives + body/font-family → alias family/sans + body/size-md → alias scale/03 +``` + +### Advanced Pattern (200+ tokens) — M3 Model + +Multiple semantic collections, 4–8 modes. Use when you need light/dark × contrast × brand or responsive breakpoints. + +``` +Collection: "M3" modes: ["Light", "Dark", "Light High Contrast", "Dark High Contrast", ...] +Collection: "Typeface" modes: ["Baseline", "Wireframe"] +Collection: "Typescale" modes: ["Value"] ← aliases into Typeface +Collection: "Shape" modes: ["Value"] +``` + +Key insight from M3: ALL 196 semantic color variables live in a SINGLE collection with 8 modes. Switching a frame's mode once updates every color simultaneously. + +--- + +## 2. Creating Collections + Modes + +### Creating a Primitives Collection + +```javascript +(async () => { + try { + const RUN_ID = "ds-build-2024-001"; // use the same RUN_ID throughout the build + + // Create the collection + const primColl = figma.variables.createVariableCollection("Primitives"); + + // Rename the default "Mode 1" to "Value" + primColl.renameMode(primColl.modes[0].modeId, "Value"); + const valueMode = primColl.modes[0].modeId; + + // Tag for idempotency + primColl.setPluginData('dsb_run_id', RUN_ID); + primColl.setPluginData('dsb_key', 'collection/primitives'); + + figma.closePlugin(JSON.stringify({ + collectionId: primColl.id, + modeId: valueMode, + name: primColl.name + })); + } catch(e) { figma.closePluginWithFailure(e.toString()); } +})(); +``` + +### Creating a Semantic Color Collection with Light/Dark Modes + +```javascript +(async () => { + try { + const RUN_ID = "ds-build-2024-001"; + + const colorColl = figma.variables.createVariableCollection("Color"); + + // Rename default "Mode 1" to "Light" + colorColl.renameMode(colorColl.modes[0].modeId, "Light"); + const lightModeId = colorColl.modes[0].modeId; + + // Add "Dark" mode — requires Professional plan or higher + // Throws "in addMode: Limited to N modes only" on Starter plan + const darkModeId = colorColl.addMode("Dark"); + + colorColl.setPluginData('dsb_run_id', RUN_ID); + colorColl.setPluginData('dsb_key', 'collection/color'); + + figma.closePlugin(JSON.stringify({ + collectionId: colorColl.id, + lightModeId, + darkModeId + })); + } catch(e) { figma.closePluginWithFailure(e.toString()); } +})(); +``` + +**Mode plan limits:** Starter = 1 mode, Professional = 4 modes, Organization/Enterprise = 40 modes. If `addMode` throws, the file is on a Starter plan — tell the user and ask how to proceed. + +### Creating a Spacing Collection (single mode) + +```javascript +(async () => { + try { + const RUN_ID = "ds-build-2024-001"; + + const spacingColl = figma.variables.createVariableCollection("Spacing"); + spacingColl.renameMode(spacingColl.modes[0].modeId, "Value"); + const valueMode = spacingColl.modes[0].modeId; + + spacingColl.setPluginData('dsb_run_id', RUN_ID); + spacingColl.setPluginData('dsb_key', 'collection/spacing'); + + figma.closePlugin(JSON.stringify({ + collectionId: spacingColl.id, + modeId: valueMode + })); + } catch(e) { figma.closePluginWithFailure(e.toString()); } +})(); +``` + +--- + +## 3. Creating All Variable Types + +### hex → {r, g, b} Conversion Helper + +Colors in the Figma Plugin API are 0–1 range, not 0–255. Embed this helper in any script that creates color variables: + +```javascript +function hexToRgb(hex) { + const clean = hex.replace('#', ''); + return { + r: parseInt(clean.substring(0, 2), 16) / 255, + g: parseInt(clean.substring(2, 4), 16) / 255, + b: parseInt(clean.substring(4, 6), 16) / 255 + }; +} + +// With alpha channel (for semi-transparent primitives like Black/200 at 10%): +function hexToRgba(hex) { + const clean = hex.replace('#', ''); + const hasAlpha = clean.length === 8; + return { + r: parseInt(clean.substring(0, 2), 16) / 255, + g: parseInt(clean.substring(2, 4), 16) / 255, + b: parseInt(clean.substring(4, 6), 16) / 255, + a: hasAlpha ? parseInt(clean.substring(6, 8), 16) / 255 : 1 + }; +} + +// Usage: +// hexToRgb('#3B82F6') → {r: 0.231, g: 0.510, b: 0.965} +// hexToRgb('#14AE5C') → {r: 0.078, g: 0.682, b: 0.361} +// hexToRgba('#0c0c0d1a') → {r: 0.047, g: 0.047, b: 0.051, a: 0.102} +``` + +### Creating Primitive Color Variables (Real SDS Data) + +This creates a subset of the Simple Design System's `Color Primitives` collection (Blue family, from the Standard pattern used by real design systems): + +```javascript +(async () => { + try { + function hexToRgb(hex) { + const c = hex.replace('#', ''); + return { r: parseInt(c.slice(0,2),16)/255, g: parseInt(c.slice(2,4),16)/255, b: parseInt(c.slice(4,6),16)/255 }; + } + + const RUN_ID = "ds-build-2024-001"; + + // Get the Primitives collection created in the previous step + const collections = await figma.variables.getLocalVariableCollectionsAsync(); + const primColl = collections.find(c => c.getPluginData('dsb_key') === 'collection/primitives'); + if (!primColl) throw new Error("Primitives collection not found — run collection creation first"); + const valueMode = primColl.modes[0].modeId; + + // Define primitives — use real values from your codebase + const primitiveColors = [ + // Blue scale + { name: 'blue/100', hex: '#EFF6FF' }, + { name: 'blue/200', hex: '#DBEAFE' }, + { name: 'blue/300', hex: '#93C5FD' }, + { name: 'blue/400', hex: '#60A5FA' }, + { name: 'blue/500', hex: '#3B82F6' }, + { name: 'blue/600', hex: '#2563EB' }, + { name: 'blue/700', hex: '#1D4ED8' }, + { name: 'blue/800', hex: '#1E40AF' }, + { name: 'blue/900', hex: '#1E3A8A' }, + // Gray scale + { name: 'gray/100', hex: '#F9FAFB' }, + { name: 'gray/200', hex: '#F3F4F6' }, + { name: 'gray/300', hex: '#D1D5DB' }, + { name: 'gray/400', hex: '#9CA3AF' }, + { name: 'gray/500', hex: '#6B7280' }, + { name: 'gray/600', hex: '#4B5563' }, + { name: 'gray/700', hex: '#374151' }, + { name: 'gray/800', hex: '#1F2937' }, + { name: 'gray/900', hex: '#111827' }, + // White / Black + { name: 'white/1000', hex: '#FFFFFF' }, + { name: 'black/1000', hex: '#000000' }, + ]; + + const created = []; + for (const { name, hex } of primitiveColors) { + const v = figma.variables.createVariable(name, primColl, 'COLOR'); + v.setValueForMode(valueMode, hexToRgb(hex)); + // Primitives: EMPTY scopes (hidden from all pickers — designers use semantics) + v.scopes = []; + // Code syntax from the actual CSS variable name + v.setVariableCodeSyntax('WEB', `var(--color-${name.replace('/', '-')})`); + v.setPluginData('dsb_run_id', RUN_ID); + v.setPluginData('dsb_key', `primitive/${name}`); + created.push({ name, id: v.id }); + } + + figma.closePlugin(JSON.stringify({ created, count: created.length })); + } catch(e) { figma.closePluginWithFailure(e.toString()); } +})(); +``` + +**Critical scope rule for primitives:** Set `v.scopes = []`. This hides primitives from every picker. Designers should only see semantic tokens. The exception is semi-transparent overlay primitives (Black/White with alpha) — those get `["EFFECT_COLOR"]` so they appear in shadow pickers. + +### Creating FLOAT Variables (Spacing, Radius, Font Size) + +```javascript +(async () => { + try { + const RUN_ID = "ds-build-2024-001"; + const collections = await figma.variables.getLocalVariableCollectionsAsync(); + const spacingColl = collections.find(c => c.getPluginData('dsb_key') === 'collection/spacing'); + if (!spacingColl) throw new Error("Spacing collection not found"); + const valueMode = spacingColl.modes[0].modeId; + + const spacingTokens = [ + { name: 'spacing/xs', value: 4, scope: 'GAP', cssVar: '--spacing-xs' }, + { name: 'spacing/sm', value: 8, scope: 'GAP', cssVar: '--spacing-sm' }, + { name: 'spacing/md', value: 16, scope: 'GAP', cssVar: '--spacing-md' }, + { name: 'spacing/lg', value: 24, scope: 'GAP', cssVar: '--spacing-lg' }, + { name: 'spacing/xl', value: 32, scope: 'GAP', cssVar: '--spacing-xl' }, + { name: 'spacing/2xl', value: 48, scope: 'GAP', cssVar: '--spacing-2xl' }, + ]; + + const radiusTokens = [ + { name: 'radius/none', value: 0, scope: 'CORNER_RADIUS', cssVar: '--radius-none' }, + { name: 'radius/sm', value: 4, scope: 'CORNER_RADIUS', cssVar: '--radius-sm' }, + { name: 'radius/md', value: 8, scope: 'CORNER_RADIUS', cssVar: '--radius-md' }, + { name: 'radius/lg', value: 16, scope: 'CORNER_RADIUS', cssVar: '--radius-lg' }, + { name: 'radius/full', value: 9999, scope: 'CORNER_RADIUS', cssVar: '--radius-full' }, + ]; + + const created = []; + for (const { name, value, scope, cssVar } of [...spacingTokens, ...radiusTokens]) { + const v = figma.variables.createVariable(name, spacingColl, 'FLOAT'); + v.setValueForMode(valueMode, value); + v.scopes = [scope]; + v.setVariableCodeSyntax('WEB', `var(${cssVar})`); + v.setPluginData('dsb_run_id', RUN_ID); + v.setPluginData('dsb_key', name); + created.push({ name, value, id: v.id }); + } + + figma.closePlugin(JSON.stringify({ created, count: created.length })); + } catch(e) { figma.closePluginWithFailure(e.toString()); } +})(); +``` + +### Creating STRING Variables (Font Family, Font Style) + +```javascript +(async () => { + try { + const RUN_ID = "ds-build-2024-001"; + const collections = await figma.variables.getLocalVariableCollectionsAsync(); + const typoPrimColl = collections.find(c => c.getPluginData('dsb_key') === 'collection/typography-primitives'); + if (!typoPrimColl) throw new Error("Typography Primitives collection not found"); + const valueMode = typoPrimColl.modes[0].modeId; + + const fontTokens = [ + { name: 'family/sans', value: 'Inter', scope: 'FONT_FAMILY', cssVar: '--font-family-sans' }, + { name: 'family/mono', value: 'Roboto Mono', scope: 'FONT_FAMILY', cssVar: '--font-family-mono' }, + // Font style strings — these are the Figma fontName.style values: + { name: 'weight/regular', value: 'Regular', scope: 'FONT_STYLE', cssVar: '--font-weight-regular' }, + { name: 'weight/medium', value: 'Medium', scope: 'FONT_STYLE', cssVar: '--font-weight-medium' }, + { name: 'weight/semibold', value: 'Semi Bold', scope: 'FONT_STYLE', cssVar: '--font-weight-semibold' }, + { name: 'weight/bold', value: 'Bold', scope: 'FONT_STYLE', cssVar: '--font-weight-bold' }, + ]; + + const created = []; + for (const { name, value, scope, cssVar } of fontTokens) { + const v = figma.variables.createVariable(name, typoPrimColl, 'STRING'); + v.setValueForMode(valueMode, value); + v.scopes = [scope]; + v.setVariableCodeSyntax('WEB', `var(${cssVar})`); + v.setPluginData('dsb_run_id', RUN_ID); + v.setPluginData('dsb_key', `typo-prim/${name}`); + created.push({ name, value, id: v.id }); + } + + figma.closePlugin(JSON.stringify({ created, count: created.length })); + } catch(e) { figma.closePluginWithFailure(e.toString()); } +})(); +``` + +### Creating BOOLEAN Variables + +BOOLEAN variables have no scopes (scopes are not supported for BOOLEAN type). + +```javascript +(async () => { + try { + const RUN_ID = "ds-build-2024-001"; + const collections = await figma.variables.getLocalVariableCollectionsAsync(); + const coll = collections.find(c => c.getPluginData('dsb_key') === 'collection/tokens'); + if (!coll) throw new Error("Collection not found"); + const valueMode = coll.modes[0].modeId; + + const v = figma.variables.createVariable('feature-flags/show-beta-badge', coll, 'BOOLEAN'); + v.setValueForMode(valueMode, false); + // No scopes — BOOLEAN does not support scopes + v.setPluginData('dsb_run_id', RUN_ID); + v.setPluginData('dsb_key', 'feature-flags/show-beta-badge'); + + figma.closePlugin(JSON.stringify({ id: v.id, name: v.name })); + } catch(e) { figma.closePluginWithFailure(e.toString()); } +})(); +``` + +--- + +## 4. Variable Aliasing (VARIABLE_ALIAS) — Primitive → Semantic Chain + +Semantic tokens reference primitives via `VARIABLE_ALIAS`. This is the core pattern that makes light/dark theming work. + +**Architecture:** +``` +Color Primitives collection (1 mode: Value) + blue/500 = #3B82F6 ← raw value + +Color collection (2 modes: Light, Dark) + color/bg/accent/default: + Light → VARIABLE_ALIAS → Primitives/blue/500 + Dark → VARIABLE_ALIAS → Primitives/blue/300 +``` + +### Complete Semantic Alias Creation Script (SDS-style) + +```javascript +(async () => { + try { + function hexToRgb(hex) { + const c = hex.replace('#', ''); + return { r: parseInt(c.slice(0,2),16)/255, g: parseInt(c.slice(2,4),16)/255, b: parseInt(c.slice(4,6),16)/255 }; + } + + const RUN_ID = "ds-build-2024-001"; + const collections = await figma.variables.getLocalVariableCollectionsAsync(); + + const primColl = collections.find(c => c.getPluginData('dsb_key') === 'collection/primitives'); + const colorColl = collections.find(c => c.getPluginData('dsb_key') === 'collection/color'); + if (!primColl || !colorColl) throw new Error("Collections not found — run primitive/color collection creation first"); + + const primValueMode = primColl.modes[0].modeId; + const lightModeId = colorColl.modes.find(m => m.name === 'Light').modeId; + const darkModeId = colorColl.modes.find(m => m.name === 'Dark').modeId; + + // Load all primitive variables for lookup + const allVars = await figma.variables.getLocalVariablesAsync(); + const primsByKey = {}; + for (const v of allVars) { + if (v.variableCollectionId === primColl.id) { + primsByKey[v.getPluginData('dsb_key')] = v; + } + } + + function getPrim(name) { + const v = primsByKey[`primitive/${name}`]; + if (!v) throw new Error(`Primitive not found: primitive/${name}`); + return v; + } + + // Define semantic → [lightPrimitiveName, darkPrimitiveName] + // Following the SDS pattern: Background/{Intent}/{Emphasis} + const semanticColors = [ + // Background + { name: 'color/bg/default/default', lightPrim: 'white/1000', darkPrim: 'gray/900', + cssVar: '--color-bg-default-default', scopes: ['FRAME_FILL', 'SHAPE_FILL'] }, + { name: 'color/bg/default/secondary', lightPrim: 'gray/100', darkPrim: 'gray/800', + cssVar: '--color-bg-default-secondary', scopes: ['FRAME_FILL', 'SHAPE_FILL'] }, + { name: 'color/bg/brand/default', lightPrim: 'blue/600', darkPrim: 'blue/300', + cssVar: '--color-bg-brand-default', scopes: ['FRAME_FILL', 'SHAPE_FILL'] }, + // Text + { name: 'color/text/default/default', lightPrim: 'gray/900', darkPrim: 'white/1000', + cssVar: '--color-text-default-default', scopes: ['TEXT_FILL'] }, + { name: 'color/text/default/secondary', lightPrim: 'gray/500', darkPrim: 'gray/400', + cssVar: '--color-text-default-secondary', scopes: ['TEXT_FILL'] }, + { name: 'color/text/brand/default', lightPrim: 'blue/700', darkPrim: 'blue/200', + cssVar: '--color-text-brand-default', scopes: ['TEXT_FILL'] }, + // Border + { name: 'color/border/default/default', lightPrim: 'gray/300', darkPrim: 'gray/600', + cssVar: '--color-border-default-default', scopes: ['STROKE_COLOR'] }, + { name: 'color/border/brand/default', lightPrim: 'blue/500', darkPrim: 'blue/400', + cssVar: '--color-border-brand-default', scopes: ['STROKE_COLOR'] }, + ]; + + const created = []; + for (const { name, lightPrim, darkPrim, cssVar, scopes } of semanticColors) { + const v = figma.variables.createVariable(name, colorColl, 'COLOR'); + // Alias to primitive in Light mode + v.setValueForMode(lightModeId, figma.variables.createVariableAlias(getPrim(lightPrim))); + // Alias to primitive in Dark mode + v.setValueForMode(darkModeId, figma.variables.createVariableAlias(getPrim(darkPrim))); + // Set scopes (semantic layer — these ARE shown in pickers) + v.scopes = scopes; + // Code syntax + v.setVariableCodeSyntax('WEB', `var(${cssVar})`); + v.setPluginData('dsb_run_id', RUN_ID); + v.setPluginData('dsb_key', name); + created.push({ name, id: v.id }); + } + + figma.closePlugin(JSON.stringify({ created, count: created.length })); + } catch(e) { figma.closePluginWithFailure(e.toString()); } +})(); +``` + +**Key API points:** +- `figma.variables.createVariableAlias(variable)` — takes a Variable object, returns `{type:'VARIABLE_ALIAS', id: variable.id}` +- The aliased variable MUST have the same `resolvedType` as the semantic variable +- Never duplicate raw values in the semantic layer — always alias + +--- + +## 5. Variable Scopes — Complete Reference Table + +| Semantic Role | Recommended Scopes | Variable Type | +|---|---|---| +| Primitive colors (raw) | `[]` — empty, hidden from all pickers | COLOR | +| Semi-transparent overlay primitives | `["EFFECT_COLOR"]` | COLOR | +| Background fills (frame, shape) | `["FRAME_FILL", "SHAPE_FILL"]` | COLOR | +| Text color | `["TEXT_FILL"]` | COLOR | +| Icon / shape fill | `["SHAPE_FILL", "STROKE_COLOR"]` | COLOR | +| Border / stroke color | `["STROKE_COLOR"]` | COLOR | +| Background + border combined | `["FRAME_FILL", "SHAPE_FILL", "STROKE_COLOR"]` | COLOR | +| Shadow color | `["EFFECT_COLOR"]` | COLOR | +| Spacing / gap between items | `["GAP"]` | FLOAT | +| Padding (if separate from gap) | `["GAP"]` | FLOAT | +| Corner radius | `["CORNER_RADIUS"]` | FLOAT | +| Width / height dimensions | `["WIDTH_HEIGHT"]` | FLOAT | +| Font size | `["FONT_SIZE"]` | FLOAT | +| Line height | `["LINE_HEIGHT"]` | FLOAT | +| Letter spacing | `["LETTER_SPACING"]` | FLOAT | +| Font weight (numeric) | `["FONT_WEIGHT"]` | FLOAT | +| Stroke width | `["STROKE_FLOAT"]` | FLOAT | +| Effect blur radius | `["EFFECT_FLOAT"]` | FLOAT | +| Opacity | `["OPACITY"]` | FLOAT | +| Font family | `["FONT_FAMILY"]` | STRING | +| Font style (e.g. "Semi Bold") | `["FONT_STYLE"]` | STRING | +| Boolean flags | *(scopes not supported)* | BOOLEAN | + +**Never use `ALL_SCOPES`** on any variable. It pollutes every picker with irrelevant tokens. The Simple Design System (SDS), the gold standard, uses targeted scopes on every variable. + +**`ALL_FILLS` note:** `ALL_FILLS` is exclusive among fill scopes — it covers `FRAME_FILL`, `SHAPE_FILL`, and `TEXT_FILL` together. If set, you cannot also add individual fill scopes. Prefer specifying individual scopes for precision. + +### Batch Scope-Setting (After Variables are Created) + +If you created variables without scopes and need to set them in batch: + +```javascript +(async () => { + try { + const allVars = await figma.variables.getLocalVariablesAsync(); + + // Scope mapping: partial name match → scopes + const scopeRules = [ + { match: 'color/bg/', scopes: ['FRAME_FILL', 'SHAPE_FILL'] }, + { match: 'color/text/', scopes: ['TEXT_FILL'] }, + { match: 'color/icon/', scopes: ['SHAPE_FILL', 'STROKE_COLOR'] }, + { match: 'color/border/', scopes: ['STROKE_COLOR'] }, + { match: 'spacing/', scopes: ['GAP'] }, + { match: 'radius/', scopes: ['CORNER_RADIUS'] }, + { match: 'blue/', scopes: [] }, // primitives — hide + { match: 'gray/', scopes: [] }, + { match: 'white/', scopes: [] }, + { match: 'black/', scopes: [] }, + ]; + + const updated = []; + for (const v of allVars) { + if (v.remote) continue; // skip library variables + for (const rule of scopeRules) { + if (v.name.startsWith(rule.match)) { + v.scopes = rule.scopes; + updated.push({ name: v.name, scopes: rule.scopes }); + break; + } + } + } + + figma.closePlugin(JSON.stringify({ updated, count: updated.length })); + } catch(e) { figma.closePluginWithFailure(e.toString()); } +})(); +``` + +--- + +## 6. Code Syntax — WEB/ANDROID/iOS + +Every variable must have code syntax set. This is what powers the developer handoff experience: + +**What code syntax does:** When a developer inspects any element in Figma Dev Mode that has a variable-bound property (fill, padding, radius, etc.), the code snippet shown uses the variable's code syntax name — not the Figma variable name. For example, a button's background fill bound to `color/bg/primary` will show `background: var(--color-bg-primary)` in the CSS snippet, not `color/bg/primary`. Without code syntax set, Dev Mode shows raw hex values or nothing useful. + +You can set up to **3 syntaxes per variable** — one per platform (Web, iOS, Android). Set all three if the codebase targets multiple platforms; set only WEB if it's a web-only project. + +```javascript +// WEB: MUST include the var() wrapper — this is the full CSS function syntax +variable.setVariableCodeSyntax('WEB', 'var(--color-bg-primary)'); +// ^^^^ ^ +// var() wrapper is REQUIRED + +// ANDROID: Kotlin property name — camelCase, no wrapper +variable.setVariableCodeSyntax('ANDROID', 'colorBgPrimary'); + +// iOS: Swift property — dot-notation, no wrapper +variable.setVariableCodeSyntax('iOS', 'Color.bgPrimary'); +``` + +> **CRITICAL — WEB code syntax MUST use the `var()` wrapper.** Setting just `--color-bg-primary` (without `var()`) will cause Dev Mode to show raw hex values instead of the CSS variable reference. Always use the full `var(--name)` form. ANDROID and iOS do NOT use a wrapper. + +**Platform derivation rules from the CSS variable name:** + +| Platform | Pattern | Example | +|---|---|---| +| WEB | **`var(--{css-var-name})`** — `var()` wrapper required | `var(--sds-color-bg-primary)` | +| ANDROID | camelCase, no wrapper, strip `--` prefix | `sdsColorBgPrimary` | +| iOS | PascalCase after `.`, no wrapper, strip `--` prefix | `Color.SdsColorBgPrimary` or `Color.bgPrimary` | + +**Always use the actual CSS variable name from the codebase** — do not derive it from the Figma variable name. If the code uses `--sds-color-background-brand-default`, that exact string is the WEB code syntax (minus the `var()` wrapper that you add). + +### Batch Code Syntax Setting + +```javascript +(async () => { + try { + const allVars = await figma.variables.getLocalVariablesAsync(); + const updated = []; + + for (const v of allVars) { + if (v.remote) continue; + // If code syntax already set, skip + if (v.codeSyntax['WEB']) continue; + + // FALLBACK: derive from Figma name: color/bg/primary → var(--color-bg-primary) + // PREFERRED: pass in a cssVarMap built from actual codebase CSS variable names + // e.g. cssVarMap = { 'color/bg/primary': '--color-bg-primary', ... } + const cssName = cssVarMap?.[v.name] + ?? v.name.replace(/\//g, '-').replace(/\s/g, '-').toLowerCase(); + v.setVariableCodeSyntax('WEB', `var(--${cssName})`); + updated.push({ name: v.name, web: `var(--${cssName})` }); + } + + figma.closePlugin(JSON.stringify({ updated, count: updated.length })); + } catch(e) { figma.closePluginWithFailure(e.toString()); } +})(); +``` + +Note: derived names are a fallback only. Always prefer overriding with actual CSS variable names from the codebase when they are known. + +--- + +## 7. Effect Styles (Shadows) and Text Styles + +Shadows and composite typography cannot be variables — they are Styles. + +### Creating Effect Styles (Shadows) + +Reference from SDS (15 effect styles) and the SDS shadow pattern `Shadow/{Level}`: + +```javascript +(async () => { + try { + const RUN_ID = "ds-build-2024-001"; + + // Shadow definitions — CSS equivalent in comments + // CSS: 0 1px 2px rgba(0,0,0,0.05) + const shadows = [ + { + name: 'Shadow/Subtle', + effects: [{ + type: 'DROP_SHADOW', + color: { r: 0, g: 0, b: 0, a: 0.05 }, + offset: { x: 0, y: 1 }, + radius: 2, + spread: 0, + visible: true, + blendMode: 'NORMAL' + }] + }, + { + // CSS: 0 4px 6px -1px rgba(0,0,0,0.10), 0 2px 4px -1px rgba(0,0,0,0.06) + name: 'Shadow/Medium', + effects: [ + { + type: 'DROP_SHADOW', + color: { r: 0, g: 0, b: 0, a: 0.10 }, + offset: { x: 0, y: 4 }, + radius: 6, + spread: -1, + visible: true, + blendMode: 'NORMAL' + }, + { + type: 'DROP_SHADOW', + color: { r: 0, g: 0, b: 0, a: 0.06 }, + offset: { x: 0, y: 2 }, + radius: 4, + spread: -1, + visible: true, + blendMode: 'NORMAL' + } + ] + }, + { + // CSS: 0 10px 15px -3px rgba(0,0,0,0.10), 0 4px 6px -2px rgba(0,0,0,0.05) + name: 'Shadow/Strong', + effects: [ + { + type: 'DROP_SHADOW', + color: { r: 0, g: 0, b: 0, a: 0.10 }, + offset: { x: 0, y: 10 }, + radius: 15, + spread: -3, + visible: true, + blendMode: 'NORMAL' + }, + { + type: 'DROP_SHADOW', + color: { r: 0, g: 0, b: 0, a: 0.05 }, + offset: { x: 0, y: 4 }, + radius: 6, + spread: -2, + visible: true, + blendMode: 'NORMAL' + } + ] + } + ]; + + // M3-style dual shadow (umbra + penumbra pattern): + const m3Shadows = [ + { + name: 'Elevation/1', + effects: [ + { type: 'DROP_SHADOW', color: {r:0,g:0,b:0,a:0.30}, offset:{x:0,y:1}, radius:2, spread:0, visible:true, blendMode:'NORMAL' }, + { type: 'DROP_SHADOW', color: {r:0,g:0,b:0,a:0.15}, offset:{x:0,y:1}, radius:3, spread:1, visible:true, blendMode:'NORMAL' } + ] + }, + { + name: 'Elevation/2', + effects: [ + { type: 'DROP_SHADOW', color: {r:0,g:0,b:0,a:0.30}, offset:{x:0,y:1}, radius:2, spread:0, visible:true, blendMode:'NORMAL' }, + { type: 'DROP_SHADOW', color: {r:0,g:0,b:0,a:0.15}, offset:{x:0,y:2}, radius:6, spread:2, visible:true, blendMode:'NORMAL' } + ] + }, + { + name: 'Elevation/3', + effects: [ + { type: 'DROP_SHADOW', color: {r:0,g:0,b:0,a:0.30}, offset:{x:0,y:1}, radius:3, spread:0, visible:true, blendMode:'NORMAL' }, + { type: 'DROP_SHADOW', color: {r:0,g:0,b:0,a:0.15}, offset:{x:0,y:4}, radius:8, spread:3, visible:true, blendMode:'NORMAL' } + ] + } + ]; + + const created = []; + for (const { name, effects } of shadows) { + const style = figma.createEffectStyle(); + style.name = name; + style.effects = effects; + style.setPluginData('dsb_run_id', RUN_ID); + style.setPluginData('dsb_key', `effect-style/${name}`); + created.push({ name, id: style.id }); + } + + figma.closePlugin(JSON.stringify({ created, count: created.length })); + } catch(e) { figma.closePluginWithFailure(e.toString()); } +})(); +``` + +### Creating Text Styles + +Fonts must be loaded before creating text styles. + +```javascript +(async () => { + try { + const RUN_ID = "ds-build-2024-001"; + + // Define text styles — based on SDS typography hierarchy + const textStyles = [ + // Display / Hero + { name: 'Display/Hero', family: 'Inter', style: 'Bold', size: 72, lineHeight: 80, letterSpacing: -1.5 }, + // Headings + { name: 'Heading/H1', family: 'Inter', style: 'Bold', size: 48, lineHeight: 56, letterSpacing: -1.0 }, + { name: 'Heading/H2', family: 'Inter', style: 'Bold', size: 40, lineHeight: 48, letterSpacing: -0.5 }, + { name: 'Heading/H3', family: 'Inter', style: 'Semi Bold', size: 32, lineHeight: 40, letterSpacing: 0 }, + { name: 'Heading/H4', family: 'Inter', style: 'Semi Bold', size: 24, lineHeight: 32, letterSpacing: 0 }, + // Body + { name: 'Body/Large', family: 'Inter', style: 'Regular', size: 18, lineHeight: 28, letterSpacing: 0 }, + { name: 'Body/Medium', family: 'Inter', style: 'Regular', size: 16, lineHeight: 24, letterSpacing: 0 }, + { name: 'Body/Small', family: 'Inter', style: 'Regular', size: 14, lineHeight: 20, letterSpacing: 0 }, + // Label + { name: 'Label/Large', family: 'Inter', style: 'Medium', size: 14, lineHeight: 20, letterSpacing: 0.1 }, + { name: 'Label/Medium', family: 'Inter', style: 'Medium', size: 12, lineHeight: 16, letterSpacing: 0.5 }, + { name: 'Label/Small', family: 'Inter', style: 'Medium', size: 11, lineHeight: 16, letterSpacing: 0.5 }, + // Code + { name: 'Code/Base', family: 'Roboto Mono', style: 'Regular', size: 14, lineHeight: 20, letterSpacing: 0 }, + ]; + + // Load all required fonts first + const fontSet = new Set(textStyles.map(s => JSON.stringify({ family: s.family, style: s.style }))); + await Promise.all([...fontSet].map(f => figma.loadFontAsync(JSON.parse(f)))); + + const created = []; + for (const { name, family, style, size, lineHeight, letterSpacing } of textStyles) { + const ts = figma.createTextStyle(); + ts.name = name; + ts.fontName = { family, style }; + ts.fontSize = size; + ts.lineHeight = { value: lineHeight, unit: 'PIXELS' }; + ts.letterSpacing = { value: letterSpacing, unit: 'PIXELS' }; + ts.setPluginData('dsb_run_id', RUN_ID); + ts.setPluginData('dsb_key', `text-style/${name}`); + created.push({ name, id: ts.id }); + } + + figma.closePlugin(JSON.stringify({ created, count: created.length })); + } catch(e) { figma.closePluginWithFailure(e.toString()); } +})(); +``` + +--- + +## 8. Idempotency — Check-Before-Create Pattern + +Every creation script should check whether the entity already exists before creating it. This prevents duplicates when a script is re-run after partial failure. + +### Check-Before-Create for Collections + +```javascript +(async () => { + try { + const DSB_KEY = 'collection/primitives'; + const RUN_ID = "ds-build-2024-001"; + + // Check if already exists + const existing = await figma.variables.getLocalVariableCollectionsAsync(); + let primColl = existing.find(c => c.getPluginData('dsb_key') === DSB_KEY); + + if (primColl) { + figma.closePlugin(JSON.stringify({ status: 'already_exists', collectionId: primColl.id, name: primColl.name })); + return; + } + + // Create only if not found + primColl = figma.variables.createVariableCollection("Primitives"); + primColl.renameMode(primColl.modes[0].modeId, "Value"); + primColl.setPluginData('dsb_run_id', RUN_ID); + primColl.setPluginData('dsb_key', DSB_KEY); + + figma.closePlugin(JSON.stringify({ status: 'created', collectionId: primColl.id })); + } catch(e) { figma.closePluginWithFailure(e.toString()); } +})(); +``` + +### Check-Before-Create for Variables + +```javascript +(async () => { + try { + const VARIABLE_KEY = 'primitive/blue/500'; + const RUN_ID = "ds-build-2024-001"; + + // Check if already exists by pluginData key + const allVars = await figma.variables.getLocalVariablesAsync(); + const existing = allVars.find(v => v.getPluginData('dsb_key') === VARIABLE_KEY); + + if (existing) { + figma.closePlugin(JSON.stringify({ status: 'already_exists', id: existing.id, name: existing.name })); + return; + } + + // ... create the variable ... + figma.closePlugin(JSON.stringify({ status: 'created' })); + } catch(e) { figma.closePluginWithFailure(e.toString()); } +})(); +``` + +### pluginData Tagging Strategy + +Tag every created node immediately after creation. The `dsb_key` is the stable logical identifier used for idempotency checks. The `dsb_run_id` identifies which build run created it (useful for cleanup). + +```javascript +node.setPluginData('dsb_run_id', RUN_ID); // build run ID +node.setPluginData('dsb_phase', 'phase1'); // which phase +node.setPluginData('dsb_key', 'color/bg/primary'); // stable logical key +``` + +**Cleanup by run ID (safe — targets only tagged nodes, never user-owned nodes):** + +```javascript +(async () => { + try { + const TARGET_RUN_ID = "ds-build-2024-001"; // run to remove + const allVars = await figma.variables.getLocalVariablesAsync(); + const removed = []; + for (const v of allVars) { + if (v.getPluginData('dsb_run_id') === TARGET_RUN_ID) { + removed.push(v.name); + v.remove(); + } + } + figma.closePlugin(JSON.stringify({ removed, count: removed.length })); + } catch(e) { figma.closePluginWithFailure(e.toString()); } +})(); +``` + +**Never clean up by name prefix** (e.g., deleting everything starting with `color/`). This will destroy user-created variables that happen to share the prefix. + +--- + +## 9. Validation — Verify Counts, Aliases, and Scopes + +Run these scripts after Phase 1 to verify everything was created correctly before proceeding to Phase 2. + +### Verify Collection and Variable Counts + +```javascript +(async () => { + try { + const collections = await figma.variables.getLocalVariableCollectionsAsync(); + const allVars = await figma.variables.getLocalVariablesAsync(); + + const summary = collections.map(c => { + const vars = allVars.filter(v => v.variableCollectionId === c.id); + return { + name: c.name, + id: c.id, + modes: c.modes.map(m => m.name), + variableCount: vars.length, + missingScopes: vars.filter(v => v.scopes.length === 0 && v.resolvedType !== 'BOOLEAN').length, + missingCodeSyntax: vars.filter(v => !v.codeSyntax['WEB'] && !v.remote).length, + sampleVariables: vars.slice(0, 3).map(v => v.name) + }; + }); + + figma.closePlugin(JSON.stringify({ + collectionCount: collections.length, + totalVariables: allVars.length, + collections: summary + })); + } catch(e) { figma.closePluginWithFailure(e.toString()); } +})(); +``` + +Interpret: `missingScopes > 0` (for non-primitives and non-BOOLEANs) → scope-setting failed, re-run scope script. `missingCodeSyntax > 0` → code syntax not set, run batch code syntax script. + +Note: primitives correctly have `scopes = []` (empty, hidden). `missingScopes` above counts non-BOOLEAN variables with empty scopes — review the list to confirm they are all primitives. + +### Verify Aliases Resolve + +```javascript +(async () => { + try { + const allVars = await figma.variables.getLocalVariablesAsync(); + const collections = await figma.variables.getLocalVariableCollectionsAsync(); + + const brokenAliases = []; + const aliasedVars = []; + + for (const v of allVars) { + if (v.remote) continue; + const coll = collections.find(c => c.id === v.variableCollectionId); + if (!coll) continue; + + for (const [modeId, val] of Object.entries(v.valuesByMode)) { + if (val && typeof val === 'object' && val.type === 'VARIABLE_ALIAS') { + aliasedVars.push({ name: v.name, aliasTargetId: val.id }); + // Verify the target exists + const target = allVars.find(t => t.id === val.id); + if (!target) { + brokenAliases.push({ variable: v.name, modeId, missingTargetId: val.id }); + } + } + } + } + + figma.closePlugin(JSON.stringify({ + totalAliased: aliasedVars.length, + brokenAliases, + brokenCount: brokenAliases.length, + status: brokenAliases.length === 0 ? 'all_aliases_resolve' : 'BROKEN_ALIASES_FOUND' + })); + } catch(e) { figma.closePluginWithFailure(e.toString()); } +})(); +``` + +Interpret: `brokenCount > 0` means a semantic variable references a primitive that was deleted or not yet created. Create the missing primitives, then re-run alias creation for the affected semantic variables. + +### Verify Style Counts + +```javascript +(async () => { + try { + const [textStyles, effectStyles] = await Promise.all([ + figma.getLocalTextStylesAsync(), + figma.getLocalEffectStylesAsync() + ]); + + figma.closePlugin(JSON.stringify({ + textStyles: textStyles.map(s => ({ name: s.name, fontSize: s.fontSize, fontFamily: s.fontName.family })), + effectStyles: effectStyles.map(s => ({ name: s.name, effectCount: s.effects.length })), + counts: { text: textStyles.length, effect: effectStyles.length } + })); + } catch(e) { figma.closePluginWithFailure(e.toString()); } +})(); +``` + +### Phase 1 Exit Criteria Checklist + +Before proceeding to Phase 2, verify all of the following: + +- Every planned collection exists with the correct number of modes +- Primitive variables: `scopes = []`, code syntax set +- Semantic variables: targeted scopes set, code syntax set, aliases pointing to primitives (not raw values) +- All broken alias count = 0 +- All planned text styles exist with correct font family/size/weight +- All planned effect styles exist with correct shadow values +- No variable has `ALL_SCOPES` unless explicitly approved by the user diff --git a/plugins/figma/skills/figma-generate-library/scripts/bindVariablesToComponent.js b/plugins/figma/skills/figma-generate-library/scripts/bindVariablesToComponent.js new file mode 100644 index 00000000..8542f0ed --- /dev/null +++ b/plugins/figma/skills/figma-generate-library/scripts/bindVariablesToComponent.js @@ -0,0 +1,110 @@ +/** + * bindVariablesToComponent + * + * Binds design token variables to the visual properties of a component node. + * Supports fills, strokes, all padding directions, item spacing, and corner radius. + * Only binds properties for which a variable ID is provided in `bindings`. + * + * This function should be called on each variant individually within a component + * set, OR on the component set itself for properties shared by all variants. + * + * @param {ComponentNode | FrameNode | RectangleNode} component + * The Figma node to mutate. Usually a ComponentNode or one of its children. + * @param {{ + * fills?: string, + * strokes?: string, + * paddingTop?: string, + * paddingBottom?: string, + * paddingLeft?: string, + * paddingRight?: string, + * itemSpacing?: string, + * cornerRadius?: string + * }} bindings + * Each key is a visual property name; each value is a Figma Variable ID + * (e.g. "VariableID:123:456"). Omit a key to skip binding that property. + * @returns {{ mutatedNodeIds: string[] }} + * List of node IDs that were mutated (for audit/validation purposes). + */ +function bindVariablesToComponent(component, bindings) { + const mutatedNodeIds = [] + + if (!component) { + return { mutatedNodeIds } + } + + // --- Fills --- + if (bindings.fills) { + const fillVar = figma.variables.getVariableById(bindings.fills) + if (fillVar) { + const existingFills = component.fills + if (Array.isArray(existingFills) && existingFills.length > 0) { + // Bind the color of the first fill to the variable + const boundFill = figma.variables.setBoundVariableForPaint( + existingFills[0], + 'color', + fillVar, + ) + component.fills = [boundFill, ...existingFills.slice(1)] + } else { + // No existing fill — create a solid fill bound to the variable + const boundFill = figma.variables.setBoundVariableForPaint( + { type: 'SOLID', color: { r: 0.5, g: 0.5, b: 0.5 } }, + 'color', + fillVar, + ) + component.fills = [boundFill] + } + mutatedNodeIds.push(component.id) + } + } + + // --- Strokes --- + if (bindings.strokes) { + const strokeVar = figma.variables.getVariableById(bindings.strokes) + if (strokeVar) { + const existingStrokes = component.strokes + if (Array.isArray(existingStrokes) && existingStrokes.length > 0) { + const boundStroke = figma.variables.setBoundVariableForPaint( + existingStrokes[0], + 'color', + strokeVar, + ) + component.strokes = [boundStroke, ...existingStrokes.slice(1)] + } else { + const boundStroke = figma.variables.setBoundVariableForPaint( + { type: 'SOLID', color: { r: 0.5, g: 0.5, b: 0.5 } }, + 'color', + strokeVar, + ) + component.strokes = [boundStroke] + } + if (!mutatedNodeIds.includes(component.id)) { + mutatedNodeIds.push(component.id) + } + } + } + + // --- Spacing properties (FLOAT variables bound via setBoundVariable) --- + const floatBindings = [ + ['paddingTop', 'paddingTop'], + ['paddingBottom', 'paddingBottom'], + ['paddingLeft', 'paddingLeft'], + ['paddingRight', 'paddingRight'], + ['itemSpacing', 'itemSpacing'], + ['cornerRadius', 'cornerRadius'], + ] + + for (const [bindingKey, figmaProp] of floatBindings) { + if (bindings[bindingKey]) { + const variable = figma.variables.getVariableById(bindings[bindingKey]) + if (variable) { + component.setBoundVariable(figmaProp, variable) + if (!mutatedNodeIds.includes(component.id)) { + mutatedNodeIds.push(component.id) + } + } + } + } + + return { mutatedNodeIds } +} diff --git a/plugins/figma/skills/figma-generate-library/scripts/cleanupOrphans.js b/plugins/figma/skills/figma-generate-library/scripts/cleanupOrphans.js new file mode 100644 index 00000000..b839187d --- /dev/null +++ b/plugins/figma/skills/figma-generate-library/scripts/cleanupOrphans.js @@ -0,0 +1,127 @@ +/** + * cleanupOrphans + * + * Finds and removes all Figma nodes (pages, frames, components, variables, + * and variable collections) that were tagged with the given `dsb_run_id` + * by a previous build run. This is safe cleanup: it uses plugin data tags, + * never name-prefix matching, so it cannot accidentally delete user-owned nodes. + * + * Use this when a build run fails mid-way and you need to reset to a clean + * slate before retrying. The function traverses the entire document looking + * for `dsb_run_id` plugin data matching `runId`. + * + * Variables and variable collections are handled separately (they are not + * scene nodes and cannot be discovered via node traversal). + * + * @param {string} runId - The dsb_run_id value to match (e.g. "ds-build-2024-001"). + * @returns {Promise<{ + * removedCount: number, + * removedIds: string[] + * }>} + */ +async function cleanupOrphans(runId) { + if (!runId) { + throw new Error('cleanupOrphans: runId is required.') + } + + const removedIds = [] + const originalPage = figma.currentPage + + // --- Remove tagged scene nodes (pages, frames, components, etc.) --- + // Collect pages to remove (can't remove during iteration) + const pagesToRemove = [] + + for (const page of figma.root.children) { + if (page.getPluginData('dsb_run_id') === runId) { + pagesToRemove.push(page) + continue + } + + // Traverse all nodes on this page + await figma.setCurrentPageAsync(page) + + const nodesToRemove = [] + page.findAll((node) => { + if (node.getPluginData('dsb_run_id') === runId) { + nodesToRemove.push(node) + return false // Don't descend — removing the parent removes its children + } + return true + }) + + // Remove deepest nodes first (children before parents) to avoid + // "parent no longer exists" errors + const sorted = nodesToRemove.sort((a, b) => { + // Sort by depth descending: deeper nodes first + return getDepth(b) - getDepth(a) + }) + + for (const node of sorted) { + if (node && node.parent) { + removedIds.push(node.id) + node.remove() + } + } + } + + // Remove tagged pages last + for (const page of pagesToRemove) { + // Cannot remove the last page in the document + if (figma.root.children.length <= 1) { + break + } + removedIds.push(page.id) + page.remove() + } + + // --- Remove tagged variables --- + const allVariables = figma.variables.getLocalVariables() + for (const variable of allVariables) { + if (variable.getPluginData('dsb_run_id') === runId) { + removedIds.push(variable.id) + variable.remove() + } + } + + // --- Remove tagged variable collections --- + // Must be done after variables are removed + const allCollections = figma.variables.getLocalVariableCollections() + for (const collection of allCollections) { + if (collection.getPluginData('dsb_run_id') === runId) { + removedIds.push(collection.id) + collection.remove() + } + } + + // Restore original page (if it still exists) + try { + await figma.setCurrentPageAsync(originalPage) + } catch (_) { + // Original page was removed — switch to first available page + if (figma.root.children.length > 0) { + await figma.setCurrentPageAsync(figma.root.children[0]) + } + } + + return { + removedCount: removedIds.length, + removedIds, + } +} + +/** + * Returns the depth of a node in the document tree. + * Root children (pages) have depth 1; their children have depth 2; etc. + * + * @param {BaseNode} node + * @returns {number} + */ +function getDepth(node) { + let depth = 0 + let current = node + while (current.parent) { + depth++ + current = current.parent + } + return depth +} diff --git a/plugins/figma/skills/figma-generate-library/scripts/createComponentWithVariants.js b/plugins/figma/skills/figma-generate-library/scripts/createComponentWithVariants.js new file mode 100644 index 00000000..8e55a537 --- /dev/null +++ b/plugins/figma/skills/figma-generate-library/scripts/createComponentWithVariants.js @@ -0,0 +1,148 @@ +/** + * createComponentWithVariants + * + * Creates a component set by generating all combinations of `variantAxes`, + * building one Figma component per combination, then calling + * `figma.combineAsVariants` to produce the component set. After combining, + * the variants are repositioned into a grid so they don't all stack at (0, 0). + * + * @param {{ + * name: string, + * variantAxes: Record, + * baseProps: { + * width: number, + * height: number, + * fills?: Paint[], + * padding?: {top?: number, bottom?: number, left?: number, right?: number}, + * radius?: number, + * layoutMode?: 'HORIZONTAL' | 'VERTICAL' | 'NONE', + * itemSpacing?: number + * }, + * page: PageNode + * }} config + * - `name`: Component set name (e.g. "Button"). + * - `variantAxes`: Each key is a variant property name; each value is an array of + * allowed values. All combinations are generated (Cartesian product). + * Example: { Size: ['Small', 'Medium', 'Large'], Style: ['Primary', 'Ghost'] } + * produces 6 variants. + * - `baseProps`: Visual properties applied to every variant. + * - `page`: The PageNode to create components on (must be set as current page by caller). + * @param {string} [runId] - Optional dsb_run_id to tag every node. + * @returns {Promise<{ + * componentSet: ComponentSetNode, + * variants: ComponentNode[] + * }>} + */ +async function createComponentWithVariants(config, runId) { + const { name, variantAxes, baseProps, page } = config + + // Ensure we are on the correct page + await figma.setCurrentPageAsync(page) + + // Compute Cartesian product of variant axes + const axisNames = Object.keys(variantAxes) + const axisValues = axisNames.map((k) => variantAxes[k]) + const combinations = cartesianProduct(axisValues) + + // Build one component per combination + const components = [] + for (const combo of combinations) { + const comp = figma.createComponent() + + // Name: "Property=Value, Property=Value, ..." + comp.name = axisNames.map((ax, i) => `${ax}=${combo[i]}`).join(', ') + + // Base geometry + comp.resize(baseProps.width, baseProps.height) + + // Fills + if (baseProps.fills !== undefined) { + comp.fills = baseProps.fills + } else { + comp.fills = [{ type: 'SOLID', color: { r: 0.9, g: 0.9, b: 0.9 } }] + } + + // Corner radius + if (baseProps.radius !== undefined) { + comp.cornerRadius = baseProps.radius + } + + // Auto-layout + if (baseProps.layoutMode && baseProps.layoutMode !== 'NONE') { + comp.layoutMode = baseProps.layoutMode + comp.primaryAxisAlignItems = 'CENTER' + comp.counterAxisAlignItems = 'CENTER' + if (baseProps.itemSpacing !== undefined) { + comp.itemSpacing = baseProps.itemSpacing + } + } + + // Padding + if (baseProps.padding) { + comp.paddingTop = baseProps.padding.top ?? 0 + comp.paddingBottom = baseProps.padding.bottom ?? 0 + comp.paddingLeft = baseProps.padding.left ?? 0 + comp.paddingRight = baseProps.padding.right ?? 0 + } + + // Plugin data + const variantKey = axisNames.map((ax, i) => `${ax}:${combo[i]}`).join('|') + comp.setPluginData('dsb_key', `component/${name}/${variantKey}`) + if (runId) { + comp.setPluginData('dsb_run_id', runId) + } + + page.appendChild(comp) + components.push(comp) + } + + // Combine into a component set + const componentSet = figma.combineAsVariants(components, page) + componentSet.name = name + componentSet.setPluginData('dsb_key', `componentSet/${name}`) + if (runId) { + componentSet.setPluginData('dsb_run_id', runId) + } + + // Grid layout — variants stack at (0, 0) after combineAsVariants; reposition them. + const GRID_GAP = 16 + const cols = Math.max(1, axisValues[axisValues.length - 1]?.length ?? 1) + const variantWidth = baseProps.width + const variantHeight = baseProps.height + + componentSet.children.forEach((variant, idx) => { + const col = idx % cols + const row = Math.floor(idx / cols) + variant.x = col * (variantWidth + GRID_GAP) + variant.y = row * (variantHeight + GRID_GAP) + }) + + // Resize component set to wrap its children with padding + const totalCols = Math.min(cols, combinations.length) + const totalRows = Math.ceil(combinations.length / cols) + const PADDING = 40 + componentSet.resize( + totalCols * variantWidth + (totalCols - 1) * GRID_GAP + PADDING * 2, + totalRows * variantHeight + (totalRows - 1) * GRID_GAP + PADDING * 2, + ) + + // Position component set at a safe canvas location + componentSet.x = 480 + componentSet.y = 80 + + return { componentSet, variants: componentSet.children } +} + +/** + * Computes the Cartesian product of multiple arrays. + * cartesianProduct([[A, B], [1, 2]]) → [[A,1], [A,2], [B,1], [B,2]] + * + * @param {Array} arrays + * @returns {string[][]} + */ +function cartesianProduct(arrays) { + return arrays.reduce( + (acc, curr) => acc.flatMap((combo) => curr.map((val) => [...combo, val])), + [[]], + ) +} diff --git a/plugins/figma/skills/figma-generate-library/scripts/createDocumentationPage.js b/plugins/figma/skills/figma-generate-library/scripts/createDocumentationPage.js new file mode 100644 index 00000000..c7eff7f1 --- /dev/null +++ b/plugins/figma/skills/figma-generate-library/scripts/createDocumentationPage.js @@ -0,0 +1,139 @@ +/** + * createDocumentationPage + * + * Creates a new Figma page with a standardized documentation layout: a page + * title, optional description, and an ordered list of sections each built by + * a caller-supplied `contentFn`. The content function receives the section + * frame and may append any nodes to it. + * + * This function is used for standalone documentation pages (e.g. a Foundations + * page, a Getting Started page, or a component page with documentation). + * It does not handle component sets — those live on separate pages created by + * createComponentWithVariants. + * + * @param {string} pageName - The Figma page name (e.g. "Foundations", "Getting Started"). + * @param {{ + * title: string, + * description?: string, + * sections: Array<{ + * name: string, + * contentFn: (sectionFrame: FrameNode) => Promise + * }> + * }} config + * - `title`: Large heading displayed at the top of the page. + * - `description`: Optional subtitle displayed below the heading. + * - `sections`: Ordered list of sections. Each section gets its own frame + * with a heading and is passed to `contentFn` for population. + * @param {string} [runId] - Optional dsb_run_id to tag every created node. + * @returns {Promise<{ + * page: PageNode, + * titleNode: TextNode, + * frameIds: string[] + * }>} + * `frameIds` is an ordered list of IDs for the root frame and each section frame. + */ +async function createDocumentationPage(pageName, config, runId) { + await figma.loadFontAsync({ family: 'Inter', style: 'Bold' }) + await figma.loadFontAsync({ family: 'Inter', style: 'Regular' }) + await figma.loadFontAsync({ family: 'Inter', style: 'Medium' }) + + // Create and activate the page + const page = figma.createPage() + page.name = pageName + await figma.setCurrentPageAsync(page) + + if (runId) { + page.setPluginData('dsb_run_id', runId) + page.setPluginData('dsb_key', `page/${pageName}`) + } + + const frameIds = [] + + // Root scroll container — 1440px wide, auto-height + const root = figma.createFrame() + root.name = pageName + root.layoutMode = 'VERTICAL' + root.primaryAxisAlignItems = 'MIN' + root.counterAxisAlignItems = 'MIN' + root.itemSpacing = 80 + root.paddingTop = 80 + root.paddingBottom = 120 + root.paddingLeft = 80 + root.paddingRight = 80 + root.layoutSizingHorizontal = 'FIXED' + root.layoutSizingVertical = 'HUG' + root.resize(1440, 1) + root.fills = [{ type: 'SOLID', color: { r: 1, g: 1, b: 1 } }] + root.x = 0 + root.y = 0 + page.appendChild(root) + + if (runId) { + root.setPluginData('dsb_run_id', runId) + root.setPluginData('dsb_key', `frame/root/${pageName}`) + } + + frameIds.push(root.id) + + // Page header: title + optional description + const header = figma.createFrame() + header.name = 'Header' + header.layoutMode = 'VERTICAL' + header.itemSpacing = 12 + header.layoutSizingHorizontal = 'FILL' + header.layoutSizingVertical = 'HUG' + header.fills = [] + root.appendChild(header) + + const titleNode = figma.createText() + titleNode.fontName = { family: 'Inter', style: 'Bold' } + titleNode.characters = config.title + titleNode.fontSize = 40 + titleNode.fills = [{ type: 'SOLID', color: { r: 0.07, g: 0.07, b: 0.07 } }] + titleNode.layoutSizingHorizontal = 'FILL' + header.appendChild(titleNode) + + if (config.description) { + const descNode = figma.createText() + descNode.fontName = { family: 'Inter', style: 'Regular' } + descNode.characters = config.description + descNode.fontSize = 16 + descNode.lineHeight = { value: 24, unit: 'PIXELS' } + descNode.fills = [{ type: 'SOLID', color: { r: 0.4, g: 0.4, b: 0.4 } }] + descNode.layoutSizingHorizontal = 'FILL' + header.appendChild(descNode) + } + + // Sections + for (const section of config.sections) { + const sectionFrame = figma.createFrame() + sectionFrame.name = `Section/${section.name}` + sectionFrame.layoutMode = 'VERTICAL' + sectionFrame.itemSpacing = 20 + sectionFrame.layoutSizingHorizontal = 'FILL' + sectionFrame.layoutSizingVertical = 'HUG' + sectionFrame.fills = [] + root.appendChild(sectionFrame) + + if (runId) { + sectionFrame.setPluginData('dsb_run_id', runId) + sectionFrame.setPluginData('dsb_key', `frame/section/${pageName}/${section.name}`) + } + + // Section heading + const sectionHeading = figma.createText() + sectionHeading.fontName = { family: 'Inter', style: 'Bold' } + sectionHeading.characters = section.name + sectionHeading.fontSize = 24 + sectionHeading.fills = [{ type: 'SOLID', color: { r: 0.07, g: 0.07, b: 0.07 } }] + sectionHeading.layoutSizingHorizontal = 'FILL' + sectionFrame.appendChild(sectionHeading) + + // Invoke the caller's content function to populate the section + await section.contentFn(sectionFrame) + + frameIds.push(sectionFrame.id) + } + + return { page, titleNode, frameIds } +} diff --git a/plugins/figma/skills/figma-generate-library/scripts/createSemanticTokens.js b/plugins/figma/skills/figma-generate-library/scripts/createSemanticTokens.js new file mode 100644 index 00000000..c221b798 --- /dev/null +++ b/plugins/figma/skills/figma-generate-library/scripts/createSemanticTokens.js @@ -0,0 +1,108 @@ +/** + * createSemanticTokens + * + * Creates a batch of Figma variables in the given collection, one per entry in + * `tokenMap`. Supports raw values, variable alias references, code syntax, and + * scopes. Returns a map of token name → Variable for use in subsequent steps. + * + * @param {VariableCollection} collection - The target variable collection. + * @param {Record} modeIds - Map of {modeName: modeId} from createVariableCollection. + * @param {Array<{ + * name: string, + * type: 'COLOR' | 'FLOAT' | 'STRING' | 'BOOLEAN', + * values: Record, + * scopes?: VariableScope[], + * codeSyntax?: {WEB?: string, ANDROID?: string, iOS?: string} + * }>} tokenMap - Ordered list of token definitions. + * - `name`: Variable name using slash hierarchy (e.g. "color/bg/primary"). + * - `type`: Figma variable type. + * - `values`: Map of {modeName: value}. Values can be raw (hex string for COLOR, + * number for FLOAT) or alias objects {type: 'VARIABLE_ALIAS', id: variableId}. + * For COLOR, raw values are accepted as hex strings ("#rrggbb" or "#rrggbbaa") + * and converted to {r, g, b, a} automatically. + * - `scopes`: Array of VariableScope strings. Omit to use [] (hidden/primitive). + * - `codeSyntax`: Platform code syntax strings. Omit to skip. + * @param {string} [runId] - Optional dsb_run_id to tag every variable. + * @returns {Promise<{variables: Record}>} + * `variables` maps each token name to its created Variable object. + */ +async function createSemanticTokens(collection, modeIds, tokenMap, runId) { + const variables = {} + + for (const token of tokenMap) { + // Create the variable + const variable = figma.variables.createVariable(token.name, collection, token.type) + + // Tag for cleanup + variable.setPluginData('dsb_key', `variable/${token.name}`) + if (runId) { + variable.setPluginData('dsb_run_id', runId) + } + + // Set values for each mode + for (const [modeName, rawValue] of Object.entries(token.values)) { + const modeId = modeIds[modeName] + if (!modeId) { + throw new Error( + `createSemanticTokens: mode "${modeName}" not found in modeIds for token "${token.name}". ` + + `Available modes: ${Object.keys(modeIds).join(', ')}`, + ) + } + + let value = rawValue + + // Convert hex strings to Figma RGBA for COLOR type + if (token.type === 'COLOR' && typeof rawValue === 'string' && rawValue.startsWith('#')) { + value = hexToFigmaColor(rawValue) + } + + variable.setValueForMode(modeId, value) + } + + // Set scopes (default: empty array = hidden from property pickers / primitives) + variable.scopes = token.scopes || [] + + // Set code syntax per platform + if (token.codeSyntax) { + if (token.codeSyntax.WEB) { + variable.setVariableCodeSyntax('WEB', token.codeSyntax.WEB) + } + if (token.codeSyntax.ANDROID) { + variable.setVariableCodeSyntax('ANDROID', token.codeSyntax.ANDROID) + } + if (token.codeSyntax.iOS) { + variable.setVariableCodeSyntax('iOS', token.codeSyntax.iOS) + } + } + + variables[token.name] = variable + } + + return { variables } +} + +/** + * Converts a hex color string to a Figma RGBA object. + * Supports "#rgb", "#rrggbb", and "#rrggbbaa". + * + * @param {string} hex + * @returns {{ r: number, g: number, b: number, a: number }} + */ +function hexToFigmaColor(hex) { + let h = hex.replace('#', '') + + // Expand shorthand #rgb → #rrggbb + if (h.length === 3) { + h = h + .split('') + .map((c) => c + c) + .join('') + } + + const r = parseInt(h.substring(0, 2), 16) / 255 + const g = parseInt(h.substring(2, 4), 16) / 255 + const b = parseInt(h.substring(4, 6), 16) / 255 + const a = h.length === 8 ? parseInt(h.substring(6, 8), 16) / 255 : 1 + + return { r, g, b, a } +} diff --git a/plugins/figma/skills/figma-generate-library/scripts/createVariableCollection.js b/plugins/figma/skills/figma-generate-library/scripts/createVariableCollection.js new file mode 100644 index 00000000..f853a284 --- /dev/null +++ b/plugins/figma/skills/figma-generate-library/scripts/createVariableCollection.js @@ -0,0 +1,49 @@ +/** + * createVariableCollection + * + * Creates a new Figma variable collection with the specified name and modes. + * If `modeNames` has more than one entry, the first mode is renamed from + * Figma's default "Mode 1" to the first name, and additional modes are added. + * + * Every created collection is tagged with `dsb_key` plugin data so it can be + * found and cleaned up idempotently by `cleanupOrphans`. + * + * @param {string} name - The display name of the collection (e.g. "Color", "Spacing"). + * @param {string[]} modeNames - Ordered list of mode names (e.g. ["Light", "Dark"] or ["Value"]). + * @param {string} [runId] - Optional dsb_run_id to tag for cleanup. + * @returns {Promise<{ + * collection: VariableCollection, + * modeIds: Record + * }>} + * `modeIds` maps each mode name to its modeId string. + */ +async function createVariableCollection(name, modeNames, runId) { + if (!modeNames || modeNames.length === 0) { + throw new Error('createVariableCollection: modeNames must have at least one entry.') + } + + // Create the collection — Figma always creates it with one mode named "Mode 1". + const collection = figma.variables.createVariableCollection(name) + + // Tag for idempotent cleanup + collection.setPluginData('dsb_key', `collection/${name}`) + if (runId) { + collection.setPluginData('dsb_run_id', runId) + } + + // modeIds accumulator + const modeIds = {} + + // Rename the default first mode + const defaultMode = collection.modes[0] + collection.renameMode(defaultMode.modeId, modeNames[0]) + modeIds[modeNames[0]] = defaultMode.modeId + + // Add additional modes + for (let i = 1; i < modeNames.length; i++) { + const newModeId = collection.addMode(modeNames[i]) + modeIds[modeNames[i]] = newModeId + } + + return { collection, modeIds } +} diff --git a/plugins/figma/skills/figma-generate-library/scripts/inspectFileStructure.js b/plugins/figma/skills/figma-generate-library/scripts/inspectFileStructure.js new file mode 100644 index 00000000..36dd319e --- /dev/null +++ b/plugins/figma/skills/figma-generate-library/scripts/inspectFileStructure.js @@ -0,0 +1,121 @@ +/** + * inspectFileStructure + * + * Reads the current Figma file and returns a structural inventory: + * all pages (with child counts), all local variable collections (with mode + * names and variable counts), all component sets, all local text styles, + * and all local effect styles. + * + * This is a read-only discovery function — it never creates or mutates nodes. + * Run it at the start of Phase 0 to understand what already exists before + * planning any creation work. + * + * @returns {Promise<{ + * pages: Array<{id: string, name: string, childCount: number}>, + * variableCollections: Array<{ + * id: string, + * name: string, + * modes: Array<{modeId: string, name: string}>, + * variableCount: number, + * variableNames: string[] + * }>, + * componentSets: Array<{id: string, name: string, variantCount: number, pageId: string, pageName: string}>, + * textStyles: Array<{id: string, name: string, fontFamily: string, fontStyle: string, fontSize: number}>, + * effectStyles: Array<{id: string, name: string, effectCount: number}> + * }>} + */ +async function inspectFileStructure() { + const result = { + pages: [], + variableCollections: [], + componentSets: [], + textStyles: [], + effectStyles: [], + } + + // --- Pages --- + for (const page of figma.root.children) { + result.pages.push({ + id: page.id, + name: page.name, + childCount: page.children.length, + }) + } + + // --- Variable collections --- + const collections = figma.variables.getLocalVariableCollections() + for (const coll of collections) { + const variableNames = coll.variableIds + .map((id) => figma.variables.getVariableById(id)) + .filter(Boolean) + .map((v) => v.name) + + result.variableCollections.push({ + id: coll.id, + name: coll.name, + modes: coll.modes.map((m) => ({ modeId: m.modeId, name: m.name })), + variableCount: coll.variableIds.length, + variableNames, + }) + } + + // --- Component sets (and standalone components) --- + // We need to load all pages to inspect components across the whole file. + const originalPage = figma.currentPage + + for (const page of figma.root.children) { + await figma.setCurrentPageAsync(page) + + const componentSetsOnPage = page.findAllWithCriteria({ types: ['COMPONENT_SET'] }) + for (const cs of componentSetsOnPage) { + result.componentSets.push({ + id: cs.id, + name: cs.name, + variantCount: cs.children.length, + pageId: page.id, + pageName: page.name, + }) + } + + // Also capture standalone components (not inside a component set) + const standaloneComponents = page + .findAllWithCriteria({ types: ['COMPONENT'] }) + .filter((c) => c.parent && c.parent.type !== 'COMPONENT_SET') + for (const comp of standaloneComponents) { + result.componentSets.push({ + id: comp.id, + name: comp.name, + variantCount: 1, + pageId: page.id, + pageName: page.name, + }) + } + } + + // Restore original page + await figma.setCurrentPageAsync(originalPage) + + // --- Text styles --- + const textStyles = figma.getLocalTextStyles() + for (const ts of textStyles) { + result.textStyles.push({ + id: ts.id, + name: ts.name, + fontFamily: ts.fontName.family, + fontStyle: ts.fontName.style, + fontSize: ts.fontSize, + }) + } + + // --- Effect styles --- + const effectStyles = figma.getLocalEffectStyles() + for (const es of effectStyles) { + result.effectStyles.push({ + id: es.id, + name: es.name, + effectCount: es.effects.length, + }) + } + + return result +} diff --git a/plugins/figma/skills/figma-generate-library/scripts/rehydrateState.js b/plugins/figma/skills/figma-generate-library/scripts/rehydrateState.js new file mode 100644 index 00000000..85ad052c --- /dev/null +++ b/plugins/figma/skills/figma-generate-library/scripts/rehydrateState.js @@ -0,0 +1,92 @@ +/** + * Scans the entire Figma file for nodes tagged with dsb_* pluginData + * and returns a complete state map for session recovery. + * + * Use this at the start of every new session, after context truncation, + * or when resuming an interrupted build. + * + * @param {string} runId - The run ID to filter by (optional — if omitted, returns ALL dsb-tagged nodes) + * @returns {{ runId: string, taggedNodes: Object, variableCollections: Array, variables: Array, styles: Array }} + */ +async function rehydrateState(runId) { + const taggedNodes = {} + const variableCollections = [] + const variables = [] + const styles = [] + + // Scan all pages for dsb-tagged scene nodes + for (const page of figma.root.children) { + await figma.setCurrentPageAsync(page) + + // Check the page itself + const pageRunId = page.getPluginData('dsb_run_id') + const pageKey = page.getPluginData('dsb_key') + if (pageKey && (!runId || pageRunId === runId)) { + taggedNodes[pageKey] = { + nodeId: page.id, + type: page.type, + name: page.name, + phase: page.getPluginData('dsb_phase') || 'unknown', + } + } + + // Scan all descendants + page.findAll((node) => { + const nodeRunId = node.getPluginData('dsb_run_id') + const nodeKey = node.getPluginData('dsb_key') + if (nodeKey && (!runId || nodeRunId === runId)) { + taggedNodes[nodeKey] = { + nodeId: node.id, + type: node.type, + name: node.name, + phase: node.getPluginData('dsb_phase') || 'unknown', + } + } + return false // don't collect, just scan + }) + } + + // Inventory variable collections (variables don't support pluginData — use name-based lookup) + const collections = figma.variables.getLocalVariableCollections() + for (const coll of collections) { + variableCollections.push({ + id: coll.id, + name: coll.name, + modes: coll.modes.map((m) => ({ modeId: m.modeId, name: m.name })), + variableCount: coll.variableIds.length, + }) + } + + // Inventory variables (name + collection for idempotency key) + const allVars = figma.variables.getLocalVariables() + for (const v of allVars) { + variables.push({ + id: v.id, + name: v.name, + collectionId: v.variableCollectionId, + resolvedType: v.resolvedType, + }) + } + + // Inventory styles + for (const s of figma.getLocalTextStyles()) { + styles.push({ id: s.id, name: s.name, type: 'TEXT' }) + } + for (const s of figma.getLocalEffectStyles()) { + styles.push({ id: s.id, name: s.name, type: 'EFFECT' }) + } + for (const s of figma.getLocalPaintStyles()) { + styles.push({ id: s.id, name: s.name, type: 'PAINT' }) + } + + return { + runId: runId || 'all', + taggedNodes, + taggedNodeCount: Object.keys(taggedNodes).length, + variableCollections, + variableCount: variables.length, + variables, + styleCount: styles.length, + styles, + } +} diff --git a/plugins/figma/skills/figma-generate-library/scripts/validateCreation.js b/plugins/figma/skills/figma-generate-library/scripts/validateCreation.js new file mode 100644 index 00000000..e607d043 --- /dev/null +++ b/plugins/figma/skills/figma-generate-library/scripts/validateCreation.js @@ -0,0 +1,83 @@ +/** + * validateCreation + * + * Verifies that a set of nodes exist and match expected structural properties. + * Designed to run immediately after a creation script to catch partial failures + * before proceeding to the next build phase. + * + * Each check specifies a node ID and any combination of expected properties. + * A check passes when all specified expectations are met; it fails (with a + * reason string) as soon as any expectation is violated. + * + * @param {Array<{ + * nodeId: string, + * expectedChildCount?: number, + * expectedName?: string, + * expectedType?: NodeType + * }>} checks + * - `nodeId`: The Figma node ID to look up via figma.getNodeById. + * - `expectedChildCount`: If set, the node must have exactly this many direct children. + * Applies to any node with a `children` property (frames, component sets, etc.). + * - `expectedName`: If set, the node's `.name` must exactly match this string. + * - `expectedType`: If set, the node's `.type` must exactly match this string. + * @returns {{ + * passed: string[], + * failed: Array<{nodeId: string, reason: string}> + * }} + * `passed`: Array of nodeIds that passed all checks. + * `failed`: Array of objects with the nodeId and a human-readable reason string. + */ +function validateCreation(checks) { + const passed = [] + const failed = [] + + for (const check of checks) { + const node = figma.getNodeById(check.nodeId) + + // Node must exist + if (!node) { + failed.push({ + nodeId: check.nodeId, + reason: `Node not found. It may not have been created, or was deleted.`, + }) + continue + } + + const reasons = [] + + // Type check + if (check.expectedType !== undefined && node.type !== check.expectedType) { + reasons.push(`type is "${node.type}", expected "${check.expectedType}"`) + } + + // Name check + if (check.expectedName !== undefined && node.name !== check.expectedName) { + reasons.push(`name is "${node.name}", expected "${check.expectedName}"`) + } + + // Child count check + if (check.expectedChildCount !== undefined) { + if (!('children' in node)) { + reasons.push( + `node type "${node.type}" does not have children, but expectedChildCount=${check.expectedChildCount} was specified`, + ) + } else { + const actualCount = node.children.length + if (actualCount !== check.expectedChildCount) { + reasons.push(`has ${actualCount} children, expected ${check.expectedChildCount}`) + } + } + } + + if (reasons.length > 0) { + failed.push({ + nodeId: check.nodeId, + reason: reasons.join('; '), + }) + } else { + passed.push(check.nodeId) + } + } + + return { passed, failed } +} diff --git a/plugins/figma/skills/figma-use/SKILL.md b/plugins/figma/skills/figma-use/SKILL.md new file mode 100644 index 00000000..484a61bd --- /dev/null +++ b/plugins/figma/skills/figma-use/SKILL.md @@ -0,0 +1,247 @@ +--- +name: figma-use +description: "**MANDATORY prerequisite** — you MUST invoke this skill BEFORE every `use_figma` tool call. NEVER call `use_figma` directly without loading this skill first. Skipping it causes common, hard-to-debug failures. Trigger whenever the user wants to perform a write action or a unique read action that requires JavaScript execution in the Figma file context — e.g. create/edit/delete nodes, set up variables or tokens, build components and variants, modify auto-layout or fills, bind variables to properties, or inspect file structure programmatically." +metadata: + mcp-server: figma, figma-staging +--- + +# use_figma — Figma Plugin API Skill + +Use `use_figma` MCP to execute JavaScript in Figma files via the Plugin API. All detailed reference docs live in `references/`. + +**Always pass `skillNames: "figma-use"` when calling `use_figma`.** This is a logging parameter used to track skill usage — it does not affect execution. + +**If the task involves building or updating a full page, screen, or multi-section layout in Figma from code**, also load [figma-generate-design](../figma-generate-design/SKILL.md). It provides the workflow for discovering design system components via `search_design_system`, importing them, and assembling screens incrementally. Both skills work together: this one for the API rules, that one for the screen-building workflow. + +Before anything, load [plugin-api-standalone.index.md](references/plugin-api-standalone.index.md) to understand what is possible. When you are asked to write plugin API code, use this context to grep [plugin-api-standalone.d.ts](references/plugin-api-standalone.d.ts) for relevant types, methods, and properties. This is the definitive source of truth for the API surface. It is a large typings file, so do not load it all at once, grep for relevant sections as needed. + +IMPORTANT: Whenever you work with design systems, start with [working-with-design-systems/wwds.md](references/working-with-design-systems/wwds.md) to understand the key concepts, processes, and guidelines for working with design systems in Figma. Then load the more specific references for components, variables, text styles, and effect styles as needed. + +## 1. Critical Rules + +1. **MUST** call `figma.closePlugin()` on success and `figma.closePluginWithFailure(e.toString())` on error — NEVER use `figma.closePlugin()` in a catch block +2. **MUST** wrap in `(async () => { try { ... } catch(e) { figma.closePluginWithFailure(e.toString()) } })()` +3. `figma.notify()` **throws "not implemented"** — never use it +4. `console.log()` is NOT returned — use `closePlugin()` for success output and `closePluginWithFailure()` for errors +5. **Work incrementally in small steps.** Break large operations into multiple `use_figma` calls. Validate after each step. This is the single most important practice for avoiding bugs. +6. Colors are **0–1 range** (not 0–255): `{r: 1, g: 0, b: 0}` = red +7. Fills/strokes are **read-only arrays** — clone, modify, reassign +8. Font **MUST** be loaded before any text operation: `await figma.loadFontAsync({family, style})` +9. **Pages load incrementally** — use `await figma.setCurrentPageAsync(page)` to switch pages and load their content (see Page Rules below) +10. `setBoundVariableForPaint` returns a **NEW** paint — must capture and reassign +11. `createVariable` accepts collection **object or ID string** (object preferred) +12. **`layoutSizingHorizontal/Vertical = 'FILL'` MUST be set AFTER `parent.appendChild(child)`** — setting before append throws. Same applies to `'HUG'` on non-auto-layout nodes. +13. **Position new top-level nodes away from (0,0).** Nodes appended directly to the page default to (0,0). Scan `figma.currentPage.children` to find a clear position (e.g., to the right of the rightmost node). This only applies to page-level nodes — nodes nested inside other frames or auto-layout containers are positioned by their parent. See [Gotchas](references/gotchas.md). +14. **On `use_figma` error, STOP. Do NOT immediately retry.** Call `get_metadata` to inspect partial state and clean up orphaned nodes BEFORE retrying — failed scripts do NOT roll back; nodes created before the error line persist and will cause duplicates on retry. See [Error Recovery](#6-error-recovery--self-correction). +15. **MUST return ALL created/mutated node IDs** in the `figma.closePlugin()` response. Whenever a script creates new nodes or mutates existing ones on the canvas, collect every affected node ID and return them in a structured JSON object (e.g. `{ createdNodeIds: [...], mutatedNodeIds: [...] }`). This is essential for subsequent calls to reference, validate, or clean up those nodes. + +> For detailed WRONG/CORRECT examples of each rule, see [Gotchas & Common Mistakes](references/gotchas.md). + +## 2. Page Rules (Critical) + +**Page context resets between `use_figma` calls** — `figma.currentPage` starts on the first page each time. + +### Switching pages + +Use `await figma.setCurrentPageAsync(page)` to switch pages and load their content. The sync setter `figma.currentPage = page` **throws an error** in `use_figma` runtimes. + +```js +// Switch to a specific page (loads its content) +const targetPage = figma.root.children.find((p) => p.name === "My Page"); +await figma.setCurrentPageAsync(targetPage); +// targetPage.children is now populated + +// Iterate over all pages +for (const page of figma.root.children) { + await figma.setCurrentPageAsync(page); + // page.children is now loaded — read or modify them here +} +``` + +### Across script runs + +`figma.currentPage` resets to the **first page** at the start of each `use_figma` call. If your workflow spans multiple calls and targets a non-default page, call `await figma.setCurrentPageAsync(page)` at the start of each invocation. + +You can call `use_figma` multiple times to incrementally build on the file state, or to retrieve information before writing another script. For example, write a script to get metadata about existing nodes, return that data via `figma.closePlugin()`, then use it in a subsequent script to modify those nodes. + +## 3. `figma.closePlugin()` Is Your Only Output Channel + +The agent sees **ONLY** the string passed to `figma.closePlugin('msg')`. Everything else is invisible. + +- **Returning IDs (CRITICAL)**: Every script that creates or mutates canvas nodes **MUST** return all affected node IDs — e.g. `{ createdNodeIds: [...], mutatedNodeIds: [...] }`. This is a hard requirement, not optional. +- **Progress reporting**: `figma.closePlugin(JSON.stringify({ createdNodeIds: [...], count: 5, errors: [] }))` +- **Error info**: On failure, pass structured error data +- `console.log()` output is **never** returned to the agent +- Always return actionable data (IDs, counts, status) so subsequent calls can reference created objects + +## 4. Editor Mode + +`use_figma` works in **design mode** (editorType `"figma"`, the default). FigJam (`"figjam"`) has a different set of available node types — most design nodes are blocked there. + +Available in design mode: Rectangle, Frame, Component, Text, Ellipse, Star, Line, Vector, Polygon, BooleanOperation, Slice, Page, Section, TextPath. + +**Blocked** in design mode: Sticky, Connector, ShapeWithText, CodeBlock, Slide, SlideRow, Webpage. + +## 5. Incremental Workflow (How to Avoid Bugs) + +The most common cause of bugs is trying to do too much in a single `use_figma` call. **Work in small steps and validate after each one.** + +### The pattern + +1. **Inspect first.** Before creating anything, run a read-only `use_figma` to discover what already exists in the file — pages, components, variables, naming conventions. Match what's there. +2. **Do one thing per call.** Create variables in one call, create components in the next, compose layouts in another. Don't try to build an entire screen in one script. +3. **Return IDs from every call.** Always return created node IDs, variable IDs, collection IDs via `figma.closePlugin(JSON.stringify({...}))`. You'll need these as inputs to subsequent calls. +4. **Validate after each step.** Use `get_metadata` to verify structure (counts, names, hierarchy, positions). Use `get_screenshot` after major milestones to catch visual issues. +5. **Fix before moving on.** If validation reveals a problem, fix it before proceeding to the next step. Don't build on a broken foundation. + +### Suggested step order for complex tasks + +``` +Step 1: Inspect file — discover existing pages, components, variables, conventions +Step 2: Create tokens/variables (if needed) + → validate with get_metadata +Step 3: Create individual components + → validate with get_metadata + get_screenshot +Step 4: Compose layouts from component instances + → validate with get_screenshot +Step 5: Final verification +``` + +### What to validate at each step + +| After... | Check with `get_metadata` | Check with `get_screenshot` | +|---|---|---| +| Creating variables | Collection count, variable count, mode names | — | +| Creating components | Child count, variant names, property definitions | Variants visible, not collapsed, grid readable | +| Binding variables | Node properties reflect bindings | Colors/tokens resolved correctly | +| Composing layouts | Instance nodes have mainComponent, hierarchy correct | No cropped/clipped text, no overlapping elements, correct spacing | + +## 6. Error Recovery & Self-Correction + +**`use_figma` does NOT roll back on failure.** If a script fails on line 50, everything executed on lines 1–49 persists. This means partial nodes, half-created components, and orphaned elements remain in the file. + +### When `use_figma` returns an error + +1. **STOP.** Do not immediately fix the code and retry. +2. **Inspect partial state.** Call `get_metadata` on the parent node (page, section, or component set) to see what was partially created. +3. **If damage is unclear**, call `get_screenshot` to see the visual state. +4. **Clean up orphaned nodes** before retrying: + +```js +(async () => { + try { + const page = figma.currentPage; + const orphans = page.findChildren(n => + n.type === 'COMPONENT' && n.name.includes('variant=') + ); + for (const orphan of orphans) orphan.remove(); + figma.closePlugin('Cleaned up ' + orphans.length + ' orphaned nodes'); + } catch(e) { figma.closePluginWithFailure(e.toString()); } +})() +``` + +5. **Fix the script** based on what you learned from inspection. +6. **Retry** only after cleanup is confirmed. + +### Common self-correction patterns + +| Error message | Likely cause | How to fix | +|---|---|---| +| `"not implemented"` | Used `figma.notify()` | Replace with `figma.closePlugin()` | +| `"node must be an auto-layout frame..."` | Set `FILL`/`HUG` before appending to auto-layout parent | Move `appendChild` before `layoutSizingX = 'FILL'` | +| `"Setting figma.currentPage is not supported"` | Used sync page setter | Use `await figma.setCurrentPageAsync(page)` | +| Property value out of range | Color channel > 1 (used 0–255 instead of 0–1) | Divide by 255 | +| `"Cannot read properties of null"` | Node doesn't exist (wrong ID, wrong page) | Check page context, verify ID | +| Script hangs / no response | Missing `figma.closePlugin()` / `figma.closePluginWithFailure()` on some code path | Add closePlugin/closePluginWithFailure to all paths | +| Duplicate nodes after retry | Retried without cleaning up partial state | Inspect with `get_metadata`, clean up first | +| `"The node with id X does not exist"` | Parent instance was implicitly detached by a child `detachInstance()`, changing IDs | Re-discover nodes by traversal from a stable (non-instance) parent frame | + +### When the script succeeds but the result looks wrong + +1. Call `get_metadata` to check structural correctness (hierarchy, counts, positions). +2. Call `get_screenshot` to check visual correctness. Look closely for cropped/clipped text (line heights cutting off content) and overlapping elements — these are common and easy to miss. +3. Identify the discrepancy — is it structural (wrong hierarchy, missing nodes) or visual (wrong colors, broken layout, clipped content)? +4. Write a targeted fix script that modifies only the broken parts — don't recreate everything. + +> For the full validation workflow, see [Validation & Error Recovery](references/validation-and-recovery.md). + +## 7. Pre-Flight Checklist + +Before submitting ANY `use_figma` call, verify: + +- [ ] Script is wrapped in `(async () => { try/catch })()` pattern +- [ ] `figma.closePlugin()` called on success path, `figma.closePluginWithFailure(e.toString())` in catch block +- [ ] `figma.closePlugin()` returns structured JSON with actionable data (IDs, counts) +- [ ] NO usage of `figma.notify()` anywhere +- [ ] NO usage of `console.log()` as output (only closePlugin / closePluginWithFailure) +- [ ] All colors use 0–1 range (not 0–255) +- [ ] Fills/strokes are reassigned as new arrays (not mutated in place) +- [ ] Page switches use `await figma.setCurrentPageAsync(page)` (sync setter throws) +- [ ] `layoutSizingVertical/Horizontal = 'FILL'` is set AFTER `parent.appendChild(child)` +- [ ] `loadFontAsync()` called BEFORE any text property changes +- [ ] `lineHeight`/`letterSpacing` use `{unit, value}` format (not bare numbers) +- [ ] `resize()` is called BEFORE setting sizing modes (resize resets them to FIXED) +- [ ] For multi-step workflows: IDs from previous calls are passed as string literals (not variables) +- [ ] New top-level nodes are positioned away from (0,0) to avoid overlapping existing content +- [ ] ALL created/mutated node IDs are collected and returned in `figma.closePlugin()` response + +## 8. Discover Conventions Before Creating + +**Always inspect the Figma file before creating anything.** Different files use different naming conventions, variable structures, and component patterns. Your code should match what's already there, not impose new conventions. + +When in doubt about any convention (naming, scoping, structure), check the Figma file first, then the user's codebase. Only fall back to common patterns when neither exists. + +### Quick inspection scripts + +**List all pages and top-level nodes:** +```js +const pages = figma.root.children.map(p => `${p.name} id=${p.id} children=${p.children.length}`); +figma.closePlugin(pages.join('\n')); +``` + +**List existing components across all pages:** +```js +const results = []; +for (const page of figma.root.children) { + await figma.setCurrentPageAsync(page); + page.findAll(n => { + if (n.type === 'COMPONENT' || n.type === 'COMPONENT_SET') + results.push(`[${page.name}] ${n.name} (${n.type}) id=${n.id}`); + return false; + }); +} +figma.closePlugin(results.join('\n')); +``` + +**List existing variable collections and their conventions:** +```js +const collections = figma.variables.getLocalVariableCollections(); +const results = collections.map(c => ({ + name: c.name, id: c.id, + varCount: c.variableIds.length, + modes: c.modes.map(m => m.name) +})); +figma.closePlugin(JSON.stringify(results)); +``` + +## 9. Reference Docs + +Load these as needed based on what your task involves: + +| Doc | When to load | What it covers | +|-----|-------------|----------------| +| [gotchas.md](references/gotchas.md) | Before any `use_figma` | Every known pitfall with WRONG/CORRECT code examples | +| [common-patterns.md](references/common-patterns.md) | Need working code examples | Script scaffolds: shapes, text, auto-layout, variables, components, multi-step workflows | +| [plugin-api-patterns.md](references/plugin-api-patterns.md) | Creating/editing nodes | Fills, strokes, Auto Layout, effects, grouping, cloning, styles | +| [api-reference.md](references/api-reference.md) | Need exact API surface | Node creation, variables API, core properties, what works and what doesn't | +| [validation-and-recovery.md](references/validation-and-recovery.md) | Multi-step writes or error recovery | `get_metadata` vs `get_screenshot` workflow, mandatory error recovery steps | +| [component-patterns.md](references/component-patterns.md) | Creating components/variants | combineAsVariants, component properties, INSTANCE_SWAP, variant layout, discovering existing components, metadata traversal | +| [variable-patterns.md](references/variable-patterns.md) | Creating/binding variables | Collections, modes, scopes, aliasing, binding patterns, discovering existing variables | +| [text-style-patterns.md](references/text-style-patterns.md) | Creating/applying text styles | Type ramps, font probing, listing styles, applying styles to nodes | +| [effect-style-patterns.md](references/effect-style-patterns.md) | Creating/applying effect styles | Drop shadows, listing styles, applying styles to nodes | +| [plugin-api-standalone.index.md](references/plugin-api-standalone.index.md) | Need to understand the full API surface | Index of all types, methods, and properties in the Plugin API | +| [plugin-api-standalone.d.ts](references/plugin-api-standalone.d.ts) | Need exact type signatures | Full typings file — grep for specific symbols, don't load all at once | + +## 10. Snippet examples + +You will see snippets throughout documentation here. These snippets contain useful plugin API code that can be repurposed. Use them as is, or as starter code as you go. If there are key concepts that are best documented as generic snippets, call them out and write to disk so you can reuse in the future. diff --git a/plugins/figma/skills/figma-use/maintainers.yml b/plugins/figma/skills/figma-use/maintainers.yml new file mode 100644 index 00000000..262e692e --- /dev/null +++ b/plugins/figma/skills/figma-use/maintainers.yml @@ -0,0 +1 @@ +SKILL.md: mcp_server diff --git a/plugins/figma/skills/figma-use/references/api-reference.md b/plugins/figma/skills/figma-use/references/api-reference.md new file mode 100644 index 00000000..fe264e32 --- /dev/null +++ b/plugins/figma/skills/figma-use/references/api-reference.md @@ -0,0 +1,301 @@ +# Figma Plugin API Reference + +> Part of the [use_figma skill](../SKILL.md). What works and what doesn't in the `use_figma` environment. + +## Contents + +- Node Creation +- Grouping and Boolean Operations +- Library Imports +- Variables API +- Core Properties +- Node Manipulation +- Descriptions and Documentation Links +- SVG and Images +- Utilities and Plugin Lifecycle +- Node Traversal +- Unsupported APIs + + +## Node Creation (Design Mode) + +```js +figma.createRectangle() +figma.createFrame() +figma.createComponent() // Creates a ComponentNode +figma.createText() +figma.createEllipse() +figma.createStar() +figma.createLine() +figma.createVector() +figma.createPolygon() +figma.createBooleanOperation() +figma.createSlice() +figma.createPage() // Page node can be created, but child persistence is limited in headless mode +figma.createSection() +figma.createTextPath() +``` + +## Grouping & Boolean Operations + +```js +figma.group(nodes, parent, index?) // Group nodes +figma.flatten(nodes, parent?, index?) // Flatten to vector +figma.union(nodes, parent?, index?) // Boolean union +figma.subtract(nodes, parent?, index?) // Boolean subtract +figma.intersect(nodes, parent?, index?) // Boolean intersect +figma.exclude(nodes, parent?, index?) // Boolean exclude +figma.combineAsVariants(components, parent?) // Combine ComponentNodes into ComponentSet (Design/Sites only) +``` + +## Library Component Import + +These methods import components from **team libraries** (not the same file you're working in). For components in the current file, use `use_figma` with `figma.getNodeByIdAsync()` or `findOne()`/`findAll()` to locate them directly. + +```js +// Import a published component from a team library by key +const comp = await figma.importComponentByKeyAsync("COMPONENT_KEY") +const instance = comp.createInstance() + +// Import a published component set from a team library by key +const compSet = await figma.importComponentSetByKeyAsync("COMPONENT_SET_KEY") +const variant = + compSet.children.find((c) => c.type === "COMPONENT" && c.name.includes("size=md")) || + compSet.defaultVariant +const variantInstance = variant.createInstance() +``` + +## Library Style Import (Team Libraries) + +These methods import styles from **team libraries** (not the same file). For styles in the current file, use `figma.getLocalPaintStyles()`, `figma.getLocalTextStyles()`, etc. + +```js +// Import a published style from a team library by key +const style = await figma.importStyleByKeyAsync("STYLE_KEY") + +// Apply the imported style to a node +await node.setFillStyleIdAsync(style.id) // for PaintStyle as fill +await node.setStrokeStyleIdAsync(style.id) // for PaintStyle as stroke +await node.setTextStyleIdAsync(style.id) // for TextStyle +await node.setEffectStyleIdAsync(style.id) // for EffectStyle +await node.setGridStyleIdAsync(style.id) // for GridStyle +``` + +## Library Variable Import (Team Libraries) + +This imports variables from **team libraries** (not the same file). For variables in the current file, use `figma.variables.getLocalVariables()` or `figma.variables.getVariableById()`. + +```js +// Import a published variable from a team library by key +const variable = await figma.variables.importVariableByKeyAsync("VARIABLE_KEY") + +// Bind the imported variable to node properties +node.setBoundVariable("width", variable) // FLOAT variable + +// Bind to fills/strokes (COLOR variable) — returns a NEW paint, must capture it +const newPaint = figma.variables.setBoundVariableForPaint(paintCopy, "color", variable) +node.fills = [newPaint] +``` + +## Variables API + +```js +// Collections +const collection = figma.variables.createVariableCollection("Name") +collection.name // Get/set name +collection.modes // Array of {modeId, name} — starts with 1 mode +collection.addMode("Dark") // Returns new modeId string +collection.renameMode(modeId, "Light") + +// Variables +const variable = figma.variables.createVariable("name", collection, "COLOR") +// ^ object or ID string +// resolvedType: "COLOR" | "FLOAT" | "STRING" | "BOOLEAN" +variable.setValueForMode(modeId, value) + +// Scopes — controls where variable appears in property pickers +variable.scopes = ["FRAME_FILL", "SHAPE_FILL"] // only fill pickers +variable.scopes = ["TEXT_FILL"] // only text color picker +variable.scopes = ["STROKE_COLOR"] // only stroke picker +variable.scopes = [] // hidden from all pickers (use for primitives) +// All valid scope values: +// ALL_SCOPES, TEXT_CONTENT, CORNER_RADIUS, WIDTH_HEIGHT, GAP, +// ALL_FILLS, FRAME_FILL, SHAPE_FILL, TEXT_FILL, +// STROKE_COLOR, STROKE_FLOAT, EFFECT_FLOAT, EFFECT_COLOR, +// OPACITY, FONT_FAMILY, FONT_STYLE, FONT_WEIGHT, FONT_SIZE, +// LINE_HEIGHT, LETTER_SPACING, PARAGRAPH_SPACING, PARAGRAPH_INDENT + +// Querying +figma.variables.getVariableById(id) +figma.variables.getLocalVariables(resolvedType?) +figma.variables.getVariableCollectionById(id) +figma.variables.getLocalVariableCollections() + +// Binding variables to paints (COLOR variables) +const newPaint = figma.variables.setBoundVariableForPaint(paintCopy, "color", variable) +// ⚠️ Returns a NEW paint — must capture return value! +node.fills = [newPaint] + +// Binding variables to effects (COLOR/FLOAT variables) +const newEffect = figma.variables.setBoundVariableForEffect(effectCopy, field, variable) +// field for shadows: "color" (COLOR), "radius" | "spread" | "offsetX" | "offsetY" (FLOAT) +// field for blurs: "radius" (FLOAT) +// ⚠️ Returns a NEW effect — must capture return value! +node.effects = [newEffect] + +// Binding variables to layout grids (FLOAT variables) +const newGrid = figma.variables.setBoundVariableForLayoutGrid(gridCopy, field, variable) +// field: "sectionSize" | "offset" | "count" | "gutterSize" +// ⚠️ Returns a NEW layout grid — must capture return value! +node.layoutGrids = [newGrid] + +// Binding variables to node properties (FLOAT/STRING/BOOLEAN) +// Layout & sizing (FLOAT): +node.setBoundVariable("width", variable) +node.setBoundVariable("height", variable) +node.setBoundVariable("minWidth", variable) +node.setBoundVariable("maxWidth", variable) +node.setBoundVariable("minHeight", variable) +node.setBoundVariable("maxHeight", variable) +node.setBoundVariable("paddingLeft", variable) +node.setBoundVariable("paddingRight", variable) +node.setBoundVariable("paddingTop", variable) +node.setBoundVariable("paddingBottom", variable) +node.setBoundVariable("itemSpacing", variable) +node.setBoundVariable("counterAxisSpacing", variable) +// Corner radii (FLOAT) — use individual corners, NOT cornerRadius: +node.setBoundVariable("topLeftRadius", variable) +node.setBoundVariable("topRightRadius", variable) +node.setBoundVariable("bottomLeftRadius", variable) +node.setBoundVariable("bottomRightRadius", variable) +// Other (FLOAT): +node.setBoundVariable("opacity", variable) +node.setBoundVariable("strokeWeight", variable) +// ⚠️ fontSize, fontWeight, lineHeight are NOT bindable via setBoundVariable +// — set these directly as values on text nodes + +// Aliases +figma.variables.createVariableAlias(variable) + +// Explicit modes — CRITICAL for variant components +node.setExplicitVariableModeForCollection(collectionId, modeId) +// Without this, all nodes use the default (first) mode of the collection +``` + +## Core Properties + +```js +figma.root // DocumentNode +figma.currentPage // Current page (read-only in use_figma; sync setter throws) +figma.setCurrentPageAsync(page) // Switch page and load its content (MUST await) +figma.fileKey // File key string +figma.mixed // Mixed sentinel value +``` + +## Node Manipulation + +```js +// Fills & Strokes (read-only arrays — must clone) +node.fills = [{ type: 'SOLID', color: { r: 1, g: 0, b: 0 } }] +node.strokes = [{ type: 'SOLID', color: { r: 0, g: 0, b: 0 } }] +node.strokeWeight = 1 +node.strokeAlign = 'INSIDE' // 'INSIDE' | 'CENTER' | 'OUTSIDE' + +// Effects +node.effects = [{ type: 'DROP_SHADOW', color: {r:0,g:0,b:0,a:0.25}, offset:{x:0,y:4}, radius:4, visible:true }] + +// Layout +node.layoutMode = 'HORIZONTAL' // 'NONE' | 'HORIZONTAL' | 'VERTICAL' +node.primaryAxisAlignItems = 'CENTER' // 'MIN' | 'CENTER' | 'MAX' | 'SPACE_BETWEEN' +node.counterAxisAlignItems = 'CENTER' // 'MIN' | 'CENTER' | 'MAX' | 'BASELINE' +node.paddingLeft = 8 +node.paddingRight = 8 +node.paddingTop = 4 +node.paddingBottom = 4 +node.itemSpacing = 4 +node.layoutSizingHorizontal = 'HUG' // 'FIXED' | 'HUG' | 'FILL' +node.layoutSizingVertical = 'HUG' // 'FIXED' | 'HUG' | 'FILL' + +// Sizing +node.resize(width, height) // ⚠️ Resets sizing modes to FIXED +node.resizeWithoutConstraints(width, height) // Doesn't affect constraints + +// Corner radius +node.cornerRadius = 8 + +// Visibility & Opacity +node.visible = true +node.opacity = 0.5 + +// Naming & Hierarchy +node.name = "My Node" +parent.appendChild(child) +parent.insertChild(index, child) +node.remove() +``` + +## Descriptions & Documentation Links + +```js +// Description — plain text, shown in Figma's component panel +node.description = "A short summary of this component's purpose and usage." + +// Documentation links — array of {uri, label} shown as clickable links +componentSet.documentationLinks = [ + { uri: "https://example.com/docs", label: "Component Docs" } +] +// ⚠️ uri MUST be a valid URL (https://...) — relative paths will throw +``` + +## SVG Import + +```js +const svgNode = figma.createNodeFromSvg('...') +``` + +## Images + +```js +const image = figma.createImage(uint8Array) +node.fills = [{ type: 'IMAGE', scaleMode: 'FILL', imageHash: image.hash }] +``` + +## Utilities + +```js +figma.base64Encode(uint8Array) // Uint8Array → base64 string +figma.base64Decode(base64String) // base64 string → Uint8Array +figma.createComponentFromNode(node) // Convert existing node to component (Design/Sites only) +``` + +## Plugin Lifecycle + +```js +figma.closePlugin("message") // Close and return a message to the agent (success) +figma.closePluginWithFailure("error msg") // Close with error — ALWAYS use in catch blocks +``` + +## Node Traversal + +```js +node.findAll(pred?) // Find all descendants matching predicate +node.findOne(pred?) // Find first descendant matching predicate +node.findChildren(pred?) // Find direct children matching predicate +node.findChild(pred?) // Find first direct child matching predicate +node.children // Direct children array +node.parent // Parent node +``` + +--- + +## What Does NOT Work + +| API | Status | +|-----|--------| +| `figma.notify()` | **Throws "not implemented"** — most common mistake | +| `figma.showUI()` | No-op (silently ignored) | +| `figma.openExternal()` | No-op (silently ignored) | +| `figma.listAvailableFontsAsync()` | Not implemented | +| `figma.loadAllPagesAsync()` | Not implemented | +| `figma.variables.extendLibraryCollectionByKeyAsync()` | Not implemented | +| `figma.teamLibrary.*` | Not implemented (requires LiveGraph) | diff --git a/plugins/figma/skills/figma-use/references/common-patterns.md b/plugins/figma/skills/figma-use/references/common-patterns.md new file mode 100644 index 00000000..30a23ac2 --- /dev/null +++ b/plugins/figma/skills/figma-use/references/common-patterns.md @@ -0,0 +1,512 @@ +# Common Patterns + +> Part of the [use_figma skill](../SKILL.md). Working code examples for frequently used operations. + +## Contents + +- Basic Script Structure +- Create a Styled Shape +- Create a Text Node +- Create Frame with Auto-Layout +- Create Variable Collections and Bindings +- Create Components and Import by Key +- Component Sets with Variable Modes +- Multi-Step Large ComponentSet Pattern +- Read Existing Nodes and Return Data + + +## Basic Script Structure + +```js +(async () => { + try { + const createdNodeIds = [] + const mutatedNodeIds = [] + + // Your code here — track every node you create or mutate + // createdNodeIds.push(newNode.id) + // mutatedNodeIds.push(existingNode.id) + + figma.closePlugin(JSON.stringify({ + success: true, + createdNodeIds, + mutatedNodeIds, + // Plus any other useful data for subsequent calls + count: createdNodeIds.length + })) + } catch (e) { + figma.closePluginWithFailure(e.toString()) + } +})() +``` + +## Create a Styled Shape + +```js +(async () => { + try { + // Find clear space to the right of existing content + const page = figma.currentPage + let maxX = 0 + for (const child of page.children) { + maxX = Math.max(maxX, child.x + child.width) + } + + const rect = figma.createRectangle() + rect.name = "Blue Box" + rect.resize(200, 100) + rect.fills = [{ type: 'SOLID', color: { r: 0.047, g: 0.549, b: 0.914 } }] + rect.cornerRadius = 8 + rect.x = maxX + 100 // offset from existing content + rect.y = 0 + figma.currentPage.appendChild(rect) + figma.closePlugin(JSON.stringify({ nodeId: rect.id })) + } catch (e) { + figma.closePluginWithFailure(e.toString()) + } +})() +``` + +## Create a Text Node + +```js +(async () => { + try { + // Find clear space to the right of existing content + const page = figma.currentPage + let maxX = 0 + for (const child of page.children) { + maxX = Math.max(maxX, child.x + child.width) + } + + await figma.loadFontAsync({ family: "Inter", style: "Regular" }) + const text = figma.createText() + text.characters = "Hello World" + text.fontSize = 16 + text.fills = [{ type: 'SOLID', color: { r: 0, g: 0, b: 0 } }] + text.textAutoResize = 'WIDTH_AND_HEIGHT' + text.x = maxX + 100 + text.y = 0 + figma.currentPage.appendChild(text) + figma.closePlugin(JSON.stringify({ nodeId: text.id })) + } catch (e) { + figma.closePluginWithFailure(e.toString()) + } +})() +``` + +## Create Frame with Auto-Layout + +```js +(async () => { + try { + // Find clear space to the right of existing content + const page = figma.currentPage + let maxX = 0 + for (const child of page.children) { + maxX = Math.max(maxX, child.x + child.width) + } + + const frame = figma.createFrame() + frame.name = "Card" + frame.layoutMode = 'VERTICAL' + frame.primaryAxisAlignItems = 'MIN' + frame.counterAxisAlignItems = 'MIN' + frame.paddingLeft = 16 + frame.paddingRight = 16 + frame.paddingTop = 12 + frame.paddingBottom = 12 + frame.itemSpacing = 8 + frame.layoutSizingHorizontal = 'HUG' + frame.layoutSizingVertical = 'HUG' + frame.fills = [{ type: 'SOLID', color: { r: 1, g: 1, b: 1 } }] + frame.cornerRadius = 8 + frame.x = maxX + 100 + frame.y = 0 + figma.currentPage.appendChild(frame) + figma.closePlugin(JSON.stringify({ nodeId: frame.id })) + } catch (e) { + figma.closePluginWithFailure(e.toString()) + } +})() +``` + +## Create Variable Collection with Multiple Modes + +```js +(async () => { + try { + const collection = figma.variables.createVariableCollection("Theme/Colors") + // Rename the default mode + collection.renameMode(collection.modes[0].modeId, "Light") + const darkModeId = collection.addMode("Dark") + const lightModeId = collection.modes[0].modeId + + const bgVar = figma.variables.createVariable("bg", collection, "COLOR") + bgVar.setValueForMode(lightModeId, { r: 1, g: 1, b: 1, a: 1 }) + bgVar.setValueForMode(darkModeId, { r: 0.1, g: 0.1, b: 0.1, a: 1 }) + + const textVar = figma.variables.createVariable("text", collection, "COLOR") + textVar.setValueForMode(lightModeId, { r: 0, g: 0, b: 0, a: 1 }) + textVar.setValueForMode(darkModeId, { r: 1, g: 1, b: 1, a: 1 }) + + figma.closePlugin(JSON.stringify({ + collectionId: collection.id, + lightModeId, + darkModeId, + bgVarId: bgVar.id, + textVarId: textVar.id + })) + } catch (e) { + figma.closePluginWithFailure(e.toString()) + } +})() +``` + +## Bind Color Variable to a Fill + +```js +(async () => { + try { + const variable = figma.variables.getVariableById("VariableID:1:2") + const rect = figma.createRectangle() + const basePaint = { type: 'SOLID', color: { r: 0, g: 0, b: 0 } } + + // setBoundVariableForPaint returns a NEW paint — capture it! + const boundPaint = figma.variables.setBoundVariableForPaint(basePaint, "color", variable) + rect.fills = [boundPaint] + + figma.closePlugin(JSON.stringify({ nodeId: rect.id })) + } catch (e) { + figma.closePluginWithFailure(e.toString()) + } +})() +``` + +## Create Component Variants with Component Properties + +Component properties (TEXT, BOOLEAN, INSTANCE_SWAP) MUST be added inside the per-variant loop, BEFORE `combineAsVariants`. The component set inherits them from its children. + +```js +(async () => { + try { + await figma.loadFontAsync({ family: "Inter", style: "Regular" }) + + // Assume defaultIconComp is an existing icon component (discovered earlier) + const defaultIconComp = figma.getNodeById('ICON_COMPONENT_ID') + + const components = [] + const variants = ["primary", "secondary"] + + for (const variant of variants) { + const comp = figma.createComponent() + comp.name = `variant=${variant}` + comp.layoutMode = 'HORIZONTAL' + comp.primaryAxisAlignItems = 'CENTER' + comp.counterAxisAlignItems = 'CENTER' + comp.paddingLeft = 12 + comp.paddingRight = 12 + comp.paddingTop = 8 + comp.paddingBottom = 8 + comp.layoutSizingHorizontal = 'HUG' + comp.layoutSizingVertical = 'HUG' + comp.cornerRadius = 6 + comp.itemSpacing = 8 + + // TEXT property — label + const labelKey = comp.addComponentProperty('Label', 'TEXT', 'Button') + const label = figma.createText() + label.characters = "Button" + label.fontSize = 14 + comp.appendChild(label) + label.componentPropertyReferences = { characters: labelKey } + + // BOOLEAN + INSTANCE_SWAP — icon slot + const showIconKey = comp.addComponentProperty('Show Icon', 'BOOLEAN', false) + const iconSlotKey = comp.addComponentProperty('Icon', 'INSTANCE_SWAP', defaultIconComp.id) + const iconInstance = defaultIconComp.createInstance() + comp.insertChild(0, iconInstance) // icon before label + iconInstance.componentPropertyReferences = { + visible: showIconKey, + mainComponent: iconSlotKey + } + + components.push(comp) + } + + const componentSet = figma.combineAsVariants(components, figma.currentPage) + componentSet.name = "Button" + + // Layout variants in a row after combining (they stack at 0,0 by default) + const colW = 140 + componentSet.children.forEach((child, i) => { + child.x = i * colW + child.y = 0 + }) + // Resize from actual child bounds — formula-based sizing is error-prone + let maxX = 0, maxY = 0 + for (const c of componentSet.children) { + maxX = Math.max(maxX, c.x + c.width) + maxY = Math.max(maxY, c.y + c.height) + } + componentSet.resizeWithoutConstraints(maxX + 40, maxY + 40) + + figma.closePlugin(JSON.stringify({ + componentSetId: componentSet.id, + componentIds: components.map(c => c.id) + })) + } catch (e) { + figma.closePluginWithFailure(e.toString()) + } +})() +``` + +## Import a Component by Key (Team Libraries) + +`importComponentByKeyAsync` and `importComponentSetByKeyAsync` import components from **team libraries** (not the same file you're working in). For components in the current file, use `figma.getNodeByIdAsync()` or `findOne()`/`findAll()` to locate them directly. + +```js +(async () => { + try { + // Import a single published component by key + const comp = await figma.importComponentByKeyAsync("COMPONENT_KEY") + const instance = comp.createInstance() + instance.x = 40 + instance.y = 40 + figma.currentPage.appendChild(instance) + + // Import a published component set by key and select a variant + const compSet = await figma.importComponentSetByKeyAsync("COMPONENT_SET_KEY") + const variant = + compSet.children.find((c) => + c.type === "COMPONENT" && c.name.includes("size=md") + ) || compSet.defaultVariant + + const variantInstance = variant.createInstance() + variantInstance.x = 240 + variantInstance.y = 40 + figma.currentPage.appendChild(variantInstance) + + figma.closePlugin(JSON.stringify({ + componentId: comp.id, + componentSetId: compSet.id, + placedInstanceIds: [instance.id, variantInstance.id] + })) + } catch (e) { + figma.closePluginWithFailure(e.toString()) + } +})() +``` + +## Component Set with Variable Modes (Full Pattern) + +```js +(async () => { + try { + await figma.loadFontAsync({ family: "Inter", style: "Medium" }) + + // 1. Create color collection with modes per variant + const colors = figma.variables.createVariableCollection("Component/Colors") + colors.renameMode(colors.modes[0].modeId, "primary") + const primaryMode = colors.modes[0].modeId + const secondaryMode = colors.addMode("secondary") + + const bgVar = figma.variables.createVariable("bg", colors, "COLOR") + bgVar.setValueForMode(primaryMode, { r: 0, g: 0.4, b: 0.9, a: 1 }) + bgVar.setValueForMode(secondaryMode, { r: 0, g: 0, b: 0, a: 0 }) + + const textVar = figma.variables.createVariable("text-color", colors, "COLOR") + textVar.setValueForMode(primaryMode, { r: 1, g: 1, b: 1, a: 1 }) + textVar.setValueForMode(secondaryMode, { r: 0.1, g: 0.1, b: 0.1, a: 1 }) + + // 2. Create components with variable bindings + const modeMap = { primary: primaryMode, secondary: secondaryMode } + const components = [] + + for (const [variantName, modeId] of Object.entries(modeMap)) { + const comp = figma.createComponent() + comp.name = "variant=" + variantName + comp.layoutMode = "HORIZONTAL" + comp.primaryAxisAlignItems = "CENTER" + comp.counterAxisAlignItems = "CENTER" + comp.paddingLeft = 12; comp.paddingRight = 12 + comp.layoutSizingHorizontal = "HUG" + comp.layoutSizingVertical = "HUG" + comp.cornerRadius = 6 + + // Bind background fill to variable + const bgPaint = figma.variables.setBoundVariableForPaint( + { type: "SOLID", color: { r: 0, g: 0, b: 0 } }, "color", bgVar + ) + comp.fills = [bgPaint] + + // Add text with bound color + const label = figma.createText() + label.fontName = { family: "Inter", style: "Medium" } + label.characters = "Button" + label.fontSize = 14 + const textPaint = figma.variables.setBoundVariableForPaint( + { type: "SOLID", color: { r: 0, g: 0, b: 0 } }, "color", textVar + ) + label.fills = [textPaint] + comp.appendChild(label) + + // 3. CRITICAL: Set explicit mode so this variant renders correctly + comp.setExplicitVariableModeForCollection(colors.id, modeId) + + components.push(comp) + } + + // 4. Combine into component set + const componentSet = figma.combineAsVariants(components, figma.currentPage) + componentSet.name = "Button" + + figma.closePlugin(JSON.stringify({ + componentSetId: componentSet.id, + colorCollectionId: colors.id + })) + } catch (e) { + figma.closePluginWithFailure(e.toString()) + } +})() +``` + +## Large ComponentSet with Variable Modes (Multi-Step Pattern) + +For component sets with many variants (50+), split into multiple `use_figma` calls: + +**Call 1: Create variable collections and return IDs** + +```js +(async () => { + try { + // Hex-to-0-1 helper + const hex = (h) => { + if (!h) return { r: 0, g: 0, b: 0, a: 0 }; // transparent + return { + r: parseInt(h.slice(1,3), 16) / 255, + g: parseInt(h.slice(3,5), 16) / 255, + b: parseInt(h.slice(5,7), 16) / 255, + a: 1 + }; + }; + + const coll = figma.variables.createVariableCollection("MyComponent/Colors"); + coll.renameMode(coll.modes[0].modeId, "mode1"); + const mode2Id = coll.addMode("mode2"); + + // Create variables from data map + const colorData = { "bg/default": ["#0B6BCB", "#636B74"], /* ... */ }; + const modeOrder = ["mode1", "mode2"]; + const modeIds = { mode1: coll.modes[0].modeId, mode2: mode2Id }; + const varIds = {}; + + for (const [name, values] of Object.entries(colorData)) { + const v = figma.variables.createVariable(name, coll, "COLOR"); + values.forEach((hex_val, i) => { + v.setValueForMode(modeIds[modeOrder[i]], hex_val ? hex(hex_val) : { r:0, g:0, b:0, a:0 }); + }); + varIds[name] = v.id; + } + + // Return ALL IDs — needed by subsequent calls + figma.closePlugin(JSON.stringify({ collId: coll.id, modeIds, varIds })); + } catch (e) { + figma.closePluginWithFailure(e.toString()); + } +})() +``` + +**Call 2: Create components using stored IDs, combine and layout** + +```js +(async () => { + try { + await figma.loadFontAsync({ family: "Inter", style: "Semi Bold" }); + + // Paste IDs from Call 1 as literals + const collId = "VariableCollectionId:X:Y"; + const modeIds = { mode1: "X:0", mode2: "X:1" }; + const varIds = { /* ... from Call 1 ... */ }; + + const getVar = (id) => figma.variables.getVariableById(id); + const bindColor = (varId) => figma.variables.setBoundVariableForPaint( + { type: 'SOLID', color: { r: 0, g: 0, b: 0 } }, 'color', getVar(varId) + ); + + const components = []; + for (const mode of ["mode1", "mode2"]) { + for (const state of ["default", "hover"]) { + const comp = figma.createComponent(); + comp.name = `mode=${mode}, state=${state}`; + comp.layoutMode = 'HORIZONTAL'; + comp.primaryAxisAlignItems = 'CENTER'; + comp.counterAxisAlignItems = 'CENTER'; + comp.layoutSizingHorizontal = 'HUG'; + comp.layoutSizingVertical = 'HUG'; + comp.fills = [bindColor(varIds[`bg/${state}`])]; + comp.setExplicitVariableModeForCollection(collId, modeIds[mode]); + // ... add text children ... + components.push(comp); + } + } + + // Combine — all children stack at (0,0)! + const cs = figma.combineAsVariants(components, figma.currentPage); + cs.name = "MyComponent"; + + // CRITICAL: layout variants in a structured grid mapped to variant axes. + const stateOrder = ["default", "hover"]; + const modeOrder2 = ["mode1", "mode2"]; + const colW = 140, rowH = 56; + + for (const child of cs.children) { + const props = Object.fromEntries( + child.name.split(', ').map(p => p.split('=')) + ); + const col = stateOrder.indexOf(props.state); + const row = modeOrder2.indexOf(props.mode); + child.x = col * colW; + child.y = row * rowH; + } + // Resize from actual child bounds + let maxX = 0, maxY = 0; + for (const child of cs.children) { + maxX = Math.max(maxX, child.x + child.width); + maxY = Math.max(maxY, child.y + child.height); + } + cs.resizeWithoutConstraints(maxX + 40, maxY + 40); + + // Wrap in section + const section = figma.createSection(); + section.name = "MyComponent Section"; + section.appendChild(cs); + section.resizeWithoutConstraints(cs.width + 200, cs.height + 200); + + figma.closePlugin(JSON.stringify({ csId: cs.id, count: components.length })); + } catch (e) { + figma.closePluginWithFailure(e.toString()); + } +})() +``` + +## Read Existing Nodes and Return Data + +```js +(async () => { + try { + const page = figma.currentPage + const nodes = page.findAll(n => n.type === 'FRAME') + const data = nodes.map(n => ({ + id: n.id, + name: n.name, + width: n.width, + height: n.height, + childCount: n.children?.length || 0 + })) + figma.closePlugin(JSON.stringify({ frames: data })) + } catch (e) { + figma.closePluginWithFailure(e.toString()) + } +})() +``` diff --git a/plugins/figma/skills/figma-use/references/component-patterns.md b/plugins/figma/skills/figma-use/references/component-patterns.md new file mode 100644 index 00000000..2b81226d --- /dev/null +++ b/plugins/figma/skills/figma-use/references/component-patterns.md @@ -0,0 +1,488 @@ +# Component & Variant API Patterns + +> Part of the [use_figma skill](../SKILL.md). How to correctly use the Plugin API for components, variants, and component properties. +> +> For design system context (when to use variants vs properties, code-to-Figma translation, property model), see [wwds-components](working-with-design-systems/wwds-components.md). + +## Contents + +- Creating a Component +- Combining Components into a Component Set (Variants) +- Laying Out Variants After combineAsVariants (Required) +- Component Properties: addComponentProperty API +- Linking Properties to Child Nodes (Required) +- INSTANCE_SWAP: Avoiding Variant Explosion +- Discovering Existing Conventions in the File +- Importing Components by Key +- Working with Instances (finding variants, setProperties, text overrides, detachInstance) + + +## Creating a Component + +`figma.createComponent()` returns a `ComponentNode`, which behaves like a `FrameNode` but can be published, instanced, and combined into variant sets. + +```javascript +const comp = figma.createComponent(); +comp.name = "MyComponent"; +comp.layoutMode = "HORIZONTAL"; +comp.primaryAxisAlignItems = "CENTER"; +comp.counterAxisAlignItems = "CENTER"; +comp.paddingLeft = 12; +comp.paddingRight = 12; +comp.layoutSizingHorizontal = "HUG"; +comp.layoutSizingVertical = "HUG"; +comp.fills = [{ type: "SOLID", color: { r: 0.2, g: 0.36, b: 0.96 } }]; +``` + +## Combining Components into a Component Set (Variants) + +`figma.combineAsVariants(components, parent)` takes an array of `ComponentNode`s (not frames — frames will throw) and groups them into a `ComponentSetNode`. + +Variant names use a `Property=Value` format. Every unique combination must exist as a child component — missing ones show as blank gaps in the variant picker. + +```javascript +// Each component's name encodes its variant properties +const comp1 = figma.createComponent(); +comp1.name = "size=md, style=primary"; +const comp2 = figma.createComponent(); +comp2.name = "size=md, style=secondary"; + +const componentSet = figma.combineAsVariants([comp1, comp2], figma.currentPage); +componentSet.name = "Button"; +``` + +**Before creating variants, inspect the file** for existing naming patterns. Different files use different conventions (`State=Default` vs `state=default` vs `State/Default`). Always match what's already there. + +## Laying Out Variants After combineAsVariants (Required) + +After `combineAsVariants`, all children stack at `(0, 0)`. You **must** position them or the component set will appear as a single collapsed element with all variants overlapping. + +```javascript +const cs = figma.combineAsVariants(components, figma.currentPage); + +// Simple row layout +cs.children.forEach((child, i) => { + child.x = i * 150; + child.y = 0; +}); + +// CRITICAL: resize the component set from actual child bounds +let maxX = 0, maxY = 0; +for (const child of cs.children) { + maxX = Math.max(maxX, child.x + child.width); + maxY = Math.max(maxY, child.y + child.height); +} +cs.resizeWithoutConstraints(maxX + 40, maxY + 40); +``` + +For multi-axis variants (e.g., size × style × state), parse the child's name to determine grid position: + +```javascript +for (const child of cs.children) { + const props = Object.fromEntries( + child.name.split(', ').map(p => p.split('=')) + ); + const col = stateValues.indexOf(props.state); + const row = styleValues.indexOf(props.style); + child.x = col * colWidth; + child.y = row * rowHeight; +} +``` + +## Component Properties: addComponentProperty API + +`addComponentProperty` adds a TEXT, BOOLEAN, or INSTANCE_SWAP property to a component. It returns a **string key** (e.g., `"label#4:0"`) — never hardcode or guess this key. + +```javascript +// Returns the key as a string — capture it! +const labelKey = comp.addComponentProperty('Label', 'TEXT', 'Default text'); +const showIconKey = comp.addComponentProperty('Show Icon', 'BOOLEAN', true); +const iconSlotKey = comp.addComponentProperty('Icon', 'INSTANCE_SWAP', iconComponentId); +``` + +**Timing**: Add component properties to each variant component **before** calling `combineAsVariants`. After combining, the component set inherits all properties from its children. Do not add properties to the `ComponentSetNode` directly. + +## Linking Properties to Child Nodes (Required) + +A property that is added but not linked to a child node does **nothing**. You must set `componentPropertyReferences` on the child: + +```javascript +// TEXT property → link to a text node's characters +const labelKey = comp.addComponentProperty('Label', 'TEXT', 'Button'); +const textNode = figma.createText(); +textNode.characters = "Button"; +comp.appendChild(textNode); +textNode.componentPropertyReferences = { characters: labelKey }; + +// BOOLEAN + INSTANCE_SWAP → link to an instance node +const showIconKey = comp.addComponentProperty('Show Icon', 'BOOLEAN', true); +const iconSlotKey = comp.addComponentProperty('Icon', 'INSTANCE_SWAP', iconComp.id); +const iconInstance = iconComp.createInstance(); +comp.appendChild(iconInstance); +iconInstance.componentPropertyReferences = { + visible: showIconKey, // BOOLEAN controls show/hide + mainComponent: iconSlotKey // INSTANCE_SWAP controls which component +}; +``` + +**Valid `componentPropertyReferences` keys:** +- `characters` — TEXT property on a TextNode +- `visible` — BOOLEAN property (any node) +- `mainComponent` — INSTANCE_SWAP property on an InstanceNode + +## INSTANCE_SWAP: Avoiding Variant Explosion + +When a component has many possible sub-elements (e.g., 30 different icons), **never** create a variant per sub-element. Use a single INSTANCE_SWAP property instead — the user picks from any compatible component at design time. + +```javascript +// Create icon as its own ComponentNode +const iconComp = figma.createComponent(); +iconComp.name = "Icon/Search"; +iconComp.resize(24, 24); +const svgNode = figma.createNodeFromSvg('...'); +iconComp.appendChild(svgNode); + +// Use it as the default for INSTANCE_SWAP +const iconSlotKey = comp.addComponentProperty('Icon', 'INSTANCE_SWAP', iconComp.id); +const instance = iconComp.createInstance(); +comp.appendChild(instance); +instance.componentPropertyReferences = { mainComponent: iconSlotKey }; +``` + +This works for icons, avatars, badges, or any swappable nested element. + +## Discovering Existing Conventions in the File + +**Always inspect the file before creating components.** Different files have different naming styles, structures, and conventions. Your code should match what's already there. + +### List all existing components across all pages + +```javascript +(async () => { + try { + const results = []; + for (const page of figma.root.children) { + await figma.setCurrentPageAsync(page); + page.findAll(n => { + if (n.type === 'COMPONENT') results.push(`[${page.name}] ${n.name} (COMPONENT) id=${n.id}`); + if (n.type === 'COMPONENT_SET') results.push(`[${page.name}] ${n.name} (COMPONENT_SET) id=${n.id}`); + return false; + }); + } + figma.closePlugin(results.join('\n')); + } catch(e) { figma.closePluginWithFailure(e.toString()); } +})() +``` + +### Inspect an existing component set's variant naming pattern + +```javascript +(async () => { + try { + const cs = await figma.getNodeByIdAsync('COMPONENT_SET_ID'); + const variantNames = cs.children.map(c => c.name); + const propDefs = cs.componentPropertyDefinitions; + figma.closePlugin(JSON.stringify({ variantNames, propDefs })); + } catch(e) { figma.closePluginWithFailure(e.toString()); } +})() +``` + +### Find existing components in the file + +```javascript +(async () => { + try { + const components = []; + for (const page of figma.root.children) { + await figma.setCurrentPageAsync(page); + page.findAll(n => { + if (n.type === 'COMPONENT') { + components.push({ name: n.name, id: n.id, page: page.name, w: n.width, h: n.height }); + } + return false; + }); + } + figma.closePlugin(JSON.stringify(components)); + } catch(e) { figma.closePluginWithFailure(e.toString()); } +})() +``` + +## Importing Components by Key (Team Libraries) + +`importComponentByKeyAsync` and `importComponentSetByKeyAsync` import components from **team libraries** (not the same file you're working in). For components in the current file, use `figma.getNodeByIdAsync()` or `findOne()`/`findAll()` to locate them directly. + +```javascript +// Import a component from a team library +const comp = await figma.importComponentByKeyAsync("COMPONENT_KEY"); +const instance = comp.createInstance(); + +// Import a component set from a team library and pick a variant +const set = await figma.importComponentSetByKeyAsync("COMPONENT_SET_KEY"); +const variant = set.children.find(c => + c.type === "COMPONENT" && c.name.includes("size=md") +) || set.defaultVariant; +const variantInstance = variant.createInstance(); +``` + +## Working with Instances + +### Finding the right variant in a component set + +Parse variant names to match on multiple properties simultaneously: + +```javascript +const compSet = await figma.importComponentSetByKeyAsync("KEY"); + +const variant = compSet.children.find(c => { + const props = Object.fromEntries( + c.name.split(', ').map(p => p.split('=')) + ); + return props.variant === "primary" && props.size === "md"; +}) || compSet.defaultVariant; + +const instance = variant.createInstance(); +``` + +### Setting variant properties on an instance + +After creating an instance from a component set, you can set variant properties via `setProperties`: + +```javascript +const instance = defaultVariant.createInstance(); +instance.setProperties({ + "variant": "primary", + "size": "medium" +}); +``` + +### Overriding text in a component instance + +**Always discover component properties BEFORE writing text overrides.** Components expose text as `TEXT`-type component properties, and `setProperties()` is the correct way to override them. Direct `node.characters` changes on property-managed text may be overridden by the component property system on render. + +**Step 1: Inspect componentProperties on a sample instance:** + +```javascript +const instance = comp.createInstance(); +const propDefs = instance.componentProperties; +// Returns e.g.: { "Label#2:0": { type: "TEXT", value: "Button" }, "Has Icon#4:64": { type: "BOOLEAN", value: true } } +figma.closePlugin(JSON.stringify(propDefs)); +``` + +Also check nested instances — a parent component may not expose text properties directly, but its nested child instances might: + +```javascript +const nestedInstances = instance.findAll(n => n.type === "INSTANCE"); +const nestedProps = nestedInstances.map(ni => ({ + name: ni.name, + id: ni.id, + properties: ni.componentProperties +})); +``` + +**Step 2: Use setProperties() for TEXT-type properties:** + +```javascript +const instance = comp.createInstance(); +const propDefs = instance.componentProperties; +for (const [key, def] of Object.entries(propDefs)) { + if (def.type === "TEXT") { + instance.setProperties({ [key]: "New text value" }); + } +} +``` + +For nested instances that expose their own TEXT properties, call `setProperties()` on the nested instance: + +```javascript +const nestedHeading = instance.findOne(n => n.type === "INSTANCE" && n.name === "Text Heading"); +if (nestedHeading) { + nestedHeading.setProperties({ "Text#2104:5": "Actual heading text" }); +} +``` + +**Step 3: Only fall back to direct node.characters for unmanaged text.** If text is NOT controlled by any component property, find text nodes directly. **Always load the node's actual font first** — instance text nodes inherit fonts from the source component, so don't assume Inter Regular: + +```javascript +const textNodes = instance.findAll(n => n.type === "TEXT"); +for (const t of textNodes) { + await figma.loadFontAsync(t.fontName); + t.characters = "Updated text"; +} +``` + +### detachInstance() invalidates ancestor node IDs + +**Warning:** When `detachInstance()` is called on a nested instance inside a library component instance, the parent instance may also get implicitly detached (converted from INSTANCE to FRAME with a **new ID**). Subsequent `getNodeByIdAsync(oldParentId)` returns null. + +```javascript +// WRONG — cached parent ID becomes invalid after child detach +const parentId = parentInstance.id; +nestedChild.detachInstance(); +const parent = await figma.getNodeByIdAsync(parentId); // null! + +// CORRECT — re-discover nodes by traversal from a stable (non-instance) parent +const stableFrame = await figma.getNodeByIdAsync(manualFrameId); // a frame YOU created +nestedChild.detachInstance(); +// Re-find the parent by traversing from the stable frame +const parent = stableFrame.findOne(n => n.name === "ParentName"); +``` + +If you must detach multiple nested instances across sibling components, do it in a **single** `use_figma` call — discover all targets by traversal at the start before any detachment mutates the tree. + +## Inspecting Component Metadata (Deep Traversal) + +These helpers extract the full property schema and descendant structure of a component. Useful for understanding complex components before creating instances or setting properties. + +```javascript +/** + * Imports a component or component set from a library by its published key. + * Tries COMPONENT first, then falls back to COMPONENT_SET. + * + * @param {string} componentKey - The published key of the component or component set. + * @returns {Promise} + */ +async function importComponentByKey(componentKey) { + try { + return await figma.importComponentByKeyAsync(componentKey); + } catch { + try { + return await figma.importComponentSetByKeyAsync(componentKey); + } catch { + throw new Error(`No Component or Component Set available with key '${componentKey}'`); + } + } +} + +/** + * Given a main component node, returns the component set parent if one exists, + * otherwise returns the component itself. Used to get the top-level node that + * holds `componentPropertyDefinitions`. + * + * @param {ComponentNode} mainComponent + * @returns {ComponentNode|ComponentSetNode} + */ +function getRelevantComponentNode(mainComponent) { + return mainComponent.parent.type === "COMPONENT_SET" + ? mainComponent.parent + : mainComponent; +} + +/** + * Extracts `componentPropertyDefinitions` from a component or component set node + * into a flat map keyed by property key. + * + * @param {ComponentNode|ComponentSetNode} node + * @returns {Record} + */ +function getComponentProps(node) { + const result = {}; + for (let key in node.componentPropertyDefinitions) { + const prop = { + name: key.replace(/#[^#]+$/, ""), + type: node.componentPropertyDefinitions[key].type, + key: key + }; + if (prop.type === "VARIANT") { + prop.variantOptions = node.componentPropertyDefinitions[key].variantOptions; + } + result[key] = prop; + } + return result; +} + +/** + * Recursively walks a component tree and collects all INSTANCE and TEXT nodes + * into `result`, keyed by `TYPE[name]`. Handles variant namespacing and + * deduplicates nodes with identical names but differing property references. + * + * @param {SceneNode} node - The node to traverse. + * @param {string[]} namespace - Accumulated variant names for the current path. + * @param {Record} result - Accumulator object populated in place. + */ +function collectDescendants(node, namespace, result) { + if (node.type === "INSTANCE" || node.type === "TEXT") { + const references = node.componentPropertyReferences || {}; + if (!node.visible && !references.visible) return; + + const object = { type: node.type, name: node.name, references }; + let key = `${node.type}[${node.name}]`; + + if (result[key] && JSON.stringify(references) !== JSON.stringify(result[key].references)) { + key += btoa(btoa(unescape(encodeURIComponent(JSON.stringify(references))))); + } + + if (node.type === "INSTANCE") { + const mainComponent = getRelevantComponentNode(node.mainComponent); + object.properties = getComponentProps(mainComponent); + object.descendants = {}; + object.mainComponentName = mainComponent.name; + collectDescendants(mainComponent, [], object.descendants); + } + + const start = namespace.length ? { variants: [] } : {}; + result[key] = Object.assign(object, result[key] || start); + if (namespace.length) result[key].variants.push(namespace[namespace.length - 1]); + } else if ("children" in node && node.visible) { + if (node.type === "COMPONENT" && node.parent.type === "COMPONENT_SET") namespace.push(node.name); + node.children.forEach(child => collectDescendants(child, namespace, result)); + } +} + +/** + * Returns structured metadata for a component or component set defined in the current file. + * + * @param {string} componentId - The node ID of a COMPONENT or COMPONENT_SET node. + * @returns {Promise<{name: string, nodeId: string, properties: object, descendants: object}|undefined>} + */ +async function getLocalComponentMetadata(componentId) { + const node = await figma.getNodeByIdAsync(componentId); + if (node.type === "COMPONENT_SET" || node.type === "COMPONENT") { + const result = { + name: node.name, + nodeId: node.id, + properties: {}, + descendants: {} + }; + result.properties = getComponentProps(node); + collectDescendants(node, [], result.descendants); + return result; + } else { + throw new Error("Node is not a Component or Component Set"); + } +} + +/** + * Returns structured metadata for a published component or component set loaded by its key. + * + * @param {string} componentKey - The published key of the component or component set. + * @returns {Promise<{name: string, nodeId: string, properties: object, descendants: object}>} + */ +async function getPublishedComponentMetadata(componentKey) { + const node = await importComponentByKey(componentKey); + const result = { + name: node.name, + nodeId: node.id, + properties: {}, + descendants: {} + }; + result.properties = getComponentProps(node); + collectDescendants(node, [], result.descendants); + return result; +} +``` + +### Full metadata extraction script + +```javascript +(async () => { + try { + // For local components, use getLocalComponentMetadata: + const result = await getLocalComponentMetadata('COMPONENT_OR_SET_ID'); + figma.closePlugin(JSON.stringify(result)); + + // For published components, use getPublishedComponentMetadata: + // const result = await getPublishedComponentMetadata('COMPONENT_KEY'); + // figma.closePlugin(JSON.stringify(result)); + } catch(e) { figma.closePluginWithFailure(e.toString()); } +})() +``` diff --git a/plugins/figma/skills/figma-use/references/effect-style-patterns.md b/plugins/figma/skills/figma-use/references/effect-style-patterns.md new file mode 100644 index 00000000..52bec418 --- /dev/null +++ b/plugins/figma/skills/figma-use/references/effect-style-patterns.md @@ -0,0 +1,123 @@ +# Effect Style API Patterns + +> Part of the [use_figma skill](../SKILL.md). How to create, apply, and inspect effect styles using the Plugin API. +> +> For design system context (effect types, variable bindings on effects, gotchas), see [wwds-effect-styles](working-with-design-systems/wwds-effect-styles.md). + +## Contents + +- Listing Effect Styles +- Creating a Drop Shadow Style +- Applying Effect Styles to Nodes + +## Listing Effect Styles + +```javascript +/** + * Lists all local effect styles. + * + * @returns {Promise>} + */ +async function listEffectStyles() { + const styles = await figma.getLocalEffectStylesAsync(); + return styles.map(s => ({ + id: s.id, + name: s.name, + key: s.key, + effectCount: s.effects.length + })); +} +``` + +Full runnable script: + +```javascript +(async () => { + try { + const results = await listEffectStyles(); + figma.closePlugin(JSON.stringify(results)); + } catch(e) { figma.closePluginWithFailure(e.toString()); } +})() +``` + +## Creating a Drop Shadow Style + +Colors are **RGBA 0–1 range**. `effects` is a read-only array — always reassign, never mutate in place. + +```javascript +/** + * Creates a drop shadow effect style. + * + * @param {string} name - e.g. "Elevation/200" + * @param {{ r: number, g: number, b: number, a: number }} color - RGBA, 0-1 range + * @param {{ x: number, y: number }} offset + * @param {number} radius - blur radius + * @param {number} [spread=0] + * @returns {EffectStyle} + */ +function createDropShadowStyle(name, color, offset, radius, spread) { + const style = figma.createEffectStyle(); + style.name = name; + style.effects = [{ + type: "DROP_SHADOW", + color, + offset, + radius, + spread: spread || 0, + visible: true, + blendMode: "NORMAL" + }]; + return style; +} +``` + +Full runnable script: + +```javascript +(async () => { + try { + const style = createDropShadowStyle( + "Elevation/200", + { r: 0, g: 0, b: 0, a: 0.15 }, + { x: 0, y: 4 }, + 12, + 0 + ); + figma.closePlugin(JSON.stringify({ id: style.id, name: style.name })); + } catch(e) { figma.closePluginWithFailure(e.toString()); } +})() +``` + +## Applying Effect Styles to Nodes + +```javascript +/** + * Applies an effect style to all nodes on the current page that match a given name pattern. + * + * @param {string} styleId - The ID of an EffectStyle. + * @param {string} nodeNamePattern - Substring match against node names. + * @returns {number} - Number of nodes the style was applied to. + */ +function applyEffectStyleToMatchingNodes(styleId, nodeNamePattern) { + const nodes = figma.currentPage.findAll(n => n.name.includes(nodeNamePattern)); + let applied = 0; + for (const node of nodes) { + if ('effectStyleId' in node) { + node.effectStyleId = styleId; + applied++; + } + } + return applied; +} +``` + +Full runnable script: + +```javascript +(async () => { + try { + const applied = applyEffectStyleToMatchingNodes('STYLE_ID', 'Card'); + figma.closePlugin(JSON.stringify({ applied })); + } catch(e) { figma.closePluginWithFailure(e.toString()); } +})() +``` diff --git a/plugins/figma/skills/figma-use/references/gotchas.md b/plugins/figma/skills/figma-use/references/gotchas.md new file mode 100644 index 00000000..5d3712cc --- /dev/null +++ b/plugins/figma/skills/figma-use/references/gotchas.md @@ -0,0 +1,599 @@ +# Gotchas & Common Mistakes + +> Part of the [use_figma skill](../SKILL.md). Every known pitfall with WRONG/CORRECT code examples. + +## Contents + +- Component properties and variant creation pitfalls +- Paint, color, and variable binding pitfalls +- Page context and plugin lifecycle pitfalls +- Auto Layout and sizing order pitfalls (including HUG/FILL interactions) +- Variant layout and geometry pitfalls +- Variable scopes and mode pitfalls +- Node cleanup and empty-fill pitfalls +- detachInstance() and node ID invalidation + + +## New nodes default to (0,0) and overlap existing content + +Every `figma.create*()` call places the node at position (0,0). If you append multiple nodes directly to the page, they all stack on top of each other and on top of any existing content. + +**This only matters for nodes appended directly to the page** (i.e., top-level nodes). Nodes appended as children of other frames, components, or auto-layout containers are positioned by their parent — don't scan for overlaps when nesting nodes. + +```js +// WRONG — top-level node lands at (0,0), overlapping existing page content +const frame = figma.createFrame() +frame.name = "My New Frame" +frame.resize(400, 300) +figma.currentPage.appendChild(frame) + +// CORRECT — find existing content bounds and place the new top-level node to the right +const page = figma.currentPage +let maxX = 0 +for (const child of page.children) { + const right = child.x + child.width + if (right > maxX) maxX = right +} +const frame = figma.createFrame() +frame.name = "My New Frame" +frame.resize(400, 300) +figma.currentPage.appendChild(frame) +frame.x = maxX + 100 // 100px gap from rightmost existing content +frame.y = 0 + +// NOT NEEDED — child nodes inside a parent don't need overlap scanning +const card = figma.createFrame() +card.layoutMode = 'VERTICAL' +const label = figma.createText() +card.appendChild(label) // positioned by auto-layout, no x/y needed +``` + +## `addComponentProperty` returns a string key, not an object — never hardcode or guess it + +Figma generates the property key dynamically (e.g. `"label#4:0"`). The suffix is unpredictable. Always capture and use the return value directly. + +```js +// WRONG — guessing / hardcoding the key +comp.addComponentProperty('label', 'TEXT', 'Button') +labelNode.componentPropertyReferences = { characters: 'label#0:1' } // Error: key not found + +// WRONG — treating the return value as an object +const result = comp.addComponentProperty('Label', 'TEXT', 'Button') +const propKey = Object.keys(result)[0] // BUG: returns '0' (first char index of string!) +labelNode.componentPropertyReferences = { characters: propKey } // Error: property '0' not found + +// CORRECT — the return value IS the key string, use it directly +const propKey = comp.addComponentProperty('Label', 'TEXT', 'Button') +// propKey === "label#4:0" (exact value varies; never assume it) +labelNode.componentPropertyReferences = { characters: propKey } +``` + +The same applies to `COMPONENT_SET` nodes — `addComponentProperty` always returns the property key as a string. + +## MUST return ALL created/mutated node IDs + +Every script that creates or mutates nodes on the canvas must track and return all affected node IDs in the `figma.closePlugin()` response. Without these IDs, subsequent calls cannot reference, validate, or clean up those nodes. + +```js +// WRONG — only returns the parent frame ID, loses track of children +const frame = figma.createFrame() +const rect = figma.createRectangle() +const text = figma.createText() +frame.appendChild(rect) +frame.appendChild(text) +figma.closePlugin(JSON.stringify({ nodeId: frame.id })) + +// CORRECT — returns all created node IDs in a structured response +const frame = figma.createFrame() +const rect = figma.createRectangle() +const text = figma.createText() +frame.appendChild(rect) +frame.appendChild(text) +figma.closePlugin(JSON.stringify({ + createdNodeIds: [frame.id, rect.id, text.id], + rootNodeId: frame.id +})) + +// CORRECT — when mutating existing nodes, return those IDs too +const nodes = figma.currentPage.findAll(n => n.name === 'Card') +for (const n of nodes) { + n.fills = [{ type: 'SOLID', color: { r: 1, g: 0, b: 0 } }] +} +figma.closePlugin(JSON.stringify({ + mutatedNodeIds: nodes.map(n => n.id), + count: nodes.length +})) +``` + +## Colors are 0–1 range + +```js +// WRONG — will throw validation error (ZeroToOne enforced) +node.fills = [{ type: 'SOLID', color: { r: 255, g: 0, b: 0 } }] + +// CORRECT +node.fills = [{ type: 'SOLID', color: { r: 1, g: 0, b: 0 } }] +``` + +## Fills/strokes are immutable arrays + +```js +// WRONG — modifying in place does nothing +node.fills[0].color = { r: 1, g: 0, b: 0 } + +// CORRECT — clone, modify, reassign +const fills = JSON.parse(JSON.stringify(node.fills)) +fills[0].color = { r: 1, g: 0, b: 0 } +node.fills = fills +``` + +## setBoundVariableForPaint returns a NEW paint + +```js +// WRONG — ignoring return value +figma.variables.setBoundVariableForPaint(paint, "color", colorVar) +node.fills = [paint] // paint is unchanged! + +// CORRECT — capture the returned new paint +const boundPaint = figma.variables.setBoundVariableForPaint(paint, "color", colorVar) +node.fills = [boundPaint] +``` + +## Variable collection starts with 1 mode + +```js +// A new collection already has one mode — rename it, don't try to add first +const collection = figma.variables.createVariableCollection("Colors") +// collection.modes = [{ modeId: "...", name: "Mode 1" }] +collection.renameMode(collection.modes[0].modeId, "Light") +const darkModeId = collection.addMode("Dark") +``` + +## combineAsVariants requires ComponentNodes + +```js +// WRONG — passing frames +const f1 = figma.createFrame() +figma.combineAsVariants([f1], figma.currentPage) // Error! + +// CORRECT — passing components +const c1 = figma.createComponent() +c1.name = "variant=primary, size=md" +const c2 = figma.createComponent() +c2.name = "variant=secondary, size=md" +figma.combineAsVariants([c1, c2], figma.currentPage) +``` + +## Page switching: sync setter throws + +The sync setter `figma.currentPage = page` **throws an error** in `use_figma` runtimes (MCP, evals, assistant). Use `await figma.setCurrentPageAsync(page)` instead — it switches the page and loads its content. + +```js +// WRONG — throws "Setting figma.currentPage is not supported in this runtime" +figma.currentPage = targetPage + +// CORRECT — async method switches and loads content +await figma.setCurrentPageAsync(targetPage) +``` + +## `get_metadata` only sees one page — use `use_figma` to discover all pages + +A Figma file can have multiple pages (canvas nodes). `get_metadata` operates on a single node/page — it cannot scan the entire document. To discover all pages and their top-level contents, use `use_figma`: + +```js +// WRONG — calling get_metadata with the file root or expecting it to list all pages +// get_metadata only returns the subtree of the node you pass it + +// CORRECT — use use_figma to list pages, then inspect each one +const pages = figma.root.children.map(p => `${p.name} id=${p.id} children=${p.children.length}`); +figma.closePlugin(pages.join('\n')); +``` + +Icons, variables, and components may live on pages other than the first. Always enumerate all pages before concluding that the file has no existing assets. + +## Never use figma.notify() + +```js +// WRONG — throws "not implemented" error +figma.notify("Done!") + +// CORRECT — use closePlugin for messaging +figma.closePlugin("Done!") +``` + +## Script must always terminate + +```js +// WRONG — no closePlugin call, script hangs +(async () => { + figma.createRectangle() +})() + +// CORRECT — always close +(async () => { + try { + figma.createRectangle() + figma.closePlugin("created") + } catch(e) { + figma.closePluginWithFailure(e.toString()) + } +})() +``` + +## setBoundVariable for paint fields only works on SOLID paints + +```js +// Only SOLID paint type supports color variable binding +// Gradient paints, image paints, etc. will throw +const solidPaint = { type: 'SOLID', color: { r: 0, g: 0, b: 0 } } +const bound = figma.variables.setBoundVariableForPaint(solidPaint, "color", colorVar) +``` + +## Explicit variable modes must be set per component + +```js +// WRONG — all variants render with the default (first) mode +const colorCollection = figma.variables.createVariableCollection("Colors") +// ... create variables and modes ... +// Components all show the first mode's values by default! + +// CORRECT — set explicit mode on each component to get variant-specific values +component.setExplicitVariableModeForCollection(colorCollection.id, targetModeId) +``` + +## `TextStyle.setBoundVariable` is not available in headless use_figma + +`setBoundVariable` exists on `TextStyle` in the typed API but is **not available** when running scripts through `use_figma` (MCP, headless assistant mode). Calling it will throw `"not a function"`. + +```js +// WRONG — throws "not a function" in use_figma / headless +const ts = figma.createTextStyle() +ts.setBoundVariable("fontSize", fontSizeVar) + +// CORRECT (headless) — set raw values; bind variables interactively in Figma later +const ts = figma.createTextStyle() +ts.fontSize = 24 +``` + +This only affects `TextStyle`. Variable binding on **nodes** (`node.setBoundVariable(...)`) and on **paint objects** (`figma.variables.setBoundVariableForPaint(...)`) still works in headless mode as expected. + +If live variable binding on text styles is required, create the styles with raw values via `use_figma`, then bind variables interactively through the Figma Styles panel or a full interactive plugin. + +## `lineHeight` and `letterSpacing` must be objects, not bare numbers + +```js +// WRONG — throws or silently does nothing +style.lineHeight = 1.5 +style.lineHeight = 24 +style.letterSpacing = 0 + +// CORRECT +style.lineHeight = { unit: "AUTO" } // auto/intrinsic +style.lineHeight = { value: 24, unit: "PIXELS" } // fixed pixel height +style.lineHeight = { value: 150, unit: "PERCENT" } // percentage of font size + +style.letterSpacing = { value: 0, unit: "PIXELS" } // no tracking +style.letterSpacing = { value: -0.5, unit: "PIXELS" } // tight +style.letterSpacing = { value: 5, unit: "PERCENT" } // percent-based +``` + +This applies to both `TextStyle` and `TextNode` properties. The same rule applies inside `use_figma`, interactive plugins, and any other plugin API context. + +## Font style names are file-dependent — probe before assuming + +Font style names vary per provider and per Figma file. `"SemiBold"` and `"Semi Bold"` are different strings. Loading a font with the wrong style string **throws silently or errors** — there is no canonical list. + +```js +// WRONG — guessing style names +await figma.loadFontAsync({ family: "Inter", style: "SemiBold" }) // may throw + +// CORRECT — probe which style names are available +const candidates = ["SemiBold", "Semi Bold", "Semibold"] +for (const style of candidates) { + try { + await figma.loadFontAsync({ family: "Inter", style }) + // capture the one that works + break + } catch (_) {} +} +``` + +When building a type ramp script, always verify font styles against the target file before hardcoding them. + +## combineAsVariants does NOT auto-layout in headless mode + +```js +// WRONG — all variants stack at position (0, 0), resulting in a tiny ComponentSet +const components = [comp1, comp2, comp3] +const cs = figma.combineAsVariants(components, figma.currentPage) +// cs.width/height will be the size of a SINGLE variant! + +// CORRECT — manually layout children in a grid after combining +const cs = figma.combineAsVariants(components, figma.currentPage) +const colWidth = 120 +const rowHeight = 56 +cs.children.forEach((child, i) => { + const col = i % numCols + const row = Math.floor(i / numCols) + child.x = col * colWidth + child.y = row * rowHeight +}) +// CRITICAL: resize from actual child bounds, not formula — formula errors leave variants outside the boundary +let maxX = 0, maxY = 0 +for (const child of cs.children) { + maxX = Math.max(maxX, child.x + child.width) + maxY = Math.max(maxY, child.y + child.height) +} +cs.resizeWithoutConstraints(maxX + 40, maxY + 40) +``` + +## COLOR variable values use {r, g, b, a} (with alpha) + +```js +// Paint colors use {r, g, b} (no alpha — opacity is a separate paint property) +node.fills = [{ type: 'SOLID', color: { r: 1, g: 0, b: 0 } }] + +// But COLOR variable values use {r, g, b, a} — alpha maps to paint opacity +const colorVar = figma.variables.createVariable("bg", collection, "COLOR") +colorVar.setValueForMode(modeId, { r: 1, g: 0, b: 0, a: 1 }) // opaque red +colorVar.setValueForMode(modeId, { r: 0, g: 0, b: 0, a: 0 }) // fully transparent + +// ⚠️ Don't confuse: {r, g, b} for paint colors vs {r, g, b, a} for variable values +``` + +## `layoutSizingVertical`/`layoutSizingHorizontal` = `'FILL'` requires auto-layout parent FIRST + +```js +// WRONG — setting FILL before the node is a child of an auto-layout frame +const child = figma.createFrame() +child.layoutSizingVertical = 'FILL' // ERROR: "FILL can only be set on children of auto-layout frames" +parent.appendChild(child) + +// CORRECT — append to auto-layout parent FIRST, then set FILL +const child = figma.createFrame() +parent.appendChild(child) // parent must have layoutMode set +child.layoutSizingVertical = 'FILL' // Works! +``` + +## HUG parents collapse FILL children + +A `HUG` parent cannot give `FILL` children meaningful size. If children have `layoutSizingHorizontal = "FILL"` but the parent is `"HUG"`, the children collapse to minimum size. The parent must be `"FILL"` or `"FIXED"` for FILL children to expand. This is a common cause of truncated text in select fields, inputs, and action rows. + +```js +// WRONG — parent hugs, so FILL children get zero extra space +const parent = figma.createFrame() +parent.layoutMode = 'HORIZONTAL' +parent.layoutSizingHorizontal = 'HUG' +const child = figma.createFrame() +parent.appendChild(child) +child.layoutSizingHorizontal = 'FILL' // collapses to min size! + +// CORRECT — parent must be FIXED or FILL for FILL children to expand +const parent = figma.createFrame() +parent.layoutMode = 'HORIZONTAL' +parent.resize(400, 50) +parent.layoutSizingHorizontal = 'FIXED' // or 'FILL' if inside another auto-layout +const child = figma.createFrame() +parent.appendChild(child) +child.layoutSizingHorizontal = 'FILL' // expands to fill remaining 400px +``` + +## `layoutGrow` with a hugging parent causes content compression + +```js +// WRONG — layoutGrow on a child when parent has primaryAxisSizingMode='AUTO' (hug) +// causes the child to SHRINK below its natural size instead of expanding +const parent = figma.createComponent() +parent.layoutMode = 'VERTICAL' +parent.primaryAxisSizingMode = 'AUTO' // hug contents +const content = figma.createFrame() +content.layoutMode = 'VERTICAL' +content.primaryAxisSizingMode = 'AUTO' +parent.appendChild(content) +content.layoutGrow = 1 // BUG: content compresses, children hidden! + +// CORRECT — only use layoutGrow when parent has FIXED sizing with extra space +content.layoutGrow = 0 // let content take its natural size +// OR: set parent to FIXED sizing first +parent.primaryAxisSizingMode = 'FIXED' +parent.resizeWithoutConstraints(300, 500) +content.layoutGrow = 1 // NOW it correctly fills remaining space +``` + +## `resize()` resets `primaryAxisSizingMode` and `counterAxisSizingMode` to FIXED + +```js +// WRONG — resize() after setting sizing mode overwrites it back to FIXED +const frame = figma.createComponent() +frame.layoutMode = 'VERTICAL' +frame.primaryAxisSizingMode = 'AUTO' // hug height +frame.counterAxisSizingMode = 'FIXED' +frame.resize(300, 10) // BUG: resets BOTH axes to 'FIXED'! Height stays at 10px forever. + +// CORRECT — call resize() FIRST, then set sizing modes +const frame = figma.createComponent() +frame.layoutMode = 'VERTICAL' +frame.resize(300, 10) // set initial dimensions first +frame.counterAxisSizingMode = 'FIXED' // keep width fixed at 300 +frame.primaryAxisSizingMode = 'AUTO' // NOW set height to hug — this sticks! +// Or use the modern shorthand (equivalent): +// frame.layoutSizingHorizontal = 'FIXED' +// frame.layoutSizingVertical = 'HUG' +``` + +## Node positions don't auto-reset after reparenting + +```js +// WRONG — assuming positions reset when moving a node into a new parent +const node = figma.createRectangle() +node.x = 500; node.y = 500; +figma.currentPage.appendChild(node) +section.appendChild(node) // node still at (500, 500) relative to section! + +// CORRECT — explicitly set x/y after ANY reparenting operation +section.appendChild(node) +node.x = 80; node.y = 80; // reset to desired position within section +``` + +## Grid layout with mixed-width rows causes overlaps + +```js +// WRONG — using a single column offset for rows with different-width items +// e.g. vertical cards (320px) and horizontal cards (500px) in a 2-row grid +for (let i = 0; i < allCards.length; i++) { + allCards[i].x = (i % 4) * 370 // 370 works for 320px cards but NOT 500px cards! +} + +// CORRECT — compute each row's spacing independently based on actual child widths +const gap = 50 +let x = 0 +for (const card of horizontalCards) { + card.x = x + x += card.width + gap // use actual width, not a fixed column size +} +``` + +## Sections don't auto-resize to fit content + +```js +// WRONG — section stays at default size, content overflows +const section = figma.createSection() +section.name = "My Section" +section.appendChild(someNode) // node may be outside section bounds + +// CORRECT — explicitly resize after adding content +const section = figma.createSection() +section.name = "My Section" +section.appendChild(someNode) +section.resizeWithoutConstraints( + Math.max(someNode.width + 100, 800), + Math.max(someNode.height + 100, 600) +) +``` + +## `counterAxisAlignItems` does NOT support `'STRETCH'` + +```js +// WRONG — 'STRETCH' is not a valid enum value +comp.counterAxisAlignItems = 'STRETCH' +// Error: Invalid enum value. Expected 'MIN' | 'MAX' | 'CENTER' | 'BASELINE', received 'STRETCH' + +// CORRECT — use 'MIN' on the parent, then set children to FILL on the cross axis +comp.counterAxisAlignItems = 'MIN' +comp.appendChild(child) +// For vertical layout, stretch width: +child.layoutSizingHorizontal = 'FILL' +// For horizontal layout, stretch height: +child.layoutSizingVertical = 'FILL' +``` + +## Variable collection mode limits are plan-dependent + +```js +// Figma limits modes per collection based on the team/org plan: +// Free: 1 mode only (no addMode) +// Professional: up to 4 modes +// Organization/Enterprise: up to 40+ modes +// +// WRONG — creating 20 modes on a Professional plan will fail silently or throw +const coll = figma.variables.createVariableCollection("Variants") +for (let i = 0; i < 20; i++) coll.addMode("mode" + i) // May fail! + +// CORRECT — if you need many modes, split across multiple collections +// E.g., instead of 1 collection with 20 modes (variant×color): +// Collection A: 4 modes (variant: plain/outlined/soft/solid) +// Collection B: 5 modes (color: neutral/primary/danger/success/warning) +// Then use setExplicitVariableModeForCollection for BOTH on each component +``` + +## Variables default to `ALL_SCOPES` — always set scopes explicitly + +```js +// WRONG — variable appears in every property picker (fills, text, strokes, spacing, etc.) +const bgColor = figma.variables.createVariable("Background/Default", coll, "COLOR") +// bgColor.scopes defaults to ["ALL_SCOPES"] — pollutes all dropdowns + +// CORRECT — restrict to relevant property pickers +const bgColor = figma.variables.createVariable("Background/Default", coll, "COLOR") +bgColor.scopes = ["FRAME_FILL", "SHAPE_FILL", "EFFECT_COLOR"] // fill pickers only + +const textColor = figma.variables.createVariable("Text/Default", coll, "COLOR") +textColor.scopes = ["TEXT_FILL"] // text color picker only + +const borderColor = figma.variables.createVariable("Border/Default", coll, "COLOR") +borderColor.scopes = ["STROKE_COLOR"] // stroke picker only + +const spacing = figma.variables.createVariable("Space/400", coll, "FLOAT") +spacing.scopes = ["GAP"] // gap/spacing pickers only + +// Hide primitives that are only referenced via aliases +const primitive = figma.variables.createVariable("Brand/500", coll, "COLOR") +primitive.scopes = [] // hidden from all pickers +``` + +## Binding fills on nodes with empty fills + +```js +// WRONG — binding to a node with no fills does nothing +const comp = figma.createComponent() +comp.fills = [] // transparent +// Can't bind a color variable to fills that don't exist + +// CORRECT — add a placeholder SOLID fill, then bind the variable +const comp = figma.createComponent() +const basePaint = { type: 'SOLID', color: { r: 0, g: 0, b: 0 } } +const boundPaint = figma.variables.setBoundVariableForPaint(basePaint, "color", colorVar) +comp.fills = [boundPaint] +// The variable's resolved value (which may be transparent) will control the actual color +``` + +## Mode names must be descriptive — never leave 'Mode 1' + +Every new `VariableCollection` starts with one mode named `'Mode 1'`. Always rename it immediately. For single-mode collections use `'Default'`; for multi-mode collections use names from the source (e.g. `'Light'`/`'Dark'`, `'Desktop'`/`'Tablet'`/`'Mobile'`). + + // WRONG — generic names give no semantic meaning + const coll = figma.variables.createVariableCollection('Colors') + // coll.modes[0].name === 'Mode 1' — left as-is + const darkId = coll.addMode('Mode 2') + + // CORRECT — rename immediately to match the source + const coll = figma.variables.createVariableCollection('Colors') + coll.renameMode(coll.modes[0].modeId, 'Light') // was 'Mode 1' + const darkId = coll.addMode('Dark') + + // For single-mode collections (primitives, spacing, etc.) + const spacing = figma.variables.createVariableCollection('Spacing') + spacing.renameMode(spacing.modes[0].modeId, 'Default') // was 'Mode 1' + +## CSS variable names must not contain spaces + +When constructing a `var(--name)` string from a Figma variable name, replace BOTH slashes AND spaces with hyphens and convert to lowercase. + + // WRONG — only replacing slashes leaves spaces like 'var(--color-bg-brand secondary hover)' + v.setVariableCodeSyntax('WEB', `var(--${figmaName.replace(/\//g, '-').toLowerCase()})`) + + // CORRECT — replace all whitespace and slashes in one pass + v.setVariableCodeSyntax('WEB', `var(--${figmaName.replace(/[\s\/]+/g, '-').toLowerCase()})`) + +**Best practice**: Preserve the original CSS variable name from the source token file rather than deriving it from the Figma name. + + // Preferred — use the source CSS name directly + v.setVariableCodeSyntax('WEB', `var(${token.cssVar})`) // e.g. '--color-bg-brand-secondary-hover' + +## `detachInstance()` invalidates ancestor node IDs + +When `detachInstance()` is called on a nested instance inside a library component instance, the parent instance may also get implicitly detached (converted from INSTANCE to FRAME with a **new ID**). Any previously cached ID for the parent becomes invalid. + +```js +// WRONG — using cached parent ID after child detach +const parentId = parentInstance.id; +nestedChild.detachInstance(); +const parent = await figma.getNodeByIdAsync(parentId); // null! ID changed. + +// CORRECT — re-discover by traversal from a stable (non-instance) frame +const stableFrame = await figma.getNodeByIdAsync(manualFrameId); +nestedChild.detachInstance(); +const parent = stableFrame.findOne(n => n.name === "ParentName"); +``` + +If detaching multiple nested instances across siblings, do it in a **single** `use_figma` call — discover all targets by traversal before any detachment mutates the tree. diff --git a/plugins/figma/skills/figma-use/references/maintainers.yml b/plugins/figma/skills/figma-use/references/maintainers.yml new file mode 100644 index 00000000..c2af2921 --- /dev/null +++ b/plugins/figma/skills/figma-use/references/maintainers.yml @@ -0,0 +1,12 @@ +api-reference.md: mcp_server +common-patterns.md: mcp_server +component-patterns.md: mcp_server +effect-style-patterns.md: mcp_server +gotchas.md: mcp_server +plugin-api-patterns.md: mcp_server +plugin-api-standalone.d.ts: mcp_server +plugin-api-standalone.index.md: mcp_server +text-style-patterns.md: mcp_server +validation-and-recovery.md: mcp_server +variable-patterns.md: mcp_server +working-with-design-systems: mcp_server diff --git a/plugins/figma/skills/figma-use/references/plugin-api-patterns.md b/plugins/figma/skills/figma-use/references/plugin-api-patterns.md new file mode 100644 index 00000000..69060dcf --- /dev/null +++ b/plugins/figma/skills/figma-use/references/plugin-api-patterns.md @@ -0,0 +1,513 @@ +# Plugin API Patterns + +> Part of the [use_figma skill](../SKILL.md). Quick reference for common Figma Plugin API operations. + +## Contents + +- Execution Basics +- Creating Nodes +- Fills and Strokes +- Auto Layout +- Effects +- Opacity and Blend Modes +- Corner Radius and Clipping +- Grouping and Organization +- Components and Variants +- Styles +- Cloning, Finding Nodes, and Grids +- Constraints and Viewport + + +## Execution Basics + +### Page Context + +Page context resets between `use_figma` calls — `figma.currentPage` always starts on the first page. Use `await figma.setCurrentPageAsync(page)` at the start of each invocation to switch to the correct page. + +```javascript +const targetPage = figma.root.children.find(p => p.name === "My Page"); +await figma.setCurrentPageAsync(targetPage); +// targetPage.children is now populated +``` + +### Closing the Plugin + +Every execution **must** call `figma.closePlugin()` on success and `figma.closePluginWithFailure()` on error: + +```javascript +figma.closePlugin("Success message describing what was done"); +figma.closePluginWithFailure("Description of what went wrong"); +``` + +`figma.notify()` does **not** exist. Return all information via the close message string. + +### Working Incrementally + +Don't build an entire screen in one call. Break work into small steps: +1. Create tokens/variables +2. Create text styles +3. Build individual components +4. Compose sections +5. Assemble screens + +Verify structure with `get_metadata` between steps. Use `get_screenshot` after each major creation milestone to catch visual problems early. + +## Creating Nodes + +### Frames + +```javascript +const frame = figma.createFrame(); +frame.name = "Container"; +frame.resize(1440, 900); +frame.x = 0; +frame.y = 0; +frame.fills = [{ type: "SOLID", color: { r: 0.98, g: 0.98, b: 0.99 } }]; +``` + +### Text + +```javascript +// MUST load font before any text operations +await figma.loadFontAsync({ family: "Inter", style: "Regular" }); + +const text = figma.createText(); +text.fontName = { family: "Inter", style: "Regular" }; +text.fontSize = 16; +text.lineHeight = { value: 24, unit: "PIXELS" }; +text.letterSpacing = { value: 0, unit: "PERCENT" }; +text.characters = "Hello World"; +text.fills = [{ type: "SOLID", color: { r: 0.1, g: 0.1, b: 0.12 } }]; +``` + +### Rectangles + +```javascript +const rect = figma.createRectangle(); +rect.name = "Background"; +rect.resize(400, 300); +rect.cornerRadius = 12; +rect.fills = [{ type: "SOLID", color: { r: 0.95, g: 0.95, b: 0.96 } }]; +``` + +### Ellipses + +```javascript +const circle = figma.createEllipse(); +circle.name = "Avatar Circle"; +circle.resize(48, 48); +circle.fills = [{ type: "SOLID", color: { r: 0.85, g: 0.87, b: 0.90 } }]; +``` + +### Lines + +```javascript +const line = figma.createLine(); +line.name = "Divider"; +line.resize(400, 0); +line.strokes = [{ type: "SOLID", color: { r: 0, g: 0, b: 0 }, opacity: 0.08 }]; +line.strokeWeight = 1; +``` + +### SVG Import + +```javascript +const svgString = ` + +`; + +const node = figma.createNodeFromSvg(svgString); +node.name = "Icon/Arrow Right"; +node.resize(24, 24); +``` + +## Fills & Strokes + +### Solid Fill + +```javascript +node.fills = [{ type: "SOLID", color: { r: 0.2, g: 0.2, b: 0.25 } }]; +``` + +### Fill with Opacity + +```javascript +node.fills = [{ type: "SOLID", color: { r: 0.2, g: 0.2, b: 0.25 }, opacity: 0.5 }]; +``` + +### No Fill (Transparent) + +```javascript +node.fills = []; +``` + +### Linear Gradient + +```javascript +node.fills = [{ + type: "GRADIENT_LINEAR", + gradientStops: [ + { color: { r: 0.2, g: 0.36, b: 0.96, a: 1 }, position: 0 }, + { color: { r: 0.56, g: 0.24, b: 0.88, a: 1 }, position: 1 } + ], + gradientTransform: [[1, 0, 0], [0, 1, 0]] +}]; +``` + +### Strokes + +```javascript +node.strokes = [{ type: "SOLID", color: { r: 0.85, g: 0.85, b: 0.87 } }]; +node.strokeWeight = 1; +node.strokeAlign = "INSIDE"; // "CENTER", "OUTSIDE" +``` + +### Multiple Fills (Layered) + +```javascript +node.fills = [ + { type: "SOLID", color: { r: 0.95, g: 0.95, b: 0.96 } }, + { type: "SOLID", color: { r: 0.2, g: 0.36, b: 0.96 }, opacity: 0.05 } +]; +``` + +## Auto Layout + +### Setting Up Auto Layout + +```javascript +const frame = figma.createFrame(); +frame.layoutMode = "VERTICAL"; // or "HORIZONTAL" +frame.primaryAxisSizingMode = "AUTO"; // Hug main axis +frame.counterAxisSizingMode = "FIXED"; // Fixed cross axis +frame.resize(360, 1); // Width fixed, height auto +frame.itemSpacing = 16; // Gap between children +frame.paddingTop = 24; +frame.paddingBottom = 24; +frame.paddingLeft = 24; +frame.paddingRight = 24; +``` + +### Alignment + +```javascript +// Main axis (direction of layout) +frame.primaryAxisAlignItems = "MIN"; // Start +frame.primaryAxisAlignItems = "CENTER"; // Center +frame.primaryAxisAlignItems = "MAX"; // End +frame.primaryAxisAlignItems = "SPACE_BETWEEN"; // Distribute + +// Cross axis +frame.counterAxisAlignItems = "MIN"; // Start +frame.counterAxisAlignItems = "CENTER"; // Center +frame.counterAxisAlignItems = "MAX"; // End +// NOTE: 'STRETCH' is NOT valid — use 'MIN' + child.layoutSizingX = 'FILL' +``` + +### Child Sizing + +```javascript +// IMPORTANT: FILL can only be set AFTER the child is appended to an auto-layout parent +parent.appendChild(child) +child.layoutSizingHorizontal = "FILL"; // Stretch to parent +child.layoutSizingHorizontal = "HUG"; // Shrink to content +child.layoutSizingHorizontal = "FIXED"; // Manual width + +child.layoutSizingVertical = "FILL"; +child.layoutSizingVertical = "HUG"; +child.layoutSizingVertical = "FIXED"; +``` + +### Wrapping (Grid-like Layout) + +```javascript +frame.layoutMode = "HORIZONTAL"; +frame.layoutWrap = "WRAP"; +frame.itemSpacing = 24; // Horizontal gap +frame.counterAxisSpacing = 24; // Vertical gap (between rows) +``` + +### Absolute Positioning Within Auto Layout + +```javascript +child.layoutPositioning = "ABSOLUTE"; +child.constraints = { horizontal: "MAX", vertical: "MIN" }; // Top-right +child.x = parentWidth - childWidth - 8; +child.y = 8; +``` + +## Effects + +### Drop Shadow + +```javascript +node.effects = [{ + type: "DROP_SHADOW", + color: { r: 0, g: 0, b: 0, a: 0.08 }, + offset: { x: 0, y: 4 }, + radius: 16, + spread: -2, + visible: true, + blendMode: "NORMAL" +}]; +``` + +### Inner Shadow + +```javascript +node.effects = [{ + type: "INNER_SHADOW", + color: { r: 0, g: 0, b: 0, a: 0.05 }, + offset: { x: 0, y: 1 }, + radius: 2, + spread: 0, + visible: true, + blendMode: "NORMAL" +}]; +``` + +### Background Blur + +```javascript +node.effects = [{ + type: "BACKGROUND_BLUR", + radius: 16, + visible: true +}]; +``` + +### Layer Blur + +```javascript +node.effects = [{ + type: "LAYER_BLUR", + radius: 8, + visible: true +}]; +``` + +### Multiple Effects + +```javascript +node.effects = [ + { type: "DROP_SHADOW", color: { r: 0, g: 0, b: 0, a: 0.04 }, offset: { x: 0, y: 1 }, radius: 3, spread: 0, visible: true, blendMode: "NORMAL" }, + { type: "DROP_SHADOW", color: { r: 0, g: 0, b: 0, a: 0.06 }, offset: { x: 0, y: 8 }, radius: 24, spread: -4, visible: true, blendMode: "NORMAL" } +]; +``` + +## Opacity & Blend Modes + +```javascript +node.opacity = 0.5; +node.blendMode = "NORMAL"; // "MULTIPLY", "SCREEN", "OVERLAY", "DARKEN", "LIGHTEN", etc. +``` + +## Corner Radius + +```javascript +// Uniform +node.cornerRadius = 12; + +// Per-corner +node.topLeftRadius = 12; +node.topRightRadius = 12; +node.bottomLeftRadius = 0; +node.bottomRightRadius = 0; +``` + +## Clipping + +```javascript +frame.clipsContent = true; // Children clipped to frame bounds +``` + +## Grouping & Organization + +### Groups + +```javascript +const group = figma.group([node1, node2, node3], figma.currentPage); +group.name = "Grouped Elements"; +``` + +### Sections + +```javascript +const section = figma.createSection(); +section.name = "My Section"; +section.resizeWithoutConstraints(800, 600); +section.x = 0; +section.y = 0; +// IMPORTANT: Sections don't auto-resize — always resize after adding content +``` + +### Appending Children + +```javascript +parentFrame.appendChild(childNode); + +// Insert at a specific index +parentFrame.insertChild(0, childNode); // Insert at beginning +``` + +## Components & Variants + +### Create Component + +```javascript +const component = figma.createComponent(); +component.name = "Button/Primary"; +component.description = "Primary action button."; +``` + +### Create Instance + +```javascript +const instance = component.createInstance(); +instance.x = 200; +instance.y = 100; +``` + +### Import Components by Key (Team Libraries) + +These methods import components from **team libraries** (not the same file). For components in the current file, use `figma.getNodeByIdAsync()` or `findOne()`/`findAll()`. + +```javascript +// Import a published component from a team library by its key +const comp = await figma.importComponentByKeyAsync(componentKey) +const instance = comp.createInstance() + +// Import a published component set from a team library by its key +const set = await figma.importComponentSetByKeyAsync(componentSetKey) +const variant = set.defaultVariant +const variantInstance = variant.createInstance() +``` + +### Combine as Variants + +```javascript +// IMPORTANT: Pass ComponentNodes (not frames) +const componentSet = figma.combineAsVariants( + [variantA, variantB, variantC], + figma.currentPage +); +componentSet.name = "Button"; +componentSet.description = "Button component with multiple variants."; + +// CRITICAL: Layout variants in a grid after combining (they stack at 0,0) +let maxX = 0, maxY = 0; +componentSet.children.forEach((child, i) => { + child.x = (i % numCols) * colWidth; + child.y = Math.floor(i / numCols) * rowHeight; +}); +for (const child of componentSet.children) { + maxX = Math.max(maxX, child.x + child.width); + maxY = Math.max(maxY, child.y + child.height); +} +componentSet.resizeWithoutConstraints(maxX + 40, maxY + 40); +``` + +### Component Properties + +```javascript +// addComponentProperty returns a STRING key — capture it! +const labelKey = component.addComponentProperty("label", "TEXT", "Button"); +const showIconKey = component.addComponentProperty("showIcon", "BOOLEAN", true); +const iconSlotKey = component.addComponentProperty("iconSlot", "INSTANCE_SWAP", defaultIconId); + +// MUST link properties to child nodes via componentPropertyReferences +labelNode.componentPropertyReferences = { characters: labelKey }; +iconInstance.componentPropertyReferences = { + visible: showIconKey, + mainComponent: iconSlotKey +}; +``` + +## Styles + +### Text Style + +```javascript +await figma.loadFontAsync({ family: "Inter", style: "Regular" }); + +const style = figma.createTextStyle(); +style.name = "Body/Default"; +style.fontName = { family: "Inter", style: "Regular" }; +style.fontSize = 16; +style.lineHeight = { value: 24, unit: "PIXELS" }; +style.letterSpacing = { value: 0, unit: "PERCENT" }; + +// Apply to a text node +textNode.textStyleId = style.id; +``` + +### Effect Style + +```javascript +const shadowStyle = figma.createEffectStyle(); +shadowStyle.name = "Shadow/Subtle"; +shadowStyle.effects = [{ + type: "DROP_SHADOW", + color: { r: 0, g: 0, b: 0, a: 0.06 }, + offset: { x: 0, y: 2 }, + radius: 8, + spread: 0, + visible: true, + blendMode: "NORMAL" +}]; + +// Apply to a node +frame.effectStyleId = shadowStyle.id; +``` + +## Cloning & Duplication + +```javascript +const clone = originalNode.clone(); +clone.x = originalNode.x + originalNode.width + 40; +clone.name = "Copy of " + originalNode.name; +``` + +## Finding Nodes + +```javascript +// Find by name on current page +const node = figma.currentPage.findOne(n => n.name === "My Frame"); + +// Find all by type +const allTexts = figma.currentPage.findAll(n => n.type === "TEXT"); + +// Find all by name pattern +const allButtons = figma.currentPage.findAll(n => n.name.startsWith("Button/")); +``` + +## Layout Grids + +```javascript +frame.layoutGrids = [ + { + pattern: "COLUMNS", + alignment: "STRETCH", + count: 12, + gutterSize: 24, + offset: 80, + visible: true + } +]; +``` + +## Constraints (Non-Auto-Layout Frames) + +```javascript +child.constraints = { + horizontal: "LEFT_RIGHT", // LEFT, RIGHT, CENTER, LEFT_RIGHT, SCALE + vertical: "TOP" // TOP, BOTTOM, CENTER, TOP_BOTTOM, SCALE +}; +``` + +## Viewport & Zoom + +```javascript +// Zoom to fit specific nodes +figma.viewport.scrollAndZoomIntoView([frame1, frame2]); +``` diff --git a/plugins/figma/skills/figma-use/references/plugin-api-standalone.d.ts b/plugins/figma/skills/figma-use/references/plugin-api-standalone.d.ts new file mode 100644 index 00000000..98984682 --- /dev/null +++ b/plugins/figma/skills/figma-use/references/plugin-api-standalone.d.ts @@ -0,0 +1,11293 @@ +// https://raw.githubusercontent.com/figma/plugin-typings/refs/heads/master/plugin-api-standalone.d.ts + +/* plugin-typings are auto-generated. Do not update them directly. See developer-docs/ for instructions. */ +/** + * NOTE: This file is useful if you want to import specific types eg. + * import type { SceneNode } from "@figma/plugin-typings/plugin-api-standalone" + */ +/** + * @see https://developers.figma.com/docs/plugins/api/properties/figma-on + */ +declare type ArgFreeEventType = + | 'selectionchange' + | 'currentpagechange' + | 'close' + | 'timerstart' + | 'timerstop' + | 'timerpause' + | 'timerresume' + | 'timeradjust' + | 'timerdone' +/** + * @see https://developers.figma.com/docs/plugins/api/figma + */ +interface PluginAPI { + /** + * The version of the Figma API this plugin is running on, as defined in your `manifest.json` in the `"api"` field. + */ + readonly apiVersion: '1.0.0' + /** + * The currently executing command from the `manifest.json` file. It is the command string in the `ManifestMenuItem` (more details in the [manifest guide](https://developers.figma.com/docs/plugins/manifest)). If the plugin does not have any menu item, this property is undefined. + */ + readonly command: string + /** + * The current editor type this plugin is running in. See also [Setting editor type](https://developers.figma.com/docs/plugins/setting-editor-type). + */ + readonly editorType: 'figma' | 'figjam' | 'dev' | 'slides' | 'buzz' + /** + * Return the context the plugin is current running in. + * + * - `default` - The plugin is running as a normal plugin. + * - `textreview` - The plugin is running to provide text review functionality. + * - `inspect` - The plugin is running in the Inspect panel in Dev Mode. + * - `codegen` - The plugin is running in the Code section of the Inspect panel in Dev Mode. + * - `linkpreview` - The plugin is generating a link preview for a [Dev resource](https://help.figma.com/hc/en-us/articles/15023124644247#Add_external_links_and_resources_for_developers) in Dev Mode. + * - `auth` - The plugin is running to authenticate a user in Dev Mode. + * + * Caution: The `linkpreview` and `auth` modes are only available to partner and Figma-owned plugins. + * + * @remarks + * Here’s a simplified example where you can create an if statement in a plugin that has one set of functionality when it is run in `Dev Mode`, and another set of functionality when run in Figma design: + * ```ts title="Code sample to determine editorType and mode" + * if (figma.editorType === "dev") { + * // Read the document and listen to API events + * if (figma.mode === "inspect") { + * // Running in inspect panel mode + * } else if (figma.mode === "codegen") { + * // Running in codegen mode + * } + * } else if (figma.editorType === "figma") { + * // If the plugin is run in Figma design, edit the document + * if (figma.mode === 'textreview') { + * // Running in text review mode + * } + * } else if (figma.editorType === "figjam") { + * // Do FigJam only operations + * if (figma.mode === 'textreview') { + * // Running in text review mode + * } + * } + * ``` + */ + readonly mode: 'default' | 'textreview' | 'inspect' | 'codegen' | 'linkpreview' | 'auth' + /** + * The value specified in the `manifest.json` "id" field. This only exists for Plugins. + */ + readonly pluginId?: string + /** + * Similar to `figma.pluginId` but for widgets. The value specified in the `manifest.json` "id" field. This only exists for Widgets. + */ + readonly widgetId?: string + /** + * The file key of the current file this plugin is running on. + * **Only [private plugins](https://help.figma.com/hc/en-us/articles/4404228629655-Create-private-organization-plugins) and Figma-owned resources (such as the Jira and Asana widgets) have access to this.** + * To enable this behavior, you need to specify `enablePrivatePluginApi` in your `manifest.json`. + */ + readonly fileKey: string | undefined + /** + * When enabled, causes all node properties and methods to skip over invisible nodes (and their descendants) inside {@link InstanceNode | instances}. + * This makes operations like document traversal much faster. + * + * Note: Defaults to true in Figma Dev Mode and false in Figma and FigJam + * + * @remarks + * + * Accessing and modifying invisible nodes and their descendants inside instances can be slow with the plugin API. + * This is especially true in large documents with tens of thousands of nodes where a call to {@link ChildrenMixin.findAll} might come across many of these invisible instance children. + * + * If your plugin does not need access to these nodes, we recommend setting `figma.skipInvisibleInstanceChildren = true` as that often makes document traversal significantly faster. + * + * When this flag is enabled, it will not be possible to access invisible nodes (and their descendants) inside instances. This has the following effects: + * + * - {@link ChildrenMixin.children} and methods such as {@link ChildrenMixin.findAll} will exclude these nodes. + * - {@link PluginAPI.getNodeByIdAsync} will return a promise containing null. + * - {@link PluginAPI.getNodeById} will return null. + * - Accessing a property on an existing node object for an invisible node will throw an error. + * + * For example, suppose that a portion of the document tree looks like this: + * + * Frame (visible) → Instance (visible) → Frame (invisible) → Text (visible) + * + * The last two frame and text nodes cannot be accessed after setting `figma.skipInvisibleInstanceChildren = true`. + * + * The benefit of enabling this flag is that document traversal methods, {@link ChildrenMixin.findAll} and {@link ChildrenMixin.findOne}, can be up to several times faster in large documents that have invisible instance children. + * {@link ChildrenMixin.findAllWithCriteria} can be up to hundreds of times faster in large documents. + */ + skipInvisibleInstanceChildren: boolean + /** + * Note: This API is only available in FigJam + * + * This property contains methods used to read, set, and modify the built in FigJam timer. + * + * Read more in the [timer section](https://developers.figma.com/docs/plugins/api/figma-timer). + */ + readonly timer?: TimerAPI + /** + * This property contains methods used to read and set the viewport, the user-visible area of the current page. + * + * Read more in the [viewport section](https://developers.figma.com/docs/plugins/api/figma-viewport). + */ + readonly viewport: ViewportAPI + /** + * Note: `currentuser` must be specified in the permissions array in `manifest.json` to access this property. + * + * This property contains details about the current user. + */ + readonly currentUser: User | null + /** + * Note: This API is only available in FigJam. + * + * `activeusers` must be specified in the permissions array in `manifest.json` to access this property. + * + * This property contains details about the active users in the file. `figma.activeUsers[0]` will match `figma.currentUser` for the `id`, `name`, `photoUrl`, `color`, and `sessionId` properties. + */ + readonly activeUsers: ActiveUser[] + /** + * Note: `textreview` must be specified in the capabilities array in `manifest.json` to access this property. + * + * This property contains methods that enable text review features in your plugin. + */ + readonly textreview?: TextReviewAPI + /** + * This property contains methods used to integrate with the Dev Mode codegen functionality. + * + * Read more in the [codegen section](https://developers.figma.com/docs/plugins/api/figma-codegen). + */ + readonly codegen: CodegenAPI + /** + * This property contains methods used to integrate with the Figma for VS Code extension. If `undefined`, the plugin is not running in VS Code. + * + * Read more in [Dev Mode plugins in Visual Studio Code](https://developers.figma.com/docs/plugins/working-in-dev-mode#dev-mode-plugins-in-visual-studio-code) + */ + readonly vscode?: VSCodeAPI + /** + * Caution: This is a private API only available to [Figma partners](https://www.figma.com/partners/) + */ + readonly devResources?: DevResourcesAPI + /** + * Note: `payments` must be specified in the permissions array in `manifest.json` to access this property. + * + * This property contains methods for plugins that require payment. + */ + readonly payments?: PaymentsAPI + /** + * Closes the plugin. You should always call this function once your plugin is done running. When called, any UI that's open will be closed and any `setTimeout` or `setInterval` timers will be cancelled. + * + * @param message - Optional -- display a visual bell toast with the message after the plugin closes. + * + * @remarks + * + * Calling `figma.closePlugin()` disables callbacks and Figma APIs. It does not, however, abort the plugin. Any lines of Javascript after this call will also run. For example, consider the following plugin that expects the user to have one layer selected: + * + * ```ts title="Simple closePlugin" + * if (figma.currentPage.selection.length !== 1) { + * figma.closePlugin() + * } + * figma.currentPage.selection[0].opacity = 0.5 + * ``` + * + * This will not work. The last line will still run, but will throw an exception because access to `figma.currentPage` has been disabled. As such, it is not recommended to run any code after calling `figma.closePlugin()`. + * + * A simple way to easily exit your plugin is to wrap your plugin in a function, instead of running code at the top-level, and always follow `figma.closePlugin()` with a `return` statement: + * + * ```ts title="Early return" + * function main() { + * if (figma.currentPage.selection.length !== 1) { + * figma.closePlugin() + * return + * } + * figma.currentPage.selection[0].opacity = 0.5 + * } + * main() + * ``` + * + * It's good practice to have all input validation done at the start of the plugin. However, there may be cases where the plugin may need to close after a chain of multiple function calls. If you expect to have to close the plugin deep within your code, but don't want to necessarily want the user to see an error, the example above will not be sufficient. + * + * One alternative is to use a top-level try-catch statement. However, you will need to be responsible for making sure that there are no usages of try-catch between the top-level try-catch and the call to `figma.closePlugin()`, or to pass along the close command if necessary. Example: + * + * ```ts title="Top-level try-catch" + * const CLOSE_PLUGIN_MSG = "_CLOSE_PLUGIN_" + * function someNestedFunctionCallThatClosesThePlugin() { + * throw CLOSE_PLUGIN_MSG + * } + * + * function main() { + * someNestedFunctionCallThatClosesThePlugin() + * } + * + * try { + * main() + * } catch (e) { + * if (e === CLOSE_PLUGIN_MSG) { + * figma.closePlugin() + * } else { + * // >> DO NOT LEAVE THIS OUT << + * // If we caught any other kind of exception, + * // it's a real error and should be passed along. + * throw e + * } + * } + * ``` + */ + closePlugin(message?: string): void + /** + * Shows a notification on the bottom of the screen. + * + * @param message - The message to show. It is limited to 100 characters. Longer messages will be truncated. + * @param options - An optional argument with the following optional parameters: + * + * ```ts + * interface NotificationOptions { + * timeout?: number; + * error?: boolean; + * onDequeue?: (reason: NotifyDequeueReason) => void + * button?: { + * text: string + * action: () => boolean | void + * } + * } + * ``` + * + * - `timeout`: How long the notification stays up in milliseconds before closing. Defaults to 3 seconds when not specified. Set the timeout to `Infinity` to make the notification show indefinitely until the plugin is closed. + * - `error`: If true, display the notification as an error message, with a different color. + * - `onDequeue`: A function that will run when the notification is dequeued. This can happen due to the timeout being reached, the notification being dismissed by the user or Figma, or the user clicking the notification's `button`. + * - The function is passed a `NotifyDequeueReason`, which is defined as the following: + * ```ts + * type NotifyDequeueReason = 'timeout' | 'dismiss' | 'action_button_click' + * ``` + * - `button`: An object representing an action button that will be added to the notification. + * - `text`: The message to display on the action button. + * - `action`: The function to execute when the user clicks the button. If this function returns `false`, the message will remain when the button is clicked. Otherwise, clicking the action button dismisses the notify message. + * + * @remarks + * + * The `notify` API is a convenient way to show a message to the user. These messages can be queued. + * + * If the message includes a custom action button, it will be closed automatically when the plugin closes. + * + * Calling `figma.notify` returns a `NotificationHandler` object. This object contains a single `handler.cancel()` method that can be used to remove the notification before it times out by itself. This is useful if the notification becomes no longer relevant. + * + * ```ts + * interface NotificationHandler { + * cancel: () => void + * } + * ``` + * + * An alternative way to show a message to the user is to pass a message to the {@link PluginAPI.closePlugin} function. + */ + notify(message: string, options?: NotificationOptions): NotificationHandler + /** + * Commits actions to undo history. This does not trigger an undo. + * + * @remarks + * + * By default, plugin actions are not committed to undo history. Call `figma.commitUndo()` so that triggered + * undos can revert a subset of plugin actions. + * + * For example, after running the following plugin code, the first triggered undo will undo both the rectangle and the ellipse: + * ```ts + * figma.createRectangle(); + * figma.createEllipse(); + * figma.closePlugin(); + * ``` + * Whereas if we call `commitUndo()` in our plugin, the first triggered undo will only undo the ellipse: + * ```ts + * figma.createRectangle(); + * figma.commitUndo(); + * figma.createEllipse(); + * figma.closePlugin(); + * ``` + */ + commitUndo(): void + /** + * Triggers an undo action. Reverts to the last `commitUndo()` state. + */ + triggerUndo(): void + /** + * Saves a new version of the file and adds it to the version history of the file. Returns the new version id. + * @param title - The title of the version. This must be a non-empty string. + * @param description - An optional argument to describe the version. + * + * Calling `saveVersionHistoryAsync` returns a promise that resolves to `null` or an instance of `VersionHistoryResult`: + * + * ```ts + * interface VersionHistoryResult { + * id: string + * } + * ``` + * + * - `id`: The version id of this newly saved version. + * + * @remarks + * + * It is not guaranteed that all changes made before this method is used will be saved to version history. + * For example, + * ```ts title="Changes may not all be saved" + * async function example() { + * await figma.createRectangle(); + * await figma.saveVersionHistoryAsync('v1'); + * figma.closePlugin(); + * } + * example().catch((e) => figma.closePluginWithFailure(e)) + * ``` + * The newly created rectangle may not be included in the v1 version. As a work around, you can wait before calling `saveVersionHistoryAsync()`. For example, + * ```ts title="Wait to save" + * async function example() { + * await figma.createRectangle(); + * await new Promise(r => setTimeout(r, 1000)); // wait for 1 second + * await figma.saveVersionHistoryAsync('v1'); + * figma.closePlugin(); + * } + * ``` + * Typically, manual changes that precede the execution of `saveVersionHistoryAsync()` will be included. If you want to use `saveVersionHistoryAsync()` before the plugin makes + * additional changes, make sure to use the method with an async/await or a Promise. + */ + saveVersionHistoryAsync(title: string, description?: string): Promise + /** + * Open a url in a new tab. + * + * @remarks + * + * In the VS Code Extension, this API is required to open a url in the browser. Read more in [Dev Mode plugins in Visual Studio Code](https://developers.figma.com/docs/plugins/working-in-dev-mode#dev-mode-plugins-in-visual-studio-code). + */ + openExternal(url: string): void + /** + * Enables you to render UI to interact with the user, or simply to access browser APIs. This function creates a modal dialog with an `