Тёмная тема во встраиваемых виджетах: как следовать переключателю темы в приложении

обновления продукта

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

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

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

Откуда может взяться тема: три источника

Когда говорят «тема пользователя», могут иметь в виду три разные вещи:

  1. Настройку операционной системы. То, что браузер сообщает через prefers-color-scheme. Её задают один раз в системных настройках, и она общая для всех сайтов.

  2. Настройку приложения. То, что пользователь выбрал в вашем переключателе: светлую, тёмную или системную тему. Она хранится в куки, в localStorage или в аккаунте.

  3. То, что приложение рисует в итоге. Обычно это класс или атрибут на <html>, который берётся из настройки, а когда настройка системная — из ОС.

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

Почему prefers-color-scheme — неправильный сигнал

Виджет, который оформляет себя через медиазапрос prefers-color-scheme, делает разумное предположение: большинство людей, которым нужна тёмная тема, включили её в ОС. Для сайта без собственной настройки темы это предположение верно.

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

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

Почему виджет не может просто посмотреть на вашу страницу

Очевидная альтернатива — пусть виджет смотрит на страницу: проверяет класс dark на <html>, следит за ним через MutationObserver и подстраивается.

Это работает на сайтах, которые следуют ровно этому соглашению, и молча ломается у всех остальных. В Tailwind стратегия class ставит dark на корневой элемент. Другие сайты используют data-theme="dark", атрибут data-mode, класс на <body> или набор CSS-переменных вообще без маркера. Некоторые по умолчанию тёмные и помечают светлую тему. Виджет, который угадывает, для кого-то ошибётся, и этому кому-то будет трудно понять, в чём дело.

Страница знает свою тему. Надёжное решение — чтобы она сама об этом и сообщала.

Контракт из двух частей

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

1. Начальная тема, объявленная заранее. Код встраивания принимает тему как настройку:

HTML
<script
  src="https://bugdump.com/sdk/latest.js"
  async
  data-api-key="your-api-key"
  data-theme="dark"
></script>

Если сервер знает выбор пользователя, выведите его прямо в тег. Тогда у виджета правильные цвета с самой первой отрисовки, ещё до того, как запустится ваш JavaScript. Системной настройке в приложении соответствует auto: решение возвращается к ОС, и это единственный случай, когда медиазапрос и есть правильный сигнал.

2. Сеттер для всех следующих изменений.

JavaScript
bugdump('setTheme', 'dark'); // 'light', 'dark' or 'auto'

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

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

Как подключить это во фреймворке

В React хватит компонента, который ничего не рисует:

tsx
import { useEffect } from 'react';

export function WidgetThemeSync({ theme }: { theme: 'light' | 'dark' | 'system' }) {
  useEffect(() => {
    window.bugdump?.('setTheme', theme === 'system' ? 'auto' : theme);
  }, [theme]);

  return null;
}

Две детали отличают рабочий вариант от почти рабочего.

Синхронизируйте настройку, а не вычисленный цвет. Многие провайдеры тем отдают и то и другое: выбор пользователя (light, dark, system) и цвет, применённый сейчас. Применённое значение часто вычисляется после первого рендера, поэтому какое-то время в нём лежит заглушка, обычно светлая. Если синхронизировать его, заглушка попадёт в виджет, и он будет вспыхивать светлым у каждого пользователя тёмной темы при каждой загрузке страницы. Настройка же верна с первого рендера.

Пусть системная тема остаётся системной. Хочется вычислить её самому и передать light или dark. Передайте вместо этого auto, и виджет сам будет следовать за ОС, без обработчиков событий на вашей стороне.

Заметки для авторов виджетов

Если вы по другую сторону — делаете то, что встраивают другие, — вот несколько решений, которые упростили мне реализацию.

Держите тему на одном элементе, остальное сделает CSS. Палитра виджета BugDump — это набор CSS-переменных на хост-элементе shadow DOM, а тема — класс на этом же элементе. Смена темы — замена одного класса: без перерисовки, без потери состояния, а открытая панель обновляется на месте.

Проверьте каждый корень, который монтируете. Редактор пометок на скриншотах в BugDump монтировался отдельно, прямо в document.body, со своим shadow root и своей копией палитры. Класса темы на хосте он не видел и тихо продолжал следовать за ОС. Пока за ОС следовало всё, этого никто не замечал; как только заработали явные темы, редактор оказался единственной светлой поверхностью в тёмном интерфейсе. Если виджет монтирует модальные окна, подсказки или оверлеи вне основного корня, тема нужна каждому из них.

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

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

Как это устроено в BugDump

Начиная с SDK 0.0.39 виджет принимает начальную тему через theme или data-theme и меняет её на месте через setTheme() — из очереди команд скрипт-тега или на экземпляре из npm. Редактор скриншотов следует той же теме.

Подробности — в записи журнала изменений, а в статье Как подстроить виджет под тему приложения есть готовые примеры для React, Vue и чистого JavaScript.

Читайте также