diff --git a/editorial/agent-rewrites/203.json b/editorial/agent-rewrites/203.json index a99c2b9..4fa2e90 100644 --- a/editorial/agent-rewrites/203.json +++ b/editorial/agent-rewrites/203.json @@ -1,7 +1,7 @@ { "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(). Спецификация не назначает им дизайн-семантику.Кнопка начинает расходиться не потому, что в ней много CSS. Обычно один код владеет className, другой — disabled, третий — текстом, а четвёртый копирует цвет. Симптом проявляется после безопасной на вид правки: новая loading-версия смотрится правильно, но action уже доступен для повторного запуска; focus ring пропадает в одном варианте; иконка получает label только в profile. Цена — review видит фрагменты, а пользователь получает разный contract для одного знакомого действия.
\nМинимальный component contract собирает эти фрагменты в данные: token names отвечают за значения, required states — за допустимые ветки, semantic fields — за смысл control, usage inventory — за известный радиус изменения. Это не универсальная система и не готовая React API. В учебном скрипте нет JSX, DOM, CSS cascade и assistive technology. Он лишь показывает, как проверить, что один договор не пропустил обязательную часть до того, как команда начнёт спорить о структуре библиотек.
\nУ contract есть четыре слоя. Первый — token layer: он знает только именованные значения и не должен решать, в каком состоянии находится кнопка. Второй — state layer: default, hover, focus-visible, disabled и loading; он определяет, какие ветки продукт обязан обсудить. Третий — semantic layer: name, role, state и disabled. Он не выводится из цвета, потому что одинаковый серый может означать disabled, loading или ошибочно применённый style. Четвёртый — inventory: он хранит известные точки применения и не выдаёт себя за поиск по всему репозиторию.
\n| Слой | Владеет | Не доказывает | Нужная внешняя проверка |
|---|---|---|---|
| Tokens | имена и значения background, foreground, focus ring, radius, gap | что все pixels в браузере обновились | собранный CSS и visual diff в выбранной среде |
| States | разрешённые default/hover/focus-visible/disabled/loading | что browser реально получил hover или focus | ручной keyboard/mouse scenario или автоматизация |
| Semantics | declared name, role button, declared state | что screen reader произнёс ожидаемую фразу | проверка DOM/accessibility tree и выбранной технологии |
| Inventory | три явно перечисленных usage | что больше usage не существует | поиск в кодовой базе и review migration |
| Visual payload | viewports, states и token names для будущего снимка | реальный screenshot, diff, score или regression | настоящий visual-regression runner с сохранённым артефактом |
Спецификация CSS Custom Properties говорит о custom properties и подстановке var(). Она не назначает им смысл. Поэтому --button-primary-background может быть технически валиден, но неясен как договор, если никто не определил, для какой роли он существует, кто меняет его значение и какие variants от него зависят. Обратная ошибка тоже частая: назвать произвольное значение «semantic» и считать, что оно должно жить в глобальном файле. Семантика появляется не в пунктуации имени, а в повторяемом решении и известном owner.
Для маленькой системы полезен направленный путь: button.primary.background → primary button → конкретные usage. Он позволяет отдельно решить, как component получает CSS variable. В одном коде это может быть custom property, в другом объект theme, в третьем stylesheet. Contract не требует выбрать транспорт заранее. Он требует не потерять связь между ролью значения и точками, на которые повлияет изменение. Такая граница оставляет миграцию обратимой.
WAI-ARIA 1.2 описывает роли, состояния и свойства для интерфейсных объектов. Для простой кнопки лучший путь обычно начинается с native button: браузер уже знает базовую keyboard-модель. Если вместо него нужен custom host, команда обязана явно воспроизвести поведение, а не только написать role=\"button\". Однако даже native host не решает вопрос имени: «Сохранить изменения» и иконка без text alternative не равны для человека, который не видит layout.
В модели semantic contract намеренно мал: name, role, state, disabled и список permitted states. Поле state — declared data, не прочитанное browser tree. Такое различие нужно сохранить в тексте review: fixture может показать, что state loading предусмотрен в contract, но не может сказать, что конкретный browser запретил повторный click или что screen reader сообщил disabled. Для этого понадобятся платформенные тесты, которых здесь нет.
Функция checkContract сопоставляет required states с permitted states, проверяет, что payload не ссылается на отсутствующий token, и что каждое известное usage имеет name и роль button. Она не ищет реальные файлы и не запускает построение styles. Именно поэтому результат true означает «данные учебной модели согласованы», а не «кнопка доступна и визуально одинакова». Это узкое утверждение легко повторить и трудно неверно истолковать.
const system = {\n tokens: {\n 'button.primary.background': '#2457D6',\n 'button.primary.foreground': '#FFFFFF',\n 'button.primary.focus-ring': '#F0B429',\n 'button.primary.radius': '8px'\n },\n requiredStates: ['default', 'hover', 'focus-visible', 'disabled', 'loading'],\n contract: {\n name: 'Сохранить изменения',\n role: 'button',\n state: 'default',\n permittedStates: ['default', 'hover', 'focus-visible', 'disabled', 'loading']\n },\n usage: [\n { id: 'profile-save', name: 'Сохранить изменения', role: 'button' },\n { id: 'billing-pay', name: 'Оплатить счёт', role: 'button' },\n { id: 'dialog-cancel', name: 'Отмена', role: 'button' }\n ],\n visualPayload: {\n tokenNames: [\n 'button.primary.background', 'button.primary.foreground',\n 'button.primary.focus-ring', 'button.primary.radius'\n ],\n states: ['default', 'hover', 'focus-visible', 'disabled', 'loading'],\n viewports: [375, 1280]\n }\n};\n\nfunction checkContract(value) {\n const missingTokens = value.visualPayload.tokenNames\n .filter((name) => !(name in value.tokens));\n const missingStates = value.requiredStates\n .filter((state) => !value.contract.permittedStates.includes(state));\n const invalidUsage = value.usage\n .filter((item) => !item.name || item.role !== 'button');\n return {\n ok: missingTokens.length === 0 && missingStates.length === 0 && invalidUsage.length === 0,\n missingTokens, missingStates, invalidUsage\n };\n}\n\nconsole.log(checkContract(system)); // { ok: true, ... }\nconst broken = {\n ...system,\n visualPayload: {\n ...system.visualPayload,\n tokenNames: [...system.visualPayload.tokenNames, 'button.primary.shadow']\n }\n};\nconsole.log(checkContract(broken).ok); // false\ntrue для исправной модели и false для unknown token.Слово «Button» не делает все действия одним компонентом. Link-like navigation, destructive confirmation, toggle, split button и async submit имеют разные риски. У них могут совпадать radius и gap, но не обязательно name, behavior или state matrix. Универсальный API, который принимает двадцать optional props ради такого сходства, обычно скрывает больше решений, чем экономит. Малый contract ценнее, когда он допускает честный ответ: этот control пока не входит в primary button.
\nНе стоит и использовать fixture как gate для чужого продукта. Её inventory полностью создан внутри примера, а values выбраны для объяснения. В настоящем проекте сначала нужно получить существующие usage и владельца правила. Затем выбрать, какие values public, какие variants поддерживаются, как маркируется deprecation и кто проводит visual review. Это следующий слой T-shape автора 2022 года: не говорить за процесс, которого не наблюдали, а назвать факт, которым можно проверить изменение.
\nМодель не меряет contrast, не запускает CSS, не сравнивает image pixels, не читает accessibility tree и не знает всех компонентов в архиве. WCAG 2.1 не разрешает заменить сочетание автоматической и ручной оценки полем role в JavaScript. Поэтому в материале нет claim о доступности или visual stability. Здесь есть только контракт данных, который уменьшает шанс забыть state или нечаянно изменить token без пути назад.
Следующий проверяемый шаг — выбрать один production-like component в отдельном репозитории, составить его реальный inventory и записать маленький test plan: browser/version, viewport, states, keyboard route и ожидаемый результат. После первого наблюдения можно привязать к contract настоящий screenshot или accessibility-tree artifact. До этого visual payload должен оставаться честным списком входов, а не «зелёным» score.
\nЗдесь использованы датированные версии, существовавшие до мая 2022 года: CSS Custom Properties CR Draft 11.11.2021 и WAI-ARIA 1.2 CR Draft 08.12.2021; WCAG 2.1 Recommendation опубликована в 2018-м. Фразы о native button и roles относятся к нормативным моделям документов. Слои contract, token names и fixture — решения этого учебного пакета, не цитата из существующей библиотеки.
\n