Формат контракта v2 (references/contract-format.md)

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

Описание компонента целиком: один файл components/<slug>.yaml. Факты в нём — из Figma, через MCP. Контракт ничего не добавляет к макету: он делает факты макета проверяемыми и читаемыми для генератора кода.

Файл

Один компонент — один файл, имя файла совпадает с полем slug. Прозы в файле нет: объяснение живёт в комментарии # там, где без него правило непонятно.

Прежняя разметка — блок в конце markdown-карточки за маркером <!-- machine: component-contract v2 --> — скриптом по-прежнему читается: так контракт достаётся из карточек базы design-system-docs. Новые контракты пишутся файлами.

Подмножество YAML

Скрипт разбирает YAML без библиотек, поэтому дозволено ровно это:

  • блочные словари key: value и списки - item, отступ — два пробела;
  • потоковые словари {a: 1, b: x} и списки [a, b], можно вкладывать и переносить на следующие строки, пока скобка не закрыта;
  • скаляры: число, true/false, null, строка без кавычек, строка в двойных или одинарных кавычках;
  • комментарии # ….

Нельзя: якоря &/*, многострочные скаляры |/>, ключи в кавычках, : внутри строки без кавычек. Значение с запятой или двоеточием — в кавычки.

Разделы

Порядок обязателен — так блок читается сверху вниз как карточка.

contract, name, slug, source, card, naming

contract: 2
name: Button                 # как заголовок карточки, проверяется
slug: button                 # имя файла карточки без .md
source: {file: Y2LtslaFUhvYD8H37S8yL5, set: "3073:3589", section: "30517:9370", page: Button}
card: {version: 1.7.3, updated: 2026-08-21}
read: 2026-09-02             # когда читали Figma
naming: "Size={Size}, Style={Style}, Type={Type}, State={State}, Line={Line}"

source.set — id набора компонентов (COMPONENT_SET), source.section — id секции документации из шапки карточки. naming — как мастер называется в Figma: подстановки {Ось}. У компонентов с именем через слеш — "Divider/{Сторона}/{Толщина}/{Отступ}".

axes — оси вариантов

componentPropertyDefinitions типа VARIANT. Имена и значения — как в макете, включая опечатки: Extral Small остаётся Extral Small, а опечатка идёт в gaps.

axes:
  - {name: Size, values: [Extral Small, Small, Medium, Large], default: Extral Small}

Семейство: оси имени набора. Когда раздел держит несколько наборов с общим именем через слеш (Navigation Bar/Medium/Default/Background/Icon Button), хвосты имени — тоже измерения, и контракт описывает их вместе с обычными. Поле in говорит, где ось живёт:

variant (по умолчанию)ось внутри набора, Size=LargeButton, Tag
nameсегмент имени набораNavigation Bar

У оси имени бывает значение —: измерение к этому сочетанию неприменимо и в имя не попадает (тон есть только без фона). У осей in: variant такое значение запрещено.

Имена собираются двумя шаблонами. naming — имя набора; когда состав сегментов зависит от осей, это список правил, первое подходящее выигрывает. masterNaming — имя мастера внутри набора; опущено — берётся Ось=Значение через запятую по осям in: variant.

naming:
  - {when: {Фон: Background}, pattern: "Navigation Bar/Medium/Default/{Фон}/{Действие}"}
  - {when: {Фон: None Background}, pattern: "Navigation Bar/Medium/Default/{Фон}/{Тон}/{Действие}"}
masterNaming: "is Web={is Web}, State={State}"

Скрипт проверяет, что каждое сочетание решётки получает имя набора и что внутри одного набора имена мастеров не повторяются: если повторяются, осей in: variant не хватает.

props — остальные свойства

Определения типа BOOLEAN, TEXT, INSTANCE_SWAP — в контракте boolean, text, instance. controls — путь узла, которым свойство управляет (по привязке componentPropertyReferences); when — при каких значениях осей свойство существует. У instance в default лежит id компонента-умолчания: он принадлежит файлу, поэтому при рендере в другом файле подменяется.

props:
  - {name: Show Icon, type: boolean, default: true, controls: Button/State layer/Icon Container}
  - {name: Description, type: text, default: Description,
     controls: Button/State layer/Text Container/Description, when: {Line: Two}}
  - {name: Swap Left Container, type: instance, default: "18948:28840",
     controls: "Tag/Text Container/[Left Container]/✱ Icon Container"}

Узел, которым свойство управляет, ссылается на него в обратную сторону: visibleWhen для булева, textFrom для текстового, swapFrom для подмены. Скрипт сверяет, что свойство с таким именем есть и что его тип подходит полю.

slots

Места, куда снаружи кладут содержимое: {name, path, accepts}. name — имя свойства подмены, path — обойма слота, accepts — slug карточки компонента, который туда встаёт. Нет слотов — slots: [].

slots:
  - {name: Swap Left Container, path: "Tag/Text Container/[Left Container]", accepts: icon-container}

masters — решётка

masters:
  count: 273                 # сколько мастеров всего
  sets: 1                    # сколько наборов; больше одного — семейство
  fullGrid: 672              # произведение длин осей, проверяется
  base: "Size=Large, Style=Text+Icon, Type=Primary, State=Default, Line=One"   # что описано в anatomy
  default: "Size=Extral Small, …"                                              # defaultVariant набора
  counts:                    # сколько мастеров у каждого значения — посчитано по детям
    Size: {Extral Small: 58, Small: 60, Medium: 60, Large: 95}
  rules:                     # где решётка не полная — выведено из перечня мастеров
    - if: {Type: Ghost}
      only: {Size: [Extral Small]}

Скрипт складывает counts по каждой оси и сверяет с count; выводит решётку из rules и сверяет с count и counts. Расхождение — предупреждение: правила описывают решётку не до конца, и это место для gaps.

tokens — таблицы значений

Значения, которые зависят от оси, лежат здесь таблицами, а в анатомии и вариантах на них ссылаются привязки. Имя токена — как в Local Variables, без группы (space-m, не var(--space-m)). Число там, где привязки нет.

tokens:
  height: {Extral Small: 32, Small: 40, Medium: 48, Large: 56}
  fill: {Primary: button-primary-bg, Secondary: button-secondary-bg}

Чего в макете не привязано — в таблицу не пишется числом с пометкой в gaps. Скрипт предупредит, что для части значений оси привязка пуста.

forms — семейство из нескольких деревьев

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

Признак механический. Разница выражается операциями set · add · remove — дерево одно. Понадобился replace корневого поддерева — форм несколько.

Тогда anatomy и variants уходят из корня внутрь форм, а шапка остаётся общей: оси, свойства, слоты, решётка, токены, привязки и дыры описывают всё семейство сразу.

forms:
  - id: medium-default
    when: {Размер: Medium, Форма: Default}
    sets: 5
    count: 20
    scope: "заголовок между двумя обоймами"
    anatomy: [...]
    variants: [...]
  - id: medium-search
    when: {Размер: Medium, Форма: Search}
    sets: 2
    count: 8
    anatomy: [...]
idдаkebab-case, уникален; этим же именем зовётся срез
whenдакакие мастера решётки берёт форма
anatomyдабазовое дерево формы
variantsнетдельты внутри формы
sets, countнетзаявленные числа, сверяются с решёткой
scopeнетстрока, объясняющая форму; уезжает в срез

Формы обязаны разбивать решётку без остатка. Каждый мастер попадает ровно в одну форму: мастер без формы — ошибка, мастер сразу в двух — тоже. when описывается теми же осями, что и всё остальное; обычно это оси in: name.

Пути узлов считаются внутри формы: одинаковый path в двух формах — разные узлы, like ищет только в своей форме. Общее у форм — оси, свойства, токены, привязки, дыры.

Контракт без forms устроен по-прежнему: anatomy и variants лежат в корне. Держать и то, и другое одновременно нельзя.

Срез по форме

--form <id> собирает из общей шапки и одной формы самостоятельный контракт: тот же формат, своё имя <slug>-<id>, оси сужены до значений формы, лишние свойства, слоты и привязки убраны, добавлено поле slice со ссылкой на исходный контракт.

slug: navigation-bar-medium-search
slice: {contract: navigation-bar, form: medium-search}

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

Две проверки стерегут срез. Решётка среза обязана дать ровно тех мастеров, которых отобрала форма, — иначе сужение осей неоднозначно. И скрипт печатает срез, тут же перечитывает его собственным парсером и сверяет с исходным объектом: расхождение значит, что записанное читается иначе, чем задумано.

anatomy — базовое дерево

Плоский список узлов, порядок — как в макете. path — адрес: имена слоёв через /. Поля узла — по словарю раскладки. Свойство не задано — не пишется.

anatomy:
  - path: Button
    kind: frame
    layout: {direction: column, mainAlign: center, crossAlign: center, gap: 0, padding: 0}
    size: {width: 327, height: hug}
    clip: true
    fill: button-primary-bg
    radius: radius-s

Виды узлов и знаки карточки: frame # · slot ▣ · text · shape ○ · instance ◆ · group △.

Имя слоя, собранное из осей. Если слой в Figma называется по-разному в разных мастерах, path даёт устойчивый адрес, а layerName — шаблон: "State layer {Type} {Size} Icon {$iconSide}". Подстановки {Ось} и {$привязка}.

Узел-инстанс — то, что в карточке записано строками «Свойства здесь» и «Переопределено»:

  - path: Button/State layer/Icon Container
    kind: instance
    of: icon-container          # slug карточки ребёнка; null — местный компонент
    visibleWhen: Show Icon      # булево свойство, которое его прячет
    props: {Size: Medium}       # componentProperties инстанса; null — прочитать не удалось
    overrides: {size: {width: 24, height: 24}}   # instance.overrides в словаре раскладки; null — не проверено

props — то, что передаётся компоненту через его свойства. overrides — правки поверх, которых через свойства не выразить: fill перекрашивает первый цветной слой внутри, textStyle ставит текстовый стиль файла на все текстовые слои инстанса. null в любом из них — признак дыры, пишется в gaps.

exposed — проброс наружу. Инстанс можно пометить так, что его собственные свойства появятся в панели родителя рядом со своими. Это isExposedInstance в Figma и часть публичного API компонента, а не оформление: у шапки навигации 12 своих свойств и ещё 11 приходят от проброшенных кнопок, стрелки и слоя виброотклика. Поле обязательно у каждого инстанса — пропуск даёт предупреждение, потому что «не проброшен» и «не посмотрели» это разные вещи.

  - path: Button/State layer/Icon Container
    kind: instance
    of: icon-container
    exposed: true            # Size и Swap Icon видны снаружи Button

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

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

Текстовый стиль против переменных. Имя токена берётся без группы и слеша не содержит, имя стиля — содержит (Special/tag/tag-small). По этому признаку проверка отличает одно от другого и не ищет стиль в выгрузке токенов. У текстового узла стиль пишется как text.style; он применяется до привязок, поэтому переменные шрифта перекрывают его значения.

Ссылка на вложенный компонент

of у инстанса — это не имя, а проверенная связь. Пока файла components/<of>.yaml нет, ссылка висит и попадает в очередь на описание. Как только ребёнок описан, проверяется содержание: каждое имя в props обязано быть его осью или свойством, а каждое значение — входить в значения этой оси.

- {path: …/[Action Container 1]/Action, kind: instance,
   of: action,                     # файл components/action.yaml
   exposed: true,                  # свойства action видны в панели у того, кто вставил родителя
   props: {Style: Brand, Type: Text, State: Default, Without Frame: "True", Text: Action},
   overrides: {fill: text-snackbar-action-brand}}

Пять фактов на узле: при каком условии он появляется (when правила), какой это компонент, в какой конфигурации взят, что переопределено сверху и проброшен ли наружу. Этого хватает, чтобы пройти по цепочке от родителя к полному описанию ребёнка и обратно.

Привязка проверяется по всей таблице. Если в props стоит $имя, подставиться может любое значение из таблицы привязки, значит годиться должно каждое: Size: $kidSize с таблицей {Light: Small, Dark: Large} сломается, если у ребёнка нет Large.

Обратная сторона связи — обратный индекс: scripts/validate.mjs печатает, кто на кого ссылается, и правка ребёнка сразу показывает, кого она затронет.

bindings — привязки

bindings:
  $height: {axis: Size, map: tokens.height}

Строка $height в любом месте анатомии или операции при развороте мастера заменяется значением из таблицы по текущему значению оси.

Покрытие таблицы проверяется по месту использования: привязка в базовом дереве обязана знать все значения оси; привязка внутри правила variants — только значения из when этого правила. Так $tint с таблицей на три состояния не даёт предупреждений, если применяется только к Hovered, Focused и Pressed.

variants — дельты

Отличия мастеров от базового. Каждое правило: when — при каких значениях осей (скаляр или список «любое из»), ops — операции над базовым деревом. Правила применяются по порядку.

«свойства изменены»setpath + поля узла; словари (layout, size, text) сливаются, остальное заменяется
«новый слой»addpath нового узла + поля; like: <path> — взять поля другого узла базы за основу; after: <имя> — после какого соседа
«слоя нет»removepath — вместе с поддеревом
«вместо»swappath старого, to — путь нового, поля нового узла; like — взять поля узла базы за основу
«переписан целиком»replacepath поддерева, nodes: [...] — новое поддерево полностью
«перенесён»movepath узла, to — новый путь; узел переезжает с поддеревом и со всем, что с ним уже сделали предыдущие правила

Перестановка соседей — set на родителя с order: [имя, имя].

Пути, которые появляются по ходу правил (add, swap … to, move … to), доступны следующим правилам: после переноса Text в Text Container правило для Disabled может править Button/State layer/Text Container/Text. Порядок правил — порядок применения.

variants:
  - when: {Size: [Extral Small, Small, Medium]}
    ops:
      - {op: set, path: Button/State layer, size: {minHeight: $height, maxHeight: $height}}
  - when: {State: Loading}
    ops:
      - {op: remove, path: Button/State layer/Text}
      - {op: add, path: Button/State layer/Circular Indeterminate, kind: instance, of: null,
         local: true, size: {width: 24, height: 24}}

interaction

Слой виброотклика (🥁 Виброотклик / …) в дерево не пишется — его свойства идут сюда. Форма одна, других не бывает:

interaction:
  - {action: tap, trigger: null, haptic: hp_impact_light, enabled: false, exposed: true,
     when: {State: Default}}
actionжест: tap, longPress, pan, scroll, refresh, drag, flip
triggerимя события из карты виброотклика, либо null
hapticHaptic ID инстанса, либо null — слой есть, отклик не назван
enabledпереключатель Haptic; у обязательной метки его нет
exposedпроброшен ли слой в публичные свойства
whenв каких мастерах слой стоит

Триггеры и идентификаторы сверяются с foundations/tokens/haptic.json: незнакомое имя — ошибка, и action обязан отвечать семейству триггера (onTap — это tap, а не haptic).

trigger: null — обычное дело, а не недосмотр. Слой виброотклика несёт только идентификатор и переключатель; на какое событие он срабатывает, в макете не записано нигде — карты «событие → компонент» в разделе Haptic нет. Догадываться нельзя: пишем null и заводим дыру.

Цвет содержимого инстанса

У значка заливка лежит на векторе внутри инстанса, а обход дерева на инстансе останавливается — иначе внук выглядел бы своим узлом. Из-за этого цвет значка не попадает никуда: ни в контракт родителя, ни в тонкий контракт самого значка, где есть только оси.

Поэтому у каждого kind: instance пишется overrides.fill — чем покрашено содержимое в этом мастере:

- {path: Select City/Chevron Down, kind: instance, of: chevron-down, exposed: false,
   size: {width: 16, height: 16}, props: {Variant: Base},
   overrides: {fill: fg-secondary}}

overrides: null означает «не читали». Отсутствие поля — предупреждение валидатора: молчание тут неотличимо от «цвета нет», а разработчик по такому контракту нарисует значок не тем цветом и проверить себя не сможет.

Случай найден на сборке select-city в core-ui: стрелка вышла тёмной, потому что код взял умолчание обоймы, а в макете она привязана к fg-secondary.

gaps — дыры

Чего в макете не хватило, чтобы заполнить контракт без догадок. Класс — как в аудите: GAP (в источнике нет) или SUSPECT (есть, но подозрительно).

gaps:
  - {kind: GAP, text: "толщина 2xs задана числом 2 — токена на неё нет"}

Контракт хранит имя, а не значение

Это не мелочь оформления, а то, что закрывает целый класс ошибок.

Значение переменной зависит от режима, а режим — от того, что закреплено на узле или на его предке. Плюс переменная сплошь и рядом ссылается на другую переменную, и цепочку надо пройти в том же режиме. В файле Astra DS восемь многорежимных коллекций, включая color с парой light-mode / dark-mode.

Отсюда обычная беда выгрузок: берут variable.valuesByMode[collection.defaultModeId] и получают не значение, а {type: "VARIABLE_ALIAS"} — ссылку. Дальше её разрешают руками, выбирая режим наугад, и в файл ложится чужое число. Знание id тут не помогает: id верный, а режим не тот.

Правильный способ один:

const c = variable.resolveForConsumer(node);   // { value, resolvedType }

Проверено на узле шапки: наивно — алиас, resolveForConsumer — #000000.

В контракте значения нет вообще, поэтому неверным оно быть не может. Разрешение нужно только для проверок и отчётов человеку.

modes — закреплённый режим

Чего контракту всё же не хватало: узел может закрепить режим коллекции, и тогда два мастера, одинаковые по контракту, покрасятся по-разному. Такое в базе есть — 33 узла из пяти компонентов что-то закрепляют. Теперь это поле узла:

- {path: …/Icon Button, kind: instance, modes: {color: dark-mode}}

Читалка снимает его из explicitVariableModes, рендер ставит обратно через setExplicitVariableModeForCollection. Если режим из подключённой библиотеки и имени у него нет, пишется id — это честнее, чем промолчать.

Переменные из библиотек

Часть переменных живёт в другом файле. Plugin API отдаёт их имя, но не всю таблицу режимов — полные значения берутся REST-запросом к файлу-источнику. Такие переменные перечислены в variables.json разделом library; сейчас там одна — colors/border/border-size из поля поиска.

Типографика — только именем стиля

Токены типографики привязаны не к текстовым узлам, а к текстовым стилям — штатной функции Figma. Узел ссылается на стиль, стиль — на переменные. Контракт знает только первое звено:

text: {style: Headline/H5, color: $titleColor, align: center, lines: 1, truncate: ellipsis}

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

Текст без стиля — style: none и запись в gaps: это дефект макета. Токены, к которым такой текст привязан напрямую, называются там словами, а не полями контракта.

Что делает валидатор:

  • text.size, text.weight и подобные — ошибка, а не предупреждение;
  • узел kind: text без text.style — ошибка; исключение — узел с like, он наследует стиль образца;
  • имя стиля, которого нет в списке файла, — ошибка. Список лежит в foundations/tokens/styles.json; привязки стилей там же, но они для фундамента — контракт читает только имена.

Что за каждым стилем стоит, описывает фундамент текстовых стилей, а что стоит за его переменными — типографика.

Сухая сверка отдельно отмечает текст, привязанный к переменным типографики напрямую — поверх стиля или вместо него: контракт этого не пишет, но это находка.

Обёртка текста сворачивается в текстовый узел

Почти всякая надпись внутри компонента вставлена не текстовым слоем, а инстансом набора из семейства «Text / …». Обёртка — приём Figma: она даёт дизайнеру выбрать в одном месте стиль, цвет, число строк и способ обрезки хвоста. Её внутреннее дерево — обойма, надпись и служебный прямоугольник цвета поверх неё — устройство приёма, а не компонента, и в контракт не переносится. Читалка сворачивает такой инстанс в текстовый узел:

- path: Text State/Label
  kind: text
  foundation: typography
  size: {width: fill, height: hug}
  text: {style: Body/H4, color: text-secondary, align: start, lines: 1, truncate: ellipsis}

foundation: typography — метка «в макете здесь обёртка». Настройки, ради которых обёртка и стоит, остаются рядом:

набор (Text / Body / H4)text.style
Tokens вложенного ⚒︎ Text Colortext.color
Max Linestext.lines
Text Overflowtext.truncate: Elipse → ellipsis, Blur → blur

truncate: blur значит, что хвост не обрывается многоточием, а гаснет градиентом: текст ограничен числом строк, а край размыт. Для разработчика это разные правила, и разница видна только здесь.

Цвет читается с прямоугольника, а не с надписи. Сама надпись под обёрткой залита литеральным чёрным — видимый цвет даёт ⚒︎ Text Color поверх неё. Читалка берёт токен оттуда; overrides.fill у такого узла не пишется вовсе.

Не сворачивается обёртка с двумя надписями — вид Type=Text + Quentity у обжимающих наборов: надписи там две, и обе значащие. Такой узел остаётся инстансом.

Семейство целиком — фундамент обёрток текста: таблица наборов со стилями, устройство приёма и его дыры.

Костыли обрезки хвоста

Со страницы Text в чужие деревья попадают два служебных компонента:

  • ⚒︎ Text Overflow Blur/Layer-01…03 — полоска градиента, гасящая хвост строки в цвет фона. Мастера различаются только цветом: по одному на слой фона;
  • ⚒︎ Elilpses Loading/1…4 — четыре кадра анимации из трёх точек, ожидание вместо текста.

Внутри обёртки «Text / …» полоску добавляет ось Text Overflow=Blur, и отдельным узлом она не пишется: обёртка сворачивается в текст с text.truncate: blur. Но её кладут и руками, рядом с надписью — так сделаны подписи action-button и значение card-input. Тогда узел остаётся в дереве и записывается как костыль:

- path: "Action Button/Text/Text 1 Fill/Blur Fill/⚒︎ Text Overflow Blur"
  kind: instance
  layerName: "⚒︎ Text Overflow Blur/Layer-02"
  of: null
  local: true
  foundation: typography
  fill: GRADIENT_LINEAR

Правило то же, что у слоя состояния и SYSTEM-слоёв: своего контракта у костыля нет (of: null, local: true), а метка foundation: typography отсылает к фундаменту обёрток. Валидатор предупреждает, если метки нет, и даёт ошибку, если у костыля появился of с контрактом.

Размер экрана: слово platform вместо числа

Локальная коллекция платформы переключает режим показа — Apple, Android, Bottom Nav Bar, Web, Internet Bank View, WebView — и подставляет в макет размер устройства. Это вариация показа, а не устройство компонента, поэтому числа устройства в контракт не идут:

- path: "Basic Dialog"
  kind: frame
  foundation: platform
  size: {width: platform, height: platform}

Пределы minHeight/maxHeight, равные высоте экрана, не пишутся: на другом режиме коллекции они другие. Сухая сверка поля со словом platform не сравнивает, а пределы у помеченного узла пропускает; валидатор предупреждает, если высота экрана (812, 800, 844, 667, 915) осталась числом.

Ширина без высоты экраном не считается: 375 у компонента, тянущегося по экрану, — это ширина родителя, и пишется она так, как стоит в макете (fill или числом).

Подробно — фундамент платформы.

Масштаб типографики: ось Type Scale

Ось варианта со значениями Base и Scale. При множителе типографики ×1 и ×1,1 компонент показывается видом Base, с ×1,2 и выше — видом Scale, собранным под крупный шрифт. Значений два при любом множителе: ×1,2, ×1,5 и ×2 дают один и тот же Scale.

Пишется как обычная ось, без особых пометок:

axes:
  - {name: Type Scale, in: variant, values: [Base, Scale], default: Base}

Порог тот же, что у форм. Scale переверстывает конструкцию, и раскладывается это ровно по механическому признаку раздела forms выше: отличия выражаются set · add · remove — дерево одно, Scale остаётся дельтой в variants; понадобился replace корневого поддерева — конструкций две, и Scale становится отдельной формой.

forms:
  - id: base
    when: {Type Scale: Base}
  - id: scale
    when: {Type Scale: Scale}
    scope: "вид под крупную типографику: ряд разворачивается в колонку"

Второго порога под типографику нет намеренно: «сильное изменение структуры» — это тот самый случай, под который формы и заведены.

Ось удваивает решётку, поэтому masters.rules обязаны сказать, какие сочетания в виде Scale существуют. Ось, удваивающая набор без единого отличия, — дыра SUSPECT, а не описание: сегодня она в базе именно такая.

Прежнее имя. До 05.10.2026 ось зовётся Typo Scale со значениями True / False и встречается в трёх контрактах — section-heading (ось), custom-layer и layer-1 (свойство вложенного инстанса). Контракты, снятые под старым именем, при пересъёмке приводятся к Type Scale / Base / Scale.

Подробно — фундамент масштаба типографики.

Версия внутри компонента: описываем только живую

У части наборов отмена спрятана не в имени, а в оси: Version со значениями 🟢 Stable и ❌ Deprecated. Обе версии лежат в одном наборе намеренно — продукт переходит на новую сменой значения свойства, а не заменой компонента: свап рвёт содержимое.

В контракт идёт только живая ветка:

axes:
  - {name: Version, in: variant, values: ["🟢 Stable"], default: "🟢 Stable"}

masters:
  count: 8          # живых мастеров, отменённые не в счёт
  fullGrid: 8

gaps:
  - {kind: GAP, text: "у набора есть отменённая версия — 4 мастеров с ❌ в имени.
     По решению они не описываются: переход на новую версию делают сменой значения оси,
     а не заменой компонента"}

Правила variants и masters.rules про отменённую ветку не пишутся; сухая сверка таких мастеров не сверяет и печатает «пропущено мастеров отменённой версии: N».

Что остаётся. Ось или свойство с крестом в самом имени (❌ Progress, ❌ Show Badge Indicator) стоят у всех мастеров, в том числе живых, — без них мастера не собрать, поэтому они описываются, а валидатор о них предупреждает. Если живой компонент вставляет отменённую версию ребёнка, это тоже предупреждение, а не ошибка: долг макета видно, контракт остаётся правдой.

Подробно — фундамент версий.

Отменённое не документируется

Раздел файла Deprication Version — склад того, что снято с использования: страницы ❌ List (Используйте Basic List), ❌ Sub-list (Используйте Basic Accordion), ❌ Format Container / Text State (Используйте Text Container State). Замена написана прямо в имени. Контрактов таким наборам не пишут: описывать нужно то, на что переходят.

Отменённый набор при этом может до сих пор лежать внутри живого компонента — так у layer-2 и layer-3 внутри стоит ❌ Format Container / Text State. Такой узел пишется без ссылки на контракт, а замена называется дырой:

- path: Layer 2/Slot Container/Text State
  layerName: "Format Container / Text State/Accent Value/Filled"
  kind: instance
  of: null
  local: true
gaps:
  - {kind: GAP, text: "внутри лежит отменённый набор «❌ Format Container / Text State»:
     живой компонент собран на том, что велено заменить на Text Container State"}

Слот при этом объявляет живую замену: accepts: text-container-state — принимать он должен то, что положат завтра, а не то, что застряло в макете сегодня.

SYSTEM-слои не документируются

Слои, подписанные заглавными — SYSTEM Status Bar, SYSTEM Bottom Nav Bar, SYSTEM Android Navigation, SYSTEM Keyboard, SYSTEM Scroll Bar, SYSTEM Device, SYSTEM Activity View, — контрактов не получают. Это не компоненты системы, а обозначение места: дизайнер ставит их, чтобы видеть, сколько остаётся содержимому. Рисует их платформа, и на разных устройствах они разные.

Внутри чужого дерева такой слой записывается меткой, а не ссылкой:

- path: "Basic Action/SYSTEM Bottom Nav Bar"
  kind: instance
  of: null
  local: true
  foundation: system-chrome
  size: {width: fill, height: 34}

Читается так: здесь встанет элемент платформы, реализовывать его не надо, а размер из макета — высота одного устройства, взятая для примера, и в код она не переносится. Подробности — фундамент системной обвязки.

Слой состояния — метка foundation: state-layer

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

Встречается в двух видах, и оба помечаются одинаково:

Обойма State layer … — тело компонента, внутри которого лежит содержимое. Красится токеном по состоянию: в покое заливки нет, в наведении hover-bg-dark, в нажатии press-bg-dark, в фокусе focus-bg.

- path: Basic List Item/State layer
  kind: frame
  foundation: state-layer
  size: {width: fill, height: hug}
  fill: none

Инстанс ⚒︎ State Layer — служебный компонент. Прозрачен, в макете не виден. Областью нажатия он становится, когда лежит абсолютно и растянут по родителю: поверх всей строки — нажимают по строке, поверх одной обоймы — только по ней. Без position: absolute это просто крашеный прямоугольник: так Button Banner набирает им полосы-заглушки в состоянии загрузки. Правило разбора: position: absolute плюс размер родителя — область нажатия, всё прочее — покраска.

- path: "Basic List Item/State layer/⚒︎ State Layer"
  kind: instance
  of: state-layer
  foundation: state-layer
  position: absolute
  size: {width: 375, height: 72}
  props: {State: Default}

Что записывать при разборе:

  • метку foundation: state-layer — всегда, у обоих видов и в операциях вариантов тоже. Без неё разработчик не отличит слой от обычной обоймы;
  • у инстанса — of: state-layer и position: absolute: путь узла показывает, площадь какого родителя становится областью нажатия;
  • исчезновение слоя — это факт, а не мелочь. Если в мастере Read Only или Skeleton инстанса нет, компонент в этом состоянии не нажимается. Такое пишется операцией remove и просится в gaps, если из имени состояния этого не видно;
  • заливку обоймы по состояниям — как обычное поле fill. Отклонение от таблицы токенов (скажем, focus-bg в нажатии) — находка для gaps.

Валидатор предупреждает, если узел с именем State layer … или ⚒︎ State Layer остался без метки. Устройство слоя, таблица токенов по состояниям и найденные отклонения — фундамент слоя состояния.

Эффекты — тоже только именем стиля

Тени и размытия живут на стилях эффектов, ровно как начертания на текстовых стилях. Контракт записывает имя стиля и больше ничего:

effect: background-blur/default

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

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

effect: none значит «эффект выставлен руками, стиля файла у него нет» — валидатор даёт на это предупреждение, а место просится в gaps.

Имя токена — не то же самое, что переменная

Контракт хранит имя-лист: font-size-l, а не полный путь и не id. Так его читает человек и так же ляжет в код. Беда в том, что в файле за одним именем может стоять несколько переменных: у Astra DS 133 неоднозначных имени из 1225. Образец один и тот же — смысловой слой typography и примитивы typography-tokens называют свои переменные одинаково. Пока типографика писалась на узлах, база использовала пятнадцать таких имён; теперь их несёт стиль, и в контрактах их нет. Выбор всё равно записан: читалка и сухая сверка видят переменные, а не стили.

Совпадение значений это не спасает: у font-size-l обе переменные дают 16. Сверка по числам такую подмену не увидит.

Поэтому выбор записан один раз на всю базу — foundations/tokens/variables.json: имя, выбранная переменная, её путь и остальные кандидаты. Это свойство файла, а не компонента, и держать его в каждом контракте было бы дублированием.

Что из этого следует:

  • валидатор предупреждает на каждом использовании неоднозначного имени и называет, какая переменная взята. Промолчать он не имеет права: молчание тут и есть ошибка;
  • сухая сверка, если выгрузка несёт карту variables вида id → полный путь, сверяет переменные, а не имена, и ругается, когда в макете стоит не та, что выбрана;
  • удалённая переменная, на которую ссылка осталась, — предупреждение с объяснением: это дефект макета, а не контракта, и место ему в gaps.

Три уровня проверки

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

валидаторвсегдабесплатно
--check-against — сухая сверка с прочитанным деревомвсегдаодна команда, ни одной записи в макет
круговой прогон в Figmaтолько на новой конструкциидорого

Сухая сверка разворачивает контракт в дерево и сравнивает с выгрузкой, снятой при разборе. Выгрузку отдаёт фрагмент 7 читалки:

{
  "component": "<slug>",
  "set": "<id набора>",
  "masters":   { "<имя мастера>": { "<путь>": { …поля… } } },
  "variables": { "<id>": { "path": "…", "at": ["<где>"], "ambiguous": true, "remote": true } },
  "styles":    { "<id>": "<имя стиля>" }
}

Поля узлов держат имя-лист — так выгрузку читает человек. Точность даёт карта variables: сверка сравнивает переменные по id, а не имена, и говорит, где именно встретилась не та. Переменная помечается ambiguous, если имя-лист в файле не одно, и remote, если она из подключённой библиотеки.

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

Сверка сильнее прогона в одном месте. Прогон сравнивает уже разрешённые значения: у индикатора прогресса он показал ноль расхождений, хотя контракт потерял три привязки к токенам — radius-ar, radius-m и space-xs были записаны числами 999, 24 и 8. Числа совпали, привязки нет. Сухая сверка это увидела сразу.

--novelty сравнивает конструкции контракта со списком обкатанных (proven.json) и говорит, стоит ли гонять прогон. Список пополняется только после прогона, который сошёлся.

Что проверяет скрипт

Ошибки (блокируют):

  • блок не найден, найден дважды, YAML не разбирается;
  • нет обязательных полей; name не совпадает с заголовком карточки; contract не равен версии в маркере;
  • ось без значений, default вне значений, имена осей повторяются;
  • fullGrid не равен произведению осей; сумма counts по оси не равна count;
  • узел с неизвестным kind; путь без родителя; путь повторяется;
  • CSS-имя в поле узла — с подсказкой, каким нейтральным именем его записать;
  • слово вне словаря там, где ждётся слово (direction: horizontal);
  • операция вне словаря; set/remove по несуществующему пути; like на несуществующий узел;
  • $привязка без описания; привязка на несуществующую ось или таблицу;
  • controls, when, if, only ссылаются на неизвестную ось, значение или путь;
  • у описанного ребёнка нет свойства, которое ему передаёт родитель, или нет такого значения оси;
  • форма без id или when, id не в kebab-case, id повторяется;
  • мастер решётки не попал ни в одну форму или попал сразу в две;
  • forms и anatomy в корне одновременно; срез не читается обратно своим же парсером.

Предупреждения (не блокируют):

  • токен не найден в выгрузке foundations/tokens/;
  • of или accepts указывает на компонент, которого в components/ ещё нет: это очередь на описание, а не ошибка контракта;
  • привязка не покрывает часть значений оси;
  • решётка из rules не сходится с count или counts;
  • у инстанса props: null или overrides: null;
  • gaps пуст, а предупреждения есть.