Как мы учили немонохромные SVG-логотипы краситься самостоятельно

Привет! Меня зовут Александр, я фронтенд‑разработчик в Точке. Моя команда занимается разработкой «системы построения интерфейсов» aka UI‑кита. В статье расскажу, как мы решали вопрос выгрузки многоцветных логотипов в формате SVG из Figma и добавления к ним поддержки тёмной темы.

Казалось бы, абсолютно простой вопрос, который мы ежедневно получали в чатах поддержки Точки, но на который было так сложно ответить!

Казалось бы, абсолютно простой вопрос, который мы ежедневно получали в чатах поддержки Точки, но на который было так сложно ответить!

Поддержка тёмной темы в дизайн-системе

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

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

Как разработчики, мы выбрали CSS‑переменные для поддержки темной темы. Они позволяют гибко управлять токенами дизайн‑системы и динамически изменять значения в зависимости от выбранной темы. Это не только упростило внедрение тёмной темы в нашу кодовую базу, но и сделало её более читаемой и открытой для разработки кастомных тем.

Но мы столкнулись и с неожиданной задачей: наши логотипы, в отличие от остальных компонентов, не адаптировались во время переключения темы. Как оказалось, все выгружаемые из Figma логотипы содержали внутри себя зашитые hex‑цвета, которые были активны на момент выгрузки цветовой темы.

Старый пайплайн выгрузки логотипов

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

У нас есть один большой централизованный документ в Figma, в который дизайнеры могут добавлять новые логотипы или изменять уже существующие.

5a573bab137d210d7b807fedf5943e2a.jpeg

Чтобы автоматизировать синхронизацию этого документа с кодом, мы используем скрипты, которые берут на себя всю рутинную работу. Они являются частью релиз‑процесса и запускаются перед каждым обновлением UI‑кита.

Основные задачи, которые решают скрипты:

  1. Выгрузка всех актуальных версий логотипов из Figma документа через REST API.

  2. Сохранение выгруженных логотипов в статические SVG‑файлы.

  3. Генерация React‑компонента для каждого выгруженного SVG‑файла.

Первые попытки

По наводке от дизайнеров, мы начали изучать статьи о том, как работают Figma-переменные. В официальной документации была информация о наличии аналогичного REST API, только для работы с переменными. Предполагалось, что нам достаточно будет немного доработать наш скрипт, используя новое API и всё взлетит само собой.

В итоге мы рассматривали несколько потенциальных подходов к адаптации логотипов к тёмной теме:

  1. Надежда на экспорт переменных из Figma. Поскольку наши логотипы были привязаны к Figma-переменным, у нас была надежда, что при экспорте в SVG через REST API вместо статичных значений цветов нам будут прилетать ссылки на переменные из дизайн-системы. Это позволило бы логотипам автоматически подстраиваться под текущую тему, используя эти переменные, как это происходит в Figma.

  2. Использование ключевого слова currentColor. В этом случае мы могли бы определять текущий цвет логотипа в зависимости от активной темы, задавая его через цвет текста.

  3. Выгрузка двух разных версий логотипов. Это был самый плохой для нас вариант, когда нам пришлось бы выгружать два разных логотипа — один для светлой темы, второй для тёмной — и отрисовывать их в рантайме в зависимости от активной темы. Его мы рассматривали как план Б на случай, если у нас вообще ничего не получится.

Во время экспериментов все подходы показали свои ограничения:

  1. Подход с переменными Figma не сработал из-за технических ограничений самой Figma. На сегодняшний день сервис не поддерживает экспорт переменных в SVG: при экспорте цвета проставляются как жёстко заданные HEX-коды цветов выбранной темы, а не как ссылки на переменные из дизайн-системы. А REST API позволяет либо выгрузить все имеющиеся переменные и коллекции переменных из файла, либо добавить новые в документ.

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

В ходе дополнительных экспериментов решили заменить зашитые статичные цвета, заданные в атрибутах SVG (например,  fill или stroke), на ссылки на CSS-переменные.

Оригинальный логотип:

Оригинальный логотип

Логотип после замены:

cf242b944c3f2c3be0fff57bbf71d612.png

Этот способ отлично сработал — иконки и логотипы начали корректно реагировать на смену тем, автоматически подстраиваясь под актуальную палитру. Осталось построить процесс выгрузки так, чтобы выгружаемые из Figma логотипы содержали не HEX-цвета, а ссылки на CSS-переменные из UI-кита.

012ddacc6ec1d202323c3c73f42e081e.gif

С чем мы столкнулись в процессе

В ходе предыдущих экспериментов мы узнали, что доступ к REST API от Figma-переменных возможен только на тарифах уровня Enterprise. А у нас был более скромный тарифный план без прямого доступа к API.

Тарифные планы, на которых есть доступ к REST API

Тарифные планы, на которых есть доступ к REST API

Эти ограничения вынудили нас перейти на Plugin API Figma. К счастью, ограничения REST API не распространялись на Plugin API, что дало нам полный доступ к работе с переменными и открыло возможности для автоматизации процесса. Дополнительным аргументом в пользу использования Plugin API стало то, что у мы уже разрабатывали небольшие внутренние плагины для упрощения рутинных процессов дизайн-ревью у дизайнеров. Так что у нас уже был опыт работы с API, и мы понимали все возможности и ограничения.

Проблема 0: поиск всех логотипов в документе

У Figma есть широкий набор методов для поиска элементов внутри документа. В нашем случае все логотипы хранились как компоненты Figma, поэтому нам подошел метод findAllWithCriteria с указанием конкретного типа элементов для поиска (компонентов).

const logos = figma.currentPage.findAllWithCriteria({ types: ["COMPONENT"] });

Проблема 1: Экспорт логотипов в SVG

Первым вызовом для нас стало понимание того, как можно использовать Plugin API для экспорта логотипов из Figma в формате SVG. Это оказалось довольно просто благодаря функции exportAsync, которая позволяет задавать формат экспорта (JPG,  PNG,  SVG,  PDF) и дополнительные параметры.

Но как бы мы ни модифицировали параметры exportAsync, выгружаемые SVG-файлы всё так же содержали жёстко зашитые HEX-цвета активной на момент выгрузки темы.

Проблема 2: Отсутствие связи между Figma и выгружаемой разметкой

Для того чтобы заменить жёстко зашитые HEX-цвета на CSS-переменные из UI-кита, нам нужно научиться сопоставлять элементы логотипа в SVG и внутри разметки Figma. Решение нашлось в использовании атрибута svgIdAttribute функции exportAsync. При включении он сохраняет оригинальные названия элементов в виде уникальных ID в экспортируемой SVG-разметке.

const logo = ...;

const svg = logo.exportAsync({ 
  format: "SVG_STRING", 
  svgIdAttribute: true 
})

Пример структуры выгружаемого логотипа в Figma:

Структура выгружаемого логотипа в Figma

Структура выгружаемого логотипа в Figma

Пример работы exportAsync на логотипе выше:

  • Без атрибута svgIdAttribute: элементы экспортируются без уникальных идентификаторов.

    Пример работы exportAsync без атрибута svgIdAttribute

    Пример работы exportAsync без атрибута svgIdAttribute

  • С атрибутом svgIdAttribute: каждый элемент в разметке сохраняет свой ID, идентичный в структуре внутри Figma. 

    Пример работы exportAsync с атрибутом svgIdAttribute

    Пример работы exportAsync с атрибутом svgIdAttribute

С наличием уникальных ID мы можем сопоставить слои в Figma с их аналогами в SVG. Теперь мы получили возможность создавать карту привязанных к элементу токенов для дальнейшей их замены на CSS-переменные в экспортируемой SVG-разметке.

Проблема 3: Повторяющиеся названия слоев в Figma

В процессе был обнаружен интересный момент, связанный с тем, как функция exportAsync с включенным атрибутом svgIdAttribute обрабатывает повторяющиеся названия вложенных элементов.

Повторяющиеся названия у вложенных элементов

Повторяющиеся названия у вложенных элементов

У части логотипов названия элементов могли повторяться, и это не создавало никаких проблем в интерфейсе Figma. Но иногда это создавало проблемы на уровне выгружаемого в SVG-логотипа. В нашем случае особыми условиями были одновременные привязки цветов ко всем элементам с одинаковыми названиями. Тогда в HTML-разметке ID этих элементов появлялся с sequential-неймингом, т.е. генерируемые Figma ID элементов выглядели вот так:  Vector_1 и Vector_2,  Ellipse_1 и Ellipse_2.

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

function uniqueifyChildrenNames(node: ComponentNode) {
  const sequences = {};

  const stack: SceneNode[] = [node];
  const visited = new Set();

  while (stack.length > 0) {
    const vertex = stack.pop()!;

    const name = vertex.name;

    if (!visited.has(vertex)) {
      if (!sequences[name]) {
        sequences[name] = 1;
      } else {
        sequences[name]++;
        vertex.name = `${vertex.name}_${sequences[name]}`;
      }

      visited.add(vertex);

      if ("children" in vertex) {
        for (const child of vertex.children) {
          stack.push(child);
        }
      }
    }
  }

  return node;
}

Учимся заменять HEX-цвета на токены из UI-кита

Шаг 1: Извлечение данных о переменных

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

Проверка выполнялась так:

  1. Если поле boundVariables содержит объект хоть с одним ключом — значит, к элементу привязаны переменные.

  2. Извлекаем значения и названия переменных.

/**
 * Метод возвращает массив переменных, хранящих цветовое значение
 * и привязанных к переданному в метод элементу
 */
function getBoundedVariables(node: SceneNode) {
  const res: Variable[] = [];

  if ("boundVariables" in node) {
    const keys = Object.keys(
      node.boundVariables,
    ) as unknown as (keyof typeof node.boundVariables)[];

    for (const key of keys) {
      if (key === "componentProperties") {
        continue;
      }

      const value = node.boundVariables[key];

      if (typeof value === "undefined") {
        continue;
      }

      if (Array.isArray(value)) {
        res.push(...value);
      } else {
        res.push(value);
      }
    }
  }

  return res;
}

Шаг 2: Формирование карты привязок цветов к токенам из UI-кита

Для связывания элементов логотипа с цветами из вашего UI-кита нужно создать карту, в которой каждый элемент будет связан с соответствующими CSS-переменными. Так как элементы в Figma имеют древовидную структуру, проще всего для этого будет воспользоваться обходом дерева в глубину (DFS).

Для этого создаём каждому элементу объект в карте, где:

  • Ключом будет уникальный ID элемента.

  • Значением — объект с значениями HEX-цветов и их соответствующими названиями CSS-переменных из UI-кита.

В результате для каждого логотипа мы получим информацию, отражающую, какие токены привязаны к каждому элементу в логотипе.

Особенности реализации:

  1. Проверка значения переменной. Поскольку не все Figma-переменные хранят цвета, добавлена функция isColorConfig, которая проверяет, является ли значение цветом в формате RGB или RGBA.

  2. Создание имени CSS-переменной. Функция resolveDesignTokenName позволяет задать логику преобразования имени переменной из Figma в имя CSS-токена.

  3. Конвертация цвета. HEX-значения цветов Figma хранит не в привычном диапазоне (0, 255), а в диапазоне (0, 1).

  4. Получение текущего значения переменной. Специфика построения дизайн-системы добавила сложности: мы используем два типа токенов — базовые (статичные значения) и семантические (зависящие от текущей темы). Семантические токены работают как алиас: указывают на разные базовые токены в зависимости от текущей темы. Из-за этого извлечь реальное значение токена может быть не так просто, потому что они могут храниться не в привязанной к элементу переменной, а где-то глубже в иерархии дизайн-токенов. Для получения фактического значения из семантических токенов в Figma предусмотрен метод resolveForConsumer. Он позволяет передать в него элемент с привязанной переменной и получить реальное значение, к которому ведет алиас — в нашем случае, фактический цвет переменной в текущей цветовой теме.

const namesRGB = ["r", "g", "b"];

/**
 * Figma хранит hex цвета в объекте с ключами
 * r,g,b (а) и значениями в диапазоне (0, 1)
 * Метод нормализует значения до привычных (0, 255)
 */
function figmaRGBToWebRGB(color: RGBA): webRGBA;
function figmaRGBToWebRGB(color: RGB): webRGB;
function figmaRGBToWebRGB(color: any): webRGB | webRGBA {
  const rgb: number[] = [];

  namesRGB.forEach((e, i) => {
    rgb[i] = Math.round(color[e] * 255);
  });

  if (color["a"] !== undefined) rgb[3] = Math.round(color["a"] * 100) / 100;

  return rgb;
}

/**
 * Метод возвращает привычный hex код из tuple
 * описывающего цвет
 */
export function figmaRGBToHex(color: RGB | RGBA): string {
  let hex = "#";

  const rgb = figmaRGBToWebRGB(color) as webRGB | webRGBA;
  hex += ((1 << 24) + (rgb[0] << 16) + (rgb[1] << 8) + rgb[2])
    .toString(16)
    .slice(1);

  if (rgb[3] !== undefined) {
    const a = Math.round(rgb[3] * 255).toString(16);
    if (a.length == 1) {
      hex += "0" + a;
    } else {
      if (a !== "ff") hex += a;
    }
  }

  return hex.toUpperCase();
}

/**
 * Не все переменные хранят HEX - цвета, проверяем что значение, хранимое
 * в переменной - это цвет
 */
function isColorConfig(v: VariableValue): v is RGB | RGBA {
  if (typeof v !== "object") {
    return false;
  }

  const keys = Object.keys(v);

  // либо 3 ключа - rgb
  // либо 4 ключа - rgba
  return (
    [3, 4].includes(keys.length) && ["r", "g", "b"].every((key) => key in v)
  );
}

/**
 * Функция, которая будет конвертировать название переменной в название
 * CSS - переменной вашей дизайн системы
 */
function resolveDesignTokenName(variable: Variable) {
  /**
   * тут может быть ваша логика генерации названия CSS переменной, для примера просто прокидываем название переменной из Figma
   */
  return variable.name;
}

 function createLogoColorMap(logo: ComponentNode) {  
  const colors: Record> = {};

  const stack: SceneNode[] = [logo];
  const visited = new Set();

  while (stack.length > 0) {
    const vertex = stack.pop()!;

    /**
     * Название элемента в иерархии Figma
     *
     * Именно это название уедет в id атрибут HTML разметки
     */
    const name = vertex.name;

	/**
	 * Новый элемент которого нет в карте - создаем новый объект
	 */
    if (!colors[name]) {
      colors[name] = {};
    }

    if (!visited.has(vertex)) {
      /**
       * 1. Первый этап
       *
       * Ищем все привязанные к текущему элементу (vertex) цвета-переменные
       */
      const bounded = getBoundedVariables(vertex);

	  // у элемента (vertex) может не быть привязанных цветов
      if (bounded.length > 0) {
        for (const variable of bounded) {
          // получаем реальное значение, на которое указывает переменная
          const resolvedConfig = variable.resolveForConsumer(logo);

          const value = resolvedConfig.value;

		  // переменная может хранить не только цвета, но и другие значения, удостоверимся, что текущая переменная хранит именно цвет
          if (!isColorConfig(value)) {
            continue;
          }

          const hex = figmaRGBToHex(value);

          if (!colors[name][hex]) {
            const designToken = resolveDesignTokenName(variable);

            colors[name][hex] = designToken;
          }
        }
      }

      visited.add(vertex);

      if (isChildrenExists(vertex)) {
        for (const neighbor of vertex.children) {
          stack.push(neighbor);
        }
      }
    }
  }

  return colors;
}

Финал: заменяем зашитые цвета на переменные из UI-кита

Имея на руках все необходимые данные, можно приступить к замене зашитых цветов на CSS-переменные. Для этого используется библиотека SVGO с самописным плагином. Этот плагин позволяет заменить статичные цветовые значения на CSS-переменные, учитывая структуру и вложенность элементов внутри SVG.

Ключевые задачи плагина:

  1. Поиск цветовых атрибутов. Плагин проходит по всем элементам SVG, проверяя атрибуты, которые могут содержать цветовые значения fill,  stroke,  color и другие.

  2. Корректное наследование цветов. С помощью функции useParentNodeTracker плагин отслеживает родительские элементы для каждого узла. Это нужно, чтобы корректно искать цветовые токены по всей структуре логотипа, включая кейсы, где цвет наследуется от родительского элемента (да, у Figma при выгрузке даже такое есть).

  3. Замена значений. Если найдено подходящее цветовое значение, которое есть в заранее подготовленной карте токенов, оно заменяется на CSS-переменную. При этом на всякий случай оставляем в ссылке на CSS-переменную fallback-значение цвета.

import { PluginConfig } from "svgo";
import { optimize } from "svgo/dist/svgo.browser";
import { XastChild, XastRoot } from "svgo/lib/types";

const MISSING_COLOR_VALUES = ["none"];

// svg-атрибуты в которых могут лежать цвета
const SVG_ATTRIBUTES_WITH_POSSIBLE_COLOR_VALUES = [
  "color",
  "fill",
  "stroke",
  "stop-color",
  "flood-color",
  "lighting-color",
];

type PluginOptions = {
  colors: Record>;
};

function useParentNodeTracker(root: XastRoot) {
  const visited = new Set();
  const parents = new Map();

  const stack: XastChild[] = root.children.slice();

  /**
   * Самые верхние элементы не имеют родителей
   * Добьем руками, чтобы не добавлять лишних проверок дальше в коде
   */
  stack.forEach((element) => parents.set(element, undefined));

  while (stack.length > 0) {
    const vertex = stack.pop()!;

    if (!visited.has(vertex)) {
      if ("children" in vertex && vertex.children.length > 0) {
        for (const child of vertex.children) {
          parents.set(child, vertex);

          stack.push(child);
        }
      }
    }

    visited.add(vertex);
  }

  return { getParent: (node: XastChild) => parents.get(node) };
}

export const createReplaceColorValuesWithCssVariablesPlugin = (
  options: PluginOptions,
): PluginConfig => {
  const { colors } = options;

  return {
    name: "replaceColorValuesWithCssVariables",
    fn: (root) => {
      const tracker = useParentNodeTracker(root);

      return {
        element: {
          enter: (node) => {
            const availableAttributes =
              SVG_ATTRIBUTES_WITH_POSSIBLE_COLOR_VALUES.filter((attr) => {
                const value = node.attributes[attr];

                return (
                  // Значение должно быть
                  value != null &&
                  // Значение должно НЕ быть пустым
                  !MISSING_COLOR_VALUES.includes(value) &&
                  // Значение не должно быть ссылкой на что либо url(#...)
                  !/url\(#\w+\)/.test(value)
                );
              });

            /**
             * Если на ноде нету аттрибутов в которых может храниться цветовое значение
             */
            if (availableAttributes.length === 0) {
              return;
            }

            availableAttributes.forEach((attr) => {
              const value = node.attributes[attr];

              let iter: XastChild | undefined = node;

              while (iter != null) {
                if (iter.type === "element") {
                  const id = iter.attributes["id"];

                  if (colors[id]?.[value] != null) {
                    node.attributes[attr] =
                      `var(--${colors[id][value]}, ${value})`;

                    break;
                  }
                }

                iter = tracker.getParent(iter);
              }
            });
          },
        },
      };
    },
  };
};

const logos = figma.currentPage.findAllWithCriteria({ types: ["COMPONENT"] });
const colors = logos.map(logo => createLogoColorMap(logo));
const plugin = createReplaceColorValuesWithCssVariablesPlugin({ colors });

/**
 * тут будут лежать ваши svg логотипы с цветами замененными
 * на CSS - переменные
 */
const data = svgs.map((logo) => optimize(logo, { plugins: [plugin] }));

Заключение

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

  1. Позволило не создавать отдельные версии логотипов для каждой темы.

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

© Habrahabr.ru