Вы сделали тёмную тему. Переключатель стоит в шапке, запоминает выбор, и всё приложение аккуратно перекрашивается. Потом кто-то открывает виджет обратной связи в углу, а он по-прежнему слепит белым.
Бывает и тоньше: виджет тёмный, но только потому, что тёмная тема стоит в системе. Пользователь, который выбрал тёмную тему в вашем приложении на машине со светлой системной темой, получает светлый виджет, а тот, кто выбрал светлую при тёмной системной теме, — наоборот.
На этой неделе я столкнулся ровно с этим в виджете самого BugDump. Исправление небольшое, но та же логика относится к любому стороннему виджету, который вы встраиваете: чатам поддержки, кнопкам обратной связи, баннерам про куки, виджетам справки. Все они отвечают на один и тот же вопрос — какая у меня тема? — и большинство ищет ответ не там.
Откуда может взяться тема: три источника
Когда говорят «тема пользователя», могут иметь в виду три разные вещи:
Настройку операционной системы. То, что браузер сообщает через
prefers-color-scheme. Её задают один раз в системных настройках, и она общая для всех сайтов.Настройку приложения. То, что пользователь выбрал в вашем переключателе: светлую, тёмную или системную тему. Она хранится в куки, в
localStorageили в аккаунте.То, что приложение рисует в итоге. Обычно это класс или атрибут на
<html>, который берётся из настройки, а когда настройка системная — из ОС.
Ваше приложение живёт во втором и третьем. Большинство встраиваемых виджетов знают только о первом, потому что только его могут прочитать без вашей помощи.
Почему prefers-color-scheme — неправильный сигнал
Виджет, который оформляет себя через медиазапрос prefers-color-scheme, делает разумное предположение: большинство людей, которым нужна тёмная тема, включили её в ОС. Для сайта без собственной настройки темы это предположение верно.
Оно перестаёт быть верным, как только в приложении появляется переключатель. Медиазапрос сообщает настройку ОС и ничего больше. Страница не может изменить его ответ, поэтому как бы аккуратно приложение ни применяло свою тему, виджет, который читает медиазапрос, об этом не узнает.
Расхождение видят только пользователи, у которых настройка в приложении не совпадает с настройкой ОС, поэтому его и не ловят на тестировании: разработчики обычно проверяют с одинаковой темой в приложении и в ОС.
Почему виджет не может просто посмотреть на вашу страницу
Очевидная альтернатива — пусть виджет смотрит на страницу: проверяет класс dark на <html>, следит за ним через MutationObserver и подстраивается.
Это работает на сайтах, которые следуют ровно этому соглашению, и молча ломается у всех остальных. В Tailwind стратегия class ставит dark на корневой элемент. Другие сайты используют data-theme="dark", атрибут data-mode, класс на <body> или набор CSS-переменных вообще без маркера. Некоторые по умолчанию тёмные и помечают светлую тему. Виджет, который угадывает, для кого-то ошибётся, и этому кому-то будет трудно понять, в чём дело.
Страница знает свою тему. Надёжное решение — чтобы она сама об этом и сообщала.
Контракт из двух частей
Решение, на котором я остановился и которое посоветовал бы любому, кто делает встраиваемый виджет, состоит из двух частей.
1. Начальная тема, объявленная заранее. Код встраивания принимает тему как настройку:
<script
src="https://bugdump.com/sdk/latest.js"
async
data-api-key="your-api-key"
data-theme="dark"
></script>
Если сервер знает выбор пользователя, выведите его прямо в тег. Тогда у виджета правильные цвета с самой первой отрисовки, ещё до того, как запустится ваш JavaScript. Системной настройке в приложении соответствует auto: решение возвращается к ОС, и это единственный случай, когда медиазапрос и есть правильный сигнал.
2. Сеттер для всех следующих изменений.
bugdump('setTheme', 'dark'); // 'light', 'dark' or 'auto'
Вызывайте его из переключателя. Виджет меняет тему сразу, в том числе в уже открытой панели.
Ни одна часть не справляется в одиночку. Конфигурация без сеттера — это то, что было у меня раньше: при загрузке тема верная, а после первого переключения устаревает до перезагрузки страницы. Сеттер без конфигурации значит, что виджет рисуется в теме по умолчанию, а потом заметно перекрашивается, когда ваш код его догонит.
Как подключить это во фреймворке
В React хватит компонента, который ничего не рисует:
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.
Читайте также
Локализация встраиваемого виджета: язык страницы или язык браузера? — тот же вопрос про язык: что виджет BugDump читает первым и почему страница важнее браузера.
Три четверти кода в моём SDK почти никто не запускал — как сторонний виджет весом 450 КБ похудел и что оказалось не так с
defer.Как добавить на сайт кнопку для баг-репортов — три способа подключить кнопку: скрипт-тег, npm-пакет или инициализация из кода.