Протокол круговых прогонов (references/round-trip.md)
Источник:
ds-docs/component-contracts/skills/astra-contract-to-figma/references/round-trip.md— справочник скилла astra-contract-to-figma
Проверка: контракт, прочитанный из Figma, собирается обратно в Figma и сравнивается с оригиналом узел за узлом. Копии лежат на тестовых страницах того же файла рядом с контрактами, которыми собирались.
Первая серия, август 2026:
| Button | 3073:3589 | 1 | 273 | 3 | 193, все третьего класса |
| Tag | 3185:17027 | 1 | 54 | 3 | 0 |
| Navigation Bar (пилот) | секция 30755:7886 | 5 из 19 | 20 | 3 | 32, две причины |
Ноль у 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.
| 1 | 1116 сырых, 630 без шума сравнения | — |
| 2 | 209 | формат: операция move, пути из предыдущих правил; рендер: clipsContent у фреймов; контракт: Light индикатора по виду, отклик по видам, Disabled-заливка только у видов с заливкой, две строки без Text Container в загрузке, Ghost и Danger как в макете |
| 3 | 193, все третьего класса | рендер: глубокая копия операций, мастер по умолчанию положением на холсте |
Остаток — только непоследовательность макета, и вся она в 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,
и ноль расхождений по всем полям снимка.
| 1 | 171 | — |
| 2 | 25 | формат: overrides.textStyle; рендер: INSTANCE_SWAP и swapFrom; сравнение: порядок ключей свойств инстанса; контракт: скругление контейнера, поле, переехавшее на корень, gap обоймы при скелетоне, minWidth суммы, имя контейнера при High |
| 3 | 0 | формат: 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 мастеров — и проверяет механику осей имени набора.
| 1 | 289 | — |
| 2 | 129 | рендер: обрезка строк и скрытие узлов после resize() (оба сбрасывались) |
| 3 | 32 | контракт: правило для 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правая обойма заменена пустым фреймомSpace32×32 вместо того, чтобы прятаться свойством. - Обрезка корня и
minWidth320 стоят у четырёх наборов из пяти, у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 → undefined | 242 узла | у отрезков скругление поугловое: слева ноль, справа 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-default | 5 | 20 | 18 | 6 |
medium-search | 1 | 4 | 6 | 2 |
medium-progress-bar | 3 | 12 | 21 | 6 |
medium-select | 2 | 8 | 18 | 3 |
medium-mask | 3 | 12 | 19 | 4 |
medium-promo | 2 | 8 | 17 | 3 |
large-default | 2 | 8 | 15 | 7 |
large-select | 1 | 4 | 15 | 4 |
Что поймала проверка среза. Три формы из восьми сначала не резались: свойства ссылались
на узлы, которых в их дереве нет. Обоймы действий названы [Action Container 1] в Medium,
[Action Container] 1 в Large и Close в Promo, а заголовок с подписью лежат на разной
глубине — в Large обоймы Title нет. В контракте на всё семейство это не видно: путь
ищется по объединению форм. Пришлось развести свойства по формам через when.
Круговой прогон по формам ещё не делался.
Проброшенные инстансы: чего не видел ни один прогон
Все три прогона сравнивали дерево, размеры, цвета и привязки — и ни один не смотрел
на isExposedInstance. Между тем это публичный API компонента: свойства проброшенного
инстанса появляются в панели у того, кто вставил компонент.
| Button | 9 | 2 (Haptic, Haptic ID от слоя виброотклика) |
| Tag | 8 | 0 |
| Navigation Bar / Icon Button | 12 | 11 (размер, вид и состояние обеих кнопок, вариант стрелки и значка, два отклика) |
Что проброшено, а что нет, читается осмысленно:
| Проброшено | Не проброшено |
|---|---|
| значок, кнопки действий, стрелка, слой отклика, маска суммы, обёртка содержимого | индикатор загрузки, скелетон, системный статус-бар |
То есть наружу вынесено то, что потребитель настраивает, а внутри оставлено состояние компонента. Отклонение от этого правила — находка для аудита.
Ограничение API. Пробросить можно только прямой инстанс — тот, над которым нет другого
инстанса. Вложенный глубже (значок внутри контейнера значка) пробрасывается на своём уровне
и всплывает каскадом; попытка выставить его сверху даёт Can only expose primary instances.
Что это значит для кода. Проброшенное свойство — заявка на то, что потребитель вправе
настроить снаружи. В React ему отвечает проп вложенного компонента, вынесенный в API родителя.
Не проброшенное остаётся внутренним. Поэтому список exposed читается вместе с проектированием
публичного API, а не после него.
Что формат приобрёл за два круга
move | Button: перенос подписи в обойму двух строк | {op: move, path, to} |
| пути из предыдущих правил | Button: правка перенесённого узла | доступны следующим правилам по порядку |
slots и swapFrom | Tag: подмена значков через свойство набора | slots: [{name, path, accepts}], узел swapFrom: <свойство> |
props типа instance | Tag: INSTANCE_SWAP у набора | {name, type: instance, default: <id компонента>} |
text.style, overrides.textStyle | Tag: типографика стилем, а не переменными | имя стиля со слешем отличает его от токена |
axes[].in: name | Navigation Bar: семейство наборов с общим именем | сегмент имени набора становится осью; — значит «неприменимо» |
naming списком правил, masterNaming | Navigation Bar: состав сегментов зависит от осей | первое подходящее правило собирает имя набора |
masters.sets | Navigation Bar: проверка числа наборов | скрипт выводит наборы из решётки и сверяет |
hidden | Navigation 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по разведке.
Как повторить
contract.mjs <карточка|yaml> --out <папка>→ JSON; убратьgaps, минифицировать.- Разведка: id дочерних наборов, умолчания свойств подмены, точные id задвоенных переменных, доступные начертания шрифта.
render.jsбатчами: первый сCONTRACT_JSON, остальные сPAGE_ID/SET_ID/KEYS. Пустойproblemsозначает, что все привязки нашлись.compare.jsсRENDERED_ID. ЧитатьpatternsиbyField.- Каждый шаблон — в один из трёх классов: «контракт не хранит», «рендер не умеет»,
«оригинал непоследователен». Первые два чинить и перезапускать; третий — либо выразить
узким правилом, либо записать в
gaps.
Tag, вторая серия: 467 на первом прогоне, 23.09.2026
Страница zz · Tag round-trip 2 (test) в копии, набор 40032221:1502.
Мастеров 54 из 54, ни одного лишнего или пропавшего. Расхождений 467.
Почему прогон вообще понадобился. Контракт Tag после августовского нуля был перестроен заново — по оригиналу файла, а не по копии, и записал больше полей. С тех пор круговым прогоном он не проверялся. Сухая сверка всё это время показывала ноль, потому что сравнивает контракт с выгрузкой той же читалки: у обеих сторон одни и те же слепые пятна. Прогон видит то, чего сверка видеть не может.
скругление записано числом, а не токеном: в макете корень привязан к radius-ar, обойма к radius-none | 168 | контракт не хранит |
порядок детей в мастерах Label + Icon + Mask: в оригинале Amount идёт перед [Right Container], в сборке наоборот | ~100 | рендер не умеет |
размер обоймы значка привязан к space-m, контракт записал число 16 | 15 | контракт не хранит |
имена слоёв: контракт зовёт левую обойму ✱ Left Icon Container, оригинал — ✱ Icon Container | 12 | оригинал непоследователен |
| размеры мастеров со скелетоном разошлись на 2–8 пунктов | ~20 | разобрать |
мастер по умолчанию: в контракте Size=Large, в макете Size=Small | 1 | контракт не хранит |
Две находки в читалке. Отступы она записывает именами токенов (pad: ["space-none", …]),
а скругления и размеры — числами, хотя в макете они привязаны к переменным. И defaultVariant
набора она не снимает вовсе, поэтому masters.default в контракте — догадка.
Что попутно исправлено. В контракте не было masters.rules: решётка ничем не
ограничивалась и давала 96 мастеров вместо 54. Добавлено правило «при Skeleton=True
живут только Label Only и Low» — решётка сошлась, предупреждение валидатора ушло,
сухая сверка осталась в нуле.
Копия отстала от оригинала. Значки переехали в новые обоймы 17.09, и в копии их id
просто нет: 40032843:262073 не существует. Пришлось завести в ENV таблицу перевода
id под файл. Это то же, о чём говорит сплошная сверка: id подстановки — плохая опора,
он принадлежит файлу, а не компоненту.