| name | create-anatomy |
| description | Generate a visual anatomy annotation in Figma showing numbered markers on a component instance with an attribute table. Use when the user mentions "anatomy", "anatomy annotation", "component anatomy", "create anatomy", or wants to annotate a component's structural elements. |
Create Anatomy Annotation
Generate a hierarchical anatomy annotation directly in Figma — a composition section showing the top-level sub-components with numbered markers and a 4-column attribute table, then per-child sections for each INSTANCE sub-component showing all its internal elements (including hidden ones).
Uses the Anatomy & Properties v2 template with #annotation-table, type indicators (#instance / #text / #slot), and #anatomy-section cloning.
Execution contract (read first).
- This file is instructions to RUN, not a document to edit. Invoking the skill = render the anatomy annotation into Figma from the input
.md.
- Never edit this
SKILL.md or 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
.md is 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
.md resolves; (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
.md spec (required, user-provided path) — the source-of-truth component spec produced by {{skill:create-component-md}}. The user tells you where this .md lives — use the exact path they provide; the .md may live anywhere. This skill seeds its element model from the .md's render-meta block and then runs one bounded minimal live walk; it does NOT re-run the old full extraction. The .md has no dedicated Anatomy section — identity comes from render-meta (component.compSetNodeId, variantAxes, booleanDefs, slotContents, subComponents), and the few facts render-meta cannot supply (live direct-child classification, the richest variant, slot default children, wrapper visuals) come from the Step 3' walk.
- Figma link to the destination (optional) — placement hint only: which page/frame to drop the rendered annotation on.
fileKey, nodeId, and compSetNodeId always come from the .md's render-meta block, never from the link.
There is no screenshot-only path and no "extract everything from a Figma URL" path. Without the component .md there is no identity to seed — 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` render-meta block (seed identity). FAIL FAST if missing.
- [ ] Step 1: Verify MCP connection
- [ ] Step 2: Read template key from uspecs.config.json
- [ ] Step 3': Seed from render-meta + run the bounded minimal live walk (classify direct children, pick richest variant, read slot defaults, check wrapper visuals)
- [ ] Step 4: Evaluate variant selection, classify elements, and enrich notes (AI reasoning)
- [ ] Step 5: Navigate to destination (if different file)
- [ ] Step 6: Import and detach the Anatomy template
- [ ] Step 7: Fill header fields and create composition section
- [ ] Step 8: Build composition artwork with markers + fill table
- [ ] Step 8b: Per-sub-component child sections (property-aware unhide)
- [ ] Step 10: Visual validation
Step 0: Require and parse the component .md (fail fast)
This skill is a consumer of the .md source of truth. Unlike the other create-* skills, the .md has no dedicated "Anatomy" body section — so create-anatomy seeds identity only from the render-meta block and supplements it with one bounded live walk (Step 3'). It does NOT parse an Anatomy section body, and it does NOT re-run the legacy full tree-walk extraction.
-
Resolve the .md path. Use the exact path the user gave, else an attached or open .md in context. The .md may 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 .md path, abort immediately with this exact single-line diagnostic and stop — do NOT fall back to extraction:
This skill requires the component's Markdown .md spec (produced by create-component-md). Provide the path to it. (create-component-md needs a _base.json from the uSpec Extract plugin.)
-
Parse the render-meta block (the fenced JSON between <!-- render-meta:start v=1 --> and <!-- render-meta:end -->). There is no Anatomy body section to parse — every seed value comes from this block:
COMP_SET_ID = render-meta.component.compSetNodeId — the target node for the Step 3' walk, the Step 6 template placement, and every artwork instance in Steps 8/8b.
IS_COMPONENT_SET = render-meta.component.isComponentSet.
COMPONENT_NAME = render-meta.component.componentName.
VARIANT_AXES = render-meta.variantAxes ({ axisName: [options] }) and VARIANT_AXES_DEFAULTS = render-meta.variantAxesDefaults — used by Step 4 sub-step 0 to decide whether a structurally richer variant exists. This replaces reading componentPropertyDefinitions live for variant axes.
BOOLEAN_DEFS = render-meta.booleanDefs[] ({ key, default, associatedLayerName, associatedLayerId }) — the source for boolean-controlled hidden elements. key is the raw component-property key setProperties expects; associatedLayerName lets Step 4 bind a boolean to a Step 3'-walked element by name. This replaces parsing booleans + resolving bindings by raw key in a live walk.
SLOT_CONTENTS = render-meta.slotContents[] ({ slotName, slotNodeType, preferredComponents: [{ componentId, componentName, componentSetId, isComponentSet, ... }] }) — resolves slot preferred instances without the old cross-page findAll search.
SUB_COMPONENTS = render-meta.subComponents[] ({ name, mainComponentName, subCompSetId, subCompVariantAxes, subCompVariantAxesDefaults, booleanOverrides }) — the constitutive sub-components that become Step 8b child sections; subCompSetId is the child node id.
FILE_KEY = render-meta.fileKey, NODE_ID = render-meta.nodeId — for the Step 11 completion link and template placement.
SOURCE_HASH = render-meta.sourceHash — recorded in the Step 11 provenance footer so drift between this annotation and the underlying _base.json is detectable.
FORBIDDEN — do NOT run the full extraction. When the component .md is present (it always is past Step 0), you MUST NOT run the legacy full-tree extraction. Specifically:
- The old Step 3 extraction script — the one that parsed
componentPropertyDefinitions for variantAxes / booleanProps / instanceSwapProps, resolved boolean bindings by raw key, and ran a cross-page findAll to resolve slot preferredValues — is deleted. It does not exist in this skill anymore. Do NOT reintroduce it.
- Do NOT re-derive
variantAxes, variantAxesDefaults, booleanDefs, slot preferred components, or sub-component identity from a live walk — they are seeded from render-meta (Step 0.3).
- The ONLY live read of the component tree permitted before rendering is the bounded minimal walk in Step 3' below, whose scope is explicitly whitelisted. The render scripts (Steps 8 / 8b) make their own live reads of the rendered instance — those are unchanged and still allowed.
Step 1: 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_figma call. No explicit check needed.
- If the first call fails: "Please verify your FIGMA_API_KEY is set correctly in your MCP configuration."
Step 2: Read Template Key
Read the file uspecs.config.json and extract:
- The
anatomyOverview value from the templateKeys object → save as ANATOMY_TEMPLATE_KEY
- The
fontFamily value → save as FONT_FAMILY (default to Inter if not set)
If the template key is empty, tell the user:
The anatomy template key is not configured. Run {{skill:firstrun}} with your Figma template library link first.
Step 3': Seed from render-meta + run the bounded minimal live walk
The element model is built in two parts: a render-meta seed (already parsed in Step 0) and a bounded minimal live walk that supplies only the facts render-meta cannot.
Seed (from render-meta, no Figma reads):
SUB_COMPONENTS (render-meta.subComponents[]) — constitutive sub-components and their subCompSetId; drives the Step 8b child sections with no live identity resolution.
SLOT_CONTENTS (render-meta.slotContents[]) — each slot's preferred components with their componentId; replaces the old cross-page findAll preferred-instance search.
BOOLEAN_DEFS (render-meta.booleanDefs[]) — boolean properties and their associatedLayerName; replaces parsing componentPropertyDefinitions and resolving boolean bindings by raw key.
Bounded minimal live walk (ONE figma_execute). This walk is NOT the old full extraction. It visits only:
- (a) the direct children of the chosen variant's child container — to classify them (
instance / instance-unwrapped / text / slot / container / structural) and capture their live layer names (so marker name-match in Step 8 works directly), node types, visibility, and seed bboxes;
- (b) the chosen variant — picking the richest variant (returned as
selectedVariantId) when the default variant is structurally thin, mirroring the old richest-variant fallback;
- (c) slot default children — the immediate children already sitting in each
SLOT node of the chosen variant (to know what populates a slot by default);
- (d) wrapper visuals — the root variant's fills/strokes/effects (
rootVariantVisuals), the frames traversed to reach the child container (traversedFrames), and childContainerIsVariant, so Step 4 can insert synthetic elements for skipped visual layers.
It does nothing else: no property-definition parsing, no boolean-binding resolution, no cross-page component search, and no recursion into instance internals (that is Step 8b's job). Boolean defs, variant axes, slot preferred components, and sub-component identity all come from the render-meta seed — not from this walk.
Wrapper traversal sync warning: The single-child auto-layout / SLOT / background-rect traversal logic appears in three places — the Step 3' walk below, the Step 8 composition artwork script, and the Step 8b per-child artwork script — and must stay in sync.
Run this walk via figma_execute, replacing __COMP_SET_NODE_ID__ with render-meta.component.compSetNodeId (= COMP_SET_ID) and __PREFERRED_VARIANT_PROPS__ with null for the initial walk (or a variant property object like { "variant": "count-forward" } when re-walking after Step 4 sub-step 0 identifies a richer variant from render-meta.variantAxes):
const TARGET_NODE_ID = '__COMP_SET_NODE_ID__';
const PREFERRED_VARIANT_PROPS = __PREFERRED_VARIANT_PROPS__;
const STRUCTURAL_TYPES = ['RECTANGLE', 'VECTOR', 'ELLIPSE', 'LINE', 'POLYGON', 'STAR', 'BOOLEAN_OPERATION'];
function hasVisuals(n) {
const f = n.fills && n.fills.length > 0 && n.fills.some(f => f.visible !== false);
const s = n.strokes && n.strokes.length > 0 && n.strokes.some(s => s.visible !== false);
const e = n.effects && n.effects.length > 0 && n.effects.some(e => e.visible !== false);
return { hasFills: !!f, hasStrokes: !!s, hasEffects: !!e, hasAny: !!f || !!s || !!e };
}
async function resolveInstanceInfo(instNode) {
try {
const mc = await instNode.getMainComponentAsync();
if (!mc) return null;
const isSet = mc.parent && mc.parent.type === 'COMPONENT_SET';
const cs = isSet ? mc.parent : null;
const info = {
mainComponentId: mc.id,
mainComponentSetId: cs ? cs.id : null,
childIsComponentSet: !!cs,
componentSetName: cs ? cs.name : mc.name,
childVariantCount: cs ? cs.children.length : 1,
childVariantAxes: []
};
if (cs) {
const csPropDefs = cs.componentPropertyDefinitions || {};
for (const [ck, cd] of Object.entries(csPropDefs)) {
if (cd.type === 'VARIANT') {
info.childVariantAxes.push({ name: ck.split('#')[0], options: cd.variantOptions || [], defaultValue: cd.defaultValue });
}
}
}
return info;
} catch { return null; }
}
async function walkToInnerInstance(node, depth) {
if (depth > 8) return null;
if (node.type === 'INSTANCE') return node;
if ((node.type === 'FRAME' || node.type === 'GROUP') && 'children' in node && node.children.length === 1) {
return walkToInnerInstance(node.children[0], depth + 1);
}
return null;
}
async function extractElement(node, index, artworkAbsX, artworkAbsY) {
const absX = node.absoluteTransform[0][2];
const absY = node.absoluteTransform[1][2];
const element = {
index,
name: node.name,
originalName: null,
nodeType: node.type,
classification: null,
visible: node.visible,
bbox: {
x: Math.round(absX - artworkAbsX),
y: Math.round(absY - artworkAbsY),
w: Math.round(node.width),
h: Math.round(node.height)
},
notes: '',
controlledByBoolean: null,
wrappedInstance: null,
mainComponentId: null,
mainComponentSetId: null,
childIsComponentSet: false,
childVariantAxes: [],
childVariantCount: 1,
shouldCreateSection: false
};
if (node.type === 'INSTANCE') {
const info = await resolveInstanceInfo(node);
if (info) {
Object.assign(element, info);
element.notes = info.componentSetName + ' instance';
}
element.classification = 'instance';
} else if (node.type === 'TEXT') {
element.classification = 'text';
const content = node.characters || '';
if (content.length > 0 && content.length <= 30) {
element.notes = 'Text element \u2014 "' + content + '"';
} else {
element.notes = 'Text element';
}
} else if (node.type === 'FRAME' || node.type === 'GROUP') {
const innerInst = await walkToInnerInstance(node, 0);
if (innerInst && innerInst !== node) {
const info = await resolveInstanceInfo(innerInst);
if (info) {
element.wrappedInstance = info;
element.originalName = element.name;
element.nodeType = 'INSTANCE';
element.classification = 'instance-unwrapped';
Object.assign(element, {
mainComponentId: info.mainComponentId,
mainComponentSetId: info.mainComponentSetId,
childIsComponentSet: info.childIsComponentSet,
childVariantAxes: info.childVariantAxes,
childVariantCount: info.childVariantCount
});
element.notes = element.name + ' instance';
} else {
element.classification = 'container';
const childCount = ('children' in node) ? node.children.length : 0;
element.notes = 'Container with ' + childCount + ' children';
}
} else if ('children' in node && node.children.length === 1 && node.children[0].type === 'TEXT') {
const textChild = node.children[0];
element.originalName = element.name;
element.nodeType = 'TEXT';
element.classification = 'text';
const content = textChild.characters || '';
if (content.length > 0 && content.length <= 30) {
element.notes = 'Text element \u2014 "' + content + '"';
} else {
element.notes = 'Text element';
}
} else {
const childCount = ('children' in node) ? node.children.length : 0;
element.classification = childCount > 0 ? 'container' : 'structural';
element.notes = childCount > 0 ? 'Container with ' + childCount + ' children' : 'Empty container';
}
} else if (node.type === 'SLOT') {
element.classification = 'slot';
const childCount = ('children' in node) ? node.children.length : 0;
element.notes = 'Composable slot with ' + childCount + ' children';
} else if (STRUCTURAL_TYPES.includes(node.type)) {
element.classification = 'structural';
element.notes = node.type;
} else {
element.classification = 'structural';
element.notes = node.type;
}
return element;
}
const node = await figma.getNodeByIdAsync(TARGET_NODE_ID);
if (!node || (node.type !== 'COMPONENT_SET' && node.type !== 'COMPONENT')) {
return { error: 'Node is not a component set or component. Type: ' + (node ? node.type : 'null') };
}
const isComponentSet = node.type === 'COMPONENT_SET';
function resolveChildContainer(v) {
let cc = v;
while (cc.children.length === 1 && cc.children[0].type === 'FRAME' && cc.children[0].layoutMode !== 'NONE') {
cc = cc.children[0];
}
if (cc.children.length === 1 && cc.children[0].type === 'SLOT') {
cc = cc.children[0];
}
if (cc === v && cc.children.length > 1) {
const autoLayoutFrames = cc.children.filter(c => c.type === 'FRAME' && c.layoutMode !== 'NONE' && ('children' in c) && c.children.length >= 2);
const structuralOnly = cc.children.filter(c => STRUCTURAL_TYPES.includes(c.type));
if (autoLayoutFrames.length === 1 && structuralOnly.length === cc.children.length - 1) {
cc = autoLayoutFrames[0];
}
}
return cc;
}
let variant;
if (PREFERRED_VARIANT_PROPS && isComponentSet) {
variant = node.children.find(v => {
const props = v.variantProperties || {};
return Object.entries(PREFERRED_VARIANT_PROPS).every(
([k, val]) => props[k] === val
);
}) || node.defaultVariant || node.children[0];
} else {
variant = isComponentSet ? (node.defaultVariant || node.children[0]) : node;
}
let childContainer = resolveChildContainer(variant);
if (isComponentSet && childContainer.children.length === 0 && node.children.length > 1) {
function countDescendants(n) {
let c = 0;
if ('children' in n) { for (const ch of n.children) { c += 1 + countDescendants(ch); } }
return c;
}
let bestVariant = variant;
let bestCount = 0;
for (const v of node.children) {
const cnt = countDescendants(v);
if (cnt > bestCount) { bestCount = cnt; bestVariant = v; }
}
if (bestVariant !== variant) {
variant = bestVariant;
childContainer = resolveChildContainer(variant);
}
}
const varAbsX = variant.absoluteTransform[0][2];
const varAbsY = variant.absoluteTransform[1][2];
const rootVis = hasVisuals(variant);
const rootVariantVisuals = {
hasFills: rootVis.hasFills, hasStrokes: rootVis.hasStrokes, hasEffects: rootVis.hasEffects,
cornerRadius: variant.cornerRadius || 0
};
const traversedFrames = [];
let walker = variant;
while (walker !== childContainer) {
if ('children' in walker && walker.children.length === 1) {
const child = walker.children[0];
const vis = hasVisuals(child);
const cAbsX = child.absoluteTransform[0][2];
const cAbsY = child.absoluteTransform[1][2];
traversedFrames.push({
name: child.name, nodeType: child.type,
hasFills: vis.hasFills, hasStrokes: vis.hasStrokes, hasEffects: vis.hasEffects,
cornerRadius: child.cornerRadius || 0,
bbox: { x: Math.round(cAbsX - varAbsX), y: Math.round(cAbsY - varAbsY), w: Math.round(child.width), h: Math.round(child.height) }
});
walker = child;
} else break;
}
const absX = varAbsX;
const absY = varAbsY;
const elements = [];
let idx = 1;
for (const child of childContainer.children) {
elements.push(await extractElement(child, idx++, absX, absY));
}
for (const el of elements) {
if (el.classification === 'instance' || el.classification === 'instance-unwrapped') {
el.shouldCreateSection = true;
const UTILITY_NAMES = ['spacer', 'divider', 'separator', 'divider line', 'gap', 'padding', 'filler'];
if (UTILITY_NAMES.includes(el.name.toLowerCase())) {
el.shouldCreateSection = false;
}
}
}
for (const el of elements) {
if (el.classification !== 'slot') continue;
const slotNode = childContainer.children[el.index - 1];
if (!slotNode) continue;
const cpRefs = slotNode.componentPropertyReferences || {};
if (cpRefs.visible) {
el.slotBooleanBinding = { propName: cpRefs.visible.split('#')[0], rawKey: cpRefs.visible };
}
if ('children' in slotNode && slotNode.children.length > 0) {
el.slotDefaultChildren = [];
for (const sc of slotNode.children) {
const scInfo = { name: sc.name, nodeType: sc.type, visible: sc.visible };
if (sc.type === 'INSTANCE') {
try {
const mc = await sc.getMainComponentAsync();
if (mc) {
scInfo.mainComponentId = mc.id;
scInfo.mainComponentKey = mc.key;
const isSet = mc.parent && mc.parent.type === 'COMPONENT_SET';
scInfo.componentSetName = isSet ? mc.parent.name : mc.name;
scInfo.componentSetId = isSet ? mc.parent.id : null;
scInfo.isComponentSet = isSet;
}
} catch {}
}
el.slotDefaultChildren.push(scInfo);
}
}
}
return {
componentName: node.name,
variantName: variant.name,
selectedVariantId: variant.id,
compSetNodeId: TARGET_NODE_ID,
isComponentSet,
rootSize: { w: Math.round(variant.width), h: Math.round(variant.height) },
elements,
rootVariantVisuals,
traversedFrames,
childContainerIsVariant: childContainer === variant
};
Save the returned JSON — you will use componentName, compSetNodeId, selectedVariantId, isComponentSet, rootSize, elements, rootVariantVisuals, traversedFrames, and childContainerIsVariant in subsequent steps. The walk no longer returns booleanProps, variantAxes, or instanceSwapProps — those come from the render-meta seed (BOOLEAN_DEFS, VARIANT_AXES, etc.) parsed in Step 0. selectedVariantId is the variant the walk actually used — it may differ from the default variant if PREFERRED_VARIANT_PROPS was set (agent-directed re-walk from Step 4 sub-step 0) or if the default produced 0 elements and the walk fell back to the richest variant.
Each element carries pre-resolved fields:
classification — closed enum: instance, instance-unwrapped, text, slot, container, structural
name — the live, designer-facing layer name (this is the marker name-match key used in Step 8). For instance-unwrapped, this is the wrapper frame's name (e.g., "Thumb"), not the inner component's name. For text classification from a FRAME-wrapped TEXT node, this is the FRAME name (e.g., "Label"). The inner component name is available in wrappedInstance.componentSetName.
controlledByBoolean — { propName, rawKey, defaultValue } or null. Not produced by the walk — Step 4 sets it by matching render-meta.booleanDefs[].associatedLayerName (or key) to the element's live name/originalName.
wrappedInstance — component info for the inner INSTANCE (only on instance-unwrapped elements): includes componentSetName (the inner component's name used by Step 8b for child section creation)
originalName — the FRAME name before unwrapping (on instance-unwrapped and FRAME-wrapped text elements)
shouldCreateSection — true for instance/instance-unwrapped, false for utility names and other types
slotBooleanBinding — { propName, rawKey } or absent. Present on slot elements when the slot's componentPropertyReferences.visible points to a boolean property.
slotDefaultChildren[] — array of { name, nodeType, visible, mainComponentId?, mainComponentKey?, componentSetName?, componentSetId?, isComponentSet? } or absent. Present on slot elements that contain children in the chosen variant. Used to identify what content populates the slot by default.
Slot preferred components are NOT walked. Where the old extraction ran a cross-page findAll to build slotPreferredInstances[], you now read render-meta.slotContents[] (the SLOT_CONTENTS seed). In Step 4, match each slot element's name to SLOT_CONTENTS[].slotName and set slotPreferredComponentId / populateWith from preferredComponents[].componentId. Sub-component identity for child sections likewise comes from render-meta.subComponents[].subCompSetId, not from a live walk.
Additional walk-level fields:
rootVariantVisuals — { hasFills, hasStrokes, hasEffects, cornerRadius } for the root variant frame. When hasFills or hasEffects is true, the variant has visual properties. Step 4 should fold these into the root container's note when a container synthetic is already being created, or insert a standalone synthetic backplate/statelayer element when no container synthetic covers the root variant. hasStrokes is included for informational purposes (to enrich the root container note with border details) but does NOT trigger a separate synthetic element — strokes on the root variant are a border property of the container frame.
traversedFrames[] — Frames the resolveChildContainer traversal walked through to reach the child container. Each entry has { name, nodeType, hasFills, hasStrokes, hasEffects, cornerRadius, bbox }. Frames with fills, strokes, or effects are visually meaningful and should be inserted as synthetic elements by Step 4.
childContainerIsVariant — true when resolveChildContainer resolved to the variant itself (no wrapper traversal). false when it traversed into a child frame, meaning the root component container was skipped by the walk. When false, Step 4 should insert a synthetic element for the root container regardless of whether it has visual properties.
The fullTree field has been removed. Classification and instance-wrapper detection are handled by the Step 3' walk; boolean bindings and slot preferred components come from the render-meta seed (resolved in Step 4 by name).
Step 4: Validate Seed + Walk and Enrich Notes (AI Reasoning)
This is a pure reasoning step — no figma_execute calls unless a re-walk is needed (sub-step 0). The Step 3' minimal walk has already performed classification and instance-wrapper unwrapping; the render-meta seed supplies variant axes, boolean defs, slot preferred components, and sub-component identity. Step 4 focuses on variant evaluation, validation, seed↔element binding, and semantic note-writing.
Read the instruction file {{ref:anatomy/agent-anatomy-instruction.md}}, then enrich the seeded + walked data in-memory before proceeding to rendering.
Process:
-
Evaluate variant selection: If isComponentSet is true and elements.length is small (1–2 elements), review render-meta.variantAxes option names for a structurally richer variant. If a clearly richer variant exists (option names suggest additional sub-components, e.g., "count-forward" adds a count badge to "forward"), re-run the Step 3' minimal walk with PREFERRED_VARIANT_PROPS set to the target variant's property values and replace all Step 3' walk output. Do NOT re-walk for purely stylistic differences (color, size, theme). See {{ref:anatomy/agent-anatomy-instruction.md}} sub-step 0 for the full evaluation rules.
-
Read {{ref:anatomy/agent-anatomy-instruction.md}} for note-writing guidelines, validation checklist, and unhide strategy rules.
-
Validate the classified walk data per the instruction file's validation checklist.
2b. Detect skipped visual layers and root container: Check childContainerIsVariant, rootVariantVisuals, and traversedFrames from the Step 3' walk output. When childContainerIsVariant is false, always insert a synthetic element for the root container. When childContainerIsVariant is true, evaluate whether the root container is architecturally meaningful (e.g., hosts composable slots, manages conditional visibility) and insert a synthetic element if so — skip it when the container is a self-evident stack of same-type sub-components. Root variant fills/effects (NOT strokes — strokes are described in the container note) are folded into the container's note when a container synthetic already exists, or inserted as standalone synthetic elements when no container synthetic covers the root variant. Also insert synthetic elements for traversed frames with fills/strokes/effects. See {{ref:anatomy/agent-anatomy-instruction.md}} sub-step 1b for the full procedure and examples.
2c. Bind render-meta.booleanDefs to walked elements: For each entry in BOOLEAN_DEFS (render-meta.booleanDefs[]), match its associatedLayerName (fall back to key.split('#')[0]) against each element's live name/originalName. On a match, set el.controlledByBoolean = { propName: key.split('#')[0], rawKey: key, defaultValue: default }. This replaces the boolean-binding loop the old extraction ran inside the walk.
- Set unhide strategy for hidden elements per the instruction file's Property-Aware Unhide Decisions section. Boolean-gated elements use the
controlledByBoolean set in sub-step 2c; their unhideStrategy.method === 'boolean' carries booleanRawKey = controlledByBoolean.rawKey (= the render-meta.booleanDefs[].key).
3b. Detect inline markers: For each element, determine whether it should use an inline marker (marker sits directly on the element's nearest edge with a short stub line) or a perimeter marker (standard marker outside the artwork). Elements nested inside another annotated element (e.g., slot default children that are also annotated) get inlineMarker: true. See {{ref:anatomy/agent-anatomy-instruction.md}} for the detection rules.
3c. Resolve + enrich slot preferred instances from the seed: For each slot element, match its live name to SLOT_CONTENTS[].slotName (render-meta.slotContents[]). When a match has preferredComponents, set slotPreferredComponentId to the first preferred component's componentId and enrich notes to mention the preferred component name(s). If the slot has slotDefaultChildren (from the walk), mention the default content. For empty/hidden slots with a preferred component, mark the slot for artwork population (set populateSlot: true and populateWith to { componentId } from the seed). This replaces the old slotPreferredInstances cross-page lookup. See {{ref:anatomy/agent-anatomy-instruction.md}} for the enrichment rules.
- Rewrite the
notes field for each element following the instruction file's note-writing guidelines.
4b. Integrate user-provided design context into notes per the instruction file's guidelines.
4c. Compose brief description: Write a 1-sentence briefDescription (max ~15 words) that describes what this component IS and does — not what the spec shows. Incorporate user-provided context when available. See {{ref:anatomy/agent-anatomy-instruction.md}} for guidelines and examples. Save this string for Step 7.
-
Deduplicate repeated composition elements: When multiple consecutive elements share the same mainComponentSetId, collapse them into a single representative element with a count field. The first element is kept; subsequent duplicates are removed from the array. The representative element's name gets an (xN) suffix in the table. Write notes for deduplicated elements per the instruction file's "Repeated composition elements" guidelines.
-
Validate using the instruction file's checklist.
The enriched elements array (with updated notes, unhideStrategy, count, isSynthetic, inlineMarker, populateSlot, populateWith, shouldCreateSection, and slotPreferredComponentId fields, plus any synthetic elements inserted for skipped visual layers) and the briefDescription string are used by all subsequent rendering steps.
Step 5: Navigate to Destination
If the user provided a separate destination file URL (placement hint only — never a source of structural facts):
figma_navigate — Switch to the destination file
If no destination was provided, stay in the current file and place the annotation beside the component identified by render-meta.component.compSetNodeId.
Step 6: Import and Detach Template
If the user provided a cross-file destination URL (navigated in Step 5), run via figma_execute:
const ANATOMY_TEMPLATE_KEY = '__ANATOMY_TEMPLATE_KEY__';
const templateComponent = await figma.importComponentByKeyAsync(ANATOMY_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__ Anatomy';
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 ANATOMY_TEMPLATE_KEY = '__ANATOMY_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(ANATOMY_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__ Anatomy';
figma.currentPage.selection = [frame];
figma.viewport.scrollAndZoomIntoView([frame]);
return { frameId: frame.id, pageId: _p.id, pageName: _p.name };
Replace __COMPONENT_NAME__ with render-meta.component.componentName (= COMPONENT_NAME). Replace __COMPONENT_NODE_ID__ with render-meta.component.compSetNodeId (= COMP_SET_ID, the same node the Step 3' walk targeted).
Save the returned frameId — you need it for all subsequent steps.
Step 7: Fill Header Fields and Create Composition Section
This step fills the top-level header and creates a dedicated anatomy section by cloning #anatomy-section. The clone is renamed so it is not affected by other skills' cleanup. After cloning, the original #anatomy-section is hidden to prevent its placeholder text from appearing in screenshots. The property skill re-shows it if it needs additional clones.
Run via figma_execute (replace __FRAME_ID__, __COMPONENT_NAME__, __BRIEF_DESCRIPTION__). Replace __BRIEF_DESCRIPTION__ with the briefDescription composed during Step 4 sub-step 4c:
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 === '#comp-name-anatomy');
if (compNameFrame) {
const t = compNameFrame.findOne(n => n.type === 'TEXT');
if (t) t.characters = '__COMPONENT_NAME__';
}
const descFrame = frame.findOne(n => n.name === '#brief-component-description');
if (descFrame) {
const t = descFrame.findOne(n => n.type === 'TEXT');
if (t) t.characters = '__BRIEF_DESCRIPTION__';
}
const anatomySectionTemplate = frame.findOne(n => n.name === '#anatomy-section');
const compositionSection = anatomySectionTemplate.clone();
anatomySectionTemplate.parent.appendChild(compositionSection);
compositionSection.name = 'Component structure';
compositionSection.visible = true;
const sectionFrame = compositionSection.findOne(n => n.name === '#section-name');
if (sectionFrame) {
const t = sectionFrame.findOne(n => n.type === 'TEXT');
if (t) t.characters = 'Component structure';
}
const sectionDescFrame = compositionSection.findOne(n => n.name === '#optional-section-description');
if (sectionDescFrame) {
const t = sectionDescFrame.findOne(n => n.type === 'TEXT');
if (t) t.characters = 'Elements that compose the __COMPONENT_NAME__ and their key attributes.';
}
anatomySectionTemplate.visible = false;
return { success: true, compositionSectionId: compositionSection.id };
Save the returned compositionSectionId — you need it for Step 8.
Step 8: Build Composition Artwork with Markers + Fill Table
Run via figma_execute. Replace __COMPOSITION_SECTION_ID__, __COMP_SET_NODE_ID__, __SELECTED_VARIANT_ID__, __IS_COMPONENT_SET__, the elements array, and __BOOLEAN_UNHIDES_JSON__ with the seed + enriched walk data. __COMP_SET_NODE_ID__ = render-meta.component.compSetNodeId (= COMP_SET_ID); __IS_COMPONENT_SET__ = render-meta.component.isComponentSet. Use the compositionSectionId from Step 7 to scope lookups. __SELECTED_VARIANT_ID__ is the selectedVariantId returned by the Step 3' walk — it may differ from the default variant. __BOOLEAN_UNHIDES_JSON__ is an array of { booleanRawKey } objects from elements whose unhideStrategy.method === 'boolean' — booleanRawKey is the render-meta.booleanDefs[].key. These booleans are toggled via setProperties instead of direct unhide. Pass [] if no boolean-controlled hidden elements exist. Element marker names come from the Step 3' walk's live name/originalName, so the artwork's name-match resolves directly against the rendered instance — there is no name-mismatch problem here. Fonts are loaded in two phases: (1) template fonts from marker and section text nodes, and (2) instance fonts via loadAllFonts after createInstance, after setProperties, and after slot population — this catches fonts used by component instances (e.g., "Uber Move") that differ from the template font. No __FONT_FAMILY__ replacement needed.
Artwork (#preview): Place a component instance with hidden children made visible via property-aware unhide, then clone #marker-example for each element with connecting lines using the nearest-edge + collision avoidance algorithm. Elements with inlineMarker: true get a short stub line on their nearest edge instead of a perimeter marker. Elements with populateSlot: true get the preferred component inserted directly into the SLOT node via appendChild; if slot insertion fails, fall back to a ghost instance at the slot's bbox position. The elements array may contain synthetic elements (isSynthetic: true) inserted by Step 4 — these include the root component container (when childContainerIsVariant is false) and any visually-meaningful skipped layers. Synthetic elements are skipped in the child-index-to-bbox loop (they have no corresponding child node), but their bboxes are updated by a separate reflow-update block that runs after all mutations — this block re-reads compInstance.width/compInstance.height, recalculates artwork dimensions, re-centers the instance, and updates synthetic bboxes to match the post-reflow component size.
Table (#annotation-table): Clone the template row for each element, filling 4 cells: #number, #indicator (show one of #instance / #text / #slot, hide the other two), #element-name, #notes.
const COMPOSITION_SECTION_ID = '__COMPOSITION_SECTION_ID__';
const COMP_SET_ID = '__COMP_SET_NODE_ID__';
const SELECTED_VARIANT_ID = '__SELECTED_VARIANT_ID__';
const IS_COMPONENT_SET = __IS_COMPONENT_SET__;
const MARKER_COLOR = { r: 0.922, g: 0, b: 0.431 };
const elements = __ELEMENTS_JSON__;
const section = await figma.getNodeByIdAsync(COMPOSITION_SECTION_ID);
const frame = section.parent.parent;
const preview = section.findOne(n => n.name === '#preview');
const markerExample = frame.findOne(n => n.name === '#marker-example');
const MARKER_SIZE = 33;
const MARKER_OFFSET = 40;
const PADDING = 80;
const MIN_W = 1400;
const MIN_H = 290;
const COLLISION_GAP = 8;
const INLINE_STUB = 16;
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(() => {})));
}
const selectedVariant = await figma.getNodeByIdAsync(SELECTED_VARIANT_ID);
const compInstance = selectedVariant.createInstance();
await loadAllFonts(compInstance);
let rootW = Math.round(compInstance.width);
let rootH = Math.round(compInstance.height);
const perimeterCount = elements.filter(el => !el.inlineMarker).length;
const markerPadding = Math.ceil(perimeterCount / 4) * (MARKER_SIZE + COLLISION_GAP);
const sideRoom = MARKER_SIZE + MARKER_OFFSET + PADDING + markerPadding;
let neededW = rootW + 2 * sideRoom;
let neededH = rootH + 2 * sideRoom;
let ARTWORK_W = Math.max(MIN_W, Math.round(neededW));
let ARTWORK_H = Math.max(MIN_H, Math.round(neededH));
const wrapper = figma.createFrame();
wrapper.name = 'Artwork wrapper';
wrapper.layoutMode = 'NONE';
wrapper.resize(ARTWORK_W, ARTWORK_H);
wrapper.clipsContent = true;
wrapper.fills = [];
preview.appendChild(wrapper);
let compX = Math.round((ARTWORK_W - rootW) / 2);
let compY = Math.round((ARTWORK_H - rootH) / 2);
wrapper.appendChild(compInstance);
compInstance.x = compX;
compInstance.y = compY;
const BOOLEAN_UNHIDES = __BOOLEAN_UNHIDES_JSON__;
if (BOOLEAN_UNHIDES.length > 0) {
const currentProps = {};
for (const bu of BOOLEAN_UNHIDES) {
currentProps[bu.booleanRawKey] = true;
}
compInstance.setProperties(currentProps);
await loadAllFonts(compInstance);
}
for (const el of elements) {
if (!el.visible && (!el.unhideStrategy || el.unhideStrategy.method === 'direct')) {
function findAndUnhide(node, targetName) {
if (node.name === targetName && !node.visible) { node.visible = true; return true; }
if ('children' in node) { for (const c of node.children) { if (findAndUnhide(c, targetName)) return true; } }
return false;
}
findAndUnhide(compInstance, el.name);
}
}
await loadAllFonts(compInstance);
const markerTextNodes = markerExample.findAll(n => n.type === 'TEXT');
const tableSectionNodes = section.findAll(n => n.type === 'TEXT');
const allPreloadTexts = [...markerTextNodes, ...tableSectionNodes];
const fontSet = new Set();
const fontsToLoad = [];
for (const tn of allPreloadTexts) {
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(() => {})));
let instAbsX = compInstance.absoluteTransform[0][2];
let instAbsY = compInstance.absoluteTransform[1][2];
let childContainer = compInstance;
while (childContainer.children.length === 1 && childContainer.children[0].type === 'FRAME' && childContainer.children[0].layoutMode !== 'NONE') {
childContainer = childContainer.children[0];
}
if (childContainer.children.length === 1 && childContainer.children[0].type === 'SLOT') {
childContainer = childContainer.children[0];
}
if (childContainer === compInstance && childContainer.children.length > 1) {
const LEAF_STRUCTURAL = ['RECTANGLE', 'VECTOR', 'ELLIPSE', 'LINE', 'POLYGON', 'STAR', 'BOOLEAN_OPERATION'];
const autoLayoutFrames = childContainer.children.filter(c => c.type === 'FRAME' && c.layoutMode !== 'NONE' && ('children' in c) && c.children.length >= 2);
const structuralOnly = childContainer.children.filter(c => LEAF_STRUCTURAL.includes(c.type));
if (autoLayoutFrames.length === 1 && structuralOnly.length === childContainer.children.length - 1) {
childContainer = autoLayoutFrames[0];
}
}
const pendingGhosts = [];
let slotChildIdx = 0;
for (const el of elements) {
if (el.isSynthetic) continue;
if (el.populateSlot && el.populateWith) {
try {
const prefNode = await figma.getNodeByIdAsync(el.populateWith.componentId);
if (prefNode && prefNode.type === 'COMPONENT') {
let inserted = false;
const slotNode = childContainer.children[slotChildIdx];
if (slotNode && slotNode.type === 'SLOT') {
const inst = prefNode.createInstance();
await loadAllFonts(inst);
try { slotNode.appendChild(inst); inserted = true; } catch {}
}
if (!inserted) {
pendingGhosts.push({ el, prefNode });
}
}
} catch {}
}
slotChildIdx++;
}
rootW = Math.round(compInstance.width);
rootH = Math.round(compInstance.height);
neededW = rootW + 2 * sideRoom;
neededH = rootH + 2 * sideRoom;
ARTWORK_W = Math.max(MIN_W, Math.round(neededW));
ARTWORK_H = Math.max(MIN_H, Math.round(neededH));
wrapper.resize(ARTWORK_W, ARTWORK_H);
compX = Math.round((ARTWORK_W - rootW) / 2);
compY = Math.round((ARTWORK_H - rootH) / 2);
compInstance.x = compX;
compInstance.y = compY;
instAbsX = compInstance.absoluteTransform[0][2];
instAbsY = compInstance.absoluteTransform[1][2];
for (const el of elements) {
if (el.isSynthetic && el.classification === 'container') {
el.bbox = { x: 0, y: 0, w: rootW, h: rootH };
}
if (el.isSynthetic && el.classification === 'structural' && el.bbox.x === 0 && el.bbox.y === 0) {
el.bbox.w = rootW;
el.bbox.h = rootH;
}
}
let childIdx = 0;
for (let i = 0; i < elements.length; i++) {
const el = elements[i];
if (el.isSynthetic) continue;
const match = childContainer.children[childIdx];
if (match) {
const absX = match.absoluteTransform[0][2];
const absY = match.absoluteTransform[1][2];
el.bbox = {
x: Math.round(absX - instAbsX),
y: Math.round(absY - instAbsY),
w: Math.round(match.width),
h: Math.round(match.height)
};
}
childIdx++;
}
for (const ghost of pendingGhosts) {
try {
const inst = ghost.prefNode.createInstance();
await loadAllFonts(inst);
wrapper.appendChild(inst);
inst.x = Math.round(compX + ghost.el.bbox.x + (ghost.el.bbox.w - inst.width) / 2);
inst.y = Math.round(compY + ghost.el.bbox.y + (ghost.el.bbox.h - inst.height) / 2);
inst.opacity = 0.6;
} catch {}
}
const LINE_WIDTH = 1;
for (const el of elements) {
const outline = figma.createRectangle();
wrapper.appendChild(outline);
outline.name = 'Outline ' + el.index;
outline.x = Math.round(compX + el.bbox.x);
outline.y = Math.round(compY + el.bbox.y);
outline.resize(Math.max(1, el.bbox.w), Math.max(1, el.bbox.h));
outline.fills = [];
outline.strokes = [{ type: 'SOLID', color: MARKER_COLOR }];
outline.strokeWeight = 1;
outline.dashPattern = [4, 4];
}
function scoreSides(el, rootW, rootH) {
const pref = { top: 0, bottom: 1, left: 2, right: 3 };
return [
{ side: 'left', dist: el.bbox.x },
{ side: 'top', dist: el.bbox.y },
{ side: 'right', dist: rootW - (el.bbox.x + el.bbox.w) },
{ side: 'bottom', dist: rootH - (el.bbox.y + el.bbox.h) }
].sort((a, b) => a.dist !== b.dist ? a.dist - b.dist : pref[a.side] - pref[b.side]);
}
function markerPos(side, el, compX, compY, rootW, rootH, offset) {
const cX = compX + el.bbox.x + el.bbox.w / 2;
const cY = compY + el.bbox.y + el.bbox.h / 2;
const eL = compX + el.bbox.x;
const eR = compX + el.bbox.x + el.bbox.w;
const eT = compY + el.bbox.y;
const eB = compY + el.bbox.y + el.bbox.h;
const off = offset || 0;
if (side === 'left') {
return { dotX: compX - MARKER_OFFSET - MARKER_SIZE, dotY: cY - MARKER_SIZE / 2 + off, anchorX: eL, anchorY: cY + off, markerEdgeX: compX - MARKER_OFFSET, markerEdgeY: cY + off };
} else if (side === 'right') {
return { dotX: compX + rootW + MARKER_OFFSET, dotY: cY - MARKER_SIZE / 2 + off, anchorX: eR, anchorY: cY + off, markerEdgeX: compX + rootW + MARKER_OFFSET, markerEdgeY: cY + off };
} else if (side === 'top') {
return { dotX: cX - MARKER_SIZE / 2 + off, dotY: compY - MARKER_OFFSET - MARKER_SIZE, anchorX: cX + off, anchorY: eT, markerEdgeX: cX + off, markerEdgeY: compY - MARKER_OFFSET };
} else {
return { dotX: cX - MARKER_SIZE / 2 + off, dotY: eB + MARKER_OFFSET, anchorX: cX + off, anchorY: eB, markerEdgeX: cX + off, markerEdgeY: eB + MARKER_OFFSET };
}
}
function overlapsPlaced(dotX, dotY, placed) {
for (const p of placed) {
if (Math.abs(dotX - p.x) < MARKER_SIZE + COLLISION_GAP && Math.abs(dotY - p.y) < MARKER_SIZE + COLLISION_GAP) return true;
}
return false;
}
function inBounds(dotX, dotY, aw, ah) {
return dotX >= -MARKER_SIZE && dotY >= -MARKER_SIZE && dotX <= aw && dotY <= ah;
}
const placed = [];
function drawLine(wrapper, x1, y1, x2, y2, name) {
if (Math.abs(x1 - x2) < 1 && Math.abs(y1 - y2) < 1) return;
const seg = figma.createRectangle();
wrapper.appendChild(seg);
seg.name = name;
seg.fills = [{ type: 'SOLID', color: MARKER_COLOR }];
if (Math.abs(x1 - x2) < 1) {
seg.x = Math.round(x1 - LINE_WIDTH / 2);
seg.y = Math.round(Math.min(y1, y2));
seg.resize(LINE_WIDTH, Math.max(1, Math.abs(y2 - y1)));
} else {
seg.x = Math.round(Math.min(x1, x2));
seg.y = Math.round(y1 - LINE_WIDTH / 2);
seg.resize(Math.max(1, Math.abs(x2 - x1)), LINE_WIDTH);
}
}
for (const el of elements) {
const elCenterX = compX + el.bbox.x + el.bbox.w / 2;
const elCenterY = compY + el.bbox.y + el.bbox.h / 2;
const dot = markerExample.clone();
wrapper.appendChild(dot);
dot.name = 'Marker ' + el.index;
dot.visible = true;
const numText = dot.findOne(n => n.type === 'TEXT');
if (numText) numText.characters = String(el.index);
if (el.inlineMarker) {
const eL = compX + el.bbox.x;
const eR = compX + el.bbox.x + el.bbox.w;
const eT = compY + el.bbox.y;
const eB = compY + el.bbox.y + el.bbox.h;
const sides = scoreSides(el, rootW, rootH);
const best = sides[0].side;
let dX, dY, stubX1, stubY1, stubX2, stubY2;
if (best === 'left') {
dX = eL - MARKER_SIZE - 4; dY = elCenterY - MARKER_SIZE / 2;
stubX1 = eL - 4; stubY1 = elCenterY; stubX2 = eL + INLINE_STUB; stubY2 = elCenterY;
} else if (best === 'right') {
dX = eR + 4; dY = elCenterY - MARKER_SIZE / 2;
stubX1 = eR + 4; stubY1 = elCenterY; stubX2 = eR - INLINE_STUB; stubY2 = elCenterY;
} else if (best === 'top') {
dX = elCenterX - MARKER_SIZE / 2; dY = eT - MARKER_SIZE - 4;
stubX1 = elCenterX; stubY1 = eT - 4; stubX2 = elCenterX; stubY2 = eT + INLINE_STUB;
} else {
dX = elCenterX - MARKER_SIZE / 2; dY = eB + 4;
stubX1 = elCenterX; stubY1 = eB + 4; stubX2 = elCenterX; stubY2 = eB - INLINE_STUB;
}
dot.x = Math.round(dX);
dot.y = Math.round(dY);
drawLine(wrapper, stubX1, stubY1, stubX2, stubY2, 'Stub ' + el.index);
continue;
}
const rankedSides = scoreSides(el, rootW, rootH);
let finalDotX, finalDotY, finalSide, finalOffset = 0;
let foundSpot = false;
for (let off = 0; off <= perimeterCount * (MARKER_SIZE + COLLISION_GAP); off += MARKER_SIZE + COLLISION_GAP) {
for (const { side } of rankedSides) {
if (off === 0) {
const pos = markerPos(side, el, compX, compY, rootW, rootH, 0);
if (inBounds(pos.dotX, pos.dotY, ARTWORK_W, ARTWORK_H) && !overlapsPlaced(pos.dotX, pos.dotY, placed)) {
finalDotX = pos.dotX; finalDotY = pos.dotY; finalSide = side; finalOffset = 0;
foundSpot = true; break;
}
} else {
for (const sign of [1, -1]) {
const perpOff = off * sign;
const pos = markerPos(side, el, compX, compY, rootW, rootH, perpOff);
if (!inBounds(pos.dotX, pos.dotY, ARTWORK_W, ARTWORK_H)) continue;
if (!overlapsPlaced(pos.dotX, pos.dotY, placed)) {
finalDotX = pos.dotX; finalDotY = pos.dotY; finalSide = side; finalOffset = perpOff;
foundSpot = true; break;
}
}
if (foundSpot) break;
}
}
if (foundSpot) break;
}
if (!foundSpot) {
const pos = markerPos(rankedSides[0].side, el, compX, compY, rootW, rootH, 0);
finalDotX = pos.dotX; finalDotY = pos.dotY; finalSide = rankedSides[0].side; finalOffset = 0;
}
placed.push({ x: finalDotX, y: finalDotY });
dot.x = Math.round(finalDotX);
dot.y = Math.round(finalDotY);
const pos = markerPos(finalSide, el, compX, compY, rootW, rootH, finalOffset);
drawLine(wrapper, pos.markerEdgeX, pos.markerEdgeY, pos.anchorX, pos.anchorY, 'Line ' + el.index);
}
markerExample.visible = false;
const annotationTable = section.findOne(n => n.name === '#annotation-table');
const rows = annotationTable.children.filter(c => c.name === 'row');
const rowTemplate = rows[rows.length - 1];
for (const el of elements) {
const row = rowTemplate.clone();
annotationTable.appendChild(row);
row.name = 'Row ' + el.index;
const numCell = row.findOne(n => n.name === '#number');
if (numCell) {
const t = numCell.findOne(n => n.type === 'TEXT');
if (t) t.characters = String(el.index);
}
const indicator = row.findOne(n => n.name === '#indicator');
if (indicator) {
const instIcon = indicator.findOne(n => n.name === '#instance');
const textIcon = indicator.findOne(n => n.name === '#text');
const slotIcon = indicator.findOne(n => n.name === '#slot');
const frameIcon = indicator.findOne(n => n.name === '#frame');
if (el.nodeType === 'INSTANCE') {
if (instIcon) instIcon.visible = true;
if (textIcon) textIcon.visible = false;
if (slotIcon) slotIcon.visible = false;
if (frameIcon) frameIcon.visible = false;
} else if (el.nodeType === 'TEXT') {
if (instIcon) instIcon.visible = false;
if (textIcon) textIcon.visible = true;
if (slotIcon) slotIcon.visible = false;
if (frameIcon) frameIcon.visible = false;
} else if (el.nodeType === 'SLOT' || el.classification === 'slot') {
if (instIcon) instIcon.visible = false;
if (textIcon) textIcon.visible = false;
if (slotIcon) slotIcon.visible = true;
if (frameIcon) frameIcon.visible = false;
} else if (el.nodeType === 'FRAME' || el.nodeType === 'GROUP') {
if (instIcon) instIcon.visible = false;
if (textIcon) textIcon.visible = false;
if (slotIcon) slotIcon.visible = false;
if (frameIcon) frameIcon.visible = true;
} else {
if (instIcon) instIcon.visible = false;
if (textIcon) textIcon.visible = false;
if (slotIcon) slotIcon.visible = false;
if (frameIcon) frameIcon.visible = false;
}
}
const nameCell = row.findOne(n => n.name === '#element-name');
if (nameCell) {
const t = nameCell.findOne(n => n.type === 'TEXT');
if (t) {
const hiddenLabel = el.visible ? '' : ' (hidden)';
const countSuffix = el.count > 1 ? ' (x' + el.count + ')' : '';
t.characters = el.name + countSuffix + hiddenLabel;
}
}
const notesCell = row.findOne(n => n.name === '#notes');
if (notesCell) {
const t = notesCell.findOne(n => n.type === 'TEXT');
if (t) t.characters = el.notes || el.nodeType;
}
}
rowTemplate.remove();
return { success: true };
Step 8b: Per-Sub-Component Child Sections
For each direct child that is an INSTANCE node (has mainComponentId or mainComponentSetId from the Step 3' walk), an instance-wrapper FRAME (has wrappedInstance set by the walk), or a slot element with a preferred instance (has slotPreferredComponentId set during Step 4 sub-step 3c from render-meta.slotContents), create a standalone anatomy section showing that child's internal structure. Sub-component identity can also be taken directly from the SUB_COMPONENTS seed (render-meta.subComponents[].subCompSetId) for constitutive children. The script starts with the default variant but falls back to the richest variant (most direct children) when the default has 1 or fewer children and the component set has multiple variants. All hidden descendants are made visible — this internal child walk is preserved and is the allowed mechanism for resolving a sub-component's own elements.
Skip this step entirely if no child elements have nodeType === 'INSTANCE', no instance-wrapper FRAMEs, and no slot elements with slotPreferredComponentId were identified in Step 4. Additionally, check shouldCreateSection on each eligible child (set during Step 4 reasoning) — skip the figma_execute call entirely for any child where shouldCreateSection === false. These are utility or trivially simple sub-components (Spacer, Divider, structural-only, etc.) that don't warrant a dedicated section. The gcElements.length <= 1 guard in the JavaScript remains as a runtime safety net, but the agent should avoid even calling figma_execute for ineligible children.
Deduplicate by component set: When multiple composition elements reference the same mainComponentSetId, create only one child section for that component set. Use the first element's data for section creation. This is particularly common with composable slot components where the slot contains multiple instances of the same sub-component (e.g., 4 buttons in a button group). For slot preferred instances, deduplicate against existing default children — if a slot's slotPreferredComponentId matches the mainComponentId of an existing direct instance child element that already has a section, skip it.
For each eligible child element (shouldCreateSection === true), run via figma_execute (replace __FRAME_ID__, __CHILD_NAME__, __CHILD_COMP_ID__, __CHILD_IS_COMP_SET__ with values from the walk + seed). For direct INSTANCE children, use mainComponentSetId if childIsComponentSet is true, otherwise use mainComponentId (both from the Step 3' walk; or use render-meta.subComponents[].subCompSetId for constitutive children). For instance-wrapper FRAMEs, use wrappedInstance.mainComponentSetId if wrappedInstance.childIsComponentSet is true, otherwise use wrappedInstance.mainComponentId. For slot elements with preferred instances (classification === 'slot' and slotPreferredComponentId is set), use slotPreferredComponentId as __CHILD_COMP_ID__ — this is the componentId from render-meta.slotContents[].preferredComponents[]. Use getNodeByIdAsync(slotPreferredComponentId) to get the component, then check if its parent is a COMPONENT_SET to determine __CHILD_IS_COMP_SET__. Replace __CHILD_BOOLEAN_PROPS_JSON__ with the child sub-component's boolean properties — take them from render-meta.slotContents[].preferredComponents[].booleanDefs (for slot-filled children) when available; otherwise pass [] and rely on directUnhide to surface hidden descendants. If the child has no boolean properties, pass []. Fonts are loaded in two phases: (1) template fonts from marker and section text nodes, and (2) instance fonts via loadAllFonts after createInstance and after directUnhide — this catches fonts used by the sub-component instance that differ from the template font. No __FONT_FAMILY__ replacement needed:
const FRAME_ID = '__FRAME_ID__';
const CHILD_NAME = '__CHILD_NAME__';
const CHILD_COMP_ID = '__CHILD_COMP_ID__';
const CHILD_IS_COMP_SET = __CHILD_IS_COMP_SET__;
const MARKER_COLOR = { r: 0.922, g: 0, b: 0.431 };
const CHILD_BOOLEAN_PROPS = __CHILD_BOOLEAN_PROPS_JSON__;
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(() => {})));
}
const frame = await figma.getNodeByIdAsync(FRAME_ID);
const anatomySectionTemplate = frame.findOne(n => n.name === '#anatomy-section');
const markerExample = frame.findOne(n => n.name === '#marker-example');
const childSection = anatomySectionTemplate.clone();
anatomySectionTemplate.parent.appendChild(childSection);
childSection.name = CHILD_NAME + ' anatomy';
childSection.visible = true;
const markerTextNodes = markerExample.findAll(n => n.type === 'TEXT');
const sectionTextNodes = childSection.findAll(n => n.type === 'TEXT');
const allTexts = [...markerTextNodes, ...sectionTextNodes];
const fontSet = new Set();
const fontsToLoad = [];
for (const tn of allTexts) {
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 sectionFrame = childSection.findOne(n => n.name === '#section-name');
if (sectionFrame) {
const t = sectionFrame.findOne(n => n.type === 'TEXT');
if (t) t.characters = CHILD_NAME + ' anatomy';
}
const sectionDescFrame = childSection.findOne(n => n.name === '#optional-section-description');
if (sectionDescFrame) {
const t = sectionDescFrame.findOne(n => n.type === 'TEXT');
if (t) t.characters = 'Internal elements of the ' + CHILD_NAME + ' sub-component.';
}
const childCompNode = await figma.getNodeByIdAsync(CHILD_COMP_ID);
function directUnhide(node) {
if (!node.visible) node.visible = true;
if ('children' in node) { for (const c of node.children) directUnhide(c); }
}
let singleVariant = CHILD_IS_COMP_SET
? (childCompNode.defaultVariant || childCompNode.children[0])
: childCompNode;
if (CHILD_IS_COMP_SET && childCompNode.children.length > 1) {
const defChildren = singleVariant.children ? singleVariant.children.length : 0;
if (defChildren <= 1) {
let bestVariant = singleVariant;
let bestCount = defChildren;
for (const v of childCompNode.children) {
const cnt = v.children ? v.children.length : 0;
if (cnt > bestCount) { bestCount = cnt; bestVariant = v; }
}
if (bestCount > defChildren) singleVariant = bestVariant;
}
}
const compInstance = singleVariant.createInstance();
await loadAllFonts(compInstance);
if (CHILD_BOOLEAN_PROPS.length > 0) {
const boolProps = {};
for (const bp of CHILD_BOOLEAN_PROPS) {
boolProps[bp.rawKey] = true;
}
try { compInstance.setProperties(boolProps); } catch {}
await loadAllFonts(compInstance);
}
directUnhide(compInstance);
await loadAllFonts(compInstance);
let grandchildContainer = compInstance;
while (grandchildContainer.children.length === 1 && grandchildContainer.children[0].type === 'FRAME' && grandchildContainer.children[0].layoutMode !== 'NONE') {
grandchildContainer = grandchildContainer.children[0];
}
if (grandchildContainer.children.length === 1 && grandchildContainer.children[0].type === 'SLOT') {
grandchildContainer = grandchildContainer.children[0];
}
if (grandchildContainer === compInstance && grandchildContainer.children.length > 1) {
const LEAF_STRUCTURAL = ['RECTANGLE', 'VECTOR', 'ELLIPSE', 'LINE', 'POLYGON', 'STAR', 'BOOLEAN_OPERATION'];
const autoLayoutFrames = grandchildContainer.children.filter(c => c.type === 'FRAME' && c.layoutMode !== 'NONE' && ('children' in c) && c.children.length >= 2);
const structuralOnly = grandchildContainer.children.filter(c => LEAF_STRUCTURAL.includes(c.type));
if (autoLayoutFrames.length === 1 && structuralOnly.length === grandchildContainer.children.length - 1) {
grandchildContainer = autoLayoutFrames[0];
}
}
const LEAF_TYPES = ['TEXT', 'INSTANCE', 'VECTOR', 'RECTANGLE', 'ELLIPSE', 'LINE', 'POLYGON', 'STAR', 'BOOLEAN_OPERATION'];
function resolveLeafElements(node, depth, maxDepth, parentVisible) {
const vis = parentVisible && node.visible;
if (LEAF_TYPES.includes(node.type)) {
return [{ node, name: node.name, visible: vis }];
}
if (('children' in node) && node.children.length > 0 && depth < maxDepth) {
const leaves = [];
for (const child of node.children) {
const resolved = resolveLeafElements(child, depth + 1, maxDepth, vis);
if (resolved.length === 1 && node.children.length === 1) {
resolved[0].name = node.name;
}
leaves.push(...resolved);
}
return leaves;
}
return [{ node, name: node.name, visible: vis }];
}
const rawLeaves = [];
for (const gc of grandchildContainer.children) {
rawLeaves.push(...resolveLeafElements(gc, 0, 4, true));
}
const gcElements = [];
let gcIdx = 1;
for (const leaf of rawLeaves) {
const gc = leaf.node;
const gcEl = {
index: gcIdx++,
name: leaf.name,
nodeType: gc.type,
visible: leaf.visible,
nodeRef: gc,
bbox: { x: 0, y: 0, w: Math.round(gc.width), h: Math.round(gc.height) },
notes: ''
};
if (gc.type === 'INSTANCE') {
try {
const mc = await gc.getMainComponentAsync();
if (mc) {
const compSetName = (mc.parent && mc.parent.type === 'COMPONENT_SET') ? mc.parent.name : mc.name;
gcEl.notes = compSetName + ' instance';
gcEl.resolvedCompKey = (mc.parent && mc.parent.type === 'COMPONENT_SET') ? mc.parent.id : mc.id;
}
} catch { gcEl.notes = 'Instance'; }
} else if (gc.type === 'TEXT') {
const content = gc.characters || '';
if (content.length > 0 && content.length <= 30) {
gcEl.notes = 'Text element — "' + content + '"';
} else {
gcEl.notes = 'Text element';
}
} else if (gc.type === 'FRAME' || gc.type === 'GROUP') {
const childCount = ('children' in gc) ? gc.children.length : 0;
gcEl.notes = childCount > 0 ? 'Contains ' + childCount + ' elements' : 'Empty container';
} else if (['VECTOR', 'RECTANGLE', 'ELLIPSE', 'LINE', 'POLYGON', 'STAR', 'BOOLEAN_OPERATION'].includes(gc.type)) {
gcEl.notes = 'Illustration';
}
gcElements.push(gcEl);
}
const grouped = [];
for (const el of gcElements) {
const groupKey = el.resolvedCompKey || el.name;
const prev = grouped[grouped.length - 1];
const prevKey = prev ? (prev.resolvedCompKey || prev.name) : null;
if (prev && prev.name === el.name && prev.nodeType === el.nodeType && prevKey === groupKey) {
prev.count = (prev.count || 1) + 1;
} else {
el.count = 1;
grouped.push(el);
}
}
let reIdx = 1;
for (const el of grouped) { el.index = reIdx++; }
const gcElementsGrouped = grouped;
if (gcElementsGrouped.length <= 1) {
childSection.remove();
compInstance.remove();
return { success: true, skipped: true, childName: CHILD_NAME, elementCount: gcElementsGrouped.length, rawLeafCount: gcElements.length, reason: 'Sub-component has 1 or fewer unique element groups — section not needed' };
}
const preview = childSection.findOne(n => n.name === '#preview');
const MARKER_SIZE = 33;
const MARKER_OFFSET = 40;
const PADDING = 80;
const MIN_W = 1400;
const MIN_H = 290;
const COLLISION_GAP = 8;
const INLINE_STUB = 16;
const rootW = Math.round(compInstance.width);
const rootH = Math.round(compInstance.height);
const perimeterCount = gcElementsGrouped.filter(el => !el.inlineMarker).length;
const markerPadding = Math.ceil(perimeterCount / 4) * (MARKER_SIZE + COLLISION_GAP);
const sideRoom = MARKER_SIZE + MARKER_OFFSET + PADDING + markerPadding;
const neededW = rootW + 2 * sideRoom;
const neededH = rootH + 2 * sideRoom;
const ARTWORK_W = Math.max(MIN_W, Math.round(neededW));
const ARTWORK_H = Math.max(MIN_H, Math.round(neededH));
const wrapper = figma.createFrame();
wrapper.name = 'Artwork wrapper';
wrapper.layoutMode = 'NONE';
wrapper.resize(ARTWORK_W, ARTWORK_H);
wrapper.clipsContent = true;
wrapper.fills = [];
preview.appendChild(wrapper);
const compX = Math.round((ARTWORK_W - rootW) / 2);
const compY = Math.round((ARTWORK_H - rootH) / 2);
wrapper.appendChild(compInstance);
compInstance.x = compX;
compInstance.y = compY;
const instAbsX = compInstance.absoluteTransform[0][2];
const instAbsY = compInstance.absoluteTransform[1][2];
for (const el of gcElementsGrouped) {
const n = el.nodeRef;
if (n && n.absoluteTransform) {
const absX = n.absoluteTransform[0][2];
const absY = n.absoluteTransform[1][2];
el.bbox = {
x: Math.round(absX - instAbsX),
y: Math.round(absY - instAbsY),
w: Math.round(n.width),
h: Math.round(n.height)
};
}
}
const LINE_WIDTH = 1;
function scoreSides(el, rW, rH) {
const pref = { top: 0, bottom: 1, left: 2, right: 3 };
return [
{ side: 'left', dist: el.bbox.x },
{ side: 'top', dist: el.bbox.y },
{ side: 'right', dist: rW - (el.bbox.x + el.bbox.w) },
{ side: 'bottom', dist: rH - (el.bbox.y + el.bbox.h) }
].sort((a, b) => a.dist !== b.dist ? a.dist - b.dist : pref[a.side] - pref[b.side]);
}
function markerPos(side, el, cX, cY, rW, rH, offset) {
const eCX = cX + el.bbox.x + el.bbox.w / 2;
const eCY = cY + el.bbox.y + el.bbox.h / 2;
const eL = cX + el.bbox.x;
const eR = cX + el.bbox.x + el.bbox.w;
const eT = cY + el.bbox.y;
const eB = cY + el.bbox.y + el.bbox.h;
const off = offset || 0;
if (side === 'left') {
return { dotX: cX - MARKER_OFFSET - MARKER_SIZE, dotY: eCY - MARKER_SIZE / 2 + off, anchorX: eL, anchorY: eCY + off, markerEdgeX: cX - MARKER_OFFSET, markerEdgeY: eCY + off };
} else if (side === 'right') {
return { dotX: cX + rW + MARKER_OFFSET, dotY: eCY - MARKER_SIZE / 2 + off, anchorX: eR, anchorY: eCY + off, markerEdgeX: cX + rW + MARKER_OFFSET, markerEdgeY: eCY + off };
} else if (side === 'top') {
return { dotX: eCX - MARKER_SIZE / 2 + off, dotY: cY - MARKER_OFFSET - MARKER_SIZE, anchorX: eCX + off, anchorY: eT, markerEdgeX: eCX + off, markerEdgeY: cY - MARKER_OFFSET };
} else {
return { dotX: eCX - MARKER_SIZE / 2 + off, dotY: eB + MARKER_OFFSET, anchorX: eCX + off, anchorY: eB, markerEdgeX: eCX + off, markerEdgeY: eB + MARKER_OFFSET };
}
}
function overlapsPlaced(dX, dY, placed) {
for (const p of placed) {
if (Math.abs(dX - p.x) < MARKER_SIZE + COLLISION_GAP && Math.abs(dY - p.y) < MARKER_SIZE + COLLISION_GAP) return true;
}
return false;
}
function inBounds(dX, dY, aw, ah) {
return dX >= -MARKER_SIZE && dY >= -MARKER_SIZE && dX <= aw && dY <= ah;
}
const placed = [];
function drawLine(wr, x1, y1, x2, y2, nm) {
if (Math.abs(x1 - x2) < 1 && Math.abs(y1 - y2) < 1) return;
const seg = figma.createRectangle();
wr.appendChild(seg);
seg.name = nm;
seg.fills = [{ type: 'SOLID', color: MARKER_COLOR }];
if (Math.abs(x1 - x2) < 1) {
seg.x = Math.round(x1 - LINE_WIDTH / 2);
seg.y = Math.round(Math.min(y1, y2));
seg.resize(LINE_WIDTH, Math.max(1, Math.abs(y2 - y1)));
} else {
seg.x = Math.round(Math.min(x1, x2));
seg.y = Math.round(y1 - LINE_WIDTH / 2);
seg.resize(Math.max(1, Math.abs(x2 - x1)), LINE_WIDTH);
}
}
for (const el of gcElementsGrouped) {
const outline = figma.createRectangle();
wrapper.appendChild(outline);
outline.name = 'Outline ' + el.index;
outline.x = Math.round(compX + el.bbox.x);
outline.y = Math.round(compY + el.bbox.y);
outline.resize(Math.max(1, el.bbox.w), Math.max(1, el.bbox.h));
outline.fills = [];
outline.strokes = [{ type: 'SOLID', color: MARKER_COLOR }];
outline.strokeWeight = 1;
outline.dashPattern = [4, 4];
}
for (const el of gcElementsGrouped) {
const elCenterX = compX + el.bbox.x + el.bbox.w / 2;
const elCenterY = compY + el.bbox.y + el.bbox.h / 2;
const dot = markerExample.clone();
wrapper.appendChild(dot);
dot.visible = true;
dot.name = 'Marker ' + el.index;
const numText = dot.findOne(n => n.type === 'TEXT');
if (numText) numText.characters = String(el.index);
if (el.inlineMarker) {
const eL = compX + el.bbox.x;
const eR = compX + el.bbox.x + el.bbox.w;
const eT = compY + el.bbox.y;
const eB = compY + el.bbox.y + el.bbox.h;
const sides = scoreSides(el, rootW, rootH);
const best = sides[0].side;
let dX, dY, sX1, sY1, sX2, sY2;
if (best === 'left') {
dX = eL - MARKER_SIZE - 4; dY = elCenterY - MARKER_SIZE / 2;
sX1 = eL - 4; sY1 = elCenterY; sX2 = eL + INLINE_STUB; sY2 = elCenterY;
} else if (best === 'right') {
dX = eR + 4; dY = elCenterY - MARKER_SIZE / 2;
sX1 = eR + 4; sY1 = elCenterY; sX2 = eR - INLINE_STUB; sY2 = elCenterY;
} else if (best === 'top') {
dX = elCenterX - MARKER_SIZE / 2; dY = eT - MARKER_SIZE - 4;
sX1 = elCenterX; sY1 = eT - 4; sX2 = elCenterX; sY2 = eT + INLINE_STUB;
} else {
dX = elCenterX - MARKER_SIZE / 2; dY = eB + 4;
sX1 = elCenterX; sY1 = eB + 4; sX2 = elCenterX; sY2 = eB - INLINE_STUB;
}
dot.x = Math.round(dX);
dot.y = Math.round(dY);
drawLine(wrapper, sX1, sY1, sX2, sY2, 'Stub ' + el.index);
continue;
}
const rankedSides = scoreSides(el, rootW, rootH);
let finalDotX, finalDotY, finalSide, finalOffset = 0;
let foundSpot = false;
for (let off = 0; off <= perimeterCount * (MARKER_SIZE + COLLISION_GAP); off += MARKER_SIZE + COLLISION_GAP) {
for (const { side } of rankedSides) {
if (off === 0) {
const pos = markerPos(side, el, compX, compY, rootW, rootH, 0);
if (inBounds(pos.dotX, pos.dotY, ARTWORK_W, ARTWORK_H) && !overlapsPlaced(pos.dotX, pos.dotY, placed)) {
finalDotX = pos.dotX; finalDotY = pos.dotY; finalSide = side; finalOffset = 0;
foundSpot = true; break;
}
} else {
for (const sign of [1, -1]) {
const perpOff = off * sign;
const pos = markerPos(side, el, compX, compY, rootW, rootH, perpOff);
if (!inBounds(pos.dotX, pos.dotY, ARTWORK_W, ARTWORK_H)) continue;
if (!overlapsPlaced(pos.dotX, pos.dotY, placed)) {
finalDotX = pos.dotX; finalDotY = pos.dotY; finalSide = side; finalOffset = perpOff;
foundSpot = true; break;
}
}
if (foundSpot) break;
}
}
if (foundSpot) break;
}
if (!foundSpot) {
const pos = markerPos(rankedSides[0].side, el, compX, compY, rootW, rootH, 0);
finalDotX = pos.dotX; finalDotY = pos.dotY; finalSide = rankedSides[0].side; finalOffset = 0;
}
placed.push({ x: finalDotX, y: finalDotY });
dot.x = Math.round(finalDotX);
dot.y = Math.round(finalDotY);
const pos = markerPos(finalSide, el, compX, compY, rootW, rootH, finalOffset);
drawLine(wrapper, pos.markerEdgeX, pos.markerEdgeY, pos.anchorX, pos.anchorY, 'Line ' + el.index);
}
const annotationTable = childSection.findOne(n => n.name === '#annotation-table');
const rows = annotationTable.children.filter(c => c.name === 'row');
const rowTemplate = rows[rows.length - 1];
for (const el of gcElementsGrouped) {
const row = rowTemplate.clone();
annotationTable.appendChild(row);
row.name = 'Row ' + el.index;
const numCell = row.findOne(n => n.name === '#number');
if (numCell) {
const t = numCell.findOne(n => n.type === 'TEXT');
if (t) t.characters = String(el.index);
}
const indicator = row.findOne(n => n.name === '#indicator');
if (indicator) {
const instIcon = indicator.findOne(n => n.name === '#instance');
const textIcon = indicator.findOne(n => n.name === '#text');
const slotIcon = indicator.findOne(n => n.name === '#slot');
const frameIcon = indicator.findOne(n => n.name === '#frame');
if (el.nodeType === 'INSTANCE') {
if (instIcon) instIcon.visible = true;
if (textIcon) textIcon.visible = false;
if (slotIcon) slotIcon.visible = false;