{ "index": 203, "slug": "editorial-2022-05-mechanism-design-system", "title": "Контракт кнопки: как не потерять поведение между токеном и интерфейсом", "excerpt": "Кнопка расходится по цвету, состояниям и доступности, когда разные слои владеют одним действием. Разбираем малый контракт, отрицательный путь и критерий готовности изменения.", "contentHtml": "
На одном экране кнопка «Сохранить» показывает loading, на другом принимает второй клик. В третьем варианте клавиатурный фокус почти не виден. Иконка выглядит одинаково, но её смысл зависит от скрытого текста. Такие сбои часто появляются после небольшой правки: кто-то поменял цвет, другой слой добавил disabled, а локальный компонент оставил старый обработчик.
Цена ошибки выше, чем разница в CSS. Повторный клик может отправить действие дважды. Потерянный focus мешает пройти форму без мыши. Неясное имя кнопки ломает сценарий для screen reader. Команда при этом видит несколько похожих компонентов и не знает, какой из них задаёт правило.
\nТезис. Малой дизайн-системе нужен не большой каталог компонентов, а явный контракт одного control. Контракт разделяет четыре вида данных: значения токенов, допустимые состояния, семантику и известные места применения. Каждый слой имеет владельца. Ни один слой не притворяется проверкой браузера.
\nТокены хранят именованные значения: фон, цвет текста, контур фокуса, радиус и отступ. Токен не знает, завершился ли запрос. Если в нём появляется флаг loading, он начинает управлять чужим поведением.
\nСлой состояний перечисляет допустимые ветки: default, hover, focus-visible, disabled и loading. Список не доказывает, что браузер реально показал каждую ветку. Он только не даёт молча забыть обязательный сценарий.
Семантический слой описывает name, role, текущее состояние и признак недоступности. Цвет не заменяет эти поля. Серый фон может означать disabled, loading или ошибочный стиль. Для простой кнопки стоит начать с native <button>: он уже имеет базовую клавиатурную модель. role="button" на другом элементе не воспроизводит её автоматически.
Инвентарь хранит известные usage. Например, profile-save, billing-pay и dialog-cancel могут выглядеть похоже, но иметь разный риск. Инвентарь показывает предполагаемый радиус правки. Он не доказывает, что в репозитории нет четвёртого usage.
| Слой | Владеет | Не доказывает | Проверка за границей |
|---|---|---|---|
| Токены | Имена и значения background, foreground, focus ring, radius, gap | Что собранный CSS применился во всех вариантах | Собранный CSS и visual diff в выбранной среде |
| Состояния | Набор default, hover, focus-visible, disabled, loading | Что пользователь увидел каждую ветку | Сценарий мышью и клавиатурой или автоматизация |
| Семантика | Имя, роль, state и disabled | Что screen reader произнёс ожидаемую фразу | DOM, accessibility tree и выбранная технология |
| Инвентарь | Явно известные места применения | Полноту поиска по кодовой базе | Поиск, классификация и review миграции |
| Visual payload | Viewports, states и имена токенов для будущего запуска | Screenshot, diff, score или regression | Реальный runner с сохранённым артефактом |
CSS Custom Properties задают пользовательские свойства и позволяют подставлять их через var(). Спецификация не решает, что означает имя. --button-primary-background технически допустимо, но этого мало. Команда должна отдельно договориться, для какой роли живёт значение, кто его меняет и какие usage зависят от него.
Полезная цепочка выглядит так: button.primary.background → primary button → конкретные usage. Она не требует одного способа реализации. Значение может попасть в CSS custom property, объект темы или stylesheet. Важно сохранить связь между ролью и радиусом изменения.
Это также объясняет отрицательный путь. Если usage просит button.primary.shadow, а такого токена нет, валидатор должен вернуть ошибку. Быстрое добавление нового ключа скрывает решение о дизайне и расширяет общий API. Если значение имеет неверный формат, например brand-blue вместо ожидаемого учебного #RRGGBB, изменение тоже нужно остановить.
Поле state в контракте — объявленное намерение. Оно не равно состоянию DOM. Запись loading ещё не блокирует клик, не меняет доступность и не объявляет прогресс. Эти эффекты должен реализовать компонент, а затем пройти отдельную проверку.
Для иконки без видимого текста нужно задать доступное имя. Для кнопки с текстом «Сохранить» имя обычно берётся из текста. Для icon-only control потребуется label или другая согласованная семантика. Не стоит рассчитывать на имя файла, title в CSS или цвет рядом с иконкой.
\nЕсли команда выбирает custom host вместо native button, она принимает дополнительный долг: нужно проверить фокус, активацию клавишей Enter или Space, disabled-поведение и передачу имени. В таких случаях запись role="button" — только часть условия.
Ниже демонстрационный JavaScript-фрагмент. Он проверяет только согласованность описания. В нём нет DOM, CSS cascade, браузера и assistive technology. Успешный результат не означает, что кнопка доступна или визуально одинакова.
\nconst system = {\n tokens: {\n 'button.primary.background': '#2457D6',\n 'button.primary.foreground': '#FFFFFF',\n 'button.focus.ring': '#111827'\n },\n requiredStates: ['default', 'hover', 'focus-visible', 'disabled', 'loading'],\n button: { name: 'Сохранить', role: 'button', state: 'default', disabled: false },\n usage: ['profile-save', 'billing-pay', 'dialog-cancel'],\n visualPayload: { viewports: [375, 1280], states: ['default', 'focus-visible'] }\n};\n\nfunction checkContract(value) {\n const statesOk = value.requiredStates.includes('focus-visible');\n const semanticsOk = value.button.role === 'button' && value.button.name.length > 0;\n const tokensOk = Object.keys(value.tokens).every((name) => name.startsWith('button.'));\n const usageOk = value.usage.length > 0;\n return { ok: statesOk && semanticsOk && tokensOk && usageOk };\n}\n\nconsole.log(checkContract(system)); // { ok: true }\nЭтот код полезен как ранняя защита границы. Если удалить focus-visible, имя кнопки или добавить токен с неизвестным namespace, проверка должна стать красной. Но она не проверяет повторный submit, computed style, contrast, реальное имя в accessibility tree или поведение на телефоне.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Loading показывает правильный цвет, но второй клик проходит | Состояние описали в style layer, а обработчик не использует его | Два последовательных события и проверка network calls | Связать loading с блокировкой действия и проверить повторный submit |
| В одном usage пропал focus ring | В state matrix нет focus-visible или его токен заменил literal | Пройти сценарий только клавиатурой и посмотреть computed style | Вернуть обязательное состояние и named token, затем повторить сценарий |
| Две кнопки с одинаковым цветом имеют разный смысл | Роль вывели из visual variant | Сравнить name, role, state и действие в DOM | Разделить contract или исправить семантические поля |
| Новый token проходит локальный review, но ломает сборку | Usage ссылается на undeclared key | Сверить ссылку с реестром имён и запустить валидатор | Остановить change; сначала объявить роль и владельца токена |
| В задаче есть «visual passed», но нет снимка | Payload перепутали с результатом runner | Найти screenshot, diff, browser и commit в артефакте | Пометить проверку как не выполненную и запустить её отдельно |
Малый контракт не заменяет дизайн-систему целиком. Он не решает темизацию, dark mode, локализацию, responsive layout, сложную анимацию, права пользователя и сетевой submit. Он не определяет, какие действия должны быть destructive или toggle. Сходство радиуса и цвета не делает controls одним компонентом.
\nИнвентарь остаётся неполным, если команда не проверила кодовую базу. Результат поиска тоже не идеален: стилизованная ссылка может выглядеть как button, а роль может появиться через обёртку. Поэтому inventory нужно помечать как подтверждённый или предполагаемый.
\nДемонстрационные значения в коде выбраны для объяснения механизма. Они не являются production-рекомендацией и не показывают реальный результат visual, accessibility или пользовательского теста. Такой предел делает вывод проверяемым: мы утверждаем согласованность модели, а не качество конкретного интерфейса.
\nИзменение готово, когда одновременно выполнены четыре условия: валидатор принимает contract и отклоняет отрицательный пример без изменения baseline; нужный usage внесён в инвентарь; реальный control проходит keyboard, pointer и accessibility-проверку в согласованной среде; visual-проверка имеет сохранённые screenshot и diff либо явно отмечена как ещё не выполненная. Дополнительно известны прежнее значение токена и точка отката.
\nЕсли отсутствует хотя бы один внешний артефакт, готовность ограничивается моделью данных. Нельзя называть её visual или accessibility regression result. Этот язык сохраняет связь между тем, что команда описала, и тем, что она действительно наблюдала.
\nvar(). Спецификация не назначает им дизайн-семантику.