Формат контракта 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=Large | Button, 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 — операции над базовым деревом. Правила применяются по порядку.
| «свойства изменены» | set | path + поля узла; словари (layout, size, text) сливаются, остальное заменяется |
| «новый слой» | add | path нового узла + поля; like: <path> — взять поля другого узла базы за основу; after: <имя> — после какого соседа |
| «слоя нет» | remove | path — вместе с поддеревом |
| «вместо» | swap | path старого, to — путь нового, поля нового узла; like — взять поля узла базы за основу |
| «переписан целиком» | replace | path поддерева, nodes: [...] — новое поддерево полностью |
| «перенесён» | move | path узла, 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 |
haptic | Haptic 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 Color | text.color |
Max Lines | text.lines |
Text Overflow | text.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пуст, а предупреждения есть.