Когда RuStore только запускался, мы строили дизайн на основе UI-кита от VK — удобно, практично, а главное помогло нам быстро вывести продукт на рынок. Но стор рос, интерфейсов становилось больше, дизайнеры — смелее, а требования — сложнее. И вот настал день, когда мы решили добавить тёмную тему. И всё сломалось. 

Именно тёмная тема вскрыла нашу главную проблему — ранняя структура цветовых токенов не готова к масштабируемости. Любое изменение запускало каскад правок и ломало синхронизацию между макетами и кодом. 

Под катом мы — Элина Шевченко, продуктовый дизайнер RuStore, и frontend-разработчик Андрей Едунов — расскажем историю, как мы полностью перестроили систему, не останавливая разработку, аккуратно вынесли всё из Figma в код и почему теперь можем масштабировать дизайн без драмы, даже если завтра понадобится не только тёмная тема, но и ностальгия по 2008-му, где primary — это чистый #000000, а secondary — оттенок тоски.

Что такое цветовые токены

Цветовые токены — это способ управлять цветами не через конкретные HEX-значения, а через их назначение в интерфейсе. Вместо того чтобы прописывать #FF0000 (красный) в каждом макете и компоненте, мы оперируем абстрактными переменными, которые описывают роль цвета, а не его числовое значение.

Благодаря этому дизайнеры и разработчики говорят на одном языке, а интерфейс легко адаптируется под тёмную тему, редизайн или изменение визуального стиля. Достаточно обновить значение токена, и оно автоматически применится во всех местах, где используется.

Разберёмся на примере:

На картинке — заданные параметры цвета для кнопки ошибки:

  • Value — #FF0000. Просто красный цвет. Без контекста.

  • Primitive token — red.100. Обозначаем базовый оттенок палитры — ещё не знаем, где он будет использоваться, просто фиксируем, что это «наш красный».

  • Semantic token — surface.negative. Здесь появляется смысл. Указываем, что этот цвет подходит для негативных состояний: ошибок, предупреждений.

  • Component — кнопка ошибки. Компонент не хранит HEX и не знает, какой именно оттенок красного использовать. Он просто берёт surface.negative

К примеру, если завтра мы решим перекрасить кнопки ошибок, то для этого изменим лишь semantic-token, и интерфейс обновится везде автоматически.

Старая система цветовых токенов

В RuStore есть два направления:

  • B2C — мобильное приложение и веб-витрина для пользователей.

  • B2B — внутренние веб-инструменты для разработчиков и модераторов (это мы).

У мобильного приложения давно есть тёмная тема (и даже розовая, и тёмно-розовая), а в наших B2B-продуктах за три года её так и не появилось. Когда мы наконец решили это исправить, выяснилось, что дело не только в переключении темы.

Несмотря на разные дизайн-системы, палитра и структура цветовых токенов у B2C и B2B была общей. При этом токены делились на два уровня:

  • Базовые (rp — reference palette) — условные названия для цветов. Например, gray.500 вместо #6D7885 (холодный серо-синий оттенок).

  • Семантические (core) — вроде surface.secondary или accentSubdued, которые уже используются в компонентах.

Все токены лежали в общем Core-файле, и отлично работали в течение нескольких лет. 

Однако время от времени B2B-продукту требовался новый цвет, которого не было в общей палитре (продукт-то развивается!). Казалось бы, что сложного? Но возникала проблема: мы не могли добавить их в общие токены — B2C они были ни к чему. Поэтому под них пришлось заводить локальные токены. Так интерфейс стал тянуть цвета из разных файлов, и чем дальше — тем сложнее стало понимать, что откуда берётся.

Долгое время мы просто жили с этим. Пока не пришла тёмная тема.

Изначальная логика с тёмной темой была проста: раз у B2C уже есть тёмная тема, давайте просто переключим Light → Dark — и готово.

Мы переключили. И всё сломали.

В итоге в интерфейсе пропала визуальная иерархия: элементы, которые должны визуально выступать над фоном, начали сливаться с ним, глубина исчезла, и пользователю пришлось напрягаться, чтобы отличить один блок от другого, а вместо того чтобы фокусироваться на содержимом страницы, взгляд упирался в яркое боковое меню — акцент сместился совсем не туда. При этом в светлой теме таких проблем нет.

Почему так произошло? В светлой теме глубина интерфейса формируется за счёт теней: чем «выше» элемент относительно поверхности, тем заметнее его тень. Но в тёмной среде тени становятся малоконтрастными или вовсе исчезают, и слои начинают сливаться. В мобильных приложениях слоёв обычно мало, поэтому эффект почти незаметен. В B2B-интерфейсах, где вложенных панелей, окон и слоёв гораздо больше, потеря глубины превращается в реальную проблему.

Так мы и обнаружили, что текущая система токенов и палитра тёмной темы, созданная в первую очередь для B2C, не масштабируются на B2B. Простое переключение с Light на Dark приводит к негармоничным цветам и ухудшает восприятие интерфейса.

Поиск решения

Стало ясно: просто включить темный режим недостаточно. Интерфейс сам не починится, нужно в целом менять подход к работе с цветами. 

Нашли три решения:

  1. Править проблемные цвета в общих токенах. Быстрое и вроде бы легкое решение: находим те цвета, которые «проваливаются» в тёмной теме, и заменяем их. 
    Но проблема с этим вариантом глобальная — общая с B2C библиотека токенов, из-за которой любые изменения цветов затронут и интерфейс мобильного приложения. Мы рисковали лечить свой продукт и параллельно портить чужой.

  2. Локальные токены для B2B. Это по сути то, что мы и так уже делали раньше: заводим свои токены под проблемные места и перекрашиваем их только у себя. Это тоже относительно быстро, но такой подход — жёсткий костыль, который через время создаст нам новые проблемы.

  3. Создать собственную группу семантических токенов для B2B. Отвязаться от общих Core-токенов на уровне семантики, но продолжать использовать общую базовую палитру. Иными словами, базовые цвета остаются едиными для B2C и B2B, но семантические токены становятся независимыми и учитывают специфику B2B-интерфейса. Так можно назначать значения заново и нормально адаптировать цветовую систему под тёмную тему.

Первые два пути отпали довольно быстро. Поправить цвета прямо в общих токенах — звучит соблазнительно, но любое изменение затронуло бы и B2C: мы могли исправить свой интерфейс и одновременно поломать чужой. Второй вариант — оставить локальные токены и продолжать «красить по месту» — тоже не решение. Это быстро, но по сути костыль, который через несколько релизов вылез бы боком и снова привёл к хаосу.

Поэтому мы выбрали третий путь — сразу навести порядок во всей системе. Разделили семантические токены для B2B, сохранив общую базовую палитру. Так мы не трогаем интерфейс B2C, избавляемся от зависимости от локальных костылей и получаем систему, которой можно управлять централизованно и масштабировать без страха «сломать всё при смене темы».

Перестройка палитры и системы токенов

Если уж менять систему токенов ради тёмной темы, то делать это основательно. Мы решили не просто «перекрасить проблемные места» в B2B, а переосмыслить базовую палитру и привести токены к логичной структуре.

Что в итоге мы сделали.

1. Обновили тёмную палитру — локально для B2B

Старая тёмная тема была классическим серо-чёрным вариантом. Мы добавили ей характер: ввели более холодные оттенки и повысили контраст, чтобы интерфейс лучше читался.

Палитра общая с B2C, но мобильная команда менять свою тему не планировала. Поэтому мы сначала обновили цвета только в B2B. Если коллеги позже решат использовать их и в B2C — перенесём. Если нет, палитры разойдутся, как и семантические токены. И это тоже ок.

2. Использовали OKLCH вместо привычных RGB/HSL 

Чтобы корректно управлять яркостью, насыщенностью и цветовым тоном (особенно в тёмной теме), мы перешли на цветовое пространство OKLCH. В нём изменения параметров дают визуально одинаковые сдвиги — цвета остаются чистыми, не «грязнятся» при затемнении и хорошо сочетаются.

3. Упростили структуру базовых токенов

Как было: десятки групп по названиям цветов (red, blue, gray и т. д.). В каждой группе лежали оттенки главного цвета плюс дубли для светлой и тёмной темы. Это всё вело к ограничению в использовании и сложности поддержки.

Как стало: оставили всего две группы:

  • Base — вся нейтральная шкала от белого до чёрного.

  • Accent — акцентные цвета.

Структура стала проще и понятнее, а главное — любой цвет из палитры можно гибко использовать в семантических токенах как для светлой, так и для тёмной темы.

4. Пересобрали семантические токены

Раньше токены описывали только тип элемента (например, background, surface). То есть указывали, где используется цвет: на фоне, поверхности или границе. Но такая логика не учитывала глубину компонента в интерфейсе. В светлой теме это почти не замечалось, а в тёмной — элементы на разных слоях визуально сливались.

Кроме того, подход с background/surface создавал больше путаницы: дизайнеры часто выбирали токен по типу элемента, хотя цвет зависел от уровня над базовым фоном. Например, компонент должен был быть окрашен в surface.primary, но на самом деле требовал цвет группы background — они в светлой теме выглядели одинаково, поэтому ошибка не была очевидна. В тёмной теме это приводило к неправильному инвертированию слоёв и визуальной плоскости интерфейса. Похожая ситуация возникала и с stroke и divider.

Поэтому в новой системе мы отказались от типовых названий в пользу уровней глубины интерфейса. Выделили 5 уровней — от базового фона до всплывающих панелей. Чем выше элемент над поверхностью, тем выше его уровень.

Эту же концепцию встроили и в названия токенов:

  • заменили background и surface на одну группу — Surface.LevelX с подразделами уровней с 1 по 5;

  • для трёх верхних уровней добавили токены для состояний наведения — Hover;

  • для мелких компонентов появилась отдельная группа Fill;

  • Stroke и Divider объединили в одну группу Line.

5. Упростили логику контрастности 

Вместо условных primary, secondary и т. п. ввели числовые индексы, отражающие контраст относительно фона. Чем выше число, тем сильнее контраст. Система работает одинаково в обеих темах.

В итоге токены стали отражать не только цвет, но и роль элемента в пространстве интерфейса, причем в разных темах одинаково.

Тестирование контрастности

При разработке новой палитры важно учитывать разницу в экранах и цветопередаче — иначе есть риск настроить «идеальные» цвета только под свой ноутбук и потом всё переделывать. Именно так у нас и вышло.

На MacBook наша новая тёмная палитра с лёгким холодным оттенком выглядела отлично. Но при тестировании на других мониторах внезапно стала уходить в болотный тон. А учитывая, что большинство пользователей работают не за Mac, пришлось корректировать цвета и перепроверять палитру на разных устройствах.

Для проверки читабельности мы использовали не только WCAG, но и APCA — он учитывает размер и толщину шрифта, а также контекст. Благодаря этому нам не пришлось осветлять плейсхолдеры: по WCAG им не хватало контраста, а по APCA они оказались идеальными — достаточно бледными, чтобы не отвлекать внимание, и при этом читаемыми.

Как токены доехали до кода

Когда мы начали внедрять токены в компоненты, разработчики выгружали их из Figma через плагин Design Tokens Manager. Он экспортирует токены в JSON, но в сыром виде (это была первая проблема): многие значения ссылались на другие токены, и приходилось вручную разбираться, что к чему. Проблему решили настройкой скрипт-конвертера: он сам берёт свежий JSON, разворачивает ссылки и генерирует набор CSS-переменных. 

Вторая сложность, с которой мы столкнулись при переносе, — названия токенов. Чтобы имена корректно доходили до кода, нужно: избегать любых символов, кроме латиницы и цифр, и не начинать название токена с цифры. Идеальная формула названия токена: название в Figma = название переменной в стилях.

В конце переноса самое главное — убедиться, что все токены корректно подтягиваются в интерфейс. Для этого достаточно открыть DevTools (F12) и посмотреть стили элемента. Если цвет указан через переменную вида var(--color-text-primary) — значит используется токен. При клике видно итоговое значение — HEX, заданный в Figma.

Перенос теней

После того как убедились, что цветовые токены корректно подтягиваются в интерфейс, взялись за тени. Они нужны для того, чтобы не потерять визуальную иерархию в тёмной теме. Без неё компоненты начнут сливаться.

В дизайне тень обычно задаётся набором параметров:

  • смещение по горизонтали (x);

  • смещение по вертикали (y);

  • радиус размытия;

  • радиус растяжения (spread);

  • цвет;

  • направление (внешняя или внутренняя тень).

В Figma (и в экспортируемом JSON) эти параметры описаны удобно, но в CSS всё должно быть сведено к одному свойству box-shadow

Например, чтобы получить обычную маленькую тень, нужно указать такую строчку:

box-shadow: 0px 0px 4px 0px #00000040;

Здесь тень имеет нулевое смещение по x и y, радиус размытия 4px, нулевой spread и полупрозрачный чёрный цвет #00000040. 

Для сложных многоуровневых теней требуется несколько слоёв:

box-shadow:
  0px 2px 4px 0px #00000040,
  0px 6px 12px 0px #00000020;

Чтобы не собирать эти значения вручную, скрипт-конвертер также обрабатывает токены теней и формирует итоговую строку для CSS. Благодаря этому переход теней из Figma в код происходит автоматически — так же, как с цветами.

В итоге мы свели всю рутинную задачу к одному действию: прогнать скрипт, который конвертирует JSON в набор CSS-переменных.

Обновляем скрипты под новую систему

Когда дизайнеры полностью перешли на новую структуру токенов, нужно было подготовить и инфраструктуру для разработки. Количество данных выросло: теперь у нас отдельный файл с базовой палитрой, отдельный с новыми B2B-токенами (часть которых ссылается на базовые), плюс временно сохраняем старые токены (для обратной совместимости, чтобы ничего не сломать в проде, потому что переход идёт поэтапно), тут же поддержка двух тем, токены теней и размеров, типографики. В общем, стало понятно, что прежний конвертер уже не справляется.

В конечном итоге все получилось. Скрипт корректно собирал токены из разных файлов, разворачивал ссылки между ними и генерировал итоговый набор CSS-переменных для фронтенда. 

Ниже расскажу, что помогло не сломать прод в процессе этого глобального переезда.

1. Таблица соответствий было → стало

Дизайнеры вели таблицу: какой старый токен заменяется на какой новый. На её основе разработчики написали скрипт, который автоматически прошёл по всем проектам и заменил старые названия на новые. Это позволило обновить кодовую базу быстро и без ручного поиска.

2. Скриншот-тесты UI

Для ключевых экранов заранее сохранены эталонные скриншоты. После обновления токенов запускаем тест: система делает новые снимки, инвертирует цвета и накладывает поверх старых с 50% прозрачностью. Если картинки идентичны, наложение даст ровный серый фон. Но если есть хотя бы малейшее отличие, мы увидим красное выделение в местах несовпадения. Так мы ловим даже минимальные визуальные расхождения, не дожидаясь реальных багов.

Что получили в итоге (и зачем всё это было)

На первый взгляд могло показаться, что мы просто «подкрутили тёмную тему». Но за этим стояла более глобальная задача — полностью перестроить фундамент системы для безопасного масштабирования продукта.

Что всё это дало:

  • Тёмная тема теперь работает как надо. Интерфейс остаётся контрастным, слои читаются, элементы не сливаются.

  • B2B и B2C больше не завязаны на одни токены. У каждого направления свои семантические токены, больше никто не подтягивает чужие цвета.

  • Понятный нейминг. Название токена сразу говорит, где его применять. И дизайнеру, и разработчику.

  • Базовая палитра стала проще и гибче. Добавить новый цвет теперь действительно можно за пару минут.

  • Обновление дизайна в коде автоматизировано. Скрипт забирает токены из Figma, генерирует стили и заменяет переменные — без ручных правок.

  • Мягкий переезд. Благодаря таблице соответствий и скриншот-тестам миграция прошла без откатов и багов.

  • Ни один смежный продукт не пострадал. Это самое главное.

Если что-то действительно помогло пройти через весь этот рефакторинг без боли — так это плотное взаимодействие дизайна и разработки с самого начала. Не «отдать макеты в конце», а прорабатывать систему вместе. Когда фронтенд подключён ещё на этапе проектирования, решения получаются точнее, архитектура — устойчивее, а внедрение — быстрее. В нашем случае именно так и было, и это сэкономило нам и время, и нервы.

Спасибо, что дочитали до конца. Надеемся, наш опыт будет вам полезен. Если есть вопросы или хотите поделиться своим опытом — будем рады обсудить в комментариях.