{ "index": 202, "slug": "editorial-2022-05-field-design-system", "title": "Как менять токен дизайн-системы и не сломать соседний экран", "excerpt": "Практический разбор правки токена: как зафиксировать симптом, оценить радиус изменения, проверить состояния кнопки и оставить точный путь к откату.", "contentHtml": "
Команда меняет цвет primary-кнопки в одной строке, а ошибка проявляется на другом экране. На форме оплаты исчезает видимый focus ring. В диалоге отмены кнопка получает цвет подтверждающего действия. В третьем месте новый токен не работает: компонент ждёт другое имя. Похожий синий цвет маскирует несколько разных разрывов контракта.
\nЦена ошибки зависит от радиуса токена. Локальная правка затрагивает один usage. Глобальная правка проходит по всем местам, где значение пришло через тему, CSS-переменную, обёртку или запасной вариант. Если команда не знает эти места и состояния, она не может объяснить diff, безопасно откатить изменение или доказать, что соседний экран не изменился.
\nТезис. Токен нельзя менять по одному скриншоту. Сначала свяжите симптом с ролью компонента, состоянием, именем токена и подтверждёнными местами использования. Затем проверьте формат и границы значения. После этого меняйте один слой, сохраняйте прежнее значение и отдельно проверяйте браузерный результат. Так правка остаётся ограниченной и обратимой.
\nCSS custom property — пользовательское CSS-свойство с именем вроде --button-primary-background. Браузер не знает, что такое «главная кнопка» как продуктовая роль: он применяет к свойству обычные правила наследования и каскада. Значение можно объявить в теме, переопределить в media query или передать через var(). Семантическое имя, список допустимых компонентов и решение о владельце — уже договорённость проекта.
Поэтому в изменении есть два разных уровня. На уровне CSS нужно понять, где вычисляется значение и какой каскад побеждает. На уровне дизайн-системы нужно понять, какую роль выражает токен и какие состояния она покрывает. Объявленный токен может быть технически валиден, но использован для неподходящего действия. И наоборот: правильная роль может потерять focus outline, если матрица состояний учитывала только обычный фон.
\nУ каждой правки должны быть четыре границы: роль, состояние, компонент и реестр использований. Например, button.primary.background описывает фон основной кнопки в обычном состоянии, но не отвечает за hover, focus-visible, disabled и loading. Эти состояния нужно перечислить отдельно. Для ссылки, переключателя и destructive-действия похожий цвет не означает одинаковый контракт.
| Симптом | Вероятная причина | Проверка | Ограниченное действие |
|---|---|---|---|
| На одном экране другой синий | Literal обошёл именованный токен или победил каскад | Найти имя, значение и источник вычисленного стиля | Исправить подтверждённый usage, не весь каталог |
| После правки исчез focus ring | Состояние не вошло в матрицу токена | Пройти control клавиатурой и посмотреть computed style | Вернуть отдельный контракт focus-visible |
| Новое имя не работает | Имя не объявлено или не читается компонентом | Проверить декларацию, область видимости и var() | Остановить правку до согласования имени |
| Платёжный экран изменился вместе с профилем | Общую роль изменили без проверки реестра | Сопоставить usages, темы и состояния | Вернуть прежнее значение или разделить роли |
| Review объявил visual-проверку без артефакта | Список входов выдали за результат | Проверить viewport, baseline, новый снимок и diff | Оставить статус «не проверено», пока нет результата |
Начните с короткого реестра, а не с массовой замены. Для каждого места запишите компонент, роль, состояние, имя токена и способ задания значения. Например, profile-save может использовать button.primary.background в состояниях default, hover, focus-visible, disabled и loading. dialog-cancel может быть secondary-кнопкой, даже если разметка выглядит почти так же.
Поиск по имени и hex-значению даёт кандидатов, но не доказывает полноту. Значение может прийти из темы, inline-стиля, запасного аргумента var() или обёртки компонента. Поиск также не определяет смысл элемента: ссылка и кнопка могут выглядеть одинаково, но иметь разную клавиатурную и семантическую модель. Поэтому каждый кандидат нужно подтвердить в точке входа и в реальном состоянии.
В реестре полезно разделить факт и гипотезу: «найдено в CSS-модуле» — факт, «используется только профилем» — гипотеза до проверки маршрута, темы и тестовых состояний. Это небольшое разделение не даёт предположению превратиться в обещание полного охвата.
\nСначала проверяется предложение об изменении, затем отдельный слой решает, применять ли его к стилям. Учебный валидатор ниже проверяет только существование роли и формат #RRGGBB. Это проектное ограничение примера, а не полный синтаксис CSS и не проверка контраста.
const tokens = Object.freeze({\n 'button.primary.background': '#2457D6',\n});\n\nfunction proposeTokenChange(tokenMap, name, nextValue) {\n const known = Object.prototype.hasOwnProperty.call(tokenMap, name);\n if (!known) return { ok: false, reason: 'unknown-token' };\n if (!/^#[0-9a-f]{6}$/i.test(nextValue)) {\n return { ok: false, reason: 'invalid-color' };\n }\n return {\n ok: true,\n name,\n previousValue: tokenMap[name],\n nextValue,\n };\n}\n\nfunction rollback(change) {\n if (!change.ok) return { ok: false, reason: 'nothing-to-rollback' };\n return {\n ok: true,\n name: change.name,\n nextValue: change.previousValue,\n rollback: true,\n };\n}\n\nconst accepted = proposeTokenChange(\n tokens,\n 'button.primary.background',\n '#1D4ED8',\n);\nconsole.assert(accepted.ok && accepted.previousValue === '#2457D6');\nconsole.assert(\n proposeTokenChange(tokens, 'button.secondary.background', '#1D4ED8').reason\n === 'unknown-token',\n);\nconsole.assert(\n proposeTokenChange(tokens, 'button.primary.background', 'blue').reason\n === 'invalid-color',\n);\nconst restored = rollback(accepted);\nconsole.assert(restored.rollback && restored.nextValue === '#2457D6');\nВ этом фрагменте карта токенов не меняется сама: функция возвращает решение, прежнее значение и новое значение. Поэтому положительный путь можно проверить отдельно от записи. Первый отрицательный путь отклоняет неизвестную роль, второй — строку, не соответствующую выбранному формату. Откат возвращает ровно #2457D6, сохранённое до изменения.
Запустите пример как обычный JavaScript-файл: все четыре console.assert должны завершиться без сообщения. Это доказывает только контракт предложения и отката. Код не собирает CSS, не открывает браузер, не вычисляет контраст и не показывает, какой компонент победит в каскаде. Для этих утверждений нужны отдельные проверки.
Список входов помогает очертить работу: viewport 375 и 1280, светлая и тёмная темы, состояния default, hover, focus-visible, disabled и loading. Он также фиксирует, что именно команда собирается смотреть. Но список не видит computed style, шрифт, перекрытый outline, порядок фокуса и фактический каскад.
Для visual-проверки нужны страница, браузер, viewport, baseline, новый снимок, правило допустимого diff и сохранённый результат. Для keyboard-проверки нужна последовательность переходов фокуса. Для accessibility-проверки нужно сопоставить конкретный критерий с наблюдаемым результатом; одного зелёного статуса валидатора токенов недостаточно. Если запуск не выполнен, это следует назвать ограничением, а не превращать объявленный объём в факт.
\nНативная кнопка тоже требует отдельной проверки поведения. Атрибут type влияет на то, отправляет ли кнопка форму, сбрасывает её или не выполняет это действие. CSS-токен может изменить цвет disabled-состояния, но не должен незаметно решать, принимает ли control клик. Смысл, состояние и стиль связаны в интерфейсе, но принадлежат разным слоям контракта.
var().focus-visible, затем проверьте disabled и loading. Обычный фон не заменяет эти состояния.previousValue, измените выбранную роль и запишите причину изменения.Подход отвечает на узкий вопрос: существует ли роль, какие места и состояния она затрагивает и можно ли вернуть прежнее значение. Он не выбирает хороший дизайн, не вычисляет контраст и не заменяет проверку экранным диктором или браузером. Он также не решает автоматически локализацию, media queries, пользовательские настройки, конкурирующие изменения и различия между браузерами.
\nОдин общий токен не всегда лучше двух. Если профиль и оплата имеют разные требования к смыслу, состояниям или цене ошибки, разделите роли после проверки usages. Не объявляйте variant только потому, что один экран случайно выглядит иначе: сначала исключите literal, неверный каскад, неправильное состояние и ошибочное имя.
\nОткат предложения не равен откату выпуска. Возврат пары name → previousValue не отменяет уже опубликованный CSS, сборку, кеш или действие пользователя. Для этих уровней нужны отдельные процедуры и артефакты. В этом примере rollback ограничен подготовленным решением и не притворяется операцией деплоя.
Правка готова, если команда может показать реестр затронутых мест, матрицу состояний и запись с прежним значением. Неизвестная роль и неверный формат останавливаются без изменения. Допустимая правка меняет только выбранный контракт. Отдельный браузерный запуск подтверждает нужные viewport и состояния сохранённым артефактом. Повторная проверка возвращает прежнее значение по previousValue, а не по памяти или догадке.
Если visual- или accessibility-запуск ещё не выполнен, критерий должен это показывать. Для учебного примера достаточно запустить четыре утверждения JavaScript и проверить отрицательные входы. Для настоящего интерфейса добавьте DOM-проверку, keyboard route, критерии доступности и историю baseline. Только так можно отличить рабочую модель данных от доказанного поведения страницы.
\nvar(). Спецификация не назначает им продуктовую семантику.type, влияющие на её поведение в форме.