name: create-structure description: Generate structure specifications documenting component dimensions, spacing, padding, and how values change across density, size, and shape variants. Use when the user mentions "structure", "structure spec", "dimensions", "spacing", "density", "sizing", or wants to document a component's dimensional properties.
Create Structure Spec
Generate a structure specification directly in Figma — tables documenting all dimensional properties of a component, organized into sections by variant axis or sub-component, with dynamic columns for size/density variants.
Execution contract (read first).
- This file is instructions to RUN, not a document to edit. Invoking the skill = render the structure spec into Figma from the input
.md. - Never edit this
SKILL.mdor any other skill file in response, even if one is open or focused in the editor. Modify a skill only when the user explicitly asks to change the skill itself. - The input component
.mdis a READ-ONLY source of truth. Never edit, append to, or "add a section" to it. The only artifact this skill produces is the Figma annotation. When the user asks to "create/add a section," "show," or "include" something, render it in the Figma annotation, never as an edit to the.md. - Never call
AskQuestion, request confirmation, or pause for input (including before Figma writes, the expected output). On ambiguity, pick the most defensible option and continue. - Only two legal stops: (a) Step 0 fail-fast when no
.mdresolves; (b) one-line abort if the Figma MCP connection is dead.
MCP Adapter
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.
Inputs Expected
- Component
.mdspec (required, user-provided path) — the source-of-truth component spec produced by {{skill:create-component-md}}. The user tells you where this.mdlives — use the exact path they provide; the.mdmay live anywhere. This skill renders the Structure section from the.md; it does NOT re-extract anything from Figma.fileKey,nodeId, andcompSetNodeIdcome from the.md'srender-metablock, never from the Figma link. - Figma link to the destination (optional) — placement hint only (which page/frame to drop the rendered spec on, including the cross-file destination). Never the source of structural facts.
- Description (optional) — a human nudge (component name, which sections to emphasize). Never a source of dimensions.
There is no screenshot-only path and no component-link extraction path. Without the component .md there is nothing to render — see Step 0's fail-fast contract.
Workflow
Copy this checklist and update as you progress:
Task Progress:
- [ ] Step 0: Require + parse the component `.md` (Structure body + render-meta). FAIL FAST if missing.
- [ ] Step 1: Read instruction file (only as needed for row-emission conventions — NOT for re-extraction)
- [ ] Step 2: Verify MCP connection
- [ ] Step 3: Read template key from uspecs.config.json
- [ ] Step 4: Build render inputs from the parsed .md (sections, COLUMNS, ROWS, general notes, provenance) + render-meta (compSetNodeId, variantAxesDefaults, subComponents, slotContents, booleanDefs) — NO extraction
- [ ] Step 5: Navigate to destination (if a separate destination file was provided)
- [ ] Step 6: Re-derive each section's sectionType via the Step 11a decision table (match columns to variantAxes; {slot} — {comp}; subComponents[].name) — NO live reads
- [ ] Step 7: Audit the assembled render inputs against the .md
- [ ] Step 9: Import and detach the Structure template
- [ ] Step 10: Fill header fields
- [ ] Step 11: For each section → render table, determine preview params, populate preview + canvas measurements
- [ ] Step 12: Visual validation
- [ ] Step 13: Completion link
Step 0: Require and parse the component .md (fail fast)
This skill is a consumer of the .md source of truth. It does not re-extract from Figma and does not re-run the dimensional measurement / section-planning layer — that work already happened in extract-structure/create-component-md and is baked into the .md's Structure section. Your job is to render that section into a Figma frame.
Resolve the
.mdpath. Use the exact path the user gave, else an attached or open.mdin context. The.mdmay live anywhere; do NOT invent or guess a path. If neither resolves to an existing file, abort per item 2. Never pause to ask the user which file to use.Require the file. If no file exists at the resolved
.mdpath, abort immediately with this exact single-line diagnostic and stop — do NOT fall back to extraction:This skill requires the component's Markdown
.mdspec (produced by create-component-md). Provide the path to it. (create-component-md needs a _base.json from the uSpec Extract plugin.)Parse the Structure section (
## Structure) from the.mdbody. Its layout is defined inreferences/component-md/agent-component-md-instruction.md§ Structure body rendering:- Confidence header — the leading italic line (
_Confidence: …_). Carry it for the Step 7 audit; it does not render into Figma. - General notes — the blockquote immediately under the confidence header (if present) →
GENERAL_NOTES. - Typography subsection (
### Typography, when present) — a consolidated per-element index. Skip it when rebuilding row identity — the per-section typography rows below remain authoritative. Do not emit a separate render section or preview for it. - State deltas subsection (
### State deltas/### {axis} deltas, when present) — non-dimensional deltas across an axis. Capture it as a candidate state-conditional section, but dedupe against the dimensional sections so a property that already appears in a dimensional section is not double-emitted. - Dimensional sections — each remaining
###sub-section is one render section: heading =sectionName, optional description paragraph, then a Markdown table whose header row issection.columns. Parse each section into{ sectionName, sectionDescription, columns: COLUMNS_JSON, rows: ROWS_JSON }. - Rows — for every table row build
{ spec, values, notes, isSubProperty, isLastInGroup, provenance }:spec— the first column with the hierarchy arrow and provenance badge stripped to the bare property name. A leading└meansisSubProperty: true, isLastInGroup: true; a leading├meansisSubProperty: true, isLastInGroup: false; no arrow meansisSubProperty: false.provenance—inferredwhen the cell carried a[inferred via <token>](or[inferred]) badge,not-measuredwhen it carried[unmeasured], otherwisemeasured. Tag rows that are purely textual / non-dimensional (sizing modes, alignment, typography style names) asmd.values— the pre-formatteddisplaycells verbatim (e.g.spacing-100 (16),—). Do not reformat or recompute them.notes— the last column verbatim.
- Confidence header — the leading italic line (
Parse the
render-metablock (the fenced JSON between<!-- render-meta:start v=1 -->and<!-- render-meta:end -->; schema inagent-component-md-instruction.md§ RENDER_META_JSON):COMP_SET_ID=component.compSetNodeId.variantAxes/variantAxesDefaults— for Step 6 section-type derivation and Step 11a preview props.BOOLEAN_DEFS= reshapebooleanDefs[]→{ [key]: default }. Eachkeyis the raw component-property keysetPropertiesexpects.subComponents[]—{ name, mainComponentName, subCompSetId, subCompVariantAxes, subCompVariantAxesDefaults, booleanOverrides }for sub-component sections.slotContents[]—{ slotName, slotNodeType, preferredComponents: [{ componentKey, componentName, componentId, componentSetId, isComponentSet, variantAxes, booleanDefs }] }for slot-content sections.sectionTargets/groupTargets— per-section / per-group{ name, nodeId }(available if a render script needs to resolve a section or group header back to a live node; preview resolution otherwise stays name-match on the live instance).fileKey,nodeId— for the Step 13 completion link and template placement.sourceHash— retain it; it identifies the_base.jsonthis.mdwas rendered from (drift detection / provenance footer).
FORBIDDEN — do NOT re-extract. When the component .md is present (it always is past Step 0), you MUST NOT run the legacy extraction/tree-walk. Specifically:
- Do NOT run the deleted Step 4b enhanced extraction script (
extractDimensions/extractChildren/extractTypography/buildLayoutTree/ the SLOTpreferredValuesresolver), the deleted Step 4d cross-variant dimensional comparison script, the deleted Step 4e non-dimensional axis-diff script, or the deleted Step 6b targeted structural-axis re-extraction. These scripts no longer exist in this skill. - Do NOT re-run the old Step 4a
figma_navigate/figma_take_screenshot/figma_get_file_datacontext-gathering, or the Step 4cfigma_get_variablesmode walk. Dimensions, tokens, sub-components, slot contents, variant axes, and boolean defs are authored in the.md+render-metaand consumed verbatim. - Do NOT re-derive the section plan, re-measure dimensions, or re-classify axes from a live walk. Section identity, columns, rows, and provenance come from the parsed
.md; section type is re-derived in Step 6 from the parsed columns +render-meta(no live reads). - The ONLY Figma calls this skill makes are the render scripts: template import/clone (Step 9), header fill (Step 10), and per-section table + preview + canvas-measurement rendering (Step 11). Those resolve elements by name-match on the live rendered/preview instance and read live
bboxfor measurement overlays — that is the cleanest case and needs no whitelisted live extraction read.
Step 1: Read Instructions (only as needed)
The dimensions, sections, columns, rows, notes, and provenance are already authored in the .md — you do NOT re-derive them. Read agent-structure-instruction.md only if you need to recall a row-emission convention while shaping render inputs (e.g. how collapsed padding maps to paddingTop/paddingBottom rows, logical-direction naming, or the annotation allowlist vocabulary). Never use it as a prompt to re-extract.
Step 2: Verify MCP Connection
Read mcpProvider from uspecs.config.json to determine which Figma MCP to use.
If figma-console:
figma_get_status— Confirm Desktop Bridge plugin is active- If connection fails: "Please open Figma Desktop and run the Desktop Bridge plugin. Then try again."
If figma-mcp:
- Connection is verified implicitly on the first
use_figmacall. No explicit check needed. - If the first call fails: "Please verify your FIGMA_API_KEY is set correctly in your MCP configuration."
Step 3: Read Template Key
Read the file uspecs.config.json and extract:
- The
structureSpecvalue from thetemplateKeysobject → save asSTRUCTURE_TEMPLATE_KEY - The
fontFamilyvalue → save asFONT_FAMILY(default toInterif not set)
If the template key is empty, tell the user:
The structure template key is not configured. Run {{skill:firstrun}} with your Figma template library link first.
Step 4: Build render inputs from the parsed .md (no extraction)
Everything the render scripts need is already in the .md you parsed in Step 0. Assemble the render inputs directly — there is no extraction call here (see the FORBIDDEN directive in Step 0).
Build these values:
COMPONENT_NAME—render-meta.component.componentName(fallback: the.md's# {name}H1).GENERAL_NOTES— the Structure section's general-notes blockquote, verbatim (empty when absent).SECTIONS— one entry per parsed dimensional###sub-section, in document order:{ sectionName, sectionDescription, columns: COLUMNS_JSON, rows: ROWS_JSON }.COLUMNS_JSONis the table header row verbatim (first columnSpec/Composition, last columnNotes).ROWS_JSONis the parsed rows ({ spec, values, notes, isSubProperty, isLastInGroup, provenance }) with the pre-formatteddisplaycells copied verbatim. The### State deltassubsection becomes a state-conditionalSECTIONSentry; the### Typographyindex is not a section — its rows already live inline in the per-section tables.COMP_SET_ID—render-meta.component.compSetNodeId.VARIANT_AXES/VARIANT_AXES_DEFAULTS—render-meta.variantAxes/render-meta.variantAxesDefaults. Used in Step 6 (section-type derivation) and Step 11a (preview props).BOOLEAN_DEFS— reshaperender-meta.booleanDefs[]→{ [key]: default }(raw component-property keyssetPropertiesexpects).SUB_COMPONENTS—render-meta.subComponents[](name,mainComponentName,subCompSetId,subCompVariantAxes,subCompVariantAxesDefaults,booleanOverrides). Drives sub-component section previews.SLOT_CONTENTS—render-meta.slotContents[](slotName,preferredComponents[].{componentId, componentSetId, isComponentSet, variantAxes, booleanDefs}). Drives slot-content section previews.
Provenance. Each parsed row carries a provenance tag (md / measured / inferred / not-measured) from Step 0.3 — keep it on the row so the Step 7 audit can confirm [unmeasured] rows render with — value cells and inferred rows keep their [inferred via <token>] reasoning in notes. Retain render-meta.sourceHash alongside the inputs as the provenance fingerprint of the _base.json this .md was rendered from.
Row identity is rebuilt from the parsed table rows, not from the Typography index, and the State-deltas section is deduped against the dimensional sections so a property is never emitted twice.
Each SECTIONS entry's sectionType (which preview Step 11a renders) is derived in Step 6.
The legacy "navigate + screenshot + run the
extractDimensions/extractChildrenextraction script + cross-variant comparison + non-dimensional axis diff" flow (old Steps 4a–4e) has been removed. Do not reintroduce it. The.md+render-metaare the complete input.
Step 5: Navigate to Destination
If the user provided a separate destination file URL:
figma_navigate— Switch to the destination file
If no destination was provided, stay in the current file.
Step 6: Re-derive each section's sectionType (light reasoning, no live reads)
The sections, columns, rows, and notes are already authored in the .md and parsed in Step 4 — you neither add nor drop sections, and you do not re-plan them. The only thing the Step 11a renderer additionally needs is each section's sectionType, which selects the preview-parameter row in Step 11a's decision table. Re-derive it cheaply from the parsed columns + render-meta — no Figma reads.
For each SECTIONS entry, match in this order and attach the resulting sectionType (plus the recorded metadata each type needs in Step 11a):
composition— the first section whose first column header isComposition(it maps parent sizes → sub-component variants). No extra metadata.subComponent— thesectionNamematches arender-meta.subComponents[].name(or itsmainComponentName). Record that entry'ssubCompSetId,subCompVariantAxes,subCompVariantAxesDefaults, andbooleanOverrides.slotContent— thesectionNamematches the"{slotName} — {componentName}"pattern, where{slotName}is arender-meta.slotContents[].slotNameand{componentName}is one of that slot'spreferredComponents[].componentName. Record the preferred component'scomponentId,componentSetId,variantAxes, andbooleanDefs.stateConditional— the### State deltassection, or any section whose value-columns are state names (matching thestateaxis inrender-meta.variantAxes, or runtime conditions likefocused/error).boolean-toggled— a standalone component (no dimension-affectingrender-meta.variantAxes) whose value-columns are boolean-combination labels (e.g.Default,With subtext). The relevant booleans are described byrender-meta.booleanDefs[].variant(default) — the section's value-columns (betweenSpecandNotes) match the options of arender-meta.variantAxesaxis whose name matches/size/i,/density/i, or/shape/i. Record which axis it is (VARIANT_AXIS) and whether it is size / density / shape (this picks the Size/Density/Shape row in Step 11a).
This is the same decision the old AI interpretation layer produced, but it reads axes, sub-components, and slots from render-meta instead of a live walk, and the section list is fixed by the .md. Do not re-measure, re-classify axes from a live walk, or run any targeted re-extraction (the old Step 6b is deleted).
Step 7: Audit the assembled render inputs
Before rendering, verify the inputs you built from the .md:
- Every
SECTIONSentry has acolumnsarray whose first cell isSpec(orComposition) and last cell isNotes, and every row'svalueslength equalscolumns.length - 2. - Every row's
displaycells are copied verbatim from the.md(no reformatting). Rows taggedprovenance: "not-measured"have—in every value cell; rows taggedinferredkeep their[inferred via <token>]reasoning innotes. - Every section resolved a
sectionTypein Step 6. Sub-component / slot-content sections resolved theirrender-metametadata (subCompSetId/ preferredcomponentId). COMP_SET_ID,BOOLEAN_DEFS,VARIANT_AXES,SUB_COMPONENTS, andSLOT_CONTENTSall come fromrender-meta— not from any live read.- The Typography index was not re-emitted as its own section, and the State-deltas rows were deduped against the dimensional sections (no property emitted twice).
- You did NOT run an extraction/tree-walk (see Step 0 FORBIDDEN).
To recall a row-emission convention (collapsed padding → paddingTop/paddingBottom rows, logical-direction naming, the annotation allowlist vocabulary), re-read agent-structure-instruction.md (Common Mistakes / Do NOT / Property naming sections). Fix any mismatch by re-parsing the .md — never by re-extracting from Figma.
Explicitly audit:
- If a section description says
See X spec, no table rows restate X's own internal structure (the.mdalready enforces this forslotContentsections). - If a section is
slotContent, confirm the table documents hosting context and placement-specific deltas only.
There is no Step 8 — the structured data (sections, columns, rows, notes, provenance) is already authored in the .md and assembled in Step 4. Do not re-generate it or re-measure dimensions.
Step 9: Import and Detach Template
If the user provided a cross-file destination URL (navigated in Step 5), run via figma_execute:
const TEMPLATE_KEY = '__STRUCTURE_TEMPLATE_KEY__';
const templateComponent = await figma.importComponentByKeyAsync(TEMPLATE_KEY);
const instance = templateComponent.createInstance();
const { x, y } = figma.viewport.center;
instance.x = x - instance.width / 2;
instance.y = y - instance.height / 2;
const frame = instance.detachInstance();
frame.name = '__COMPONENT_NAME__ Structure';
figma.currentPage.selection = [frame];
figma.viewport.scrollAndZoomIntoView([frame]);
return { frameId: frame.id };
If no destination was provided (default), run via figma_execute — this places the spec on the component's page, to its right:
const TEMPLATE_KEY = '__STRUCTURE_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__ Structure';
figma.currentPage.selection = [frame];
figma.viewport.scrollAndZoomIntoView([frame]);
return { frameId: frame.id, pageId: _p.id, pageName: _p.name };
Replace __COMPONENT_NODE_ID__ with COMP_SET_ID = render-meta.component.compSetNodeId (from the .md parsed in Step 0).
Save the returned frameId — you need it for all subsequent steps.
Cross-file note: All structural facts come from the local component .md (Step 0), so nothing needs to run in the component's file before navigating to the destination. The template import above uses importComponentByKeyAsync which works across files, and the preview scripts (Step 11c) resolve COMP_SET_ID / preferred components by node id via getNodeByIdAsync.
Step 10: Fill Header Fields
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-structure-notes');
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. If false, the general notes frame is hidden.
Step 11: Render Sections (table + preview per section)
Process one section at a time, completing both the table and its preview before moving to the next section. For each section, perform sub-steps 11a, 11b, and 11c in order.
Step 11a: Determine preview parameters for this section
Before rendering, determine the preview configuration for the current section. This is mandatory — every section needs its own preview showing relevant variant instances.
Preview parameter decision table:
| Section type | SUB_COMP_SET_ID |
VARIANT_AXIS |
COLUMN_VALUES |
PROPERTY_OVERRIDES |
SUB_COMP_OVERRIDES |
SLOT_POPULATION |
|---|---|---|---|---|---|---|
| Size/variant (columns are size names like Large, Medium, Small) | '' |
The axis name (e.g., "Size") |
Size names from the axis | Enable all parent-level booleans from BOOLEAN_DEFS (render-meta.booleanDefs) to true so all documented children are visible in the preview |
[] |
null |
| Density (columns are density modes from variable collections) | '' |
'' |
Mode names (e.g., ["Compact", "Default", "Spacious"]) |
Enable all parent-level booleans from BOOLEAN_DEFS (render-meta.booleanDefs) to true so all documented children are visible in the preview |
[] |
null |
| Shape (columns are shape variants) | '' |
The axis name (e.g., "Shape") |
Shape names from the axis | Enable all parent-level booleans from BOOLEAN_DEFS (render-meta.booleanDefs) to true so all documented children are visible in the preview |
[] |
null |
| Sub-component (columns are size names showing a specific child) | The sub-component's own component set ID (from render-meta.subComponents[].subCompSetId, recorded in Step 6) |
The sub-component's size axis name (from render-meta.subComponents[].subCompVariantAxes) |
Size names from the sub-component's own size axis | [] |
Boolean properties to enable on each sub-component instance so all internal children are visible (from render-meta.subComponents[].booleanOverrides — set all values to true) |
null |
| Composition (columns show sub-component variant mappings) | '' |
'' |
Size names | Configure each column's specific property combination | [] |
null |
| Behavior/Configuration (columns are size names) | '' |
Size axis name | Size names from the axis | Enable all parent-level booleans from BOOLEAN_DEFS (render-meta.booleanDefs) to true so all documented children are visible. Do not vary the configuration axis — use the default configuration |
[] |
null |
| State-conditional (columns show default vs active state) | '' |
'' |
State names | Enable all parent-level booleans from BOOLEAN_DEFS (render-meta.booleanDefs) to true, then for each column also set the state variant property for that column |
[] |
null |
| Slot content (columns are parent size names showing a preferred component placed in the slot) | '' (preview is sourced from the parent — preferred is nested via SLOT_POPULATION) |
The parent's size axis name (so the parent renders at each column's size) | Size names from the parent's size axis | Enable all parent-level booleans from BOOLEAN_DEFS (render-meta.booleanDefs) to true (so the slot is visible) |
[] |
{ slotName: '<from Step 6>', preferredComponentId: '<from Step 6>', preferredComponentSetId: '<from Step 6> or null', preferredVariantAxis: '<preferred component\'s size axis name from render-meta.slotContents[].preferredComponents[].variantAxes, or null>', preferredBooleanDefs: { <all render-meta.slotContents[].preferredComponents[].booleanDefs keys → true> } } |
| Boolean-toggled (standalone component with booleans controlling structural elements like slots, accessories, subtext) | '' |
'' |
One label per meaningful boolean combination (e.g., ["Default", "With subtext", "No micro button"]) |
Each entry is a PROPERTY_OVERRIDES object setting the relevant booleans for that combination |
[] |
null |
Boolean-toggled previews: For standalone components with no variant axes, show meaningful boolean combinations as separate labeled preview instances. Always include the default state (all booleans at their defaults) plus the fully-enabled state. When the section documents a specific boolean-controlled element (e.g., heading accessory, subtext), show both the on and off states for that element. Boolean-toggled is the only section type that does NOT auto-enable parent booleans or recursively enable nested booleans — its per-column PROPERTY_OVERRIDES is the configuration spec and must not be clobbered.
Sub-component preview sourcing: When SUB_COMP_SET_ID is non-empty, the preview script creates instances from the sub-component's own component set instead of the parent's COMP_SET_ID. This ensures sub-component section previews show the sub-component in isolation (e.g., four Label instances at different sizes) rather than four full parent component instances. The SUB_COMP_OVERRIDES parameter specifies boolean properties to enable on each sub-component instance after creation, so optional internal children (e.g., character count, status icon) are visible in the preview. Both subCompSetId and booleanOverrides come from render-meta.subComponents[] (parsed in Step 4, matched to the section in Step 6) — no figma_execute exploration is needed to discover them.
Slot content preview sourcing: slotContent previews show the parent component with the preferred component nested inside the actual SLOT node (not as a standalone preview). The script sources the parent inst at the column's parent size, locates the SLOT node by SLOT_POPULATION.slotName, creates an instance of the preferred component (matched to its own size axis when present), and slotNode.appendChild(prefInst). This makes the preview a faithful reference for the table — the SLOT's contextual padding, sizing mode, and spacing are live in the inst tree, so canvas measurements drawn on this preview correctly reflect the slot-imposed values the table documents. If appendChild fails for any reason, the preferred component is placed as a 0.6-opacity ghost overlay at the slot's bbox and annotation is skipped for that column. Row ownership in the table is unchanged: it still documents only the hosting container and slot-imposed deltas, not a second full structure spec for the preferred component.
Recursive nested-boolean enable: Every section type except boolean-toggled runs a recursive walker (mirrors the equivalent walker in {{skill:create-color}}) after createInstance + setProperties. The walker descends every nested INSTANCE in the inst tree and enables every BOOLEAN property on it. This guarantees that any optional child documented in the section's table is visible in the preview even when it's gated by a sub-component's own boolean (e.g., a Label's "Show character count" inside a Text Field's Size section). Boolean-toggled sections are excluded so their per-column PROPERTY_OVERRIDES remains authoritative.
Build the annotation plan (mandatory before 11c):
The annotation plan controls which canvas measurement overlays Step 11c draws on each preview instance. It is built strictly from the section's ROWS — never from inspecting the inst — so overlays can only ever reflect what the table documents.
For each value-column index i, build annotationPlan[i] — an object whose keys are drawn ONLY from this allowlist:
Row spec (from the parsed .md, Step 4) |
annotationPlan[i] keys emitted |
Notes |
|---|---|---|
padding |
paddingTop, paddingBottom, paddingStart, paddingEnd (all four with the same {token}) |
Uniform padding |
verticalPadding |
paddingTop, paddingBottom |
Symmetric vertical |
horizontalPadding |
paddingStart, paddingEnd |
Symmetric horizontal |
paddingTop / paddingBottom / paddingStart / paddingEnd |
that one side | Per-side |
itemSpacing / contentSpacing / gapBetween / iconLabelSpacing |
itemSpacing |
Auto-layout gap |
minWidth / maxWidth / minHeight / maxHeight |
that one constraint | Single-axis overlay with freeText: "min N" / "max N" |
Each entry is { token: string|null }. token is the variable name when the row's display cell was token-bound ("spacing-md (16)" → token = "spacing-md"), or null when hardcoded ("16" → token = null). Derive token from the parsed row's display cell (the text before " ("); the .md already pre-formatted these cells, so a simple split is all that is needed.
Explicit blocklist — any row with one of these spec names contributes nothing to annotationPlan, even if it appears in the table:
cornerRadius, cornerRadiusTopStart, cornerRadiusTopEnd, cornerRadiusBottomStart, cornerRadiusBottomEnd, borderWidth, strokeWeight, width, height, fixedWidth, fixedHeight, iconSize, leadingIconSize, trailingIconSize, slotWidth, slotMinWidth, slotMaxWidth, widthMode, heightMode, verticalAlignment, horizontalAlignment, clipsContent, textStyle, fontSize, fontWeight, lineHeight, letterSpacing, iconName, leadingIcon, trailingIcon, group-header rows (all-– values), and anything not in the allowlist above.
If annotationPlan[i] is empty for every column (e.g., a shape-only or typography-only section), 11c draws nothing and measurementCount is 0 by design. That is the correct outcome.
Padding anchor rule (mandatory): Padding rows are drawn between the container edge and the child whose edge sits on the container's inner-content edge for that side (within a 0.5-px epsilon of paddingTop / paddingBottom / paddingLeft / paddingRight). This guarantees the line length — and therefore Figma's default numeric label — equals the autolayout value the table documents, even when other children are HUG-sized and centered along the cross-axis. If no child aligns to that edge, the line is drawn against the first/last visible child with a freeText override carrying the autolayout value so the label still matches the table. The Step 11c annotate function implements this via findEdgeAnchor; no per-row configuration is required.
Annotation scope (ANNOTATE_SCOPE):
"rootOnly"for variant / density / shape / composition / behavior / state-conditional / boolean-toggled sections (the table documents the root container's own auto-layout settings)."fullTree"forsubComponentandslotContentsections (the table documents the inst's internal structure, including the SLOT node forslotContent). Recursion stops at nested INSTANCE boundaries — those have their own spec sections.
Step 11b: Render the table
Run one figma_execute call for this section's table. Replace all __PLACEHOLDER__ values with the section's data from Step 4 (the parsed .md sections / columns / rows).
const FRAME_ID = '__FRAME_ID__';
const SECTION_NAME = '__SECTION_NAME__';
const SECTION_DESCRIPTION = '__SECTION_DESCRIPTION__';
const HAS_DESCRIPTION = __HAS_DESCRIPTION__;
const COLUMNS = __COLUMNS_JSON__;
const ROWS = __ROWS_JSON__;
const frame = await figma.getNodeByIdAsync(FRAME_ID);
const sectionTemplate = frame.findOne(n => n.name === '#section-template');
const section = sectionTemplate.clone();
sectionTemplate.parent.appendChild(section);
section.name = SECTION_NAME;
section.visible = true;
const textNodes = section.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 titleFrame = section.findOne(n => n.name === '#section-title');
if (titleFrame) {
const t = titleFrame.findOne(n => n.type === 'TEXT');
if (t) t.characters = SECTION_NAME;
}
const descFrame = section.findOne(n => n.name === '#section-description');
if (descFrame) {
if (!HAS_DESCRIPTION) {
descFrame.visible = false;
} else {
const t = descFrame.findOne(n => n.type === 'TEXT');
if (t) t.characters = SECTION_DESCRIPTION;
}
}
const specTable = section.findOne(n => n.name === '#spec-table');
const variantTitleFrame = specTable.findOne(n => n.name === '#variant-title');
if (variantTitleFrame) {
const t = variantTitleFrame.findOne(n => n.type === 'TEXT');
if (t) t.characters = COLUMNS[0];
}
const headerRow = specTable.children.find(c => c.name === 'Header row');
const variantValueTemplate = headerRow.findOne(n => n.name === '#variant-value');
const notesHeader = headerRow.findOne(n => n.name === '#notes-header');
const notesIndex = notesHeader ? headerRow.children.indexOf(notesHeader) : -1;
const valueColumns = COLUMNS.slice(1, -1);
if (notesHeader) {
notesHeader.layoutSizingHorizontal = 'FILL';
}
const headerClones = [];
for (let i = 0; i < valueColumns.length; i++) {
const clone = variantValueTemplate.clone();
headerClones.push(clone);
if (notesIndex >= 0) {
headerRow.insertChild(notesIndex + i, clone);
} else {
headerRow.appendChild(clone);
}
}
variantValueTemplate.remove();
for (let i = 0; i < headerClones.length; i++) {
headerClones[i].layoutSizingHorizontal = 'FILL';
const textNode = headerClones[i].children.find(c => c.type === 'TEXT');
if (textNode) textNode.characters = valueColumns[i];
}
const rowTemplate = specTable.findOne(n => n.name === '#row-template');
for (const rowData of ROWS) {
const row = rowTemplate.clone();
specTable.appendChild(row);
row.name = 'Row ' + rowData.spec;
const propNameFrame = row.findOne(n => n.name === '#property-name');
if (propNameFrame) {
const t = propNameFrame.findOne(n => n.type === 'TEXT');
if (t) t.characters = rowData.spec;
}
const propNotesFrame = row.findOne(n => n.name === '#property-notes');
if (propNotesFrame) {
const t = propNotesFrame.findOne(n => n.type === 'TEXT');
if (t) t.characters = rowData.notes;
propNotesFrame.layoutSizingHorizontal = 'FILL';
}
const hierarchyFrame = row.findOne(n => n.name === '#hierarchy-indicator');
if (hierarchyFrame) {
if (rowData.isSubProperty) {
hierarchyFrame.visible = true;
const withinGroup = hierarchyFrame.children.find(c => c.name === 'within-group');
const lastInGroup = hierarchyFrame.children.find(c => c.name === '#hierarchy-indicator-last');
if (rowData.isLastInGroup) {
if (withinGroup) withinGroup.visible = false;
if (lastInGroup) lastInGroup.visible = true;
} else {
if (withinGroup) withinGroup.visible = true;
if (lastInGroup) lastInGroup.visible = false;
}
} else {
hierarchyFrame.visible = false;
}
}
const valueCellTemplate = row.findOne(n => n.name === '#property-value-cell');
const notesCell = row.findOne(n => n.name === '#property-notes');
const notesCellIndex = notesCell ? row.children.indexOf(notesCell) : -1;
const cellClones = [];
for (let i = 0; i < rowData.values.length; i++) {
const clone = valueCellTemplate.clone();
cellClones.push(clone);
if (notesCellIndex >= 0) {
row.insertChild(notesCellIndex + i, clone);
} else {
row.appendChild(clone);
}
}
valueCellTemplate.remove();
for (let i = 0; i < cellClones.length; i++) {
cellClones[i].layoutSizingHorizontal = 'FILL';
const textNode = cellClones[i].children.find(c => c.type === 'TEXT');
if (textNode) textNode.characters = rowData.values[i];
}
}
rowTemplate.remove();
return { success: true, section: SECTION_NAME, sectionId: section.id };
Save the returned sectionId — pass it to Step 11c as __SECTION_ID__ so the preview script can locate the section by ID instead of by name.
Step 11c: Populate this section's preview
Immediately after the table is rendered for this section, populate its #Preview frame with annotated component instances. Use the preview parameters determined in Step 11a.
Replace the following placeholders with the values from Step 11a:
__SECTION_ID__— the section's node ID returned by Step 11b (sectionIdin the return value)__COMP_SET_NODE_ID__— the component set (or standalone component) node ID__SUB_COMP_SET_NODE_ID__— the sub-component's own component set ID fromrender-meta.subComponents[].subCompSetId(empty string''for non-sub-component sections; also''forslotContent— the preferred component is nested viaSLOT_POPULATION, not sourced asSUB_COMP_SET_ID)__DEFAULT_PROPS_JSON__— object mapping all variant axis names to their default values (fromrender-meta.variantAxesDefaults). WhenSUB_COMP_SET_IDis non-empty, use the sub-component's own variant-axes defaults fromrender-meta.subComponents[].subCompVariantAxesDefaultsinstead.__VARIANT_AXIS__— from the decision table in Step 11a__COLUMN_VALUES_JSON__— from the decision table in Step 11a__PROPERTY_OVERRIDES_JSON__— from the decision table in Step 11a__SUB_COMP_OVERRIDES_JSON__— object mapping sub-component boolean property keys totrue, fromrender-meta.subComponents[].booleanOverrides(empty object{}for non-sub-component sections)__SLOT_POPULATION_JSON__— from the decision table in Step 11a (nullfor every section type EXCEPTslotContent; an object describing the slot to populate forslotContentsections). When non-null, the script sources from the parent, locates the slot byslotName, andslotNode.appendChild()an instance of the preferred component.__IS_BOOLEAN_TOGGLED__—trueonly forboolean-toggledsections;falseeverywhere else. Whenfalse, the script runs the recursive nested-boolean enabler so all documented optional children are visible. Whentrue, it's skipped because per-columnPROPERTY_OVERRIDESis the configuration spec.__ANNOTATION_PLAN_JSON__— from "Build the annotation plan" in Step 11a. Array of lengthCOLUMN_VALUES.length. Each entry is either{}(no annotations for that column) or an object whose keys are drawn from the allowlist (paddingTop,paddingBottom,paddingStart,paddingEnd,itemSpacing,minWidth,maxWidth,minHeight,maxHeight) and whose values are{ token: string|null }.__ANNOTATE_SCOPE__—"rootOnly"or"fullTree", from Step 11a's annotation-scope rule.
const SECTION_ID = '__SECTION_ID__';
const COMP_SET_ID = '__COMP_SET_NODE_ID__';
const SUB_COMP_SET_ID = '__SUB_COMP_SET_NODE_ID__';
const DEFAULT_PROPS = __DEFAULT_PROPS_JSON__;
const VARIANT_AXIS = '__VARIANT_AXIS__';
const COLUMN_VALUES = __COLUMN_VALUES_JSON__;
const PROPERTY_OVERRIDES = __PROPERTY_OVERRIDES_JSON__;
const SUB_COMP_OVERRIDES = __SUB_COMP_OVERRIDES_JSON__;
const SLOT_POPULATION = __SLOT_POPULATION_JSON__;
const IS_BOOLEAN_TOGGLED = __IS_BOOLEAN_TOGGLED__;
const ANNOTATION_PLAN = __ANNOTATION_PLAN_JSON__;
const ANNOTATE_SCOPE = '__ANNOTATE_SCOPE__';
const FONT_FAMILY = '__FONT_FAMILY__';
async function loadAllFonts(rootNode) {
const textNodes = rootNode.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(() => {})));
}
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') {
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 {}
}
const section = await figma.getNodeByIdAsync(SECTION_ID);
if (!section) return { error: 'Section not found: ' + SECTION_ID };
let _p = section; while (_p.parent && _p.parent.type !== 'DOCUMENT') _p = _p.parent;
if (_p.type === 'PAGE') await figma.setCurrentPageAsync(_p);
const page = _p.type === 'PAGE' ? _p : figma.currentPage;
const preview = section.findOne(n => n.name === '#Preview');
if (!preview) return { error: 'No #Preview frame in section: ' + SECTION_ID };
const useSubComp = SUB_COMP_SET_ID && SUB_COMP_SET_ID !== '';
const sourceId = useSubComp ? SUB_COMP_SET_ID : COMP_SET_ID;
const compNode = await figma.getNodeByIdAsync(sourceId);
if (!compNode) return { error: 'Component not found: ' + sourceId };
const isComponentSet = compNode.type === 'COMPONENT_SET';
const instances = [];
for (let i = 0; i < COLUMN_VALUES.length; i++) {
const colValue = COLUMN_VALUES[i];
const variantProps = { ...DEFAULT_PROPS };
if (VARIANT_AXIS && VARIANT_AXIS !== '') {
variantProps[VARIANT_AXIS] = colValue;
}
if (PROPERTY_OVERRIDES.length > i) {
for (const [k, v] of Object.entries(PROPERTY_OVERRIDES[i])) {
variantProps[k] = v;
}
}
let targetVariant = null;
if (isComponentSet) {
let bestFallback = null;
let bestFallbackScore = -1;
for (const child of compNode.children) {
const vp = child.variantProperties || {};
let score = 0;
let exactMatch = true;
for (const [k, v] of Object.entries(variantProps)) {
if (vp[k] === v) { score++; } else { exactMatch = false; }
}
if (exactMatch) { targetVariant = child; break; }
if (score > bestFallbackScore) { bestFallbackScore = score; bestFallback = child; }
}
if (!targetVariant) targetVariant = bestFallback;
} else {
targetVariant = compNode;
}
instances.push({ colValue, targetVariant, overrideIndex: i });
}
const LABEL_FONT = await loadFontWithFallback(FONT_FAMILY, 'Medium');
const wrappers = [];
for (const entry of instances) {
const wrapper = figma.createFrame();
wrapper.name = 'Instance ' + entry.colValue;
wrapper.layoutMode = 'VERTICAL';
wrapper.primaryAxisAlignItems = 'CENTER';
wrapper.counterAxisAlignItems = 'CENTER';
wrapper.layoutSizingHorizontal = 'HUG';
wrapper.layoutSizingVertical = 'HUG';
wrapper.itemSpacing = 10;
wrapper.fills = [];
if (!entry.targetVariant) {
const placeholder = figma.createText();
await figma.loadFontAsync({ family: 'Inter', style: 'Regular' });
placeholder.characters = 'Variant unavailable';
placeholder.fontSize = 12;
placeholder.fills = [{ type: 'SOLID', color: { r: 0.6, g: 0.6, b: 0.6 } }];
wrapper.appendChild(placeholder);
} else {
const inst = entry.targetVariant.createInstance();
await loadAllFonts(inst);
if (useSubComp && Object.keys(SUB_COMP_OVERRIDES).length > 0) {
inst.setProperties(SUB_COMP_OVERRIDES);
await loadAllFonts(inst);
}
if (!useSubComp && PROPERTY_OVERRIDES.length > entry.overrideIndex && Object.keys(PROPERTY_OVERRIDES[entry.overrideIndex]).length > 0) {
inst.setProperties(PROPERTY_OVERRIDES[entry.overrideIndex]);
await loadAllFonts(inst);
}
if (!IS_BOOLEAN_TOGGLED) {
enableNestedBooleans(inst);
await loadAllFonts(inst);
}
wrapper.appendChild(inst);
entry._inst = inst;
entry._ghostOnly = false;
}
const label = figma.createText();
label.fontName = LABEL_FONT;
label.characters = entry.colValue;
label.fontSize = 14;
label.fills = [{ type: 'SOLID', color: { r: 0.29, g: 0.29, b: 0.29 } }];
wrapper.appendChild(label);
preview.appendChild(wrapper);
wrappers.push({ wrapper, entry });
}
if (SLOT_POPULATION && SLOT_POPULATION.slotName) {
const prefSourceId = SLOT_POPULATION.preferredComponentSetId || SLOT_POPULATION.preferredComponentId;
const prefSourceNode = await figma.getNodeByIdAsync(prefSourceId);
const prefIsCS = prefSourceNode && prefSourceNode.type === 'COMPONENT_SET';
const prefBoolDefs = SLOT_POPULATION.preferredBooleanDefs || {};
const prefAxis = SLOT_POPULATION.preferredVariantAxis || '';
for (let i = 0; i < wrappers.length; i++) {
const entry = wrappers[i].entry;
if (!entry._inst || !prefSourceNode) continue;
const slotNode = entry._inst.findOne(n => n.type === 'SLOT' && n.name === SLOT_POPULATION.slotName);
if (!slotNode) continue;
let prefVariant = prefSourceNode;
if (prefIsCS) {
const target = {};
if (prefAxis) target[prefAxis] = entry.colValue;
let bestFallback = prefSourceNode.children[0];
let bestScore = -1;
for (const child of prefSourceNode.children) {
const vp = child.variantProperties || {};
let score = 0;
let exact = true;
for (const [k, v] of Object.entries(target)) {
if (vp[k] === v) { score++; } else { exact = false; }
}
if (exact) { prefVariant = child; break; }
if (score > bestScore) { bestScore = score; bestFallback = child; }
}
if (prefVariant === prefSourceNode) prefVariant = bestFallback;
}
if (!prefVariant || (prefVariant.type !== 'COMPONENT' && prefVariant.type !== 'INSTANCE')) continue;
let prefInst;
try { prefInst = prefVariant.createInstance(); } catch { continue; }
await loadAllFonts(prefInst);
if (Object.keys(prefBoolDefs).length > 0) {
try { prefInst.setProperties(prefBoolDefs); } catch {}
await loadAllFonts(prefInst);
}
enableNestedBooleans(prefInst);
await loadAllFonts(prefInst);
let inserted = false;
try { slotNode.appendChild(prefInst); inserted = true; } catch {}
if (!inserted) {
try {
wrappers[i].wrapper.layoutMode = 'NONE';
wrappers[i].wrapper.appendChild(prefInst);
const slotAbsX = slotNode.absoluteTransform[0][2];
const slotAbsY = slotNode.absoluteTransform[1][2];
const wrapAbsX = wrappers[i].wrapper.absoluteTransform[0][2];
const wrapAbsY = wrappers[i].wrapper.absoluteTransform[1][2];
prefInst.x = Math.round(slotAbsX - wrapAbsX + (slotNode.width - prefInst.width) / 2);
prefInst.y = Math.round(slotAbsY - wrapAbsY + (slotNode.height - prefInst.height) / 2);
prefInst.opacity = 0.6;
entry._ghostOnly = true;
} catch {}
}
}
}
function findEdgeAnchor(container, side, kids) {
if (!kids || kids.length === 0) return null;
const EPS = 0.5;
let pTop = 0, pBottom = 0, pLeft = 0, pRight = 0;
try { pTop = Number(container.paddingTop) || 0; } catch {}
try { pBottom = Number(container.paddingBottom) || 0; } catch {}
try { pLeft = Number(container.paddingLeft) || 0; } catch {}
try { pRight = Number(container.paddingRight) || 0; } catch {}
let cw = 0, ch = 0;
try { cw = Number(container.width) || 0; } catch {}
try { ch = Number(container.height) || 0; } catch {}
const innerTop = pTop;
const innerBottom = ch - pBottom;
const innerLeft = pLeft;
const innerRight = cw - pRight;
for (const k of kids) {
let kx = 0, ky = 0, kw = 0, kh = 0;
try { kx = Number(k.x) || 0; } catch {}
try { ky = Number(k.y) || 0; } catch {}
try { kw = Number(k.width) || 0; } catch {}
try { kh = Number(k.height) || 0; } catch {}
if (side === 'TOP' && Math.abs(ky - innerTop) <= EPS) return k;
if (side === 'BOTTOM' && Math.abs((ky + kh) - innerBottom) <= EPS) return k;
if (side === 'LEFT' && Math.abs(kx - innerLeft) <= EPS) return k;
if (side === 'RIGHT' && Math.abs((kx + kw) - innerRight) <= EPS) return k;
}
return null;
}
function annotate(node, plan, isRoot, scope) {
if (!node.visible) return 0;
let count = 0;
const isAuto = node.layoutMode && node.layoutMode !== 'NONE';
const kids = ('children' in node) ? node.children.filter(c => c.visible) : [];
const first = kids[0], last = kids[kids.length - 1];
if (isAuto && first) {
const sideToProp = { TOP: 'paddingTop', BOTTOM: 'paddingBottom', LEFT: 'paddingLeft', RIGHT: 'paddingRight' };
const paddingSides = [
{ key: 'paddingTop', side: 'TOP', fallback: first },
{ key: 'paddingBottom', side: 'BOTTOM', fallback: last },
{ key: 'paddingStart', side: 'LEFT', fallback: first },
{ key: 'paddingEnd', side: 'RIGHT', fallback: last },
];
for (const { key, side, fallback } of paddingSides) {
const entry = plan && plan[key];
if (!entry) continue;
const anchor = findEdgeAnchor(node, side, kids);
const child = anchor || fallback;
let from, to;
if (side === 'TOP') { from = { node: node, side: 'TOP' }; to = { node: child, side: 'TOP' }; }
else if (side === 'BOTTOM') { from = { node: child, side: 'BOTTOM' }; to = { node: node, side: 'BOTTOM' }; }
else if (side === 'LEFT') { from = { node: node, side: 'LEFT' }; to = { node: child, side: 'LEFT' }; }
else { from = { node: child, side: 'RIGHT' }; to = { node: node, side: 'RIGHT' }; }
let opts;
if (entry.token) {
opts = { freeText: entry.token };
} else if (!anchor) {
let autoVal = 0;
try { autoVal = Number(node[sideToProp[side]]) || 0; } catch {}
opts = { freeText: String(Math.round(autoVal)) };
}
try { page.addMeasurement(from, to, opts); count++; } catch {}
}
const gapEntry = plan && plan.itemSpacing;
if (gapEntry && kids.length > 1 && (node.itemSpacing || 0) > 0) {
const isH = node.layoutMode === 'HORIZONTAL';
const opts = gapEntry.token ? { freeText: gapEntry.token } : undefined;
for (let i = 0; i < kids.length - 1; i++) {
try {
page.addMeasurement(
{ node: kids[i], side: isH ? 'RIGHT' : 'BOTTOM' },
{ node: kids[i + 1], side: isH ? 'LEFT' : 'TOP' },
opts
);
count++;
} catch {}
}
}
}
for (const [key, axis] of [['minWidth','H'],['maxWidth','H'],['minHeight','V'],['maxHeight','V']]) {
const entry = plan && plan[key];
if (!entry) continue;
const v = node[key];
if (typeof v !== 'number' || v <= 0 || v >= 10000) continue;
const prefix = key.startsWith('min') ? 'min ' : 'max ';
try {
page.addMeasurement(
{ node: node, side: axis === 'H' ? 'LEFT' : 'TOP' },
{ node: node, side: axis === 'H' ? 'RIGHT' : 'BOTTOM' },
{ freeText: prefix + Math.round(v) }
);
count++;
} catch {}
}
if (scope === 'fullTree' && (isRoot || node.type !== 'INSTANCE')) {
for (const c of kids) count += annotate(c, plan, false, scope);
}
return count;
}
let measurementCount = 0;
let plannedColumns = 0;
for (let i = 0; i < wrappers.length; i++) {
const entry = wrappers[i].entry;
if (!entry._inst || entry._ghostOnly) continue;
const plan = ANNOTATION_PLAN[i];
if (!plan || Object.keys(plan).length === 0) continue;
plannedColumns++;
try { for (const m of page.getMeasurementsForNode(entry._inst)) page.deleteMeasurement(m.id); } catch {}
measurementCount += annotate(entry._inst, plan, true, ANNOTATE_SCOPE);
}
return { success: true, section: SECTION_ID, measurementCount: measurementCount, plannedColumns: plannedColumns };
Step 12: Visual Validation
figma_take_screenshotwith theframeId— Capture the completed spec- Verify visually (from the screenshot):
- All sections are present with correct titles
- Column headers match the expected variants/sizes
- Row values are filled correctly
- Hierarchy indicators (├─ / └─) appear on sub-properties
- General notes are visible or hidden as expected
- Each section's
#Previewframe has at least one child instance and the instances are visible - Preview layout: Instances are placed inside the
#Previewframe. Each instance has a label below it. The template's#Previewframe provides the layout — the script does not override any of its properties. - Column widths look balanced — the notes column is not crushed
- Sub-component preview correctness: Sub-component section previews show instances from the sub-component's own component set (not the parent). Verify that the preview shows the sub-component in isolation (e.g., four Label instances at different sizes, not four full Text Field instances). If
SUB_COMP_OVERRIDESwas specified, verify that optional internal children (e.g., character count, icons) are visible on each preview instance. - Slot content preview correctness:
slotContentsection previews show the parent component with the preferred component nested inside the actual SLOT node (not a standalone preferred-component preview). Verify that the preferred component appears inside the parent at each parent size, with all parent-level booleans enabled so the slot is visible. - Recursive boolean enable: For every section type except
boolean-toggled, optional children documented in the table should be visible on every preview instance — even children gated by booleans deep inside nested sub-components. - Behavior variant preview simplicity: When a behavior/configuration axis exists (e.g., Static vs Interactive), the preview shows only the default configuration — one row of instances at each size. Do NOT duplicate instances for each configuration.
- Verify measurements (NOT from the screenshot — measurements are a canvas overlay produced by
page.addMeasurement(...)and they DO NOT appear infigma_take_screenshot/get_screenshotoutput):- For each section's Step 11c return value, compare
measurementCountagainstplannedColumns. IfplannedColumns > 0andmeasurementCount === 0, the inst was likely missing or hidden — re-run that section's 11c. - Sections whose tables contain only blocklisted properties (cornerRadius / borderWidth / typography / sizing modes / icon refs / etc.) are expected to return
plannedColumns === 0and need no follow-up. - For
slotContentsections specifically, if the preferred component fell back to ghost-overlay placement (appendChildfailed), annotation is intentionally skipped for that column. Confirm visually in the screenshot that the preferred component is overlaid at the slot bbox at 0.6 opacity — if so, the table values still apply but the overlay can't be drawn for that column.
- For each section's Step 11c return value, compare
- If issues are found, fix via
figma_execute/use_figmaand re-capture (up to 3 iterations)
Step 13: Completion Link
Print a clickable Figma URL to the completed spec in chat. Construct the URL from the fileKey (render-meta.fileKey) and the frameId (returned by Step 9), replacing : with - in the node ID:
Structure spec complete: https://www.figma.com/design/{fileKey}/?node-id={frameId}
Notes
- The target node referenced by
render-meta.component.compSetNodeIdcan be either aCOMPONENT_SET(multi-variant) or a standaloneCOMPONENT(single variant). The Step 11c preview script detects the type at render time (compNode.type === 'COMPONENT_SET') and falls back tocompNode.createInstance()directly for standalone components. For standalone componentsrender-meta.variantAxesis empty and there are no variant columns. - Dynamic columns: The
#variant-valuetemplate in the header row and#property-value-cellin each data row are cloned once per value column, then the original template is removed. Clones are inserted before the Notes column to maintain correct column order. All value columns and the Notes column uselayoutSizingHorizontal = 'FILL'so Figma's auto-layout distributes width equally across them. - Each section is rendered in a separate
figma_executecall to avoid timeouts. - Native canvas measurements: Step 11c annotates each preview instance with native Figma measurement overlays via
page.addMeasurement(...). Annotation is gated by the section's table — only properties present inANNOTATION_PLAN(paddings, gap/itemSpacing, min/max width/height) are drawn. Token-bound rows render the token name on the line viafreeText. Hardcoded padding rows are anchored to the child whose edge sits on the container's inner-content edge for that side (computed from the container's autolayout paddings), so Figma's default numeric label naturally matches the autolayout value the table documents. When no child aligns to that edge — e.g., a horizontal capsule whose children are HUG-sized andcounterAxisAlignItems=CENTER— the line falls back to the first/last visible child but carries afreeTextoverride of the autolayout value so the label still matches the table. Hardcoded gap/itemSpacing rows continue to let Figma's default numeric label show through (consecutive children sit edge-to-edge with the gap by definition). Min/max constraints render with a"min N"/"max N"prefix. Per-instance idempotency is provided bygetMeasurementsForNode+deleteMeasurementbefore each annotation pass. Bothfigma-console(figma_execute) andfigma-mcp(use_figma) execute the identical JS — no MCP-specific branch is needed. Measurements are a canvas overlay and do NOT appear in screenshot output; verify via themeasurementCount/plannedColumnsreturned by Step 11c. - Slot content preview faithfulness:
slotContentpreviews source the parent component at each column's parent size and useslotNode.appendChild()to nest the preferred component inside the actual SLOT node (mirrors the slot-nesting pattern used in {{skill:create-anatomy}}). This makes the preview a faithful reference for the table — the SLOT's contextual padding, sizing, and spacing are live in the inst tree, so canvas measurements correctly reflect the slot-imposed values. Ghost-overlay fallback (0.6 opacity at the slot's bbox) handles the rare case whereappendChildfails; annotation is skipped for that column when ghost fallback fires. - Recursive nested-boolean enable: Every section type except
boolean-toggledruns a recursive walker aftercreateInstance+setPropertiesthat enables every BOOLEAN property on every nested INSTANCE (mirrors the equivalent walker in {{skill:create-color}}). This guarantees that any optional child documented in the section's table is visible in the preview, even when it's gated by a sub-component's own boolean (e.g., a Label's "Show character count" inside a Text Field's Size section). Boolean-toggled sections are excluded so their per-columnPROPERTY_OVERRIDESremains authoritative.