create-anatomy

star 216

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.

redongreen By redongreen schedule Updated 6/5/2026

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.jsonmcpProvider. 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.

  1. 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.

  2. 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.)

  3. 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;
    }
  }
}

// --- Slot default children only (scope (c)) ---
// Boolean defs, slot preferred components, variant axes, and sub-component identity are
// SEEDED from render-meta (Step 0.3). This walk does NOT parse componentPropertyDefinitions
// and does NOT run a cross-page findAll for preferred values. For each direct-child SLOT it
// reads only the slot's existing default children plus the slot's own visibility binding;
// Step 4 cross-references render-meta.booleanDefs / render-meta.slotContents by name.
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)
  • shouldCreateSectiontrue 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.
  • childContainerIsVarianttrue 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:

  1. 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.

  2. Read {{ref:anatomy/agent-anatomy-instruction.md}} for note-writing guidelines, validation checklist, and unhide strategy rules.

  3. 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.

  1. 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.

  1. 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.

  1. 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.

  2. 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];
  }
}

// --- Populate slot content via appendChild into SLOT node ---
// Must run BEFORE bbox re-computation so auto-layout reflow is captured in positions.
// Ghost overlays (fallback when appendChild fails) are deferred to after reflow so they
// use final compX/compY and recomputed el.bbox.
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++;
}

// --- Update dimensions after all mutations (boolean unhide + slot population may reflow auto-layout) ---
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;
  }
}

// --- Re-compute bboxes from live instance positions (after slot population + auto-layout reflow) ---
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++;
}

// --- Create ghost overlays for failed slot insertions (using final compX/compY and recomputed bboxes) ---
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;

// --- Draw dashed outlines ---
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];
}

// --- Nearest-edge marker placement with collision avoidance ---
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;

// --- Fill annotation table ---
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);
}

// --- Collapse repeated identical siblings ---
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' };
}

// --- Build artwork in #preview ---
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;

// --- Nearest-edge marker placement with collision avoidance ---
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);
}

// --- Fill annotation table ---
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;
      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') {
      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, childSectionId: childSection.id, childName: CHILD_NAME, elementCount: gcElementsGrouped.length, groupedElements: gcElementsGrouped.map(el => ({ index: el.index, name: el.name, nodeType: el.nodeType, visible: el.visible, notes: el.notes, count: el.count })) };

Save each returned childSectionId and groupedElements array (which includes count for grouped siblings).

Enrich per-child notes (AI reasoning): The script above produces generic notes for groupedElements (e.g., "Label instance", "Contains 3 elements"). After each figma_execute returns, apply the same reasoning process as Step 4 — read the note-writing guidelines from {{ref:anatomy/agent-anatomy-instruction.md}} and rewrite each element's notes with semantic descriptions. Then run a lightweight figma_execute to update the table text:

const CHILD_SECTION_ID = '__CHILD_SECTION_ID__';
const ENRICHED_ELEMENTS = __ENRICHED_ELEMENTS_JSON__;

const section = await figma.getNodeByIdAsync(CHILD_SECTION_ID);
const annotationTable = section.findOne(n => n.name === '#annotation-table');
const rows = annotationTable.children.filter(c => c.name.startsWith('Row '));

const textNodes = annotationTable.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(() => {})));

for (const el of ENRICHED_ELEMENTS) {
  const row = rows.find(r => r.name === 'Row ' + el.index);
  if (!row) continue;
  const notesCell = row.findOne(n => n.name === '#notes');
  if (notesCell) {
    const t = notesCell.findOne(n => n.type === 'TEXT');
    if (t) t.characters = el.notes;
  }
}

return { success: true };

Replace __CHILD_SECTION_ID__ with the returned childSectionId and __ENRICHED_ELEMENTS_JSON__ with the enriched elements array (only index and notes fields are needed).

Repeat for every eligible child element (shouldCreateSection === true) from the Step 3' walk + Step 4 data — this includes direct INSTANCE children, instance-wrapper FRAMEs with wrappedInstance, and slot elements with slotPreferredComponentId. Skip any child where shouldCreateSection === false — do not call figma_execute for it.

After all per-child sections are processed, update the composition table's #notes cells: for each child (INSTANCE or instance-wrapper FRAME) that was not skipped (i.e., a section was created and shouldCreateSection === true), append — See <child name> anatomy section to the existing notes text in the corresponding row. Do not add cross-references for skipped or ineligible children.

Step 10: Visual Validation

  1. figma_take_screenshot with the frameId — Capture the completed annotation
  2. Verify:
    • All sections (composition and per-child) have pink dashed outlines around each annotated element, correct markers, and 4-column table with type icons
    • Each sub-component with shouldCreateSection: true has its own section with artwork showing all elements visible
    • No sections were created for ineligible children (shouldCreateSection: false)
    • Type indicators correctly show diamond for INSTANCE, T for TEXT, slot icon for SLOT, all three hidden for FRAME/container/structural
    • Hidden elements labeled "(hidden)" in element name column
    • Grouped elements show (xN) suffix in element name column
    • Notes column has brief functional descriptions; grouped elements mention their count
    • All markers fit within preview area
    • Per-child section titles use designer-facing names
  3. If issues are found, fix via figma_execute and re-capture (up to 3 iterations)

Step 11: Completion Link + Provenance

Print a clickable Figma URL to the completed spec in chat. Construct the URL from render-meta.fileKey (= FILE_KEY) and the frameId (returned by Step 6), replacing : with - in the node ID:

Anatomy spec complete: https://www.figma.com/design/{fileKey}/?node-id={frameId}

Provenance footer. Record the source-of-truth hash so drift between this annotation and the underlying _base.json is detectable. Append a footer line with render-meta.sourceHash (= SOURCE_HASH):

Source: {mdPath} (render-meta sourceHash {SOURCE_HASH})

If SOURCE_HASH is absent from render-meta, the .md predates the render-meta hash — note that the .md should be regenerated by re-running create-component-md.

Install via CLI
npx skills add https://github.com/redongreen/uSpec --skill create-anatomy
Repository Details
star Stars 216
call_split Forks 29
navigation Branch main
article Path SKILL.md
More from Creator