Generate color annotation specifications mapping UI elements to design tokens. Use when the user mentions "color", "color annotation", "color spec", "tokens", "design tokens", or wants to document which color tokens a component uses.
77
72%
Does it follow best practices?
Impact
Pending
No eval scenarios have been run
Advisory
Suggest reviewing before use
Optimize this skill with Tessl
npx tessl skill review --optimize ./.cursor/skills/create-color/SKILL.mdGenerate a color annotation directly in Figma — tables mapping each visual element to its design token, organized by variant and state.
Read uspecs.config.json → mcpProvider. Follow the matching column for every MCP call in this skill.
| Operation | figma-console | figma-mcp |
|---|---|---|
| Verify connection | figma_get_status | Skip — implicit. If first use_figma call fails, guide user to check MCP setup. |
| Navigate to file | figma_navigate with URL | Extract fileKey from URL (figma.com/design/:fileKey/...). No navigate needed. |
| Take screenshot | figma_take_screenshot | get_screenshot with fileKey + nodeId |
| Execute Plugin JS | figma_execute with code | use_figma with fileKey, code, description. JS code is identical — no wrapper changes. |
| Search components | figma_search_components | search_design_system with query + fileKey + includeComponents: true |
| Get file/component data | figma_get_file_data / figma_get_component | get_metadata or get_design_context with fileKey + nodeId |
| Get variables (file-wide) | figma_get_variables | use_figma script: return await figma.variables.getLocalVariableCollectionsAsync(); |
| Get token values | figma_get_token_values | use_figma script reading variable values per mode/collection |
| Get styles | figma_get_styles | search_design_system with includeStyles: true, or use_figma: return figma.getLocalPaintStyles(); |
| Get selection | figma_get_selection | use_figma script: return figma.currentPage.selection.map(n => ({id: n.id, name: n.name, type: n.type})); |
figma-mcp requires fileKey on every call. Extract it once from the user's Figma URL at the start of the workflow. For branch URLs (figma.com/design/:fileKey/branch/:branchKey/:fileName), use :branchKey as the fileKey.
figma-mcp page context: use_figma resets figma.currentPage to the first page on every call. When a script accesses a node from a previous step via getNodeByIdAsync(ID), the page content may not be loaded — findAll, findOne, and characters will fail with TypeError until the page is activated. Insert this page-loading block immediately after getNodeByIdAsync:
let _p = node; while (_p.parent && _p.parent.type !== 'DOCUMENT') _p = _p.parent;
if (_p.type === 'PAGE') await figma.setCurrentPageAsync(_p);This walks up to the PAGE ancestor and loads its content. Console MCP does not need this — figma_execute inherits the Desktop page context.
Copy this checklist and update as you progress:
Task Progress:
- [ ] Step 1: Read instruction file
- [ ] Step 2: Verify MCP connection (if Figma link provided)
- [ ] Step 3: Read template key from uspecs.config.json
- [ ] Step 4: Gather context (MCP tools + user-provided input)
- [ ] Step 4b: Run consolidated extraction script (tokens, axis classification, boolean enrichment, mode detection, sub-component tagging)
- [ ] Step 4c: Interpret extraction data (strategy selection, variant plan, sub-component refs, element-to-token mappings)
- [ ] Step 7: Organize analysis into structured data (component name, general notes, variants with tables and rows)
- [ ] Step 8: Re-read instruction file (Common Mistakes, Do NOT sections) and audit
- [ ] Step 9: Import and detach the Color Annotation template
- [ ] Step 10: Fill header fields
- [ ] Step 11: Render variants (Strategy A or B, one figma_execute per variant)
- [ ] Step 12: Visual validationRead agent-color-instruction.md
If a Figma link is provided, read mcpProvider from uspecs.config.json and verify the connection:
If figma-console:
figma_get_status — Confirm Desktop Bridge plugin is activeIf figma-mcp:
use_figma call. No explicit check needed.Read the file uspecs.config.json and extract:
colorAnnotation value from the templateKeys object → save as COLOR_TEMPLATE_KEYfontFamily value → save as FONT_FAMILY (default to Inter if not set)If the template key is empty, tell the user:
The color annotation template key is not configured. Run
@firstrunwith your Figma template library link first.
Use ALL available sources to maximize context:
From user:
From MCP tools (when Figma link provided):
figma_navigate — Open the component URLfigma_take_screenshot — Capture the component layout and statesfigma_get_file_data — Get detailed structure with fill/stroke informationfigma_get_component — Get component data including visual propertiesfigma_get_variables — Get variable collections and token definitionsfigma_get_token_values — Get all variable values organized by collection and modefigma_get_styles — Get color styles if component uses styles instead of variablesfigma_search_components — Find component by name if neededWhen a Figma link is provided, run this extraction script via figma_execute to programmatically walk the component tree and resolve all color variable bindings, classify variant axes, detect boolean-gated elements, and discover mode-controlled color collections — all in a single call.
Set __SKIP_AXES_JSON__ to {} for the initial run — the script will walk all variants. After interpreting the results in Step 4c, you may optionally re-run this script with color-irrelevant axes populated to get a reduced dataset (see Step 4c-ii).
Replace __NODE_ID__ with the component set node ID extracted from the URL (node-id=123-456 → 123:456). Replace __SKIP_AXES_JSON__ with {} (or a JSON object mapping color-irrelevant axis names to their default/representative value if re-running after Step 4c, e.g., {"Size": "Medium", "Density": "Default"}):
const TARGET_NODE_ID = '__NODE_ID__';
const SKIP_AXES = __SKIP_AXES_JSON__;
function rgbToHex(c) {
return '#' + [c.r, c.g, c.b].map(v => Math.round(v * 255).toString(16).padStart(2, '0')).join('');
}
const collectionIdSet = new Set();
async function resolveVariableToken(binding) {
if (!binding?.id) return null;
try {
const v = await figma.variables.getVariableByIdAsync(binding.id);
if (v) {
collectionIdSet.add(v.variableCollectionId);
return v.codeSyntax?.WEB || v.name;
}
} catch {}
return null;
}
async function extractColorBindings(node, path) {
const entries = [];
const elementName = path || node.name;
if (node.fills && Array.isArray(node.fills)) {
for (const fill of node.fills) {
if (fill.visible === false) continue;
if (fill.type === 'SOLID') {
const hex = rgbToHex(fill.color);
let token = fill.boundVariables?.color
? await resolveVariableToken(fill.boundVariables.color)
: null;
if (!token && node.fillStyleId && node.fillStyleId !== '' && typeof node.fillStyleId === 'string') {
try { const style = await figma.getStyleByIdAsync(node.fillStyleId); if (style) token = style.name; } catch {}
}
const prop = node.type === 'TEXT' ? 'text fill' : 'fill';
entries.push({ element: elementName, property: prop, hex, token, opacity: fill.opacity });
}
}
}
if (node.strokes && Array.isArray(node.strokes)) {
for (const stroke of node.strokes) {
if (stroke.visible === false) continue;
if (stroke.type === 'SOLID') {
const hex = rgbToHex(stroke.color);
let token = stroke.boundVariables?.color
? await resolveVariableToken(stroke.boundVariables.color)
: null;
if (!token && node.strokeStyleId && node.strokeStyleId !== '' && typeof node.strokeStyleId === 'string') {
try { const style = await figma.getStyleByIdAsync(node.strokeStyleId); if (style) token = style.name; } catch {}
}
entries.push({ element: elementName, property: 'stroke', hex, token, opacity: stroke.opacity });
}
}
}
if (node.effects && Array.isArray(node.effects)) {
let effectStyleName = null;
if (node.effectStyleId && node.effectStyleId !== '' && typeof node.effectStyleId === 'string') {
try { const style = await figma.getStyleByIdAsync(node.effectStyleId); if (style) effectStyleName = style.name; } catch {}
}
if (effectStyleName) {
entries.push({ element: elementName, property: 'effect style', hex: '', token: effectStyleName, opacity: 1 });
} else {
for (const effect of node.effects) {
if (effect.visible === false) continue;
if (effect.color) {
const hex = rgbToHex(effect.color);
let token = effect.boundVariables?.color
? await resolveVariableToken(effect.boundVariables.color)
: null;
const effectType = effect.type === 'DROP_SHADOW' ? 'drop shadow'
: effect.type === 'INNER_SHADOW' ? 'inner shadow'
: effect.type;
entries.push({ element: elementName, property: effectType, hex, token, opacity: effect.color.a });
}
}
}
}
return entries;
}
async function walkTree(node, parentPath) {
const currentPath = parentPath ? parentPath + ' > ' + node.name : node.name;
let entries = await extractColorBindings(node, node.name);
if (node.type === 'INSTANCE') {
let compSetName = null;
try {
const mainComp = await node.getMainComponentAsync();
if (mainComp && mainComp.parent && mainComp.parent.type === 'COMPONENT_SET') {
compSetName = mainComp.parent.name;
}
} catch {}
if (compSetName) {
entries = entries.map(e => ({ ...e, subComponentName: compSetName }));
}
for (const child of node.children) {
const childEntries = await walkTree(child, currentPath);
if (compSetName) {
childEntries.forEach(e => { if (!e.subComponentName) e.subComponentName = compSetName; });
}
entries = entries.concat(childEntries);
}
} else if ('children' in node) {
for (const child of node.children) {
entries = entries.concat(await walkTree(child, currentPath));
}
}
return entries;
}
const node = await figma.getNodeByIdAsync(TARGET_NODE_ID);
if (!node || (node.type !== 'COMPONENT_SET' && node.type !== 'COMPONENT')) {
figma.closePlugin(JSON.stringify({ error: 'Node is not a component set or component. Type: ' + (node ? node.type : 'null') }));
return;
}
// Ensure the correct page context is loaded for stable child traversal
let _p = node; while (_p.parent && _p.parent.type !== 'PAGE') _p = _p.parent;
if (_p.parent && _p.parent.type === 'PAGE') await figma.setCurrentPageAsync(_p.parent);
const isComponentSet = node.type === 'COMPONENT_SET';
const propDefs = node.componentPropertyDefinitions;
const propertyDefs = {};
if (propDefs) {
for (const [key, def] of Object.entries(propDefs)) {
propertyDefs[key] = { type: def.type, defaultValue: def.defaultValue };
if (def.variantOptions) propertyDefs[key].variantOptions = def.variantOptions;
}
}
const variantAxes = {};
if (isComponentSet && node.variantGroupProperties) {
for (const [key, val] of Object.entries(node.variantGroupProperties)) {
variantAxes[key] = val.values;
}
}
const variantChildren = isComponentSet ? node.children : [node];
const skipAxes = SKIP_AXES || {};
const filteredVariants = variantChildren.filter(variant => {
const props = variant.variantProperties || {};
for (const [axis, defaultVal] of Object.entries(skipAxes)) {
if (props[axis] && props[axis] !== defaultVal) return false;
}
return true;
});
// Phase 1: Walk all variants — color bindings + axis fingerprints + sub-component tagging
const stateKeywords = ['enabled', 'hover', 'pressed', 'disabled', 'active', 'rest', 'focused', 'selected', 'dragged', 'error', 'loading'];
const axisTokenSets = {};
const variantColorData = [];
for (const variant of filteredVariants) {
const colorEntries = await walkTree(variant, null);
const tokenFingerprint = colorEntries
.filter(e => e.token)
.map(e => e.token)
.sort()
.join('|');
const vProps = variant.variantProperties || {};
for (const [axis, val] of Object.entries(vProps)) {
if (!axisTokenSets[axis]) axisTokenSets[axis] = {};
if (!axisTokenSets[axis][val]) axisTokenSets[axis][val] = tokenFingerprint;
}
variantColorData.push({
name: variant.name,
variantProperties: vProps,
colorEntries
});
}
// Phase 2: Axis classification
const axisClassification = {};
for (const [axis, values] of Object.entries(variantAxes)) {
const tokenSets = axisTokenSets[axis] || {};
const uniqueSets = new Set(Object.values(tokenSets));
const isState = values.some(v => stateKeywords.includes(v.toLowerCase()));
axisClassification[axis] = {
values,
isState,
colorRelevant: uniqueSets.size > 1,
tokenSetsByValue: tokenSets
};
}
// Phase 3: Boolean enrichment
let booleanDelta = { booleanPropsToggled: [], deltaCount: 0, delta: [] };
const boolProps = {};
for (const [key, def] of Object.entries(propertyDefs)) {
if (def.type === 'BOOLEAN') boolProps[key] = true;
}
if (Object.keys(boolProps).length > 0) {
const defaultVariant = isComponentSet
? (node.defaultVariant || node.children[0])
: node;
const baselineKeys = new Set();
const baselineEntries = await walkTree(defaultVariant, null);
for (const e of baselineEntries) {
baselineKeys.add(e.element + '|' + e.property + '|' + (e.token || e.hex));
}
async function loadAllFonts(rootNode) {
const textNodes = [];
function collect(node) {
try {
if (node.type === 'TEXT') textNodes.push(node);
if ('children' in node && node.children) {
for (const c of node.children) { try { collect(c); } catch {} }
}
} catch {}
}
collect(rootNode);
const fontSet = new Set();
const fontsToLoad = [];
for (const tn of textNodes) {
try {
const fn = tn.fontName;
if (fn && fn !== figma.mixed && fn.family) {
const key = fn.family + '|' + fn.style;
if (!fontSet.has(key)) { fontSet.add(key); fontsToLoad.push(fn); }
}
} catch {}
}
await Promise.all(fontsToLoad.map(f => figma.loadFontAsync(f).catch(() => {})));
}
const instance = defaultVariant.createInstance();
instance.x = defaultVariant.x + defaultVariant.width + 100;
instance.y = defaultVariant.y;
instance.setProperties(boolProps);
await loadAllFonts(instance);
function enableNestedBooleans(node) {
try {
if (node.type === 'INSTANCE') {
const childProps = node.componentProperties;
if (childProps) {
const childBoolProps = {};
for (const [key, val] of Object.entries(childProps)) {
if (val.type === 'BOOLEAN') childBoolProps[key] = true;
}
if (Object.keys(childBoolProps).length > 0) {
try { node.setProperties(childBoolProps); } catch {}
}
}
}
if ('children' in node && node.children) {
for (const child of node.children) { try { enableNestedBooleans(child); } catch {} }
}
} catch {}
}
function directUnhide(node) {
try { if (!node.visible) node.visible = true; } catch {}
if ('children' in node && node.children) {
for (const child of node.children) { try { directUnhide(child); } catch {} }
}
}
enableNestedBooleans(instance);
directUnhide(instance);
await loadAllFonts(instance);
const enrichedEntries = await walkTree(instance, null);
const delta = [];
for (const e of enrichedEntries) {
const key = e.element + '|' + e.property + '|' + (e.token || e.hex);
if (!baselineKeys.has(key)) delta.push(e);
}
instance.remove();
booleanDelta = {
booleanPropsToggled: Object.keys(boolProps),
deltaCount: delta.length,
delta
};
}
// Phase 4: Mode detection
let modeDetection = { hasModeCollection: false, collectionName: null, modes: [], modeTokenMap: {} };
if (collectionIdSet.size > 0) {
const allCollections = await figma.variables.getLocalVariableCollectionsAsync();
for (const colId of collectionIdSet) {
const col = allCollections.find(c => c.id === colId);
if (!col || col.modes.length <= 1) continue;
const modeTokenMap = {};
for (const mode of col.modes) {
modeTokenMap[mode.name] = {};
for (const varId of col.variableIds) {
const variable = await figma.variables.getVariableByIdAsync(varId);
if (!variable) continue;
const modeValue = variable.valuesByMode[mode.modeId];
if (modeValue && modeValue.type === 'VARIABLE_ALIAS') {
const aliased = await figma.variables.getVariableByIdAsync(modeValue.id);
if (aliased) {
modeTokenMap[mode.name][variable.codeSyntax?.WEB || variable.name] = aliased.codeSyntax?.WEB || aliased.name;
}
} else {
modeTokenMap[mode.name][variable.codeSyntax?.WEB || variable.name] = variable.codeSyntax?.WEB || variable.name;
}
}
}
modeDetection = {
hasModeCollection: true,
collectionName: col.name,
collectionId: col.id,
modes: col.modes.map(m => m.name),
modeIds: Object.fromEntries(col.modes.map(m => [m.name, m.modeId])),
modeTokenMap
};
break;
}
}
figma.closePlugin(JSON.stringify({
componentName: node.name,
compSetNodeId: TARGET_NODE_ID,
isComponentSet,
variantAxes,
propertyDefs,
variantCount: variantChildren.length,
sampledCount: filteredVariants.length,
skippedAxes: Object.keys(skipAxes),
variantColorData,
axisClassification,
booleanDelta,
modeDetection
}));Save the returned JSON. This consolidated extraction provides:
compSetNodeId — needed for creating live preview instances in Step 11variantAxes — variant axis names and their options, for mapping variant sections to Figma property keyspropertyDefs — exact Figma property keys (including #nodeId suffixes) for setProperties() when placing preview instancesvariantCount / sampledCount / skippedAxes — extraction scope metadatavariantColorData — per-variant array of colorEntries, each with element, property (fill, text fill, stroke, drop shadow, inner shadow, or effect style), hex, token, opacity, and optional subComponentName (string) identifying which nested component the entry belongs to (e.g., "Button"). Always show the actual token; use subComponentName for richer notes. When property is "effect style", the entry represents a composed effect style (e.g., a shadow style) — the token is the style name and individual shadow layers are not emittedaxisClassification — per-axis classification with isState, colorRelevant, and tokenSetsByValuebooleanDelta — elements discovered behind boolean toggles (deltaCount, delta entries, booleanPropsToggled)modeDetection — mode-controlled collection info (hasModeCollection, collectionName, collectionId, modes, modeIds, modeTokenMap)Use this data in Step 4c to interpret and plan the rendering strategy. Entries with subComponentName come from nested instances — always include their actual tokens and use the sub-component name for descriptive notes and element names.
Using the consolidated extraction output from Step 4b, perform the following interpretation steps (no additional figma_execute calls needed — all data is already in the extraction payload):
variantColorData is non-empty and sampledCount > 0. If the component is a standalone COMPONENT (not a set), expect a single variant entry.
1b. Container detection: Check if the parent component has any direct color entries (entries WITHOUT subComponentName). If ALL entries across all variants have subComponentName and the parent contributes no direct color entries, this is a container/slot component. In this case:
subComponentName value as a COMPONENT_SET)generalNotesbooleanDelta.deltaCount > 0, merge the booleanDelta.delta entries into the default variant's color entries. These represent elements hidden behind boolean toggles.subComponentName come from nested instances. Include their actual tokens — use the sub-component name to write descriptive notes (e.g., "Button container fill"). Group sub-component entries together in the table when it aids readability.variantColorData entries, build element-to-token mappings. Entries with a non-null token field have a resolved variable binding; entries with token: null use a hard-coded color (note this in output).propertyDefs and variantAxes from the extraction to map variant section names to correct Figma property values for setProperties().Using axisClassification and modeDetection from the extraction output, choose a rendering strategy by following the Rendering Strategies and Decision Logic (Two-Gate Model) sections in the instruction file.
Template note: Strategy A renames the template's #state-title column header from "State" to "Token" at render time.
If Strategy B, also record:
stateAxisName: name of the state axis (e.g., "State")stateValues: ordered list of state values (columns)nonStateAxes: the remaining color-relevant axes whose combinations form sectionsBased on the strategy chosen in Step 4c-i, determine which sections to render. Follow the Variable Mode Colors section in the instruction file for mode-controlled components and the Color-Irrelevant Axes section for axis filtering.
Mode-controlled components: If modeDetection.hasModeCollection is true:
collectionId from modeDetection on the top-level data structure.modeId for each section so the rendering step can apply the correct variable mode to preview instances.modeDetection.modeTokenMap[modeName] to resolve generic tokens to semantic aliases per mode.Optional re-extraction: If the component is complex (many variants) and Step 4b was run with SKIP_AXES = {}, re-run Step 4b now with __SKIP_AXES_JSON__ populated with the color-irrelevant axes identified above (e.g., {"Size": "Medium", "Density": "Default"}) to get a focused dataset. For components with few variants (≤ 10), there is no need to re-run.
Use the extraction output fields directly — compSetNodeId for creating live preview instances in Step 11, variantAxes for mapping sections to Figma property keys, propertyDefs for exact Figma property keys (including #nodeId suffixes).
Follow the Data Structure Reference in the instruction file — use the Strategy A (ColorAnnotationData) or Strategy B (ConsolidatedColorAnnotationData) interfaces. Build an internal working model that feeds directly into the Figma rendering steps — no JSON output artifact is needed.
Rendering-critical fields consumed by Step 11 scripts:
variantProperties — maps Figma property keys to values for setProperties() on preview instancescollectionId / modeId (Strategy B only) — passed to rendering scripts for setExplicitVariableModeForCollectionRe-read the instruction file, focusing on:
Check your output against each rule. Fix any violations.
Run via figma_execute (replace __COLOR_TEMPLATE_KEY__, __COMPONENT_NAME__, and __COMPONENT_NODE_ID__ with the node ID extracted from the component URL):
const TEMPLATE_KEY = '__COLOR_TEMPLATE_KEY__';
const COMP_NODE_ID = '__COMPONENT_NODE_ID__';
const compNode = await figma.getNodeByIdAsync(COMP_NODE_ID);
let _p = compNode;
while (_p.parent && _p.parent.type !== 'DOCUMENT') _p = _p.parent;
if (_p.type === 'PAGE') await figma.setCurrentPageAsync(_p);
const templateComponent = await figma.importComponentByKeyAsync(TEMPLATE_KEY);
const instance = templateComponent.createInstance();
const frame = instance.detachInstance();
const GAP = 200;
frame.x = compNode.x + compNode.width + GAP;
frame.y = compNode.y;
frame.name = '__COMPONENT_NAME__ Color';
figma.currentPage.selection = [frame];
figma.viewport.scrollAndZoomIntoView([frame]);
return { frameId: frame.id, pageId: _p.id, pageName: _p.name };Save the returned frameId — you need it for all subsequent steps.
Run via figma_execute (replace __FRAME_ID__, __COMPONENT_NAME__, and __GENERAL_NOTES__):
const frame = await figma.getNodeByIdAsync('__FRAME_ID__');
const textNodes = frame.findAll(n => n.type === 'TEXT');
const fontSet = new Set();
const fontsToLoad = [];
for (const tn of textNodes) {
try {
const fn = tn.fontName;
if (fn && fn !== figma.mixed && fn.family) {
const key = fn.family + '|' + fn.style;
if (!fontSet.has(key)) { fontSet.add(key); fontsToLoad.push(fn); }
}
} catch {}
}
await Promise.all(fontsToLoad.map(f => figma.loadFontAsync(f).catch(() => {})));
const compNameFrame = frame.findOne(n => n.name === '#compName');
if (compNameFrame) {
const t = compNameFrame.findOne(n => n.type === 'TEXT');
if (t) t.characters = '__COMPONENT_NAME__';
}
const notesFrame = frame.findOne(n => n.name === '#general-color-assignment-description');
if (notesFrame) {
const hasNotes = __HAS_GENERAL_NOTES__;
if (!hasNotes) {
notesFrame.visible = false;
} else {
const t = notesFrame.findOne(n => n.type === 'TEXT');
if (t) t.characters = '__GENERAL_NOTES__';
}
}
return { success: true };Replace __HAS_GENERAL_NOTES__ with true or false.
Use the rendering strategy determined in Step 4c-i. Run one figma_execute call per variant to avoid timeouts.
For each variant in the data, run the following script. Replace all __PLACEHOLDER__ values with actual data. __TABLES_JSON__ is the tables array for this variant (each element has element, token, notes).
__COMPONENT_SET_NODE_ID__ is the node ID of the component set (from Step 4b extraction: compSetNodeId). Set to '' if not available.__VARIANT_PROPERTIES_JSON__ is an object mapping Figma property keys (exactly as returned by componentPropertyDefinitions) to values for this variant. Set to {} if not available.__FONT_FAMILY__ is the fontFamily value from uspecs.config.json (default: Inter).__BOOLEAN_UNHIDES_JSON__ is an array of { booleanRawKey: string } objects derived from booleanDelta.booleanPropsToggled in the extraction output. Set to [] if booleanDelta.deltaCount === 0.const FRAME_ID = '__FRAME_ID__';
const VARIANT_NAME = '__VARIANT_NAME__';
const COMPONENT_NAME = '__COMPONENT_NAME__';
const COMPONENT_SET_ID = '__COMPONENT_SET_NODE_ID__';
const VARIANT_PROPS = __VARIANT_PROPERTIES_JSON__;
const TABLES = __TABLES_JSON__;
const FONT_FAMILY = '__FONT_FAMILY__';
const BOOLEAN_UNHIDES = __BOOLEAN_UNHIDES_JSON__;
async function loadAllFonts(rootNode) {
const textNodes = [];
function collect(node) {
try {
if (node.type === 'TEXT') textNodes.push(node);
if ('children' in node && node.children) {
for (const c of node.children) { try { collect(c); } catch {} }
}
} catch {}
}
collect(rootNode);
const fontSet = new Set();
const fontsToLoad = [];
for (const tn of textNodes) {
try {
const fn = tn.fontName;
if (fn && fn !== figma.mixed && fn.family) {
const key = fn.family + '|' + fn.style;
if (!fontSet.has(key)) { fontSet.add(key); fontsToLoad.push(fn); }
}
} catch {}
}
await Promise.all(fontsToLoad.map(f => figma.loadFontAsync(f).catch(() => {})));
}
async function loadFontWithFallback(family, preferredStyle, fallbackStyle) {
fallbackStyle = fallbackStyle || 'Regular';
const allFonts = await figma.listAvailableFontsAsync();
const familyFonts = allFonts.filter(f => f.fontName.family === family);
const match = familyFonts.find(f => f.fontName.style === preferredStyle);
if (match) { await figma.loadFontAsync(match.fontName); return match.fontName; }
const fallback = familyFonts.find(f => f.fontName.style === fallbackStyle);
if (fallback) { await figma.loadFontAsync(fallback.fontName); return fallback.fontName; }
if (familyFonts.length > 0) { await figma.loadFontAsync(familyFonts[0].fontName); return familyFonts[0].fontName; }
await figma.loadFontAsync({ family: 'Inter', style: 'Regular' });
return { family: 'Inter', style: 'Regular' };
}
function enableNestedBooleans(node) {
try {
if (node.type === 'INSTANCE') {
try {
const childProps = node.componentProperties;
if (childProps) {
const childBoolProps = {};
for (const [key, val] of Object.entries(childProps)) {
if (val.type === 'BOOLEAN') childBoolProps[key] = true;
}
if (Object.keys(childBoolProps).length > 0) {
try { node.setProperties(childBoolProps); } catch {}
}
}
} catch {}
}
if ('children' in node && node.children) {
for (const child of node.children) { try { enableNestedBooleans(child); } catch {} }
}
} catch {}
}
const frame = await figma.getNodeByIdAsync(FRAME_ID);
const variantTemplate = frame.findOne(n => n.name === '#variant-template');
const variant = variantTemplate.clone();
variantTemplate.parent.appendChild(variant);
variant.name = VARIANT_NAME;
variant.visible = true;
await loadAllFonts(variant);
// Set variant title
const titleFrame = variant.findOne(n => n.name === '#variant-title');
if (titleFrame) {
const t = titleFrame.findOne(n => n.type === 'TEXT');
if (t) t.characters = VARIANT_NAME;
}
const previewContainer = variant.findOne(n => n.name === '#preview');
if (previewContainer && COMPONENT_SET_ID) {
const componentSet = await figma.getNodeByIdAsync(COMPONENT_SET_ID);
if (componentSet) {
const isCompSet = componentSet.type === 'COMPONENT_SET';
let targetVariant = null;
if (isCompSet && VARIANT_PROPS && Object.keys(VARIANT_PROPS).length > 0) {
let bestFallback = null;
let bestScore = -1;
for (const child of componentSet.children) {
const vp = child.variantProperties || {};
let score = 0;
let exactMatch = true;
for (const [k, v] of Object.entries(VARIANT_PROPS)) {
if (vp[k] === v) { score++; } else { exactMatch = false; }
}
if (exactMatch) { targetVariant = child; break; }
if (score > bestScore) { bestScore = score; bestFallback = child; }
}
if (!targetVariant) targetVariant = bestFallback;
}
if (!targetVariant) {
targetVariant = isCompSet
? (componentSet.defaultVariant || componentSet.children[0])
: componentSet;
}
const LABEL_FONT = await loadFontWithFallback(FONT_FAMILY, 'Medium');
for (const containerName of ['Light theme preview placeholder']) {
const container = previewContainer.findOne(n => n.name === containerName);
if (container) {
const placeholder = container.findOne(n => n.name === 'Placeholder');
if (placeholder) placeholder.remove();
const wrapper = figma.createFrame();
wrapper.name = VARIANT_NAME;
wrapper.layoutMode = 'VERTICAL';
wrapper.primaryAxisAlignItems = 'CENTER';
wrapper.counterAxisAlignItems = 'CENTER';
wrapper.itemSpacing = 8;
wrapper.fills = [];
wrapper.primaryAxisSizingMode = 'AUTO';
wrapper.counterAxisSizingMode = 'AUTO';
container.appendChild(wrapper);
const instance = targetVariant.createInstance();
await loadAllFonts(instance);
if (BOOLEAN_UNHIDES.length > 0) {
const boolProps = {};
for (const bu of BOOLEAN_UNHIDES) boolProps[bu.booleanRawKey] = true;
instance.setProperties(boolProps);
await loadAllFonts(instance);
}
wrapper.appendChild(instance);
enableNestedBooleans(instance);
await loadAllFonts(instance);
const label = figma.createText();
label.fontName = LABEL_FONT;
label.characters = VARIANT_NAME;
label.fontSize = 14;
label.fills = [{ type: 'SOLID', color: { r: 0.29, g: 0.29, b: 0.29 } }];
wrapper.appendChild(label);
}
}
}
} else {
const previewText = VARIANT_NAME === COMPONENT_NAME
? COMPONENT_NAME
: COMPONENT_NAME + ' ' + VARIANT_NAME;
const lightFrame = variant.findOne(n => n.name === '#preview-instruction-light');
if (lightFrame) {
const textNodesInFrame = lightFrame.children.filter(c => c.type === 'TEXT');
if (textNodesInFrame[1]) textNodesInFrame[1].characters = previewText;
}
}
// Clone and fill tables (Strategy A: Element | Token | Notes)
const tableTemplate = variant.findOne(n => n.name === '#color-table-template');
for (let t = 0; t < TABLES.length; t++) {
const tableData = TABLES[t];
const tableClone = tableTemplate.clone();
tableTemplate.parent.appendChild(tableClone);
tableClone.name = tableData.name;
tableClone.visible = true;
const tableTitleFrame = tableClone.findOne(n => n.name === '#table-title');
if (tableTitleFrame) {
const txt = tableTitleFrame.findOne(n => n.type === 'TEXT');
if (txt) txt.characters = tableData.name;
}
// Rename header: "State" → "Token"
const headerRow = tableClone.findOne(n => n.name === '#color-table')?.findOne(n => n.name === '#header-row');
if (headerRow) {
const stateTitle = headerRow.findOne(n => n.name === '#state-title');
if (stateTitle) {
const txt = stateTitle.findOne(n => n.type === 'TEXT');
if (txt) txt.characters = 'Token';
}
}
const colorTable = tableClone.findOne(n => n.name === '#color-table');
const rowTemplate = colorTable.findOne(n => n.name === '#element-row-template');
for (const element of tableData.elements) {
const row = rowTemplate.clone();
colorTable.appendChild(row);
row.name = 'Row ' + element.element;
const elemFrame = row.findOne(n => n.name === '#element-name');
if (elemFrame) {
const txt = elemFrame.findOne(n => n.type === 'TEXT');
if (txt) txt.characters = element.element;
}
const tokenFrame = row.findOne(n => n.name === '#state-name');
if (tokenFrame) {
const txt = tokenFrame.findOne(n => n.type === 'TEXT');
if (txt) txt.characters = element.token;
}
const notesFrame = row.findOne(n => n.name === '#element-notes');
if (notesFrame) {
const txt = notesFrame.findOne(n => n.type === 'TEXT');
if (txt) txt.characters = element.notes;
}
}
rowTemplate.remove();
}
tableTemplate.remove();
return { success: true, variant: VARIANT_NAME };For each variant in the data, run the following script. Replace all __PLACEHOLDER__ values with actual data.
__STATE_COLUMNS_JSON__ is the ordered array of state names that become column headers (e.g. ["Enabled", "Hover", "Pressed", "Active", "Disabled"]).__STATE_AXIS_NAME__ is the Figma variant axis name for states (e.g. "State").__TABLES_JSON__ is the tables array for this variant. Each element has element, tokensByState (object mapping state name → token), and notes.__COLLECTION_ID__ is the variable collection ID for mode-controlled colors (e.g. "VariableCollectionId:6006:13874"). Set to '' if not mode-controlled.__MODE_ID__ is the variable mode ID for this section (e.g. "6006:2" for Gray). Set to '' if not mode-controlled.__FONT_FAMILY__ is the fontFamily value from uspecs.config.json (default: Inter).__BOOLEAN_UNHIDES_JSON__ is an array of { booleanRawKey: string } objects derived from booleanDelta.booleanPropsToggled in the extraction output. Set to [] if booleanDelta.deltaCount === 0.const FRAME_ID = '__FRAME_ID__';
const VARIANT_NAME = '__VARIANT_NAME__';
const COMPONENT_NAME = '__COMPONENT_NAME__';
const COMPONENT_SET_ID = '__COMPONENT_SET_NODE_ID__';
const VARIANT_PROPS = __VARIANT_PROPERTIES_JSON__;
const STATE_COLUMNS = __STATE_COLUMNS_JSON__;
const STATE_AXIS_NAME = '__STATE_AXIS_NAME__';
const TABLES = __TABLES_JSON__;
const COLLECTION_ID = '__COLLECTION_ID__';
const MODE_ID = '__MODE_ID__';
const FONT_FAMILY = '__FONT_FAMILY__';
const BOOLEAN_UNHIDES = __BOOLEAN_UNHIDES_JSON__;
async function loadAllFonts(rootNode) {
const textNodes = [];
function collect(node) {
try {
if (node.type === 'TEXT') textNodes.push(node);
if ('children' in node && node.children) {
for (const c of node.children) { try { collect(c); } catch {} }
}
} catch {}
}
collect(rootNode);
const fontSet = new Set();
const fontsToLoad = [];
for (const tn of textNodes) {
try {
const fn = tn.fontName;
if (fn && fn !== figma.mixed && fn.family) {
const key = fn.family + '|' + fn.style;
if (!fontSet.has(key)) { fontSet.add(key); fontsToLoad.push(fn); }
}
} catch {}
}
await Promise.all(fontsToLoad.map(f => figma.loadFontAsync(f).catch(() => {})));
}
async function loadFontWithFallback(family, preferredStyle, fallbackStyle) {
fallbackStyle = fallbackStyle || 'Regular';
const allFonts = await figma.listAvailableFontsAsync();
const familyFonts = allFonts.filter(f => f.fontName.family === family);
const match = familyFonts.find(f => f.fontName.style === preferredStyle);
if (match) { await figma.loadFontAsync(match.fontName); return match.fontName; }
const fallback = familyFonts.find(f => f.fontName.style === fallbackStyle);
if (fallback) { await figma.loadFontAsync(fallback.fontName); return fallback.fontName; }
if (familyFonts.length > 0) { await figma.loadFontAsync(familyFonts[0].fontName); return familyFonts[0].fontName; }
await figma.loadFontAsync({ family: 'Inter', style: 'Regular' });
return { family: 'Inter', style: 'Regular' };
}
function enableNestedBooleans(node) {
try {
if (node.type === 'INSTANCE') {
try {
const childProps = node.componentProperties;
if (childProps) {
const childBoolProps = {};
for (const [key, val] of Object.entries(childProps)) {
if (val.type === 'BOOLEAN') childBoolProps[key] = true;
}
if (Object.keys(childBoolProps).length > 0) {
try { node.setProperties(childBoolProps); } catch {}
}
}
} catch {}
}
if ('children' in node && node.children) {
for (const child of node.children) { try { enableNestedBooleans(child); } catch {} }
}
} catch {}
}
const frame = await figma.getNodeByIdAsync(FRAME_ID);
const variantTemplate = frame.findOne(n => n.name === '#variant-template');
const variant = variantTemplate.clone();
variantTemplate.parent.appendChild(variant);
variant.name = VARIANT_NAME;
variant.visible = true;
await loadAllFonts(variant);
const titleFrame = variant.findOne(n => n.name === '#variant-title');
if (titleFrame) {
const t = titleFrame.findOne(n => n.type === 'TEXT');
if (t) t.characters = VARIANT_NAME;
}
let collection = null;
if (COLLECTION_ID) {
const collections = await figma.variables.getLocalVariableCollectionsAsync();
collection = collections.find(c => c.id === COLLECTION_ID) || null;
}
function clearModesRecursive(node, col) {
try { node.clearExplicitVariableModeForCollection(col); } catch {}
if ('children' in node) {
for (const child of node.children) clearModesRecursive(child, col);
}
}
const previewContainer = variant.findOne(n => n.name === '#preview');
if (previewContainer && COMPONENT_SET_ID) {
const componentSet = await figma.getNodeByIdAsync(COMPONENT_SET_ID);
if (componentSet) {
const isCompSet = componentSet.type === 'COMPONENT_SET';
const LABEL_FONT = await loadFontWithFallback(FONT_FAMILY, 'Medium');
for (const containerName of ['Light theme preview placeholder']) {
const container = previewContainer.findOne(n => n.name === containerName);
if (!container) continue;
const placeholder = container.findOne(n => n.name === 'Placeholder');
if (placeholder) placeholder.remove();
container.itemSpacing = 24;
for (let s = 0; s < STATE_COLUMNS.length; s++) {
const stateProps = { ...VARIANT_PROPS };
stateProps[STATE_AXIS_NAME] = STATE_COLUMNS[s];
let targetVariant = null;
let bestFallback = null;
let bestScore = -1;
for (const child of componentSet.children) {
const vp = child.variantProperties || {};
let score = 0;
let exactMatch = true;
for (const [k, v] of Object.entries(stateProps)) {
if (vp[k] === v) { score++; } else { exactMatch = false; }
}
if (exactMatch) { targetVariant = child; break; }
if (score > bestScore) { bestScore = score; bestFallback = child; }
}
if (!targetVariant) targetVariant = bestFallback;
if (!targetVariant) targetVariant = isCompSet ? (componentSet.defaultVariant || componentSet.children[0]) : componentSet;
const wrapper = figma.createFrame();
wrapper.name = STATE_COLUMNS[s];
wrapper.layoutMode = 'VERTICAL';
wrapper.primaryAxisAlignItems = 'CENTER';
wrapper.counterAxisAlignItems = 'CENTER';
wrapper.itemSpacing = 8;
wrapper.fills = [];
wrapper.primaryAxisSizingMode = 'AUTO';
wrapper.counterAxisSizingMode = 'AUTO';
container.appendChild(wrapper);
if (collection && MODE_ID) {
wrapper.setExplicitVariableModeForCollection(collection, MODE_ID);
}
const inst = targetVariant.createInstance();
await loadAllFonts(inst);
if (BOOLEAN_UNHIDES.length > 0) {
const boolProps = {};
for (const bu of BOOLEAN_UNHIDES) boolProps[bu.booleanRawKey] = true;
inst.setProperties(boolProps);
await loadAllFonts(inst);
}
wrapper.appendChild(inst);
if (collection) clearModesRecursive(inst, collection);
enableNestedBooleans(inst);
await loadAllFonts(inst);
const label = figma.createText();
label.fontName = LABEL_FONT;
label.characters = STATE_COLUMNS[s];
label.fontSize = 14;
label.fills = [{ type: 'SOLID', color: { r: 0.29, g: 0.29, b: 0.29 } }];
wrapper.appendChild(label);
}
}
}
} else {
const previewText = VARIANT_NAME === COMPONENT_NAME
? COMPONENT_NAME
: COMPONENT_NAME + ' ' + VARIANT_NAME;
const lightFrame = variant.findOne(n => n.name === '#preview-instruction-light');
if (lightFrame) {
const textNodesInFrame = lightFrame.children.filter(c => c.type === 'TEXT');
if (textNodesInFrame[1]) textNodesInFrame[1].characters = previewText;
}
}
// Clone and fill tables (Strategy B: Element | State1 | State2 | ... | Notes)
const N = STATE_COLUMNS.length;
const tableTemplate = variant.findOne(n => n.name === '#color-table-template');
for (let t = 0; t < TABLES.length; t++) {
const tableData = TABLES[t];
const tableClone = tableTemplate.clone();
tableTemplate.parent.appendChild(tableClone);
tableClone.name = tableData.name;
tableClone.visible = true;
const tableTitleFrame = tableClone.findOne(n => n.name === '#table-title');
if (tableTitleFrame) {
const txt = tableTitleFrame.findOne(n => n.type === 'TEXT');
if (txt) txt.characters = tableData.name;
}
const colorTable = tableClone.findOne(n => n.name === '#color-table');
const headerRow = colorTable.findOne(n => n.name === '#header-row');
if (headerRow) {
const stateTitle = headerRow.findOne(n => n.name === '#state-title');
const notesTitle = headerRow.findOne(n => n.name === '#notes-title');
const notesIndex = notesTitle ? headerRow.children.indexOf(notesTitle) : -1;
if (stateTitle) {
const headerClones = [];
for (let s = 0; s < N; s++) {
const col = stateTitle.clone();
headerClones.push(col);
if (notesIndex >= 0) {
headerRow.insertChild(notesIndex + s, col);
} else {
headerRow.appendChild(col);
}
}
stateTitle.remove();
for (let s = 0; s < headerClones.length; s++) {
headerClones[s].name = 'state-col-' + s;
headerClones[s].layoutSizingHorizontal = 'FILL';
const txt = headerClones[s].findOne(n => n.type === 'TEXT');
if (txt) txt.characters = STATE_COLUMNS[s];
}
}
if (notesTitle) {
notesTitle.layoutSizingHorizontal = 'FILL';
}
}
const rowTemplate = colorTable.findOne(n => n.name === '#element-row-template');
for (const element of tableData.elements) {
const row = rowTemplate.clone();
colorTable.appendChild(row);
row.name = 'Row ' + element.element;
const elemFrame = row.findOne(n => n.name === '#element-name');
if (elemFrame) {
const txt = elemFrame.findOne(n => n.type === 'TEXT');
if (txt) txt.characters = element.element;
}
const stateCell = row.findOne(n => n.name === '#state-name');
const notesFrame = row.findOne(n => n.name === '#element-notes');
const notesCellIndex = notesFrame ? row.children.indexOf(notesFrame) : -1;
if (stateCell) {
const cellClones = [];
for (let s = 0; s < N; s++) {
const col = stateCell.clone();
cellClones.push(col);
if (notesCellIndex >= 0) {
row.insertChild(notesCellIndex + s, col);
} else {
row.appendChild(col);
}
}
stateCell.remove();
for (let s = 0; s < cellClones.length; s++) {
cellClones[s].name = 'state-val-' + s;
cellClones[s].layoutSizingHorizontal = 'FILL';
const txt = cellClones[s].findOne(n => n.type === 'TEXT');
if (txt) txt.characters = element.tokensByState[STATE_COLUMNS[s]] || 'none';
}
}
if (notesFrame) {
notesFrame.layoutSizingHorizontal = 'FILL';
const txt = notesFrame.findOne(n => n.type === 'TEXT');
if (txt) txt.characters = element.notes;
}
}
rowTemplate.remove();
}
tableTemplate.remove();
return { success: true, variant: VARIANT_NAME };After all variants are rendered (regardless of strategy), hide the original #variant-template:
const frame = await figma.getNodeByIdAsync('__FRAME_ID__');
const variantTemplate = frame.findOne(n => n.name === '#variant-template');
if (variantTemplate) variantTemplate.visible = false;
return { success: true };figma_take_screenshot with the frameId — Capture the completed annotationfigma_execute and re-capture (up to 3 iterations)Print a clickable Figma URL to the completed spec in chat. Construct the URL from the fileKey (extracted from the user's input URL) and the frameId (returned by Step 9), replacing : with - in the node ID:
Color spec complete: https://www.figma.com/design/{fileKey}/?node-id={frameId}uspecs.config.json under templateKeys.colorAnnotation and is configured via @firstrun.COMPONENT_SET (multi-variant) or a standalone COMPONENT (single variant). The extraction script detects the type and returns isComponentSet accordingly. When the node is a standalone component, it is treated as a single-entry variant array and there are no variant axes. Preview instance creation in Step 11 uses the component directly for standalone components.#variant-template, each table from #color-table-template, and each row from #element-row-template.#preview-instruction-light frame contains multiple TEXT nodes. The second TEXT node (index 1) receives the preview text formatted as "{ComponentName} {VariantName}".SKIP_AXES — pass color-irrelevant axes and their default values to avoid extracting redundant variants. For components with few variants (≤ 10), extracting all variants is fine.color/agent-color-instruction.md) contains the data structure reference, examples, and element-to-token mapping rules that guide the analysis phase.Placeholder child is removed and replaced with live component instances.
HORIZONTAL layout with itemSpacing: 24 so instances flow left to right.setExplicitVariableModeForCollection(collection, modeId) applied so the correct color mode renders. After creating each instance, clearModesRecursive is called to remove any baked-in modes so the instance inherits from the wrapper.hasModeCollection: true, every mode is rendered as its own section(s) — one per Type × Mode combination. Section names use the format "{Type} / {Mode}" (e.g., "Primary / Gray"). Tokens are resolved per mode via modeDetection.modeTokenMap from the extraction output. The collectionId and modeId are passed to the rendering script for preview mode application.setProperties(). This handles sparse component sets where some variant combinations may not exist.#state-title layer originally displays "State", but the column actually holds token names. Strategy A renames this to "Token" at render time. Strategy B replaces the column entirely with per-state columns.#state-title / #state-name cells N times (one per state). All cloned state columns and the Notes column use layoutSizingHorizontal = 'FILL' so Figma's auto-layout distributes width equally — no hardcoded pixel widths needed.b1213ef
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.