{ "index": 203, "slug": "editorial-2022-05-mechanism-design-system", "title": "Контракт кнопки: как не потерять поведение между токеном и интерфейсом", "excerpt": "Кнопка расходится по цвету, состояниям и доступности, когда разные слои владеют одним действием. Разбираем малый контракт, отрицательный путь и критерий готовности изменения.", "contentHtml": "

На одном экране кнопка «Сохранить» показывает loading, на другом принимает второй клик. В третьем варианте клавиатурный фокус почти не виден. Иконка выглядит одинаково, но её смысл зависит от скрытого текста. Такие сбои часто появляются после небольшой правки: кто-то поменял цвет, другой слой добавил disabled, а локальный компонент оставил старый обработчик.

\n

Цена ошибки выше, чем разница в CSS. Повторный клик может отправить действие дважды. Потерянный focus мешает пройти форму без мыши. Неясное имя кнопки ломает сценарий для screen reader. Команда при этом видит несколько похожих компонентов и не знает, какой из них задаёт правило.

\n

Тезис. Малой дизайн-системе нужен не большой каталог компонентов, а явный контракт одного control. Контракт разделяет четыре вида данных: значения токенов, допустимые состояния, семантику и известные места применения. Каждый слой имеет владельца. Ни один слой не притворяется проверкой браузера.

\n

Механизм: четыре слоя одного действия

\n

Токены хранят именованные значения: фон, цвет текста, контур фокуса, радиус и отступ. Токен не знает, завершился ли запрос. Если в нём появляется флаг loading, он начинает управлять чужим поведением.

\n

Слой состояний перечисляет допустимые ветки: default, hover, focus-visible, disabled и loading. Список не доказывает, что браузер реально показал каждую ветку. Он только не даёт молча забыть обязательный сценарий.

\n

Семантический слой описывает name, role, текущее состояние и признак недоступности. Цвет не заменяет эти поля. Серый фон может означать disabled, loading или ошибочный стиль. Для простой кнопки стоит начать с native <button>: он уже имеет базовую клавиатурную модель. role="button" на другом элементе не воспроизводит её автоматически.

\n

Инвентарь хранит известные usage. Например, profile-save, billing-pay и dialog-cancel могут выглядеть похоже, но иметь разный риск. Инвентарь показывает предполагаемый радиус правки. Он не доказывает, что в репозитории нет четвёртого usage.

\n
Границы малого контракта кнопки
СлойВладеетНе доказываетПроверка за границей
ТокеныИмена и значения 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 payloadViewports, states и имена токенов для будущего запускаScreenshot, diff, score или regressionРеальный runner с сохранённым артефактом
\n

Почему имя CSS-переменной не создаёт семантику

\n

CSS Custom Properties задают пользовательские свойства и позволяют подставлять их через var(). Спецификация не решает, что означает имя. --button-primary-background технически допустимо, но этого мало. Команда должна отдельно договориться, для какой роли живёт значение, кто его меняет и какие usage зависят от него.

\n

Полезная цепочка выглядит так: button.primary.background → primary button → конкретные usage. Она не требует одного способа реализации. Значение может попасть в CSS custom property, объект темы или stylesheet. Важно сохранить связь между ролью и радиусом изменения.

\n

Это также объясняет отрицательный путь. Если usage просит button.primary.shadow, а такого токена нет, валидатор должен вернуть ошибку. Быстрое добавление нового ключа скрывает решение о дизайне и расширяет общий API. Если значение имеет неверный формат, например brand-blue вместо ожидаемого учебного #RRGGBB, изменение тоже нужно остановить.

\n

Семантика и состояние не выводятся из цвета

\n

Поле state в контракте — объявленное намерение. Оно не равно состоянию DOM. Запись loading ещё не блокирует клик, не меняет доступность и не объявляет прогресс. Эти эффекты должен реализовать компонент, а затем пройти отдельную проверку.

\n

Для иконки без видимого текста нужно задать доступное имя. Для кнопки с текстом «Сохранить» имя обычно берётся из текста. Для icon-only control потребуется label или другая согласованная семантика. Не стоит рассчитывать на имя файла, title в CSS или цвет рядом с иконкой.

\n

Если команда выбирает custom host вместо native button, она принимает дополнительный долг: нужно проверить фокус, активацию клавишей Enter или Space, disabled-поведение и передачу имени. В таких случаях запись role="button" — только часть условия.

\n

Исполнимый пример: проверка данных до UI

\n

Ниже демонстрационный JavaScript-фрагмент. Он проверяет только согласованность описания. В нём нет DOM, CSS cascade, браузера и assistive technology. Успешный результат не означает, что кнопка доступна или визуально одинакова.

\n
const 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 или поведение на телефоне.

\n
Схема контракта primary button: токены, обязательные состояния, семантические поля и инвентарь usage соединены до внешних проверок браузера и доступности.
Контракт удерживает четыре слоя отдельно. Внешние проверки подтверждают то, чего модель данных сама увидеть не может.
\n

Симптом → причина → проверка → действие

\n
Диагностика расхождения кнопки
СимптомПричинаПроверкаДействие
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 в артефактеПометить проверку как не выполненную и запустить её отдельно
\n

Порядок действий

\n
  1. Зафиксируйте симптом. Назовите один control, одно состояние, один usage и наблюдаемое действие. Не начинайте с общего «кнопки выглядят по-разному».
  2. Определите цену. Запишите, что может произойти: повторная операция, недоступная клавиатура, неверное имя или незаметный общий blast radius.
  3. Разложите владельцев. Отдельно выпишите токен, state matrix, semantic fields и inventory. Если один файл владеет всем сразу, отметьте это как риск.
  4. Проверьте отрицательный путь. Перед допустимой правкой отправьте неизвестный token и неверное значение. Убедитесь, что валидатор отказал и baseline не изменился.
  5. Сделайте одну правку. Вынесите literal в существующий токен, добавьте пропущенное state или исправьте native host. Не совмещайте это с полной миграцией библиотеки.
  6. Проверьте реальный control. Откройте страницу в согласованных browser и viewport, пройдите mouse и keyboard сценарии, проверьте DOM и accessibility tree.
  7. Сохраните артефакты. Для visual проверки нужны screenshot и diff; для доступности — зафиксированный сценарий и инструмент. Payload оставьте списком входов.
  8. Подготовьте откат. Запишите прежнее значение токена и ограничьте изменение известным inventory. Если появился неожиданный effect, верните только эту правку.
\n

Ограничения

\n

Малый контракт не заменяет дизайн-систему целиком. Он не решает темизацию, dark mode, локализацию, responsive layout, сложную анимацию, права пользователя и сетевой submit. Он не определяет, какие действия должны быть destructive или toggle. Сходство радиуса и цвета не делает controls одним компонентом.

\n

Инвентарь остаётся неполным, если команда не проверила кодовую базу. Результат поиска тоже не идеален: стилизованная ссылка может выглядеть как button, а роль может появиться через обёртку. Поэтому inventory нужно помечать как подтверждённый или предполагаемый.

\n

Демонстрационные значения в коде выбраны для объяснения механизма. Они не являются production-рекомендацией и не показывают реальный результат visual, accessibility или пользовательского теста. Такой предел делает вывод проверяемым: мы утверждаем согласованность модели, а не качество конкретного интерфейса.

\n

Проверяемый критерий готовности

\n

Изменение готово, когда одновременно выполнены четыре условия: валидатор принимает contract и отклоняет отрицательный пример без изменения baseline; нужный usage внесён в инвентарь; реальный control проходит keyboard, pointer и accessibility-проверку в согласованной среде; visual-проверка имеет сохранённые screenshot и diff либо явно отмечена как ещё не выполненная. Дополнительно известны прежнее значение токена и точка отката.

\n

Если отсутствует хотя бы один внешний артефакт, готовность ограничивается моделью данных. Нельзя называть её visual или accessibility regression result. Этот язык сохраняет связь между тем, что команда описала, и тем, что она действительно наблюдала.

\n

Проверяемые источники

\n" }