From fcd7c09097794357589670c0a50eee2e12b65fc5 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Thu, 3 Sep 2026 19:56:12 +0300 Subject: [PATCH] editorial: refine mechanism design-system article 203 --- editorial/agent-rewrites/203.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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, а локальный компонент оставил старый обработчик.

\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" + "title": "Контракт кнопки: как связать токены, состояния и доступную семантику", + "excerpt": "Разбор малого component contract: какие данные принадлежат токенам, состояниям, семантике и usage inventory, а какие нельзя подменять модельным visual payload.", +"contentHtml": "

Кнопка начинает расходиться не потому, что в ней много 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

Четыре владельца вместо одного большого объекта

\n

У contract есть четыре слоя. Первый — token layer: он знает только именованные значения и не должен решать, в каком состоянии находится кнопка. Второй — state layer: default, hover, focus-visible, disabled и loading; он определяет, какие ветки продукт обязан обсудить. Третий — semantic layer: name, role, state и disabled. Он не выводится из цвета, потому что одинаковый серый может означать disabled, loading или ошибочно применённый style. Четвёртый — inventory: он хранит известные точки применения и не выдаёт себя за поиск по всему репозиторию.

\n
Границы малого component contract
СлойВладеетНе доказываетНужная внешняя проверка
Tokensимена и значения background, foreground, focus ring, radius, gapчто все pixels в браузере обновилисьсобранный CSS и visual diff в выбранной среде
Statesразрешённые default/hover/focus-visible/disabled/loadingчто browser реально получил hover или focusручной keyboard/mouse scenario или автоматизация
Semanticsdeclared name, role button, declared stateчто screen reader произнёс ожидаемую фразупроверка DOM/accessibility tree и выбранной технологии
Inventoryтри явно перечисленных usageчто больше usage не существуетпоиск в кодовой базе и review migration
Visual payloadviewports, states и token names для будущего снимкареальный screenshot, diff, score или regressionнастоящий visual-regression runner с сохранённым артефактом
\n

Почему CSS variable не является semantic token автоматически

\n

Спецификация CSS Custom Properties говорит о custom properties и подстановке var(). Она не назначает им смысл. Поэтому --button-primary-background может быть технически валиден, но неясен как договор, если никто не определил, для какой роли он существует, кто меняет его значение и какие variants от него зависят. Обратная ошибка тоже частая: назвать произвольное значение «semantic» и считать, что оно должно жить в глобальном файле. Семантика появляется не в пунктуации имени, а в повторяемом решении и известном owner.

\n

Для маленькой системы полезен направленный путь: button.primary.background → primary button → конкретные usage. Он позволяет отдельно решить, как component получает CSS variable. В одном коде это может быть custom property, в другом объект theme, в третьем stylesheet. Contract не требует выбрать транспорт заранее. Он требует не потерять связь между ролью значения и точками, на которые повлияет изменение. Такая граница оставляет миграцию обратимой.

\n

Name, role и state — не оформление

\n

WAI-ARIA 1.2 описывает роли, состояния и свойства для интерфейсных объектов. Для простой кнопки лучший путь обычно начинается с native button: браузер уже знает базовую keyboard-модель. Если вместо него нужен custom host, команда обязана явно воспроизвести поведение, а не только написать role=\"button\". Однако даже native host не решает вопрос имени: «Сохранить изменения» и иконка без text alternative не равны для человека, который не видит layout.

\n

В модели semantic contract намеренно мал: name, role, state, disabled и список permitted states. Поле state — declared data, не прочитанное browser tree. Такое различие нужно сохранить в тексте review: fixture может показать, что state loading предусмотрен в contract, но не может сказать, что конкретный browser запретил повторный click или что screen reader сообщил disabled. Для этого понадобятся платформенные тесты, которых здесь нет.

\n

Исполнимый пример: проверить contract без UI

\n

Функция checkContract сопоставляет required states с permitted states, проверяет, что payload не ссылается на отсутствующий token, и что каждое известное usage имеет name и роль button. Она не ищет реальные файлы и не запускает построение styles. Именно поэтому результат true означает «данные учебной модели согласованы», а не «кнопка доступна и визуально одинакова». Это узкое утверждение легко повторить и трудно неверно истолковать.

\n
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
\n
\"Схема
Компонент получает несколько независимых видов данных. Схема показывает границы ответственности, чтобы token, состояние и семантика не превращались в один неразличимый объект.
\n

Маршрут: симптом → причина → проверка → действие

\n
  1. Симптом. Запишите один разрыв: кнопка теряет focus, loading не блокирует повтор, label отличается в одинаковом действии или literal color появился в новом usage.
  2. Причина. Разложите изменение по четырём владельцам. Если token пытается хранить state, а CSS class несёт name, граница уже размыта.
  3. Проверка contract. Сверьте required states, token names, semantic fields и inventory. Запустите фрагмент; он должен вывести true для исправной модели и false для unknown token.
  4. Проверка платформы. Отдельно создайте реальный control и пройдите согласованный keyboard/mouse/a11y сценарий. Результат сохраните как новый артефакт, не внутри model.
  5. Действие. Сначала поменяйте один owner: вынесите literal в named token, добавьте state или верните native host. Не объединяйте это с переписыванием всей библиотеки.
  6. Откат. Применяйте token correction с сохранённым previous value. Если inventory показывает неожиданный effect, откатите малый change и сузьте variant.
\n

Против ложной универсальности

\n

Слово «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

Ограничение и следующий проверяемый шаг

\n

Модель не меряет contrast, не запускает CSS, не сравнивает image pixels, не читает accessibility tree и не знает всех компонентов в архиве. WCAG 2.1 не разрешает заменить сочетание автоматической и ручной оценки полем role в JavaScript. Поэтому в материале нет claim о доступности или visual stability. Здесь есть только контракт данных, который уменьшает шанс забыть state или нечаянно изменить token без пути назад.

\n

Следующий проверяемый шаг — выбрать один production-like component в отдельном репозитории, составить его реальный inventory и записать маленький test plan: browser/version, viewport, states, keyboard route и ожидаемый результат. После первого наблюдения можно привязать к contract настоящий screenshot или accessibility-tree artifact. До этого visual payload должен оставаться честным списком входов, а не «зелёным» score.

\n

Историческая граница мая 2022

\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

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

\n" }