cc-figma-component
cc-figma-component — Component Contracts Component Skill
Generate Figma component sets from component contracts. This skill reads a contract, derives component-scoped (Tier 3) tokens, and builds a fully variable-bound component set in Figma.
Prerequisites — load these skills first:
figma-use— Plugin API syntax rules (mandatory before everyuse_figmacall)figma-generate-library— Design system workflow and component creation patternscc-figma-tokensmust have been run —PrimitivesandSemanticcollections must exist in the target Figma file
Always pass skillNames: "cc-figma-component" when calling use_figma as part of this skill.
Global Rules
Frame sizing defaults to HUG
Every frame created by this skill must have both sizing properties set to 'HUG' immediately after creation — no exceptions.
node.layoutSizingHorizontal = 'HUG';
node.layoutSizingVertical = 'HUG';
This applies to: the base component frame, every variant duplicate, the Generation Notes frame, and any other frame created during the run. Never rely on Figma's default — it sets new frames to FIXED at 100px, which produces portrait-aspect components regardless of padding values. Only deviate from HUG when a slot child's figma.sizing is explicitly set to "fixed" in the contract. In that case use the contract's figma.width and figma.height values directly:
if (child.figma?.sizing === 'fixed') {
node.layoutSizingHorizontal = 'FIXED';
node.layoutSizingVertical = 'FIXED';
node.resize(child.figma.width, child.figma.height);
}
This is the only legitimate exception to the HUG default.
1. Configuration
Before anything else, read .component-contracts from the project root. Never output FIGMA_ACCESS_TOKEN in any response.
FIGMA_ACCESS_TOKEN=... # never output this value
FIGMA_FILE_KEY=... # use for all use_figma calls
TOKENS_DIR=... # root directory of token files
CONTRACTS_DIR=... # root directory of contract files
2. Contract Structure
A component contract is a JSON file at {CONTRACTS_DIR}/{id}.contract.json. Read it fully before planning anything.
Key sections used by this skill:
tokens — defines the component's Tier 3 token bindings, organized by category:
{
"tokens": {
"color": {
"background.primary": "brand.500",
"text.primary": "neutral.0",
"border.secondary": "brand.500"
},
"spacing": {
"padding.x.sm": "space.sm",
"padding.y.sm": "space.xs"
},
"typography": {
"label.size": "textSize.sm",
"label.weight": "weight.semibold"
},
"border": {
"radius": "radius.md",
"width": "border.width.sm"
},
"motion": {
"transition": "duration.fast"
}
}
}
props — defines the component's variant axes (e.g. variant, size, state)
a11y — accessibility requirements (ARIA roles, keyboard behavior)
platforms — target platforms (web, ios, android)
3. Tier 3 Token Derivation
Component-scoped (Tier 3) tokens are derived from the contract's tokens section — they are NOT stored as a separate file. Derive them at runtime using these rules:
Naming convention
--ds-{componentId}-{role}-{qualifier}
Where:
componentId— the contract'sidfield (e.g.button,accordion)role— the Figma-aligned role name (see role translation table below)qualifier— derived from the Semantic alias name in the contract value
Role translation table
| Contract category + key prefix | CSS/Figma role |
|---|---|
color.background.* |
fill |
color.text.* |
text |
color.border.* |
border |
color.focus.* |
focus |
spacing.* |
spacing |
typography.* |
typography |
border.radius |
border (as border-radius) |
border.width |
border (as border-width) |
motion.* |
transition |
Qualifier derivation
The qualifier comes from the Semantic alias value in the contract, with the scale suffix dropped:
"background.primary": "brand.500" → qualifier = "brand" → --ds-button-fill-brand
"background.primary.hover": "brand.600" → qualifier = "brand-hover"
"text.primary": "neutral.0" → qualifier = "neutral-0"
State suffixes (hover, active, focus, disabled) are appended after the qualifier:
--ds-button-fill-brand-hover
--ds-button-fill-brand-active
Semantic variable lookup
Each Tier 3 token must bind to the corresponding Semantic variable in Figma. Look up the variable by name:
contract value "brand.500" → Semantic variable named "brand/500"
contract value "space.sm" → Semantic variable named "space/sm"
contract value "radius.md" → Semantic variable named "radius/md"
If a Semantic variable is not found, stop and report the missing variable to the user before proceeding.
4. Component Set Architecture
Each value of the primary variant prop (e.g. variant) becomes a separate component set, not a single combined set. Secondary axes (Size, State) become variant properties within each set.
Naming convention
{ComponentName}/{VariantValue} → Button/Primary, Accordion/Multiple
- Component name: title case (
Button,Accordion) - Variant value: title case (
Primary,Secondary) - Separator:
/
Variant axes within each component set
All axes except the primary variant become properties within the component set:
Button/Primary:
Size=Small, State=Default
Size=Small, State=Hover
Size=Small, State=Focus
Size=Small, State=Active
Size=Small, State=Disabled
Size=Small, State=Loading
Size=Medium, State=Default
... (18 total variants per set)
Property naming
- Property names: title case, match contract prop names (
Size,State) - Property values: title case (
Small,Medium,Large,Default,Hover,Focus,Active,Disabled,Loading)
Standard state values
Always read states from the contract's states array — do not use a hardcoded list. Title-case each value for Figma property values (e.g. "default" → "Default"). Different components have different state sets — Button has 6 including Loading, Accordion has 5 with no Loading.
5. Execution Modes
Default (fast): Run all phases sequentially without stopping. Present the Phase 0 plan and await approval, then execute Phases 1–7 without interruption. This is the default for all runs.
Debug mode: Stop after each phase and await explicit approval before proceeding. Use when diagnosing issues or validating intermediate output. Activate by including "debug mode" in your prompt. Scripts are preserved in debug mode only — clean them up manually when done.
Scripts location: All working files MUST be written to the
scripts/subdirectory — never to the repo root. Root-level dotfiles (.mcp-args-phase3.json,.phase2-code-raw.js, etc.) are not cleaned up by the rimraf step and will accumulate in the repo.
Note: Add
scripts/to your.gitignore— these are generated working files, not source artifacts.
6. Workflow
Phase 0 — Discovery (read-only)
- Read
.component-contractsconfiguration - Read the contract file from
CONTRACTS_DIR - Inspect the Figma file — check for existing Semantic/Primitive collections and any existing component with this name
- Verify that Semantic and Primitive collections exist — if not, stop and tell the user to run
cc-figma-tokensfirst - Derive the full Tier 3 token list from the contract's
tokenssection - Look up every Semantic variable — confirm all exist before proceeding
- Build the full variant matrix from the contract's
propssection - Present a complete plan to the user:
- Component name and variant count
- Full Tier 3 token list with Semantic aliases
- Any missing Semantic variables
- In debug mode: Await explicit user approval before proceeding
Phase 1 — Page setup
- Create a dedicated page for the component:
{ComponentName}(e.g.Button,Accordion) - Switch to that page with
await figma.setCurrentPageAsync(page) - Return the page ID
Phase 2 — Base component
Build one base component (the Default state of the primary variant) first:
- Create a Frame with auto-layout, horizontal direction
- Immediately after creation, set sizing to HUG using these exact two lines — both are required, one is not enough:
Figma defaults new frames toframe.layoutSizingHorizontal = 'HUG'; frame.layoutSizingVertical = 'HUG';FIXEDat 100px. Omitting the vertical line produces a portrait-aspect button regardless of padding values. Do this before adding any children or bindings. - Apply all structural properties (padding, gap, radius) bound to Semantic variables
- Add child nodes by reading the contract's
composition.slotssection. UsechildOrderto determine render order — do not assume a fixed order:- Read
composition.slots.{slotName}.childOrder— this array defines the order children are added to the frame - For each child in
childOrder, read itsfigmahints:figma.textProp— create a text node, wire to that component property namefigma.booleanProp— create an icon placeholder frame, setvisible: false, wire to that BOOLEAN prop namefigma.position: "trailing"— this child goes last regardless of other childrenfigma.rotateOnExpand— note in Generation Notes that rotation is not variable-bindablefigma.visibleWhen— note in Generation Notes that visibility is state-driven
- If no
composition.slotsis defined, fall back to: icon placeholder (hidden), then label text node - Icon placeholder frames are always fixed size (20×20 for md), no fill,
visible: falseby default
- Read
- Bind all visual properties to Semantic variables — NO hardcoded values
- Run a post-creation sizing check immediately after all children are added. This corrects any frame the minifier may have dropped HUG assignments for:
// Walk every frame in the component subtree and enforce HUG function enforceHug(node) { if (node.type === 'FRAME' || node.type === 'COMPONENT') { if (node.layoutSizingVertical === 'FIXED') { node.layoutSizingHorizontal = 'HUG'; node.layoutSizingVertical = 'HUG'; } } if ('children' in node) node.children.forEach(enforceHug); } enforceHug(baseComponent); - Apply
setExplicitVariableModeForCollectionon all nodes recursively - Validate:
get_screenshot— confirm button is wider than tall before proceeding - In debug mode: Await user checkpoint
Phase 3 — Variant matrix
For each value of the primary variant prop, create a separate component set:
- Duplicate the base component for every
Size × Statecombination - On every duplicate, reset sizing to HUG using these exact two lines before applying any token overrides:
Duplication may preserve the original's sizing mode but this must be re-set explicitly on every copy without exception.duplicate.layoutSizingHorizontal = 'HUG'; duplicate.layoutSizingVertical = 'HUG'; - Apply variant-specific token overrides per state (e.g. hover changes fill color)
- Name each component:
Size=Small, State=Defaultetc. (title case, Property=Value) - Before calling
combineAsVariants, run a HUG enforcement pass over all variant frames. This is a safety net — catch any frame the agent missed:
Any variant still at 100px height after this pass indicates a binding issue, not a sizing issue.variants.forEach(v => { v.layoutSizingHorizontal = 'HUG'; v.layoutSizingVertical = 'HUG'; }); - Call
combineAsVariantsto create the component set - Rename the component set to
{ComponentName}/{VariantValue}(e.g.Button/Primary) - Immediately after
combineAsVariants: manually grid-layout the variants and resize to actual bounds:const COLS = 6; // one column per state const GAP = 40; const PADDING = 40; // padding inside component set on all sides variants.forEach((v, i) => { v.x = PADDING + (i % COLS) * (v.width + GAP); v.y = PADDING + Math.floor(i / COLS) * (v.height + GAP); }); // Resize to actual content bounds — never guess, always calculate const maxRight = Math.max(...variants.map(v => v.x + v.width)); const maxBottom = Math.max(...variants.map(v => v.y + v.height)); componentSet.resize(maxRight + PADDING, maxBottom + PADDING); - Repeat for each variant value (Primary, Secondary, Ghost, Destructive)
- Validate:
get_screenshot— confirm buttons are wider than tall at all sizes - In debug mode: Await user checkpoint
Phase 4 — Variable binding pass
For every variant node:
- Bind fill properties to the correct Semantic color variable
- Bind stroke properties to the correct Semantic color variable
- Bind padding/gap to the correct Semantic spacing variable
- Bind corner radius to the correct Semantic radius variable
- Call
setExplicitVariableModeForCollectionon every node and all children recursively after binding - Validate:
get_screenshot— confirm colors resolve correctly (no ghosted/unresolved variables)
Phase 5 — Component properties
- Define component properties matching the contract's
propssection:- TEXT
label— linked to the Label text node'scharacterson ALL variants including Loading. Never substitute a hardcoded string like "Saving" — the label text is always driven by thelabelprop value. - BOOLEAN
iconStart— linked to the Icon placeholder'svisibleproperty viacomponentPropertyReferences. Defaultfalse(hidden). This is the correct way to toggle icon slot visibility. - BOOLEAN
iconOnly— defined on the component set. Not linked to layout automatically (see Known Constraints). - BOOLEAN
fullWidth— defined on the component set. Not linked to layout automatically (see Known Constraints).
- TEXT
- Do NOT add
disabledorloadingas BOOLEAN props — these are handled exclusively as variant states - Do NOT add
INSTANCE_SWAPfor icons unless a real icon component exists in the file - Validate: confirm property panel matches contract props
Phase 6 — Canvas annotations
Before final validation, create a ⚠️ Generation Notes frame on the component page documenting everything the agent assumed, flagged, or couldn't resolve. This makes the output self-documenting for anyone opening the file.
The frame should be placed below all component sets, clearly separated. Use a light yellow fill (#FFFBEB), auto-layout vertical direction, and set sizing explicitly:
notesFrame.layoutSizingHorizontal = 'HUG';
notesFrame.layoutSizingVertical = 'HUG';
This ensures the frame expands to contain all note text rather than clipping at a fixed height. Add a title text node followed by individual note entries.
Always include a note for each of the following if applicable:
| Condition | Note to write |
|---|---|
focus/default same color as a variant fill |
"Focus ring color conflict: focus/default may be invisible on [variant] fill. Define a focus/onBrand Semantic token and re-run." |
| Loading state has no spinner component | "State=Loading: No spinner component found. Label text preserved. Add a spinner instance and wire to iconStart, or create a dedicated loading indicator." |
| Any token bound to Primitives instead of Semantic | "Token gap: [token name] has no Semantic alias. Bound to Primitive directly. Add a Semantic alias to align with the token architecture." |
fullWidth or iconOnly not wired to layout |
"fullWidth / iconOnly are defined as component properties but do not drive layout automatically. See known constraints." |
iconEnd prop present in contract |
"iconEnd is not modeled in Figma — no trailing icon slot created. Add a second icon placeholder frame and wire to an iconEnd BOOLEAN if needed." |
| Any contract prop not modeled in Figma | "[prop name] from contract not modeled in Figma: [reason]." |
| Any assumption made during generation | Document it explicitly. |
If there are no notes, create the frame anyway with the text "No issues — all tokens resolved, all props wired."
Phase 7 — Final validation
get_metadata— confirm structure: variant count, property definitions, hierarchyget_screenshot— visual check: buttons are wider than tall at all sizes, no clipped text, no overlapping elements, correct spacing, no cropped variants- Return structured summary:
{
"component": "Button",
"componentSets": [
{ "name": "Button/Primary", "variantCount": 18, "id": "..." },
{ "name": "Button/Secondary", "variantCount": 18, "id": "..." },
{ "name": "Button/Ghost", "variantCount": 18, "id": "..." },
{ "name": "Button/Destructive", "variantCount": 18, "id": "..." }
],
"propertiesDefined": ["Size", "State", "label", "iconStart", "iconOnly", "fullWidth"],
"annotations": ["list of notes written to canvas"],
"tier3Tokens": [...],
"pageId": "..."
}
-
Cleanup — always run this at the end of every run, successful or not. The only exception is debug mode.
The scripts are disposable. Do not suggest keeping, reusing, or regenerating them. Do not instruct the user to run terser or rewrite invoke files. Every run generates a fresh set from scratch. Treat them as build cache, not source artifacts.
Delete the
scripts/directory and any working files written to the repo root:npx rimraf scripts/ rm -f .mcp-*.json .mcp-*.txt .*-phase*.js .*-phase*.json .*-args*.json .*-code*.js .*-code*.json .tmp-*.jsIn debug mode, leave all files in place for inspection.
7. Variable Binding Rules
- Components bind exclusively to Semantic variables — never directly to Primitives
- Every visual property with a corresponding token MUST be variable-bound — no hardcoded values
setExplicitVariableModeForCollectionMUST be called on every node AND all children recursively after any variable binding operationsetBoundVariableon text nodes requires the font to be loaded first:await figma.loadFontAsync({family, style})cornerRadiusdoes NOT supportsetBoundVariabledirectly — use individual corners:topLeftRadius,topRightRadius,bottomLeftRadius,bottomRightRadius- Always use
setSharedPluginData/getSharedPluginData— neversetPluginData/getPluginData. The non-shared version uses a debug UUID as the plugin identifier that changes on each tool execution, making stored data permanently unretrievable and causing file bloat. Always use thecomponent_contractsnamespace:// CORRECT node.setSharedPluginData('component_contracts', 'dsb_key', 'component/button/base'); node.getSharedPluginData('component_contracts', 'dsb_key'); // WRONG — UUID changes per execution, data becomes orphaned node.setPluginData('dsb_key', 'component/button/base');
8. Known Constraints
-
Frame sizing defaults to FIXED at 100px — Figma sets new frames to
FIXEDsizing at 100px height. You must set both lines explicitly on every frame, every variant duplicate, and the Generation Notes frame:node.layoutSizingHorizontal = 'HUG'; node.layoutSizingVertical = 'HUG';Setting only horizontal and omitting vertical is the most common cause of portrait-aspect buttons. There is no shorthand — both lines are always required.
-
Letter-spacing — cannot be bound to a variable (em units incompatible with Figma FLOAT). Set as a fixed pixel value derived from the token's raw value.
-
combineAsVariants does not auto-layout — variants stack at (0,0). Always manually grid-layout immediately after. Always resize the component set to actual content bounds using
Math.maxover all variant positions + sizes + padding. Never guess the resize dimensions. -
layoutSizingHorizontal/Vertical = 'FILL'must be set AFTERparent.appendChild(child)— setting before throws. -
Ghost mode — always call
setExplicitVariableModeForCollectionafter every binding operation, on the node AND all children recursively. Skipping this causes variables to appear unresolved. -
Variant count cap — if Size × State > 30 variants per component set, raise this with the user before creating. Consider splitting into sub-components.
-
Focus ring color conflict — the skill does NOT make judgment calls about focus ring color. Read the contract's
color.focus.*tokens and use whatever Semantic variable is defined there for ALL variants. Iffocus/defaultis the same color as a variant's fill, flag this in the Generation Notes frame and suggest the user define afocus/onBrandSemantic token. Do not substituteneutral/0or any other color on the skill's own initiative. -
fullWidthandiconOnlycannot drive layout automatically — the Figma Plugin API'scomponentPropertyReferencesonly supportsvisible,characters, andmainComponent. It does not support linking a boolean tolayoutSizingHorizontalor toggling between icon-only and full layouts. These props are defined for spec/Code Connect purposes. Document this limitation for consumers — layout changes require manual variant creation or a follow-up plugin. -
Figma plugin sandbox —
atobis undefined — the plugin runtime has no native base64 decoding. Do not useatoborbtoain plugin scripts. If a script exceeds the 20k character limit and must be chunked, use a base64 polyfill or split the logic into multiple sequentialuse_figmacalls instead. -
Rotation values are always in degrees, never radians — the Figma Plugin API's
node.rotationproperty accepts degrees. Always pass degree values directly from the contract (e.g.-90). Never convert to radians (-Math.PI/2). Passing radians produces values like-1.57°in Figma's UI which looks wrong even though the visual result is correct. The contract'sexpandedRotationanddefaultRotationfields are always in degrees. -
Figma plugin sandbox —
evalis blocked — usenew Function(code)()as the workaround when dynamic code execution is required. This is the only reliable pattern for assembling and running chunked plugin scripts in the Figma environment. -
Icon slot visibility — the icon placeholder frame must be hidden by default (
visible: false) and wired to a BOOLEAN component property (iconStart) viacomponentPropertyReferenceswith typevisible. Never leave the icon slot always-visible — it will appear as an empty box on every variant that doesn't use an icon.