UI-kit, которым пользуются несколько продуктовых команд, нельзя просто отправить в будущее и переписать под новый дизайн. За ним тянется прошлое: старые CSS-переменные, публичный API, кастомизации у потребителей и независимые релизные циклы. При этом бренд уже меняется, дизайнеры приносят новую палитру, типографику, motion, скругления и тёмную тему, и всё это нужно аккуратно посадить на компоненты, которые продолжают работать в продакшене.

Привет, Хабр! Меня зовут Амир, я Senior Vue.js Frontend Developer в экосистеме «Лукоморье». Уже шесть лет я развиваю фронтенд большой внутренней ERP-платформы, в том числе внутренний UI-kit: около 50 компонентов на Vue 3 и TypeScript, которыми пользуются несколько продуктовых команд в Ростелекоме.

В этой статье расскажу, как мы устроили для UI-kit такое «назад в будущее»: в одной долгоживущей ветке sovaпровели редизайн поверх работающих компонентов, сохранили публичный API для команд-потребителей и перевели визуальный слой на новую архитектуру.

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


Контекст

Внутренняя UI-библиотека Ростелекома живёт уже шесть лет. Сейчас в ней около пятидесяти компонентов: schema-driven формы, императивные оверлеи, динамическая темизация через CSS-переменные. 

Недавно мы в Лукоморье запустили редизайн портала, и под него нужно было обновить UI: палитру, типографику, motion, скругления и опциональную тёмную тему.

Часть компонентов под новый стиль всё равно пришлось дорабатывать: появились новые размеры у EsmpInput, новые состояния и motion. Но базовую цветовую и типографическую логику мы решили вынести из компонентов в дизайн-токены, иначе при следующем обновлении  бренда снова пришлось бы проходить по десяткам SCSS-файлам и искать значения вроде --button-primary-bg. В той же ветке мы закрыли ещё несколько технических долгов: типы шаблонов, доставку SVG-иконок, размер бандла и утилиту темизации. 

Дальше расскажу про 3-tier архитектуру, Style Dictionary и о том, как всё это уживалось в одной ветке sova.

Немного про UI-kit

@esmpfrontend/ui — внутренняя библиотека компонентов Ростелекома. Это Vue 3, около 50 компонентов, публикация как npm-пакет во внутренний GitLab Registry и подключение несколькими продуктовыми командами. В целом всё стандартно: формы, таблицы, оверлеи, нотификации, типография, иконки. В библиотеке примерно сто тысяч строк, а у части компонентов есть styleguide-страница с примерами.

Темизация устроена через публичный набор CSS-переменных. Проект-потребитель сам решает, какие значения использовать: подставить свои дефолты в стилях или переопределить переменные на лету через <head>, например, темой, которая пришла с API. Сам UI-kit при этом остаётся неизменным: без отдельных веток под каждого клиента и без «фирменных» сборок. Есть один пакет и один контракт в виде имён CSS-переменных.

Каждый компонент, например, EsmpInput, EsmpSelect, EsmpSidebar, EsmpDatepicker, живёт со своим набором props, emits, стилевых переменных, размеров и состояний: disabled, readonly, error, loading. 

Когда обновляется бренд, изменения затрагивают сразу большой набор компонентов. Поэтому пересмотр визуала требует синхронизации в нескольких местах одновременно.

До этого: одноуровневый Tier без чёткой структуры

В нашем SCSS жил большой набор переменных такого вида:

/* src/styles/variables/_colors.scss */
$color-accent-primary: #0055ff;
$color-accent-primary-hover: #0044cc;
$color-text-on-accent: #ffffff;
$color-bg-surface: #ffffff;
$color-border-default: #d1d5db;
// ...

/* src/styles/_root.scss */
:root {
  --esmp-ui-button-view-primary-background-color: #{variables.$color-accent-primary};
  --esmp-ui-button-view-primary-background-color-hover: #{variables.$color-accent-primary-hover};
  --esmp-ui-button-view-primary-text-color: #{variables.$color-text-on-accent};

  --esmp-ui-input-background-color: #{variables.$color-bg-surface};
  --esmp-ui-input-border-color: #{variables.$color-border-default};
  --esmp-ui-input-border-color-active: #{variables.$color-accent-primary};

  --esmp-ui-sidebar-background-color: #{variables.$color-bg-surface};
  /* ... ещё около двухсот таких переменных */
}

Базовые значения лежали в SCSS-переменных, а в :root из них собирались CSS-переменные с именами «по компоненту». Так проект-потребитель мог переопределять их под себя. Когда дизайнер сдвигал акцентный цвет на полтона, всё действительно обновлялось из одного места: мы меняли SCSS-переменную, и она прорастала в десятки CSS-переменных.

На тот момент это был осознанный выбор, и многие команды до сих пор живут именно так. Проблема была не в дублях hex-значений, а в структуре. По сути уровень был один: компонентные переменные, привязанные к SCSS-исходникам. Чёткого разделения «палитра / семантика / компонент» не было.

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

С прозрачностями была отдельная история. Значения вроде rgba(0, 85, 255, 0.1) можно хранить в CSS-переменной, но дальше есть два варианта. Первый — завести отдельную переменную под каждый уровень прозрачности: --accent-10, --accent-20 и так далее.  Второй — взять hex из переменной, распарсить его в JS, превратить его в rgb-строку и добавить alpha. Мы использовали второй вариант. Он рабочий, но всё-таки больше похож на хак, чем на устойчивое решение.

Тёмной темы в библиотеке не было. В основном дизайне портала её нет и сейчас, но в рамках редизайна дизайнеры впервые перенесли её в дизайн-токенах как альтернативный набор значений Tier 2. Предполагалось, что сама библиотека должна уметь  подключать тёмную тему через data-theme="dark". В продакшен-дизайне она пока не используется, но как «бонус из коробки» вошла в эту работу.

Размер бандла тоже оставлял вопросы. dist/ui.es.js весил около 940 KB raw. Внутрь библиотеки были скомпилированы lodash, dayjs, mitt, @popperjs/core и другие зависимости. В итоге у потребителя в node_modules могли лежать две копии lodash: наша и его собственная.

Иконки доставлялись через vite-plugin-svg-icons. Каждый проект-потребитель отдельно настраивал сборщик: указывал директорию с SVG и подключал плагин в vite.config.ts. Это пять-семь строк boilerplate в каждом проекте плюс необходимость держать набор иконок у себя.

Типизация шаблонов не помогала с именами. Опечатался в bgSurfac1 — получишь runtime warning, а IDE ничего не подчеркнёт.

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

Редизайн поверх работающего UI

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

Ограничений было несколько, и все они были жёсткими.

API компонентов должен был остаться стабильным: props, emits, slots, имена CSS-классов без breaking changes. Потребители не должны делать ничего сверх обычного yarn upgrade. У каждой команды свои релизные циклы и дедлайны, и они не не завязаны на наш темп: при желании любая команда может оставаться на старой версии пакета сколько угодно. Но регрессионное тестирование всё равно было нужно и нам, и потребителям — хотя бы чтобы убедиться, что после автогенерации переменных ничего не поехало.

Темизация со стороны потребителя тоже должна была продолжить работать. Возможность переопределять CSS-переменные библиотеки оставалась публичным контрактом.

Тёмная тема нужна была опционально. В токенах от дизайнеров уже было два набора значений — светлый и тёмный. На стороне библиотеки они привязывались к data-theme="dark". В продакшене портала пока используется светлая тема, но возможность переключиться есть из коробки.

Внутри команды задача формулировалась одной фразой: провести редизайн без переписывания базы. Часть компонентов под новый стиль всё равно приходилось дорабатывать: появились новые размеры у EsmpInput, новые состояния и motion. Но базовая цветовая, типографическая и spacing-логика должна была переехать в дизайн-токены и больше не жить размазанной по сорока SCSS-файлам.

В таких условиях дизайн-токены перестают быть архитектурной «красотой» и становятся обычным инженерным инструментом — способом изолировать визуальный слой от логики компонента.

Альтернативы, которые рассматривались

Прежде чем спроектировать свой подход, мы посмотрели, какие решения уже есть в индустрии. Нас интересовали три направления.

Vanilla CSS variables без build-tool

Самое лёгкое решение — ничего не менять и продолжать вручную править SCSS-файлы под новый дизайн. Дизайнер прислал hex — заменили hex. Тёмную тему добавили отдельным блоком через [data-theme="dark"] { ... } руками.

Плюсы очевидны: ноль зависимостей, высокая читаемость, любой джун понимает, что происходит. Минусы — те же, с которыми мы уже жили. Единого источника правды нет, синхронизация с Figma превращается в ручной копипаст. Прозрачности остаются либо JS-хаком, либо набором отдельных переменных под каждый уровень alpha. Любая опечатка в имени всплывает уже в проде. 

Этот вариант мы быстро отбросили: он не решал ни одной реальной проблемы, а только фиксировал текущие ограничения.

Tailwind как источник правды

С Tailwind ситуация интереснее. В Tailwind v4 (январь 2025) конфигурация переехала из tailwind.config.js в CSS. Теперь токены можно описывать прямо в стилях через директиву @theme:

@import "tailwindcss";

@theme {
  --color-accent-500: #0055ff;
  --color-accent-600: #0044cc;
  --color-fg-default: #0a0e1a;
  --spacing-md: 16px;
}

Из этих переменных Tailwind собирает утилитарные классы: bg-accent-500, text-fg-default, p-md. При этом значения остаются доступны как обычные CSS-переменные. 

Идеологически это близко к тому, к чему мы и так приходили: один источник правды в CSS и никакого промежуточного JS-конфига.

Но как источник правды для нашего UI-kit Tailwind не подходил. У нас уже была библиотека компонентов со своими SCSS-стилями, а Tailwind из коробки не закрывает кейс «компонент с собственным внутренним стилем». Он рассчитан на утилитарный подход прямо в разметке. 

Темизация со стороны потребителя тоже ложилась плохо. Значения внутри @theme уже скомпилированы в финальный CSS, и «перезалить» их с API без пересборки не получится.

Tailwind остаётся как способ потребления токенов в проектах, которые на него опираются. Tailwind preset для нашего UI-kit становится одним из артефактов сборки, а не основной точкой контракта.

CSS-in-JS: Vanilla Extract, Stitches, styled-components

Идея привлекательная: типобезопасные токены прямо в TypeScript. IDE подсказывает имена, рефакторинг работает, а несовпадения ловятся ещё на этапе компиляции.

Но для нашего стека проблемы видны быстро. Vue 3 + CSS-in-JS  — не самая каноничная связка: большинство решений написано под React, а для Vue нужны обёртки и дополнительная поддержка. Главная же претензия — runtime cost. styled-components и похожие решения  генерируют CSS на лету и инжектят <style> в <head> в ответ на изменение props и состояний. 

Сама библиотека не такая большая — около 12 KB gzip, но это добавляет работу JS-движку перед первой отрисовкой и влияет на FCP в больших дашбордах. Для библиотеки на пятьдесят компонентов это лишний JS-парсинг на каждом рендере.

Главное: наша темизация — это runtime через CSS-переменные. Проект-потребитель может подмешивать CSS-переменные в <head>, и интерфейс перекрашивается без JS-рантайма. Перейти на CSS-in-JS означал бы  переписать механизм темизации с нуля. Это прямое противоречие ограничению «API остаётся».

Что выбрали

Мы выбрали собственный подход на той же базе CSS-переменных, но с уровнями абстракции и автогенерацией. Три уровня переменных от палитры до компонента с жёстким правилом ссылок. 

Источник правды — JSON из Tokens Studio в Figma. Style Dictionary выступает build-tool: один build-шаг генерирует SCSS, JSON-манифест, Tailwind preset для v3-проектов, CSS-файл с @theme-блоком для v4-проектов и TypeScript-типы. Channel-syntax -rgb решает прозрачности без JS-хака.

Build-step действительно добавляется, но он снимает всю ручную синхронизацию с дизайнерами и закрывает кейс прозрачностей. Это осознанный trade-off.

3-tier архитектура

Идея простая: три уровня переменных и одно жёсткое правило. Переменная уровня N может ссылаться только на переменные уровня ≤ N. Это убирает циклические зависимости и делает понятным, куда добавлять новые значения.

Tier 1: примитивы

Первый уровень — палитра и базовые значения. Имена выглядят так:  --esmp-ui-accent-500, --esmp-ui-neutral-100. Здесь нет семантики, только значения. 

Это внутренний уровень, и проектам-потребителям нельзя на него опираться напрямую: переименования и удаления здесь рутина.

Каждый opaque-цвет получает -rgb сиблинг с channel-значением:

:root {
  --esmp-ui-accent-500-rgb: 0 85 255;
  --esmp-ui-accent-500: rgb(var(--esmp-ui-accent-500-rgb));

  --esmp-ui-neutral-990-rgb: 10 14 26;
  --esmp-ui-neutral-990: rgb(var(--esmp-ui-neutral-990-rgb));
}

У такой записи есть минус: с цветами теперь приходится работать не в привычном hex, а в RGB-каналах. Значение #0055ff нужно представить как 0 85 255. 

В Tokens Studio это значение видно в палитре сразу, поэтому для дизайнера процесс остаётся прозрачным. У разработчика, который привык открыть DevTools и увидеть #0055ff, поначалу  может возникнуть вопрос: «А где здесь мой синий». На практике это не стало проблемой: Tier 1 руками почти не правится, значения генерируются автоматически из JSON-файла токенов.

Зато прозрачности теперь описываются обычным CSS, без JS-хака:

.banner {
  background: rgb(var(--esmp-ui-accent-500-rgb) / 0.1);
}

Ровно эту же механику использует Tailwind preset для alpha-modifier: класс bg-esmp-accent/40 собирается под капотом как rgb(var(--esmp-ui-accent-500-rgb) / 0.4). То, что раньше требовало color2k или собственного JS-хелпера, теперь живёт в CSS-движке.

Tier 2: семантические роли

Tier 2 — это публичный контракт. Именно на эти переменные опираются компоненты и проекты-потребители при кастомизации. Имена описывают роль переменной, а не конкретный компонент:

:root {
  --esmp-ui-fg-default: var(--esmp-ui-neutral-990);
  --esmp-ui-fg-default-rgb: var(--esmp-ui-neutral-990-rgb);

  --esmp-ui-bg-surface0: var(--esmp-ui-neutral-50);
  --esmp-ui-bg-surface1: var(--esmp-ui-neutral-100);

  --esmp-ui-interactive-accent: var(--esmp-ui-accent-500);
  --esmp-ui-interactive-accent-hover: var(--esmp-ui-accent-600);
  --esmp-ui-interactive-on-accent: var(--esmp-ui-neutral-50);

  --esmp-ui-accent-container-default:
    rgb(var(--esmp-ui-accent-500-rgb) / 0.1);

  --esmp-ui-border-soft: var(--esmp-ui-neutral-200);
}

[data-theme="dark"] {
  --esmp-ui-fg-default: var(--esmp-ui-neutral-dark-50);
  --esmp-ui-bg-surface0: var(--esmp-ui-neutral-dark-900);
  --esmp-ui-interactive-accent: var(--esmp-ui-accent-200);
}

Тёмная тема живёт в отдельном блоке Tier 2 под селектором [data-theme="dark"]. Tier 1 при этом не меняется — меняются только связи Tier 2 → Tier 1. 

Отдельного набора семантических для светлой и тёмной темы нет. Набор ролей остаётся один: каждая роль получает значение из светлой или тёмной палитры Tier 1 в зависимости от data-theme. 

В Sova-токенах от дизайнеров было два набора цветов: default/light и default/dark. В esmp-токенах они переключаются стандартным решением через атрибут на корневом элементе.

Имена ролей, например, fg-default, bg-surface1, interactive-accent, border-soft, живут отдельно от конкретного компонента. Кнопка использует --esmp-ui-interactive-accent, потому что для неё это «акцентный интерактивный фон». Если позже  акцентный фон понадобится вкладке или бейджу, мы переиспользуем существующую роль, а не заводим новую переменную.

Список переменных, которые,  проект-потребитель имеет право переопределять, описан в dist/tokens.manifest.json:

{
  "version": "4.0.0",
  "tokens": [
    {
      "name": "fgDefault",
      "cssVar": "--esmp-ui-fg-default",
      "type": "color",
      "customizable": true,
      "defaultValue": "#0a0e1a",
      "description": "Primary foreground color"
    },
    {
      "name": "interactiveAccent",
      "cssVar": "--esmp-ui-interactive-accent",
      "type": "color",
      "customizable": true,
      "defaultValue": "#0055ff"
    }
  ]
}

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

Затем конфиг темы приходит в проект-потребитель как набор соответствий «имя CSS-переменной — значение» и применяется как переопределения в <head> или через утилиту $EsmpTheming

Раньше для каждой такой задачи приходилось вычитывать список доступных  «крутилок» руками. Теперь это машиночитаемый контракт.

Tier 3: компонент-специфичные алиасы

Старые имена вида --esmp-ui-button-view-primary-background-color никуда не делись. Они остались как legacy-алиасы:

:root {
  --esmp-ui-button-view-primary-background-color:
    var(--esmp-ui-interactive-accent);
  --esmp-ui-button-view-primary-background-color-hover:
    var(--esmp-ui-interactive-accent-hover);
  --esmp-ui-input-background-color:
    var(--esmp-ui-bg-surface0);
  --esmp-ui-sidebar-background-color:
    var(--esmp-ui-bg-surface1);
}

Так мы сохраняем полную обратную совместимость для проектов-потребителей, которые уже опирались на эти имена. При этом внутри новых компонентов писать на Tier 3 запрещено стайлгайдом: всё новое должно идти через Tier 2 напрямую.

В реестре src/tokens/deprecations.json хранится список устаревших имён с указанием замены и версии удаления, например, removalVersion: "5.0". В dev-сборке утилита темизации логирует console.warn при обращении к deprecated-имени один раз на токен. Удаление будет позже в большом релизе. 

Жёсткое правило ссылок

Tier N может ссылаться только на Tier ≤ N. Это правило важнее любых конкретных имён. Оно убирает циклические зависимости, делает понятным, где живёт источник правды, и упрощает рефакторинг. Если завтра дизайнеры захотят переименовать accent-500 в brand-base, всё переименование сведётся к одному месту в Tier 1 и автоматической пересборке зависимых уровней.

И главное, именно это правило позволяет проводить редизайн без переписывания компонентов. Компонент опирается на Tier 2; при редизайне меняется Tier 1 (палитра) и связи Tier 1 → Tier 2. Сам компонент остаётся прежним.

От Figma до пакета: pipeline

Источник правды — JSON-файл из Tokens Studio for Figma. Плагин позволяет дизайнерам работать с токенами прямо внутри Figma и экспортировать их в один JSON. Файл лежит в репозитории по пути src/tokens/source/sova-tokens.json

Внутри четыре набора сета: 

default/base — палитры accent, neutral, success, warning, error, info по 17 ступеней, а также motion и spacing primitives; 

default/alias — composite typography, spacing scale, z-index;

default/light и default/dark — тематические наборы светлой и тёмной темы. 

За превращение этого JSON в код отвечает Style Dictionary — open-source библиотека от Amazon для мультиплатформенных  дизайн-токенов. Из коробки она закрывает базовые сценарии генерации, например, CSS-переменные и JS-объект, но главное, позволяет регистрировать кастомные форматы и трансформации.

Наш конфиг (build/style-dictionary.config.mjs, фрагмент):

import StyleDictionary from 'style-dictionary';
import { register } from '@tokens-studio/sd-transforms';

register(StyleDictionary);

StyleDictionary.registerFormat({
  name: 'scss/primitives-channel',
  format: ({ dictionary }) => {
    const colors = dictionary.allTokens
      .filter(t => t.$type === 'color' && t.path[0] === 'base')
      .map(t => {
        const { r, g, b } = parseHex(t.$value);
        return `  --esmp-ui-${kebab(t.path)}-rgb: ${r} ${g} ${b};\n` +
               `  --esmp-ui-${kebab(t.path)}: rgb(var(--esmp-ui-${kebab(t.path)}-rgb));`;
      })
      .join('\n');
    return `:root {\n${colors}\n}\n`;
  },
});

StyleDictionary.registerFormat({
  name: 'json/tokens-manifest',
  format: ({ dictionary }) => JSON.stringify({
    version: pkg.version,
    tokens: dictionary.allTokens
      .filter(t => t.path[0] === 'light' && t.customizable !== false)
      .map(t => ({
        name: camelCase(t.path.slice(1)),
        cssVar: `--esmp-ui-${kebab(t.path.slice(1))}`,
        type: t.$type,
        customizable: true,
        defaultValue: t.$value,
        description: t.$description ?? '',
      })),
  }, null, 2),
});

// ...ещё форматы: tailwind preset, tailwind v4 theme.css, theming-tokens.d.ts

Пакет @tokens-studio/sd-transforms умеет раскрывать Tokens Studio-специфичные конструкции: модификаторы lighten, darken, mix, alpha (в нескольких color spaces: srgb, hsl, lch, p3), references вида {accent.500}, composite-токены (типография, тени), математику единиц. Без него Style Dictionary видит сырой JSON Tokens Studio и понимает только примитивные значения.

Из одного JSON-файла после yarn build:tokens рождается семь артефактов: primitives.generated.scss (Tier 1, channel-syntax), typography.generated.scss (Tier 1 typography), _semantic.generated.scss (Tier 2 light + dark), tokens.manifest.json для систем кастомизации, tailwind.preset.cjs для Tailwind v3-проектов, tailwind.theme.css для Tailwind v4-проектов (с блоком @theme) и theming-tokens.generated.d.ts с TypeScript-интерфейсом.

Цикл обновления простой. Дизайнеры приносят свежий JSON, кладём его в src/tokens/source/sova-tokens.json, запускаем yarn build:tokens, смотрим diff. Если есть переименования, заполняем deprecations.json. Коммит, PR, ревью. На стороне дизайнеров никаких ручных «перепиши hex в SCSS», на нашей никакого перевода имён из camelCase в kebab-case руками.

Билд токенов запускается автоматически перед yarn build:library, в опубликованном пакете токены всегда актуальны.

Что доставляется вместе с токенами

Если бы работа остановилась на токенах, статья закончилась бы тут, но дизайн-система в широком смысле это не только цвета. К моменту, когда мы взялись за ветку sova, накопились ещё четыре долга, и каждый раз в квартал просился в работу. Раз токены меняли поверхность контракта между библиотекой и потребителями, разумно было закрыть оставшиеся точки доставки в той же ветке.

Типы для шаблонов

tokens.manifest.json используется дважды. Для системы кастомизации это  JSON-контракт, а для TypeScript — источник типов. Из манифеста на том же build-шаге генерируется theming-tokens.generated.d.ts:

/**
 * AUTO-GENERATED by Style Dictionary from src/tokens/source/sova-tokens.json.
 * Do not edit directly. Run `yarn build:tokens` after updating the source JSON.
 */

export interface EsmpThemingTokens {
  /** --esmp-ui-fg-default */
  fgDefault: string;
  /** --esmp-ui-fg-secondary */
  fgSecondary: string;
  /** --esmp-ui-bg-surface0 */
  bgSurface0: string;
  /** --esmp-ui-bg-surface1 */
  bgSurface1: string;
  /** --esmp-ui-interactive-accent */
  interactiveAccent: string;
  /** --esmp-ui-interactive-accent-hover */
  interactiveAccentHover: string;
  // ... все customizable роли из tokens.manifest.json
}

Утилита $EsmpTheming

$EsmpTheming регистрируется в app.config.globalProperties при app.use(EsmpUI). Под капотом утилита читает и записывает CSS-переменные в :root через document.documentElement.style.setProperty. 

До рефакторинга это был Proxy<{}> без типов: разработчик мог обратиться к любому свойству, а валидное оно или нет, узнавал только в runtime.

Зачем вообще нужна утилита, если CSS-переменную можно переопределить в стилях? На практике тема нередко приходит с API в виде JSON-словаря, например, от сервиса кастомизации брендов. Такой JSON удобнее распарсить и применить через JS-утилиту, чем генерировать большую CSS-строку и инжектить её в <head>

Утилита знает, какие имена допустимы, и при попытке записать значение в несуществующее имя, сразу ругается через console.warn. Простая инъекция в <head> так не умеет.

Иногда тему нужно изменить не глобально, а только внутри конкретного поддерева, например, в демо-блоке «вот так компонент выглядит в тёмной теме». В таком случае нужен программный контроль из Vue-компонента, а не статический CSS.

Для deprecated имён утилита тоже берёт на себя логирование. Например, при обращении к старому имени:

proxy.$EsmpTheming.buttonViewPrimaryBackgroundColor = '#fff'

В dev-сборке она пишет: это имя устарело, используй interactiveAccent.

После рефакторинга $EsmpTheming типизирован как EsmpThemingClass & EsmpThemingTokens. IDE подсказывает имена при $EsmpTheming.<TAB>, а опечатки подсвечиваются сразу:

proxy.$EsmpTheming.fgDefault = '#ff5722';     // ✅ ok
proxy.$EsmpTheming.bgSurface1 = '#ffffff';    // ✅ ok

proxy.$EsmpTheming.fgDefualt = '#fff';
// ❌ Property 'fgDefualt' does not exist on type 'EsmpThemingTokens'.
//    Did you mean 'fgDefault'?

proxy.$EsmpTheming.setVarValue('totallyMadeUp', '#fff');
// ❌ Argument of type '"totallyMadeUp"' is not assignable
//    to parameter of type 'keyof EsmpThemingTokens'.

Runtime при этом не изменился: тот же Proxy, тот же theme.utils.js. Изменилась только типизация поверх. 

Это полезный паттерн: если контракт уже описан в JSON для одной аудитории, например, системы кастомизации, его почти бесплатно можно превратить в TypeScript-контракт для другой аудитории: разработчиков проектов-потребителей.

SVG-иконки в одном пакете

В библиотеке около 816 иконок — всё, что используют команды-потребители на портале. Они хранятся как .svg-файлы в src/icons/svg/, на этапе yarn build:library из них собирается один SVG-sprite.

Это обычный XML-документ:

<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
  <symbol id="24-user" viewBox="0 0 24 24">
    <path d="M12 12c2.21 0 4-1.79 4-4..." />
  </symbol>
  <symbol id="24-settings" viewBox="0 0 24 24">
    <path d="..." />
  </symbol>
  <!-- ... 814 symbol-ов -->
</svg>

SVG-sprite вшивается в JS-бандл библиотеки строкой. Когда потребитель вызывает app.use(EsmpUI), под капотом срабатывает injectSprite(): sprite, один раз вставляется в document.body, а дальше все <EsmpIcon> ссылаются на нужный <symbol> через <use href="#24-user" />. Никакой дополнительной конфигурации сборщика на стороне потребителя не требуется.

EsmpIcon — тонкая обёртка над <svg><use>:

<template>
  <EsmpIcon name="24-user" size="24" />
  <EsmpIcon name="16-arrow-down" size="16" />
</template>

Имя иконки типизировано:

type EsmpIconName = '24-user' | '24-settings' | /* ... 816 имён */ | (string & {});

IDE подсказывает все встроенные имена, а строковый fallback оставляет возможность передать произвольный URL, например, на CDN-иконку: 

<EsmpIcon name="https://cdn.example.com/custom.svg" />.

После перехода на встроенный sprite из конфигов проектов-потребителей исчез createSvgIconsPlugin({ iconDirs: [...] }) и пять строк boilerplate. Вместе с этим ушли лишние шаги сборки: больше не нужно держать плагин для сборки иконок, отдельный каталог .svg и его конфигурацию.

В планах — дать возможность вставить кастомные иконки на стороне потребителя так, чтобы они добавлялись в тот же sprite:

import EsmpUI from '@esmpfrontend/ui';
import customIcons from '@/icons/*.svg?raw';

app.use(EsmpUI, {
  customIcons,
});

Идея в том, чтобы проект-потребитель в идеале не ставил дополнительные плагины и пользовался тем, что уже даёт библиотека. Встроенные иконки проходят через UI-kit, а кастомные добавляются через ту же точку входа. Пока этой возможности нет, но это следующий шаг.

Externalize runtime deps

До начала работ бандл библиотеки весил 940 KB raw. Значительную часть этого объёма занимали lodash, dayjs, mitt, @popperjs/core и другие зависимости, скомпилированные внутрь библиотеки.

Изначальная идея была понятной: потребителю не нужно устанавливать дополнительные пакеты. Но на практике это приводило к обратному эффекту. Например,  lodash есть почти в каждом JS-проекте, и у потребителя в node_modules оказывались две копии одной и той же зависимости.

Runtime-зависимости вынесены в peerDependencies через Rollup external:

// vite.config.ts
build: {
  lib: { entry: 'src/index.ts', formats: ['es'] },
  rollupOptions: {
    external: [
      'vue',
      'lodash',
      /^lodash\//,           // 'lodash/kebabCase' и т.п.
      'dayjs',
      /^dayjs\//,            // 'dayjs/locale/ru'
      'mitt',
      '@popperjs/core',
      'autosize',
      'color2k',
      '@melloware/coloris',
      'vue-draggable-plus',
    ],
  },
},

В package.json dependencies опустели, а зависимости переехали в peerDependencies:

{
  "dependencies": {},
  "peerDependencies": {
    "vue": "^3.5.0",
    "lodash": "^4.17.21",
    "mitt": "^3.0.0",
    "@popperjs/core": "^2.11.0",
    "dayjs": "^1.11.0"
  },
  "peerDependenciesMeta": {
    "@melloware/coloris": { "optional": true },
    "autosize": { "optional": true },
    "color2k": { "optional": true },
    "vue-draggable-plus": { "optional": true }
  }
}

Часть peer-зависимостей сделана optional: они нужны только при использовании конкретных компонентов. @melloware/coloris нужен для EsmpColorPicker, autosize для EsmpInput с textarea, vue-draggable-plus для drag-and-drop. Потребитель устанавливает только то, что действительно использует. Required peers остались общими: vue, lodash, mitt, @popperjs/core, dayjs.

dayjs пока остаётся required: он нужен EsmpDatepicker, который импортирует его на module-load. Сама библиотека небольшая — около 7 KB gzip с одной локалью, — и нести её в бандле было бы не критично. Но dayjsпопулярен, в любом более-менее живом фронтенд-проекте он уже установлен, поэтому ещё одна копия его в node_modules потребителя — раздражающий артефакт. Мы размышляем, как избавиться от необходимости тянуть dayjs потребителем.

Самый перспективный путь на 2026 — встроенный в JS Temporal API. В марте 2026 он достиг TC39 Stage 4 и нативно поддерживается в Chrome 144+, Firefox 139+, Edge 144+. В Safari он пока доступен только в Technical Preview за флагом, поэтому для него нужен temporal-polyfill — около 20 KB gzip, проходит весь TC39 test suite. 

Дальнейшее направление: переписать EsmpDatepicker на Temporal с polyfill для Safari и вывести dayjs из required peer-зависимостей. Это даст ту же поверхность для работы с датами — date arithmetic, time zones, durations, но без зависимости от сторонней библиотеки.

Эффект текущего этапа externalize: dist/ui.es.jsупал с 940 KB до примерно 150 KB raw (!). У потребителя в node_modules одна копия lodash и одна копия dayjs, как и должно быть.

Заодно из dependencies удалили core-js и vue-router. Core-js не использовался — grep по src/ был пустым. Vue-router нужен был только в dev-приложении библиотеки, а в runtime не использовался.

Обратная совместимость через deprecations.json

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

У нас несколько команд, и они не всегда обновляются синхронно. Кто-то на v3.2, кто-то на v3.5.

Контракт обратной совместимости описан в src/tokens/deprecations.json:

{
  "version": 1,
  "removalVersion": "5.0",
  "entries": [
    {
      "oldName": "buttonViewPrimaryBackgroundColor",
      "oldCssVar": "--esmp-ui-button-view-primary-background-color",
      "newName": "interactiveAccent",
      "newCssVar": "--esmp-ui-interactive-accent",
      "reason": "Component-specific token replaced by Tier 2 semantic role."
    },
    {
      "oldName": "inputBackgroundColor",
      "oldCssVar": "--esmp-ui-input-background-color",
      "newName": "bgSurface0",
      "newCssVar": "--esmp-ui-bg-surface0",
      "reason": "Component-specific token replaced by Tier 2 semantic role."
    }
  ]
}

В dev-сборке $EsmpTheming логирует console.warn при обращении к deprecated-имени один раз на токен, без спама в консоль. Старые имена работают как aliases (см. Tier 3) до версии removalVersion. Команда-потребитель сама решает, когда обновляться: автоматической миграции это не даёт, но и не давит сроками.

deprecations.json коммитится в репозиторий и обновляется вручную при каждом переименовании в источнике токенов. Это пока ручной процесс: Tokens Studio не знает о deprecated-именах и экспортирует переименование как новый ключ. 

Старый ключ и его новый адрес приходится прописывать отдельно. В работе автоматизация: если хранить два JSON-снэпшота, старый и новый, сравнение pathcssVar даст список переименований. Тогда deprecations.json можно генерировать тем же style-dictionary-шагом, оставляя вручную только поле reason.

Полгода в ветке sova

Ветка sova прожила почти шесть месяцев. Это было длительное направление работ, а не классический pull request на пару дней. В сумме получилось около 131 коммита без учёта merge-коммитов из main.

Обновлений из main за это время было не так много. Несколько команд внутри Ростелекома используют библиотеку, но в основном как потребители: берут опубликованную версию пакета и подключают её в своих проектах В саму библиотеку они коммитят редко. 

По сути, основной разработчик библиотеки находится внутри нашей команды, а внешние правки обычно сводятся к мелким фиксам и редким новым компонентам по запросу. Жёстких ситуаций в духе «другая команда сломала наш alias через старое имя в main» не возникало: при еженедельных merge’ах встречались небольшие диффы, без блокеров и переписывания.

Сам объём работ внутри sova мы разложили на пять направлений, чтобы держать понятные границы. Это был один большой проект «привести библиотеку в современный удобный вид», но внутри него были отдельные темы:

  1. Типы шаблонов. Volar/WebStorm IntelliSense, augmentation GlobalComponents и ComponentCustomProperties. Для проверки завели consumer-fixture — отдельный мини-проект на vue-tsc --noEmit, который в CI проверяет, что типы видны у потребителя.

  2. Императивные оверлеи: $EsmpNotify, $EsmpModal, $EsmpLoader теперь монтируются в install() основного приложения через createVNode + render, вместо того чтобы создавать отдельные Vue-приложения на module load. В DevTools больше нет четырёх отдельных app, остаётся один. Overlays получают доступ к provide/inject основного приложения.

  3. Типизированный $EsmpTheming и 3-tier токены — то, что описано выше про generated .d.ts.

  4. SVG sprite auto-inject. Pre-built sprite в JS-бандле.

  5. Externalize runtime deps. То, что  описано выше про peerDependencies.

Тесты и проверки типов были отдельной нитью в этой работе. Каждое изменение из этих пяти направлений приходило вместе с тестами и type-чеками. Иначе при следующем merge из main рисковали потерять контроль.

Самым ценным артефактом ветки оказался CI-гейт типов. 

tests/types/consumer-fixture/— это мини-проект на Vue + vue-tsc, который проверяет, что у потребителя <EsmpInput size="huge" />падает с типовой ошибкой, а <EsmpInput size="large" /> — нет. Он же проверяет, что после app.use(EsmpUI) свойства $EsmpTheming, $EsmpNotify подсказываются в IDE и проходят tsc

Эти проверки не про runtime, а про структуру .d.ts. Фикстура на vue-tsc ожидает ошибку компиляции на несуществующем свойстве и отсутствие ошибки на корректном. Каждое breaking change добавляло сюда новый кейс, и в нескольких случаях эта фикстура поймала регрессию по типам ещё до того, как она дошла бы до релиза.

Регрессионные runtime-тесты компонентов покрывают визуальные изменения, например, ситуацию, когда после перегенерации Tier 1 — скажем, если accent-500 сдвинулся на полтона — какой-нибудь компонент начинает выглядеть не так, как ожидалось. Это юнит-тесты на отдельные сценарии: hover, disabled, error. Плюс – ручной styleguide-обход глазами.

Из реально болевшего — модификаторы Tokens Studio и их порядок трансформаций в @tokens-studio/sd-transforms

Модификаторы — это lighten, darken, mix, alpha;  каждый из них может работать в разных color spaces:srgb, hsl, lch, p3. Из коробки sd-transforms раскрывает их значения, но порядок регистрации трансформаций влияет на итоговый hex. 

Один раз светлый и тёмный hex для одного и того же токена да и разный результат после darken. Пришлось разбираться с порядком регистрации и color space. После этого все цветовые модификаторы идут через srgb + lighten/darken по согласованной с дизайнерами таблице.

Документация писалась параллельно. docs/migration.md постепенно накапливал заметки про каждое breaking change. Каждая тема добавляла туда свою секцию: что было, что стало, что делать потребителю. К моменту слияния sova в main migration guide уже был готов — не пришлось писать его задним числом.

Что получили

Главный результат — UI-kit удалось обновить без разрыва с прошлым. Компоненты сохранили публичный API, старые CSS-переменные остались работать через legacy-алиасы, а новый визуальный слой переехал на 3-tier-архитектуру токенов.

Для проектов-потребителей подключение библиотеки стало легче. Бандл сократился с 940 КБ до примерно 150 КБ raw. В финальном бандле потребителя теперь одна копия lodash и одна копия dayjs, без дублей зависимостей. Для крупных приложений это заметный выигрыш.

Иконки тоже стали проще в использовании. Все 816 иконок собираются в один SVG-sprite внутри JS-бандла и автоматически инжектятся при подключении библиотеки. Потребителю больше не нужно настраивать vite-plugin-svg-icons, держать отдельный каталог .svg и добавлять boilerplate в конфигурацию сборки.

Для разработчиков стало меньше ошибок на поздних этапах. Типизация шаблонов теперь ловит некорректные значения сразу в IDE: например, <EsmpInput size="huge" /> падает на этапе вёрстки, а не всплывает на ревью или в runtime.

Темизация стала предсказуемой. Атрибут data-theme="dark" на <html> переключает все Tier 2-роли, а компоненты при этом не нужно править. Новый компонент, написанный на семантических ролях Tier 2, сразу получает поддержку тёмной темы и потребительской кастомизации.

Tailwind-проекты тоже получили готовые точки входа. tailwind.preset.cjs для v3 и tailwind.theme.css для v4 публикуются как артефакты пакета, поэтому классы вроде bg-esmp-accent-default/40 работают в проектах-потребителях без ручного переноса токенов.

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

  • tokens.manifest.json — для систем кастомизации;

  • theming-tokens.generated.d.ts — для TypeScript;

  • tailwind.preset.cjs и tailwind.theme.css — для Tailwind-классов.

Все эти артефакты собираются из одного источника правды: sova-tokens.json из Tokens Studio и одним build-шагом. Обновление токенов больше не требует вручную синхронизировать SCSS, TypeScript-типы, Tailwind-конфиг и список доступных переменных для кастомизации.

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

Что бы сделали иначе

Style Dictionary стоило подключить раньше — ещё до начала редизайна. С готовым build-шагом любое обновление от дизайнеров обходилось бы дешевле: меньше ручных правок в SCSS, меньше переименований глазами.  Мы вошли в редизайн уже с собранным пайплайном, и это спасло. Но если бы он стоял заранее, начало работ было бы заметно спокойнее.

С Tier 3 как полноценным слоем мы тоже промахнулись. Сейчас он живёт только как набор устаревших имён для обратной совместимости, а в новом коде туда уже не пишут. 

Идея «иногда семантическая роль не покрывает кейс, поэтому нужен компонент-специфичный токен» на практике почти не срабатывает. Когда кажется, что нужен новый компонентный токен,  почти всегда правильнее расширить набор Tier 2 ролей.

dayjs тоже пока остаётся незакрытым вопросом.  Он всё ещё required peer, потому что EsmpUI импортирует его на module-load в EsmpDatepicker. Если бы зависимость грузилась динамически, потребители без датапикера могли бы вообще не устанавливать dayjs

Следующее направление — Temporal API с polyfill для Safari. По результатам этой работы dayjs должен уйти из required peers.

Из того, что точно сделали бы так же: Tokens Studio как источник правды, channel syntax -rgb для прозрачностей и само правило ссылок Tier N → Tier ≤ N

Tokens Studio убирает значительную часть ручной синхронизации с дизайнерами: JSON выгружается из Figma одной кнопкой. Channel syntax -rgb закрывает целый класс JS-хаков с парсингом hex, а правило ссылок не даёт дизайн-системе превратиться в граф с циклами и неочевидным источником значения. 

Tree-shaking per-component — следующий шаг после sova. Если у вас был похожий опыт: большая ветка-рефакторинг, переезд на новые токены без переписывания компонентов, миграция на ESM-only или tree-shaking per-component, поделитесь в комментариях: как вы держали обратную совместимость и какие подводные камни вылезли.

Готов разобрать в комментариях любую часть подробнее, в том числе детали, которые в статью не влезли. К критике подхода я отношусь спокойно: если что-то можно было сделать проще или иначе — буду рад обсудить. Спасибо, что дочитали до конца!