Рецепты Plugin API (references/build-recipes.md)

Источник: ds-skills/src/skills/astra-figma-doc-build/references/build-recipes.md — справочник скилла astra-figma-doc-build

Код ниже — чистый Plugin API: выполняется через use_figma или любой другой инструмент, который запускает JS в файле Figma. Контракт вызова — в SKILL.md, раздел «Как запускать».

Код написан без ?., ?? и structuredClone: песочница Figma MCP их не знает. Значения в ВЕРХНЕМ_РЕГИСТРЕ — подстановки: id и числа из ответа прошлого вызова, переданные строкой. Состояние между вызовами не живёт.


0. Общие функции

Вставляются в начало каждого вызова, который пишет.

// свойство по имени до «#» — точно, с проверкой типа:
// у ds-log есть «Описание» (BOOLEAN) и «Описание » (TEXT) — отличаются пробелом
function propKey(inst, name, type) {
  const props = inst.componentProperties;
  const hits = Object.keys(props).filter(k =>
    k.split("#")[0] === name && (!type || props[k].type === type));
  if (hits.length !== 1) {
    throw new Error("свойство «" + name + "» (" + (type || "любой тип") + "): найдено " +
      hits.length + " из " + Object.keys(props).join(", "));
  }
  return hits[0];
}

// map: { имя: значение }; тип выводится из значения: boolean → BOOLEAN,
// строка → TEXT или VARIANT (что найдётся)
function setProps(inst, map) {
  const out = {};
  for (const name of Object.keys(map)) {
    const v = map[name];
    let key;
    if (typeof v === "boolean") key = propKey(inst, name, "BOOLEAN");
    else {
      try { key = propKey(inst, name, "TEXT"); }
      catch (e) { key = propKey(inst, name, "VARIANT"); }
    }
    out[key] = v;
  }
  inst.setProperties(out);
}

function variantOf(set, props) {
  const hit = set.children.find(c =>
    Object.keys(props).every(k => c.variantProperties[k] === props[k]));
  if (!hit) throw new Error(set.name + ": нет варианта " + JSON.stringify(props));
  return hit;
}

// неразрывные пробелы — texts.md, раздел 4
const NBSP = String.fromCharCode(160); // \s в регулярках его и так ловит
function nb(s) {
  let t = String(s);
  for (let i = 0; i < 2; i++) {
    t = t.replace(/(^|[\s(«])([а-яёА-ЯЁ]{1,2}|для|без|при|под|над|про|или|как) /g, "$1$2" + NBSP);
  }
  return t.replace(/ (—|–)/g, NBSP + "$1").replace(/(\d) (?=[^\s\d—–])/g, "$1" + NBSP);
}

1. Инвентарь компонента (только чтение)

Проверка входа — здесь, до любой записи.

const node = await figma.getNodeByIdAsync("SRC_ID");
if (!node) return { stop: true, reason: "узел не найден" };
if (node.type !== "COMPONENT_SET" && node.type !== "COMPONENT") {
  return { stop: true, got: { type: node.type, name: node.name },
           reason: "нужна ссылка на сам компонент: COMPONENT_SET или COMPONENT" };
}
const set = node.type === "COMPONENT" && node.parent && node.parent.type === "COMPONENT_SET"
  ? node.parent : node;

let page = set; while (page.type !== "PAGE") page = page.parent;
let section = set.parent;
while (section && section.type !== "SECTION" && section.type !== "PAGE") section = section.parent;
const oldDocs = section && section.type === "SECTION"
  ? { id: section.id, name: section.name, frames: section.children.map(c => c.name) } : null;

const defs = set.componentPropertyDefinitions;
const props = Object.keys(defs).map(k => ({
  key: k, name: k.split("#")[0], type: defs[k].type,
  values: defs[k].variantOptions, def: defs[k].defaultValue
}));

const sample = set.type === "COMPONENT_SET" ? set.defaultVariant : set;
function tree(n, d) {
  const o = { id: n.id, name: n.name, type: n.type };
  if (n.layoutMode && n.layoutMode !== "NONE") o.layout = n.layoutMode;
  if (n.componentPropertyReferences) o.refs = n.componentPropertyReferences;
  if (n.type === "INSTANCE") o.main = n.mainComponent ? n.mainComponent.name : null;
  if ("children" in n && d < 3 && n.type !== "INSTANCE") o.kids = n.children.map(c => tree(c, d + 1));
  return o;
}

return {
  page: { id: page.id, name: page.name }, oldDocs,
  set: { id: set.id, type: set.type, name: set.name, key: set.key,
         description: set.description, docs: set.documentationLinks,
         w: Math.round(set.width), h: Math.round(set.height) },
  props,
  defaultVariant: sample.id,
  variants: set.type === "COMPONENT_SET"
    ? set.children.slice(0, 300).map(c => ({ id: c.id, p: c.variantProperties,
        x: Math.round(c.x), y: Math.round(c.y), w: Math.round(c.width), h: Math.round(c.height) }))
    : [],
  anatomy: tree(sample, 0)
};

Больше 300 вариантов или ответ упёрся в 20 КБ — снимать варианты отдельным вызовом кусками по slice. Токены мастера по умолчанию — отдельно, get_variable_defs.

Для BOOLEAN найти слой, который он прячет: узел дерева с refs.visible = ключ свойства. Имя этого слоя пойдёт в описание блока (texts.md).


2. Импорт атомов и шрифты

Каждый вызов, который пишет, начинает с импорта нужного и загрузки шрифтов.

const K = {
  template:  "8f7cbc56b451e2dafa03085a38c7b28835d397fa",   // ds-component, только проверка
  pageLog:   "cc0b93384365461d9de59ebef15fbbe2f7c7ee4c",   // История изменений
  pageSpec:  "54dba64bb1ed6734946f020991c07b30459cc21f",   // Спецификация
  pageComp:  "cb2cff7d2706d72096e1e206734e05e275771489",   // ❖ Компонент
  devMode:   "a9e5a749b4add3badd7752e7c068489722c5d2be",   // ds-paragraph-dev-mode
  head:      "5d877a1052d6fd833a5969feac53fb80621aece8",   // ds-format-component-head
  footer:    "72910704eec91328387532f35dc2640b7b6e9acf"    // ds-format-component-footer
};
const KS = {
  log:   "00d9e32bc28904a27517489b84f9f281a39b2011",   // ds-log
  para:  "ca2f04a5efaa20c9d9ae32a396330b7a13e696e9",   // ds-paragraph
  mc:    "f9ee550e7ecc537fc63075015db4c76d0355a619",   // ds-mcp-component
  state: "ddafef2738b4d3183aa5e0e418da8fa23ede1cf2",   // ds-mcp-component-state
  label: "9d9fc72e383e614ac585a65acdeb7a621e094875"    // ds-mcp-component-label
};

const C = {}, S = {}, missing = [];
for (const n of Object.keys(K)) {
  try { C[n] = await figma.importComponentByKeyAsync(K[n]); } catch (e) { missing.push(n); }
}
for (const n of Object.keys(KS)) {
  try { S[n] = await figma.importComponentSetByKeyAsync(KS[n]); } catch (e) { missing.push(n); }
}
if (missing.length) return { stop: true, missing,
  reason: "не импортируется — включите «Astra DS: Docs» в Assets → Libraries или обновите engine-map.md" };

// шрифты — с текстов страниц и атомов, не списком
const fonts = new Map();
for (const c of [C.pageLog, C.pageSpec, C.pageComp, S.state.defaultVariant, S.label.defaultVariant]) {
  for (const t of c.findAllWithCriteria({ types: ["TEXT"] })) {
    for (const seg of t.getStyledTextSegments(["fontName"])) {
      fonts.set(seg.fontName.family + "|" + seg.fontName.style, seg.fontName);
    }
  }
}
await Promise.all([...fonts.values()].map(f => figma.loadFontAsync(f)));

В шаге 0 вызов возвращает { ok: true, fonts: [...fonts.keys()] }. В остальных вызовах этот блок просто предваряет работу: в вызов вставляются только нужные ключи.


2a. Секция и её оформление

const page = await figma.getNodeByIdAsync("PAGE_ID");
await figma.setCurrentPageAsync(page);

const right = page.children.reduce((m, n) => Math.max(m, n.x + n.width), 0);
const section = figma.createSection();
section.name = "COMPONENT_NAME";
section.x = right + 400;
section.y = 0;
section.resizeWithoutConstraints(8000, 8000);   // временно, подгонка — рецепт 7

// образец: готовая секция документации на этой же странице
const sample = page.children.find(n => n.type === "SECTION" && n.id !== section.id &&
  n.children.some(c => c.name === "История изменений"));

if (sample) {
  section.fills = sample.fills;
  section.strokes = sample.strokes;
  section.strokeWeight = sample.strokeWeight;
  section.strokeAlign = sample.strokeAlign;
  const r = sample.boundVariables && sample.boundVariables.topLeftRadius;
  if (r) {
    const v = await figma.variables.getVariableByIdAsync(r.id);
    for (const c of ["topLeftRadius", "topRightRadius", "bottomLeftRadius", "bottomRightRadius"])
      section.setBoundVariable(c, v);
  }
} else {
  const V = {
    base:   "27e361c2f3293fcb62995b9c94b1eb22b64490d3",   // color/section/ds-section-02
    film:   "fe38f9ec9248426ff35d8322cb882f61b23ea523",   // color/section/ds-section-01
    stroke: "e3ac4487860ac9fdce1eaee3f42c678ca573cf19",   // color/ds-tertiary
    radius: "6c0eb5739ed61e6eebcc4dea47f415a74e8119a1"    // radius/ds-radius-section
  };
  const v = {};
  for (const k of Object.keys(V)) v[k] = await figma.variables.importVariableByKeyAsync(V[k]);
  const solid = op => ({ type: "SOLID", color: { r: 0, g: 0, b: 0 }, opacity: op });
  section.fills = [
    figma.variables.setBoundVariableForPaint(solid(1), "color", v.base),
    figma.variables.setBoundVariableForPaint(solid(0.01), "color", v.film)
  ];
  section.strokes = [figma.variables.setBoundVariableForPaint(solid(0.4), "color", v.stroke)];
  section.strokeWeight = 1;
  section.strokeAlign = "INSIDE";
  for (const c of ["topLeftRadius", "topRightRadius", "bottomLeftRadius", "bottomRightRadius"])
    section.setBoundVariable(c, v.radius);
}
return { sectionId: section.id, styledFrom: sample ? sample.id : "keys" };

setBoundVariableForPaint возвращает новую заливку — результат обязательно присвоить.


3. Страница: импорт → открепление → в секцию

Один вызов на страницу. После return — снимок.

const page = await figma.getNodeByIdAsync("PAGE_ID");
await figma.setCurrentPageAsync(page);
const section = await figma.getNodeByIdAsync("SECTION_ID");

// …импорт и шрифты из рецепта 2…
const pc = await figma.importComponentByKeyAsync("PAGE_KEY");
const frame = pc.createInstance().detachInstance();   // → FrameNode
section.appendChild(frame);
frame.name = pc.name;                                // «История изменений», «Спецификация», «❖ Компонент»

const PAD = 100, GAP = 100;
const others = section.children.filter(c => c.id !== frame.id);
frame.x = others.reduce((m, c) => Math.max(m, c.x + c.width + GAP), PAD);
frame.y = PAD;

const content = frame.findChild(n => n.name === "Content");
// снять геометрию демо-блоков ДО удаления — её копируют новые блоки
const demo = content.children.map(c => ({ name: c.name, type: c.type, id: c.id,
  gap: c.itemSpacing, sizingH: c.layoutSizingHorizontal }));
return { frameId: frame.id, contentId: content.id, demo };

Демо удаляется в том же вызове, где ставятся свои блоки: пустой Content между вызовами не нужен. «Спецификация» и «❖ Компонент» используют демо как образец (рецепты 5 и 6), поэтому удаляют его сами.


4. «История изменений»

// …страница, импорт S.log, шрифты…
const content = await figma.getNodeByIdAsync("CONTENT_ID");
const demo = content.children.slice();                 // демо ds-log шаблона
const sizingH = demo.length ? demo[0].layoutSizingHorizontal : "FILL";

const log = variantOf(S.log, { "Релизовано": "False" }).createInstance();
content.appendChild(log);                              // новая запись — ВНИЗ
const fresh = content.children[content.children.length - 1];
fresh.layoutSizingHorizontal = sizingH;

// СНАЧАЛА свойства самого ds-log: слой «Дата» появляется в инстансе,
// только когда «Дата изменения» = true (проверено 22.10.2026 — иначе findOne вернёт null)
setProps(fresh, { "Описание": false, "Дата изменения": true });
// запись с текстом: { "Описание": true, "Описание ": nb("…") } — второе имя с пробелом
const inner = name => fresh.findOne(n => n.type === "INSTANCE" && n.name === name);
setProps(inner("Версия"), { major: "MAJOR", minor: "MINOR", patch: "PATCH" });
setProps(inner("Дата"),   { Day: "DD", Month: "MM", Year: "YY" });   // с ведущими нулями
setProps(inner("Тип изменения"), { "Тип изменения": "Новая версия" });

for (const d of demo) d.remove();
return { logId: fresh.id };

Дату передаёт агент строкой — так результат не зависит от часов машины.

Пересборка. Записи старой секции переносятся копиями, по порядку, до новой записи:

const old = await figma.getNodeByIdAsync("OLD_LOG_CONTENT_ID");
for (const entry of old.children) {
  if (entry.type !== "INSTANCE") continue;
  content.appendChild(entry.clone());
}

Текущая версия — Версия последней записи:

function readVersion(log) {
  const v = log.findOne(n => n.type === "INSTANCE" && n.name === "Версия");
  const get = name => Number(v.componentProperties[propKey(v, name, "TEXT")].value);
  return { major: get("major"), minor: get("minor"), patch: get("patch") };
}
function bump(prev, type) {
  if (type === "Новая версия")         return { major: prev.major + 1, minor: 0, patch: 0 };
  if (type === "Доработка компонента") return { major: prev.major, minor: prev.minor + 1, patch: 0 };
  return { major: prev.major, minor: prev.minor, patch: prev.patch + 1 };   // Исправление бага
}

5. «Спецификация»

Три вызова и больше: лид со «Структурой», затем блоки свойств — по два-три блока за вызов, чтобы ответ и ошибка оставались обозримыми.

5.1 Лид и «Структура»

// …страница, импорт S.para, S.mc, шрифты…
const content = await figma.getNodeByIdAsync("CONTENT_ID");
const groups = content.children.filter(c => c.name === "Group");
const structure = groups[0];                 // ds-paragraph-dev-mode + ds-mcp-component View=Anatomy
const proto = groups[1];                     // образец блока свойства: заголовок + View=State
proto.name = "__proto";                      // удаляется после последнего блока (5.2)

// лид — первым в Content, без заголовка
const lead = variantOf(S.para, { Type: "H1" }).createInstance();
content.insertChild(0, lead);
lead.layoutSizingHorizontal = "FILL";
setProps(lead, { "Show Title": false, "Description": nb("LEAD") });

// анатомия: демо в слоте — простой FRAME, чистится обычным циклом
const anatomy = structure.findOne(n => n.type === "INSTANCE" && n.name === "ds-mcp-component");
const slot = anatomy.findOne(n => n.type === "SLOT" && n.name === "Slot Container");
while (slot.children.length) slot.children[0].remove();
const sample = (await figma.getNodeByIdAsync("DEFAULT_VARIANT_ID")).createInstance();
slot.appendChild(sample);
const example = slot.children[slot.children.length - 1];
setProps(anatomy, { "Show Title": false, "Show Desciption": false });

return { leadId: lead.id, anatomyId: anatomy.id, exampleId: example.id, protoId: proto.id };

Аннотации на example — отдельным вызовом по annotations.md: после setProps(anatomy) id внутри инстанса могли смениться, поэтому слои ищутся заново от ANATOMY_ID.

5.2 Блок свойства

// …страница, импорт S.para, S.mc, S.state, шрифты…
const content = await figma.getNodeByIdAsync("CONTENT_ID");
const proto = await figma.getNodeByIdAsync("PROTO_ID");

// BLOCK = { title, desc, rows: [{ label, variantId, bool: { name, value } | null, position }] }
const BLOCK = BLOCK_JSON;

// группа копирует раскладку образца — без чисел
const group = proto.clone();
group.name = "Group";
content.insertChild(content.children.indexOf(proto), group);   // перед образцом
for (const c of group.children.slice()) c.remove();

const h = variantOf(S.para, { Type: "H1" }).createInstance();
group.appendChild(h);
h.layoutSizingHorizontal = "FILL";

const host = variantOf(S.mc, { View: "State" }).createInstance();
group.appendChild(host);
host.layoutSizingHorizontal = "FILL";

// 1. хирургия слотов — до любого setProperties
const slot = host.findOne(n => n.type === "SLOT" && n.name === "Slot Container State");
clearSlotWithMarker(slot);                 // демо — инстансы ds-mcp-component-state; функция — 5.3, вставить в вызов
for (const row of BLOCK.rows) {
  const r = variantOf(S.state, { Position: row.position }).createInstance();
  slot.appendChild(r);
  const fresh = slot.children[slot.children.length - 1];
  fresh.layoutSizingHorizontal = "FILL";
  const inner = fresh.findOne(n => n.type === "SLOT" && n.name === "Slot Container");
  while (inner.children.length) inner.children[0].remove();   // демо — один ds-slot
  const ex = (await figma.getNodeByIdAsync(row.variantId)).createInstance();
  if (row.bool) setProps(ex, { [row.bool.name]: row.bool.value });
  inner.appendChild(ex);
}
// 2. свойства — от листьев к корню: строки → заголовок → хозяин
slot.children.forEach((r, i) => setProps(r, {
  "Type": nb(BLOCK.rows[i].label), "Show Description": false }));
setProps(h, { "Title": nb(BLOCK.title), "Description": nb(BLOCK.desc),
              "Show Title": true, "Show Description": true });
setProps(host, { "Show Title": false, "Show Desciption": false });

return { groupId: group.id, rows: slot.children.map(r => r.id) };

После последнего блока — proto.remove().

Выбор варианта для строки (row.variantId) агент делает по инвентарю, до вызова:

// default — variantProperties мастера по умолчанию; axis — свойство блока; value — значение
function pick(variants, def, axis, value) {
  const same = variants.filter(v => v.p[axis] === value);
  let best = null, score = -1;
  for (const v of same) {
    const s = Object.keys(def).filter(k => k !== axis && v.p[k] === def[k]).length;
    if (s > score) { best = v; score = s; }
  }
  return { id: best ? best.id : null,
           exact: best ? score === Object.keys(def).length - 1 : false };
}

exact: false — строка в отчёт как разреженная. id: null — строки нет, в отчёт. Для BOOLEAN обе строки берут мастер по умолчанию, а значение ставится на инстанс (row.bool).

Position: Horizontal, если ширина варианта меньше 320, иначе Vertical.

5.3 Очистка слота

Обычный цикл — когда демо в слоте простые узлы или инстансы другого компонента: Slot Container у View=Anatomy (FRAME Button), Slot Container строки (ds-slot).

while (slot.children.length) slot.children[0].remove();

Метка — когда демо — инстансы того же компонента, который сейчас добавится: Slot Container State (демо — строки ds-mcp-component-state). Обычный цикл там падает на in remove: Node not found: после первого remove() остальные демо становятся фантомами.

function clearSlotWithMarker(slot) {
  const marker = figma.createFrame();
  marker.name = "__MARKER__";
  marker.resize(1, 1);
  slot.appendChild(marker);
  while (true) {
    const d = slot.children.find(c => c.name !== "__MARKER__");
    if (!d) break;
    d.remove();
  }
  slot.children.find(c => c.name === "__MARKER__").remove();
}

Пустой слот демо не возвращает. slot.resetSlot() слот не очищает — возвращает демо.


6. «❖ Компонент»

Два вызова: перенос набора и шапка с подвалом, затем подписи осей.

6.1 Перенос набора, шапка, подвал

// …страница, шрифты…
const frame = await figma.getNodeByIdAsync("COMP_FRAME_ID");
const src = await figma.getNodeByIdAsync("SET_ID");
const from = src.parent.name + " (" + src.parent.type + ")";

const grid = frame.findOne(n => n.name === "Components" && n.layoutMode === "GRID");
const box = grid.findChild(n => n.name === "Components");
const container = box.findChild(n => n.name === "Container");
for (const c of container.children.slice()) c.remove();
container.appendChild(src);                       // набор — единственный узел

const title = frame.findOne(n => n.type === "INSTANCE" && n.name === "ds-format-component-head-title");
setProps(title, { "Название компонента": src.name });

const footer = frame.findOne(n => n.type === "INSTANCE" && n.name === "ds-format-component-footer");
const ver = footer.findOne(n => n.type === "INSTANCE" && n.name === ".ds-log-changelog-version");
setProps(ver, { major: "MAJOR", minor: "MINOR", patch: "PATCH" });

return { from, setId: src.id, gridId: grid.id };

Слой версии в подвале может называться иначе — тогда искать по n.mainComponent.name === ".ds-log-changelog-version".

6.2 Оси по геометрии набора

Агент считает уровни по инвентарю, до вызова:

// variants: [{ x, y, w, h, p }], axes: имена VARIANT-свойств
function groups(items, pos, size) {          // варианты с пересекающимся диапазоном — одна группа
  const s = items.slice().sort((a, b) => a[pos] - b[pos]);
  const out = [];
  for (const it of s) {
    const last = out[out.length - 1];
    if (last && it[pos] < last.end) { last.items.push(it); last.end = Math.max(last.end, it[pos] + it[size]); }
    else out.push({ start: it[pos], end: it[pos] + it[size], items: [it] });
  }
  return out;
}
function constant(gs, axis) { return gs.every(g => g.items.every(it => it.p[axis] === g.items[0].p[axis])); }
function runs(gs, axis) {                     // соседние группы с одним значением сливаются в одну скобку
  const out = [];
  for (const g of gs) {
    const v = g.items[0].p[axis], last = out[out.length - 1];
    if (last && last.value === v) last.end = g.end;
    else out.push({ value: v, start: g.start, end: g.end });
  }
  return out;
}
const cols = groups(variants, "x", "w"), rows = groups(variants, "y", "h");
const H = [], V = [], unplaced = [];
for (const axis of axes) {
  if (cols.length > 1 && constant(cols, axis) && runs(cols, axis).length > 1) H.push({ axis, runs: runs(cols, axis) });
  else if (rows.length > 1 && constant(rows, axis) && runs(rows, axis).length > 1) V.push({ axis, runs: runs(rows, axis) });
  else unplaced.push(axis);
}
H.sort((a, b) => a.runs.length - b.runs.length);   // внешний уровень — первым
V.sort((a, b) => a.runs.length - b.runs.length);

Вызов ставит подписи. Координаты переводятся через абсолютные позиции, поэтому отступы сетки не важны:

// …страница, импорт S.label, шрифты…
const grid = await figma.getNodeByIdAsync("GRID_ID");
const src = await figma.getNodeByIdAsync("SET_ID");
const hp = grid.findChild(n => n.name === "Horizontal Props");
const vp = grid.findChild(n => n.name === "Vertical Props");
const H = H_JSON, V = V_JSON;                       // из расчёта выше

for (const c of hp.children.slice()) c.remove();
for (const c of vp.children.slice()) c.remove();

const ax = n => n.absoluteTransform[0][2], ay = n => n.absoluteTransform[1][2];

function level(parent, horizontal) {
  const f = figma.createFrame();
  f.name = "Variable"; f.fills = []; f.clipsContent = false;
  parent.appendChild(f);
  return f;
}
function label(text, large, vertical) {
  const i = variantOf(S.label, { Large: large ? "True" : "False", Vertical: vertical ? "True" : "False" })
    .createInstance();
  setProps(i, { "Label": text });
  return i;
}

H.forEach((lvl, idx) => {
  const row = level(hp, true);
  let maxH = 0;
  const made = lvl.runs.map(r => { const l = label(r.value, idx === 0, false); row.appendChild(l); maxH = Math.max(maxH, l.height); return [l, r]; });
  row.resize(Math.max(1, src.width + (ax(src) - ax(row))), maxH);
  for (const [l, r] of made) { l.resize(Math.max(1, r.end - r.start), l.height); l.x = ax(src) - ax(row) + r.start; l.y = 0; }
});
// COL_PAD — paddingLeft внутреннего «Variable» демо-столбца, снятый ДО удаления демо (в шаблоне 16)
V.forEach((lvl, idx) => {
  const col = level(vp, false);
  const made = lvl.runs.map(r => {
    const l = label(r.value, idx === 0, true);
    col.appendChild(l);
    l.primaryAxisSizingMode = "AUTO";   // у атома ширина фиксирована — длинный текст вылезает влево
    return [l, r];
  });
  const w = made.reduce((m, p) => Math.max(m, p[0].width), 1) + COL_PAD;
  col.resize(w, Math.max(1, src.height + (ay(src) - ay(col))));
  for (const [l, r] of made) {
    l.resize(l.width, Math.max(1, r.end - r.start));
    l.primaryAxisSizingMode = "AUTO";   // resize сбрасывает режим в FIXED
    l.x = w - l.width;                  // скобкой к набору
    l.y = ay(src) - ay(col) + r.start;
  }
});
return { rows: hp.children.map(c => c.id), cols: vp.children.map(c => c.id) };

Снимок после вызова обязателен: скобка каждой подписи должна накрывать свою группу вариантов. Подпись переносит текст посреди слова — подписи не хватает ширины: у текста textAutoResize = "WIDTH_AND_HEIGHT", у подписи — ширина группы, но не меньше ширины текста.


7. Подгонка секции

Последний вызов, когда высоты страниц окончательные.

const section = await figma.getNodeByIdAsync("SECTION_ID");
const PAD = 100;
let maxR = 0, maxB = 0;
for (const c of section.children) { maxR = Math.max(maxR, c.x + c.width); maxB = Math.max(maxB, c.y + c.height); }
section.resizeWithoutConstraints(Math.ceil(maxR + PAD), Math.ceil(maxB + PAD));
return { w: section.width, h: section.height };

8. Ловушки

Component with key … not foundбиблиотека не включена в файле или атом удалёнAssets → Libraries; иначе обновить engine-map.md
вложенный .ds-log-* по ключу не импортируетсяприватный атом, не опубликовандоставать через родителя: findOne по имени слоя
поиск по имени нашёл не тот атомимена в библиотеке задвоенытолько ключи
свойство «Описание»: найдено 2у ds-log два свойства, отличаются пробеломимя точно, с типом: Описание — BOOLEAN, Описание — TEXT
Show Description у ds-mcp-component не найденопечатка в атомеShow Desciption
Cannot write to node with unloaded fontшрифт не загруженрецепт 2 в начале каждого вызова, который пишет
setProperties на слот падаетслот задаётся детьмиappendChild в узел SLOT
in remove: Node not found в Slot Container Stateдемо — инстансы того же атомаметка, рецепт 5.3
то же после setPropertiesлюбой setProperties пересоздаёт id поддеревався хирургия слотов до свойств; свойства — от листьев к корню
Internal Figma Error: Parent not foundссылка устарела после appendChildперечитать через parent.children
FILL can only be set on children of auto-layout frameslayoutSizing* до appendChildсначала родитель, потом размер
инстанс в слоте вылез за крайпосле вставки он FIXEDlayoutSizingHorizontal = "FILL" у строк; у примера компонента — нет
страница не принимает новые блокиэто инстанс, а не фреймdetachInstance() обёртки страницы
импортированный атом пропал между вызовамиid импорта живёт один вызовимпортировать заново в каждом вызове
node.screenshot() мастера внутри набора пустойособенность рендераснимать набор или страницу целиком
figma.createFrame() режет содержимоепо умолчанию clipsContent: trueсбросить вручную