Протокол круговых прогонов (references/round-trip.md)

Источник: ds-docs/component-contracts/skills/astra-contract-to-figma/references/round-trip.md — справочник скилла astra-contract-to-figma

Проверка: контракт, прочитанный из Figma, собирается обратно в Figma и сравнивается с оригиналом узел за узлом. Копии лежат на тестовых страницах того же файла рядом с контрактами, которыми собирались.

Первая серия, август 2026:

Button3073:358912733193, все третьего класса
Tag3185:1702715430
Navigation Bar (пилот)секция 30755:78865 из 1920332, две причины

Ноль у Tag относится к контракту той редакции. Контракт с тех пор перестроен по оригиналу файла, и вторая серия 23.09.2026 началась с 467 — разбор в конце этого файла.


Button: 1116 → 209 → 193

Страница zz · Contract round-trip (test): Button · прогон 1, Button · прогон 2, итоговый Button (40032117:2467), рядом contract.v1.json, contract.v2.json, contract.json.

11116 сырых, 630 без шума сравнения—
2209формат: операция move, пути из предыдущих правил; рендер: clipsContent у фреймов; контракт: Light индикатора по виду, отклик по видам, Disabled-заливка только у видов с заливкой, две строки без Text Container в загрузке, Ghost и Danger как в макете
3193, все третьего классарендер: глубокая копия операций, мастер по умолчанию положением на холсте

Остаток — только непоследовательность макета, и вся она в gaps: 168 имён слоёв (двойные пробелы, «Small» вместо «Extral Small», пояснение названо «Text»), 12 привязок к удалённой переменной button-danger-primary-bg, 8 нулевых полей через space-none, 2 высоты FIXED, 2 цвета надписи, 1 отсутствующий слой отклика.

Что вскрылось в оригинале: Light индикатора True только у Primary; слой отклика у Link не стоит нигде; типографические переменные задвоены; button-danger-primary-bg удалена, но 12 мастеров держатся за неё.


Tag: 171 → 25 → 0

Страница zz · Tag round-trip (test): Tag · прогон 1, Tag · прогон 2, итоговый Tag (40032143:1617). Совпадение полное: 54 мастера из 54, определения свойств, defaultVariant, и ноль расхождений по всем полям снимка.

1171—
225формат: overrides.textStyle; рендер: INSTANCE_SWAP и swapFrom; сравнение: порядок ключей свойств инстанса; контракт: скругление контейнера, поле, переехавшее на корень, gap обоймы при скелетоне, minWidth суммы, имя контейнера при High
30формат: text.style у текстового узла; контракт: стиль подписи и исключение по стилю маски

Tag сошёлся до нуля там, где Button остановился на 193, по одной причине: у Tag каждая непоследовательность макета выражена узким правилом when, а не оставлена в gaps. Правил вышло 19 на 4 узла базового дерева. Это осознанный размен: контракт длиннее, зато описывает реальность без остатка.

Что вскрылось в оригинале Tag — 19 записей в gaps, из них показательные:

  • у Icon + Label + Icon контейнер назван Text Container {Size} Label + Icon: левый значок в имени не отражён;
  • у Medium и Large при Contrast=High и при Skeleton=True хвост «Label Only» из имени контейнера пропадает, у Small — нет;
  • у Medium при Label + Icon слот назван [Right Container Small], у Small и Large — [Right Container];
  • при Icon + Label + Icon левое поле не уменьшается, хотя слева стоит значок;
  • у семи мастеров Small вертикальное поле переехало с контейнера на корень;
  • у Small / Icon + Label / Mask / Low стиль внутри маски не переопределён: остался родной Mask/Mask Regular/XSmall, у остальных 23 стоит tag-small или tag-regular;
  • подпись несёт одновременно текстовый стиль Special/tag/* и привязки переменных шрифта.


Navigation Bar: 289 → 129 → 32 (пилот на 5 наборах из 19)

Страница zz · Navigation Bar round-trip (test). Первый компонент-семейство: раздел держит 19 наборов вида Navigation Bar/{размер}/{вид}/{фон}/{тон}/{действие} по 4 мастера, и правило базы схлопывает их в одну карточку. Пилот берёт подсемейство Medium/Default — 5 наборов, 20 мастеров — и проверяет механику осей имени набора.

1289—
2129рендер: обрезка строк и скрытие узлов после resize() (оба сбрасывались)
332контракт: правило для State без фона, обрезка и минимальная ширина по наборам; рендер: auto-layout набора как в оригинале, FILL корню после сборки набора

Осталось две причины: у скрытой подписи ширина заморожена и в каждом наборе своя (12), и Swapper Utility в оригинале имеет переопределённую высоту при сохранённом HUG, чего Plugin API не позволяет (20). Обе в gaps.

Что вскрылось в оригинале

  • Ось State работает не везде. В наборах с фоном Fixed и Scroll совпадают полностью — проверка диффом по всем полям дала пустой список. В наборах без фона Scroll возвращает шапке фон bg-layer-01, обычные цвета и неинвертированный статус-бар. То есть 12 мастеров из 20 в пилоте — дубликаты, а в полном семействе таких большинство.
  • Сам COMPONENT_SET несёт auto-layout. Мастера могут быть FILL только потому, что набор вертикальный auto-layout шириной 375. Контракт свойств набора не хранит — до этого прогона это было неочевидно, потому что у Button и Tag наборы обычные.
  • Ширина обоймы слева привязана к space-xl (24), а фактические 64 берутся из minWidth: привязка не работает ни в одном наборе.
  • У None Action правая обойма заменена пустым фреймом Space 32×32 вместо того, чтобы прятаться свойством.
  • Обрезка корня и minWidth 320 стоят у четырёх наборов из пяти, у None Action — нет.

Предел схлопывания

Правило базы «слеш схлопывается до общего имени, хвост становится измерением» работает для карточки-прозы. Для контракта-дельты у него есть предел, и Navigation Bar за ним: 19 наборов — это восемь структурно разных деревьев, а не одно дерево с осями.

Medium/Defaultбазовая: обойма слева, заголовок, обойма справа
Medium/Searchни главной обоймы, ни заголовка — только контейнер с Search Bar, 5 узлов вместо 17
Medium/Progress barв заголовок добавлена полоса прогресса, слева появляется Space
Medium/Selectвместо подзаголовка инстанс Select City
Medium/Maskвместо текста заголовка обойма с Amount Mask
Medium/Promoзначок внутри заголовка, подзаголовок перед заголовком, справа Close
Large/Defaultобоймы слева нет вообще, заголовок на уровень выше, слоты названы [Action Container] 1
Large/Selectто же плюс Select City

Дельтами это выражается только через replace целых поддеревьев, и контракт перестаёт быть компактнее девятнадцати отдельных.

Решено 03.09.2026: один файл, восемь форм, срез на выдаче. Развилка «один контракт или девятнадцать» оказалась ложной. Граница контракта равна границе карточки, а карточка у Navigation Bar одна по правилу схлопывания. Значит файл один. Но дерево внутри не одно: anatomy и variants уходят в раздел forms, шапка остаётся общей, а --form <id> собирает из шапки и одной формы самостоятельный контракт. Хранится вместе, в работу уходит частями.

Правило, по которому это решается в любом семействе:

Граница контракта равна границе карточки. Внутри — по одному дереву на форму. Форма это то, отличие чего от базы не выражается операциями set · add · remove, а требует replace целого поддерева.

Довод про объём не сработал: скилл со справочниками весит 38 КБ, полное семейство по оценке около 60 КБ, вместе примерно 35 тысяч токенов при окне в 200 тысяч. Дробить из-за размера было бы не за что; дробят из-за структуры.

Первый прогон в новой базе: Determinate Progress Bars Stages

22 мастера, ноль расхождений со второго прогона. Первый дал два — оба оказались дефектами контракта, а не рендера:

r: mixed → undefined242 узлау отрезков скругление поугловое: слева ноль, справа 999. Формат хранил только одно значение на все углы
wh: [43,8] → [8,8]110 узловподложка светлой темы стоит абсолютом и первой в порядке детей. В контракте она была обычным ребёнком и съедала всю ширину у растянутых отрезков

Оба починены: в формат добавлено radius: {topLeft, topRight, bottomRight, bottomLeft}, в контракт — position: absolute с inset и set … order для порядка детей.

Чего не умел рендер. kind: shape он никогда не встречал — Button, Tag и шапка состоят из фреймов, текстов и инстансов. Фигуры создавались фреймами и оставались без заливки, потому что заливка ставилась только фреймам и корню. Плюс имя мастера бралось из naming, которого у компонента без семейства нет вовсе.

Семейство собрано

navigation-bar.yaml — 19 наборов, 76 мастеров, 129 узлов, 35 правил, 24 находки. Решётка из семи осей сходится с макетом до имени набора: --grid выводит ровно те 19 имён, что стоят в Figma.

medium-default520186
medium-search1462
medium-progress-bar312216
medium-select28183
medium-mask312194
medium-promo28173
large-default28157
large-select14154

Что поймала проверка среза. Три формы из восьми сначала не резались: свойства ссылались на узлы, которых в их дереве нет. Обоймы действий названы [Action Container 1] в Medium, [Action Container] 1 в Large и Close в Promo, а заголовок с подписью лежат на разной глубине — в Large обоймы Title нет. В контракте на всё семейство это не видно: путь ищется по объединению форм. Пришлось развести свойства по формам через when.

Круговой прогон по формам ещё не делался.


Проброшенные инстансы: чего не видел ни один прогон

Все три прогона сравнивали дерево, размеры, цвета и привязки — и ни один не смотрел на isExposedInstance. Между тем это публичный API компонента: свойства проброшенного инстанса появляются в панели у того, кто вставил компонент.

Button92 (Haptic, Haptic ID от слоя виброотклика)
Tag80
Navigation Bar / Icon Button1211 (размер, вид и состояние обеих кнопок, вариант стрелки и значка, два отклика)

Что проброшено, а что нет, читается осмысленно:

ПроброшеноНе проброшено
значок, кнопки действий, стрелка, слой отклика, маска суммы, обёртка содержимогоиндикатор загрузки, скелетон, системный статус-бар

То есть наружу вынесено то, что потребитель настраивает, а внутри оставлено состояние компонента. Отклонение от этого правила — находка для аудита.

Ограничение API. Пробросить можно только прямой инстанс — тот, над которым нет другого инстанса. Вложенный глубже (значок внутри контейнера значка) пробрасывается на своём уровне и всплывает каскадом; попытка выставить его сверху даёт Can only expose primary instances.

Что это значит для кода. Проброшенное свойство — заявка на то, что потребитель вправе настроить снаружи. В React ему отвечает проп вложенного компонента, вынесенный в API родителя. Не проброшенное остаётся внутренним. Поэтому список exposed читается вместе с проектированием публичного API, а не после него.

Что формат приобрёл за два круга

moveButton: перенос подписи в обойму двух строк{op: move, path, to}
пути из предыдущих правилButton: правка перенесённого узладоступны следующим правилам по порядку
slots и swapFromTag: подмена значков через свойство набораslots: [{name, path, accepts}], узел swapFrom: <свойство>
props типа instanceTag: INSTANCE_SWAP у набора{name, type: instance, default: <id компонента>}
text.style, overrides.textStyleTag: типографика стилем, а не переменнымиимя стиля со слешем отличает его от токена
axes[].in: nameNavigation Bar: семейство наборов с общим именемсегмент имени набора становится осью; — значит «неприменимо»
naming списком правил, masterNamingNavigation Bar: состав сегментов зависит от осейпервое подходящее правило собирает имя набора
masters.setsNavigation Bar: проверка числа наборовскрипт выводит наборы из решётки и сверяет
hiddenNavigation Bar: узлы, скрытые по умолчаниюставится после размеров: скрытый узел не пересчитывает HUG

Дефекты рендера, найденные обратным ходом

createFrame() даёт clipsContent: trueфреймы создаются с clipsContent: false
вложенные словари операции попадали в дерево по ссылке — один set правил контракт для всех следующих мастеровглубокая копия операции перед применением; та же ошибка была латентной в contract.mjs
defaultVariant — не первый ребёнок набора, а верхний левый на холстемастер masters.default ставится в позицию (0, −dy)
ключи свойств с файловым суффиксом #id и их порядоксравнение по имени без суффикса, ключи сортируются

Особенности инструмента

  • Ответ use_figma обрезается на 20 КБ: не больше 6–8 мастеров с полным деревом за вызов.
  • Песочница не знает ?., ??, structuredClone — в скриптах рендера их нет.
  • node.screenshot() на отдельном мастере внутри свежесобранного COMPONENT_SET возвращает пустое изображение. Снимать надо набор целиком: он рендерится верно.
  • Имена-листья переменных задвоены (по две font-size-m, font-family и т. д.). Рендер берёт первую и предупреждает; точный id ставится в ENV.prefer по разведке.

Как повторить

  1. contract.mjs <карточка|yaml> --out <папка> → JSON; убрать gaps, минифицировать.
  2. Разведка: id дочерних наборов, умолчания свойств подмены, точные id задвоенных переменных, доступные начертания шрифта.
  3. render.js батчами: первый с CONTRACT_JSON, остальные с PAGE_ID / SET_ID / KEYS. Пустой problems означает, что все привязки нашлись.
  4. compare.js с RENDERED_ID. Читать patterns и byField.
  5. Каждый шаблон — в один из трёх классов: «контракт не хранит», «рендер не умеет», «оригинал непоследователен». Первые два чинить и перезапускать; третий — либо выразить узким правилом, либо записать в gaps.

Tag, вторая серия: 467 на первом прогоне, 23.09.2026

Страница zz · Tag round-trip 2 (test) в копии, набор 40032221:1502. Мастеров 54 из 54, ни одного лишнего или пропавшего. Расхождений 467.

Почему прогон вообще понадобился. Контракт Tag после августовского нуля был перестроен заново — по оригиналу файла, а не по копии, и записал больше полей. С тех пор круговым прогоном он не проверялся. Сухая сверка всё это время показывала ноль, потому что сравнивает контракт с выгрузкой той же читалки: у обеих сторон одни и те же слепые пятна. Прогон видит то, чего сверка видеть не может.

скругление записано числом, а не токеном: в макете корень привязан к radius-ar, обойма к radius-none168контракт не хранит
порядок детей в мастерах Label + Icon + Mask: в оригинале Amount идёт перед [Right Container], в сборке наоборот~100рендер не умеет
размер обоймы значка привязан к space-m, контракт записал число 1615контракт не хранит
имена слоёв: контракт зовёт левую обойму ✱ Left Icon Container, оригинал — ✱ Icon Container12оригинал непоследователен
размеры мастеров со скелетоном разошлись на 2–8 пунктов~20разобрать
мастер по умолчанию: в контракте Size=Large, в макете Size=Small1контракт не хранит

Две находки в читалке. Отступы она записывает именами токенов (pad: ["space-none", …]), а скругления и размеры — числами, хотя в макете они привязаны к переменным. И defaultVariant набора она не снимает вовсе, поэтому masters.default в контракте — догадка.

Что попутно исправлено. В контракте не было masters.rules: решётка ничем не ограничивалась и давала 96 мастеров вместо 54. Добавлено правило «при Skeleton=True живут только Label Only и Low» — решётка сошлась, предупреждение валидатора ушло, сухая сверка осталась в нуле.

Копия отстала от оригинала. Значки переехали в новые обоймы 17.09, и в копии их id просто нет: 40032843:262073 не существует. Пришлось завести в ENV таблицу перевода id под файл. Это то же, о чём говорит сплошная сверка: id подстановки — плохая опора, он принадлежит файлу, а не компоненту.