Как создать тёмную тему и не навредить. Опыт команды Яндекс.Почты
Меня зовут Владимир, я занимаюсь мобильным фронтендом в Яндекс.Почте. В нашем приложении уже была тёмная тема, но недожатая: мы умели перекрашивать интерфейс и простые письма. Но письма с форматированием оставались светлыми и контрастировали с тёмным интерфейсом, из-за чего глаза ночью могли уставать.
Сегодня я расскажу читателям Хабра о том, как мы решили эту проблему. Вы узнаете про два простых способа, которые нам не подошли, затем — про наш основной способ адаптивной перекраски страниц и, наконец, про направление для следующей итерации: перекраску картинок. Хотя сама задача — перекрашивать страницы с произвольным форматированием — специфическая, думаю, наш опыт будет полезен и вам.
Простые способы
Прежде чем дойти до нашего волшебного «перекрашивателя», мы опробовали два простых, как пробка, варианта: навесить на элемент дополнительный тёмный стиль или CSS-фильтр. Нам они не подошли, но, возможно, для каких-то случаев будут даже лучше (потому что просто = круто).
Переопределение стилей
Самый простецкий способ, логично расширяющий тёмную тему самого приложения в CSS: повесим тёмные стили на контейнер для писем (в общем случае — для чужого контента, который нужно перекрасить):
.message--dark {
background-color: black;
color: white;
}
Но если у элементов внутри письма есть свои стили, они переопределят наш корневой стиль. Нет, !important
не поможет. Идею можно дожать, отрубив наследование:
.message--dark * {
background-color: black !important;
color: white !important;
border-color: #333 !important;
}
В данном случае без !important
не обойтись, потому что сам по себе селектор не очень специфичный. Тем более это понадобится, чтобы переопределить инлайн-стили (а инлайн-стили с !important
всё равно пролезут, ничего не поделать).
Наш стиль довольно топорный и красит всё одинаково, так что вылезает другая проблема: вероятно, дизайнер хотел что-то сказать расстановкой цветов (приоритеты элементов и прочие дизайнерские штуки), но мы взяли и выкинули всю эту задумку.
Если вы уважаете дизайнеров меньше, чем я, и всё же решите использовать такой метод, не забудьте допилить неочевидные мелочи:
box-shadow
— только цвет переопределить не выйдет, придётся все тени убрать или жить со светлыми.- Цвета семантических элементов — ссылок, элементов ввода.
- Инлайн-SVG — вместо
background
им нужно выставлятьfill
, а вместоcolor
—stroke
, но это не точно, смотря какой SVG — может быть и наоборот.
Технически способ неплох: это три строчки кода (ладно, тридцать для продакшн-реди версии с корнеркейсами), совместимость со всеми браузерами мира, обработка динамических страниц из коробки и никакой привязки к способу подключения стилей в исходном документе. Особый бонус —можно легко подкрутить цвета в стиле, чтобы они подходили к основному приложению (скажем, сделать фон #bbbbb8
вместо чёрного).
Кстати, раньше мы перекрашивали письма именно так, но, если находили внутри письма любые стили, пугались и оставляли письмо светлым.
CSS-фильтр
Очень остроумный и элегантный вариант. Перекрасить страницу можно CSS-фильтром:
.message--dark {
filter: invert(100) hue-rotate(180deg); /* hue-rotate возвращает тона обратно */
}
После этого фотографии станут криповыми, но это не беда — их перекрасим обратно:
.message-dark img {
filter: invert(100) hue-rotate(180deg);
}
Остаются проблемы с контентными картинками, привязанными через background
(знаем, так удобнее подстраивать соотношение сторон, но как же семантика?). Допустим, что мы сможем найти все такие элементы, явно их пометить и перекрасить обратно.
Способ хорош тем, что сохраняет оригинальное соотношение яркостей и контрастов. С другой стороны, проблем куча, и они скорее перевешивают достоинства:
- Тёмные страницы осветляются.
- Итоговыми цветами невозможно управлять — какой фильтр наложить, чтобы подстроить фон к вашему фирменному
#bbbbb8
? Загадка. - После двух перекрасов картинки выцветают.
- Всё тормозит (особенно на телефонах) — логично, теперь вместо простой отрисовки браузеру нужно на каждом экране гонять обработку изображений.
Этот метод подошёл бы для писем, состоящих из текста в нейтральных тонах, но кто те эстеты, что набирают себе полный инбокс такого своеобразного контента? Зато фильтрами можно перекрашивать элементы, к содержимому которых нет доступа — фреймы, веб-компоненты, картинки.
Адаптивная тема
Настало время волшебства! Из недостатков первых двух подходов собираем чеклист:
- Делать фон тёмным, текст — светлым, границы — средними.
- Определять уже тёмные страницы и не перекрашивать их.
- Сохранять оригинальное соотношение яркостей и контрастов.
- Давать возможность настройки цветов.
- Оставлять тона такими, какими они были вначале.
Нам нужно изменить цвета стилей так, чтобы фон был тёмным. И почему бы не сделать это буквально? Просто берём все стили, ищем правила, связанные с цветами (color
, background
, border
, box-shadow
, их подсвойства), и заменяем его на «затемнённое» — затемняем фон, осветляем текст, границы затемняем меньше фона и т. д.
У такого метода есть одно невероятное достоинство, которое согреет душу любому разработчику. Каждому свойству можно настраивать (да, прямо кодом описывать!) свои правила преобразования цветов. При достаточном воображении можно интегрироваться с любой внешней темой, делать любую цветокоррекцию (например, вместо тёмной темы делать светлую или серо-буро-малиновую) и даже добавлять немного контекстности — скажем, по-разному обрабатывать широкие и узкие границы.
Недостатки — стандартные для «everything-in-js». Да, мы гоняем скрипты, ломаем инкапсуляцию стилей и парсим CSS регэкспами. Ну, в отличие от HTML, последнее не так уж позорно, потому что грамматика (нужного нам уровня) CSS всё-таки регулярная.
План перекраски такой:
- Нормализуем легаси-свойства стиля (
bgcolor
и друзей), перекладываем их вstyle="..."
. - Находим все инлайн-стили.
- В каждом стиле находим все цветные правила (
background-color
,color
,box-shadow
и т. д.). - Из всех цветных правил достаём цвета, находим нужный преобразователь (затемнитель для фона, осветлитель для текста).
- Вызываем преобразователь.
- Собираем преобразованные правила обратно в CSS.
Обвязка (нормализация, поиск стилей, парсинг) довольно простая. Разберёмся с тем, как именно работает наш волшебный преобразователь.
HSL-преобразования
«Затемнить цвет» — не такое простое действие, как может показаться, особенно если мы хотим сохранить тон (голубой становится тёмно-синим, а не оранжевым). Сделать это в нормальном RGB можно, но проблематично. Любители алгоритмического дизайна знают, что там даже градиенты получаются кривоватые. Зато работать с цветами в HSL — чистое наслаждение: вместо Red, Green и Blue, с которыми непонятно, что делать, у нас появляются другие три канала:
- Hue — как раз тон, который мы хотим сохранить.
- Saturaion — насыщенность, которая нам сейчас не очень важна.
- Lightness — яркость, которую мы будем менять.
Такое пространство удобно представлять в виде цилиндра. А наша задача — перевернуть этот цилиндр с ног на голову. Функции цветокоррекции делают что-то вроде (h, s, l) => [h, s, 1 - l]
.
Цвета, с которыми и так всё хорошо
Иногда ситуация складывается удачно: эксклюзивный дизайн письма (или его части) уже тёмный. В таком случае не нужно ничего менять, лучше просто тихонько порадоваться — вероятно, дизайнер подобрал цвета не хуже нашего алгоритма. В HSL достаточно смотреть на L — яркость. Если она выше (для текста) или ниже (для фона) порога (который, конечно, настраивается) — ничего не делаем.
Динамический цирк
Хотя нам это и не понадобилось (ещё раз спасибо, санитайзер, ты спас меня от безумия!), но я всё же расскажу, какие допилы нужны адаптивной теме, чтобы затемнять полные страницы, а не только дурацкие статические письма из девяностых. Аккуратнее, это задача для тех, кто любит запах селекторов по утрам.
Динамические инлайн-стили
Самый простой кейс, который ломает нашу затемнённую страницу — изменение инлайн-стилей. Операция частая, но и фикс простой: добавляем MutationObserver
и оперативно чиним инлайн-стили при изменениях.
Внешние стили
Работать со стилями из изнутри страницы довольно мучительно из-за асинхронности и
@import
, да и от CORS не веселее. Кажется, эту проблему можно было бы довольно элегантно решить через веб-воркер (прокси для *.css
).
Динамические стили
Наконец, собирая в кучу все наши проблемы, вспоминаем, что скрипт вообще может добавлять, удалять и переставлять (специфичность! каскад!) и
, да ещё и менять правила в
. Решается всё тем же
MutationObserver
на элементы стилей, но на каждое изменение — больше обработки.
CSS-переменные
Совершенно новый виток безумия наступает, когда в игру входят CSS-переменные. Затемнять сами переменные мы не можем: даже если допустить, что мы по формату угадаем, что в переменной находится цвет (хотя я бы не советовал этим заниматься), неизвестно, в какой роли он нам встретится — фон, текст, граница, всё сразу? Более того, значения переменных наследуются, так что нам уже нужно учитывать не только стили, но и элементы, к которым они применяются, и всё это быстро эскалируется и взрывается.
Если CSS-переменные доедут до мейнстрима, у нас проблемы. С другой стороны, к тому времени уже начнут подвозить color()
, с которым можно будет не менять цвета в JS, а просто заменять цвета на color(var(--bg) lightness(-50%))
.
Резюме
Для нашего случая, когда санитайзер оставляет только инлайн-стили, адаптивное затемнение на уровне CSS подходит отлично: даёт лучшее качество затемнения, не ломает письма и работает относительно просто и быстро. Не уверен, что вариант со всем фаршем для динамики стоит того. К счастью, если вы работаете с пользовательским контентом и при этом пишете не браузер, ваш санитайзер должен делать то же самое.
На практике адаптивный режим нужно использовать вместе с переопределением стилей: к стандартным элементам вроде или
стили обычно явно не применяют, а по умолчанию они светлые.
Как затемнять картинки
Перекраска картинок — отдельная проблема, которая очень беспокоит меня лично. Это интересно, и у меня наконец-то есть шанс использовать словосочетание «спектральный анализ». С картинками в тёмной теме есть несколько типичных проблем.
Во-первых, слишком светлые картинки. Работает так же, как непрокрашенные письма, с которых всё и началось. Часто (но не обязательно) это обычные фотографии. Поскольку верстать рассылки не очень весело, многие ребята просто экспортируют сложную часть письма как картинку, она не перекрашивается и по ночам освещает мой перфекционизм. Такие картинки нужно затемнять, но не инвертировать — иначе выйдет страшный негатив.
Во-вторых, тёмные картинки с настоящей прозрачностью. Эта проблема часто встречается на логотипах — они рассчитаны на светлый фон и, когда мы подменяем его на тёмный, сливаются с ним. Такие картинки нужно инвертировать.
Где-то посередине есть картинки, для которых белый изображал «прозрачный фон», но теперь они просто стоят в каком-то непонятном белом прямоугольнике. В идеальном мире мы бы подменили белый фон на прозрачный, но если вы когда-либо работали с волшебной палочкой в фоторедакторе, то знаете, что сделать это автоматически не так-то просто.
Интересно, что иногда картинки вообще не несут никакой смысловой нагрузки — это трекинг-пиксели и «держатели формата» в особо извращённой вёрстке. Такие можно смело сделать невидимыми (скажем, opacity: 0
).
Эвристики с интроспекцией
Чтобы решить, что делать с картинкой, нам нужно залезть внутрь и проанализировать её содержимое — причём простым и быстрым способом. По нашему набору проблем вырисовывается первая версия алгоритма. Вот она.
Считаем тёмные, светлые и прозрачные пиксели на картинке, причём не все, а выборочно — очевидная оптимизация. Определяем общую яркость картинки (светлая, тёмная, средняя) и наличие прозрачности. Тёмные картинки с прозрачностью инвертируем, светлые без прозрачности — приглушаем, остальные не трогаем.
Радость от этой чудесной эвристики кончилась, когда мне попалась рассылка про благотворительность с фотографией урока в африканской школе. Всё было бы нормально, но дизайнер отцентрировал её, добавив по краям прозрачные пиксели. Оказаться в центре новой истории про оскорбительное распознавание картинок не хотелось, и мы решили в первой версии обработку картинок не делать совсем.
В будущем от таких проблем должна защитить дополнительная эвристика, которую я как раз и называю «спектральный анализ» — считаем количество разных цветов на картинке и инвертируем, только если их мало. Этим же критерием можно искать графичные светлые картинки и их тоже перекрашивать — звучит заманчиво.
Итог
Для полноценной тёмной темы в почте нам не хватало перекраски писем со стилями, и мы выяснили, как это устроить. Два простых варианта на чистом CSS — переопределение стилей и CSS-фильтр — не подошли: первый слишком сурово обходится с исходным дизайном, второй просто плохо работает. В итоге мы используем адаптивное затемнение — разбираем стили, заменяем цвета более подходящими и собираем обратно. Сейчас работаем над расширением темы на картинки — для этого нужно анализировать их содержимое и перекрашивать только некоторые.
Если вам когда-нибудь потребуется перекрашивать произвольный пользовательский HTML под тёмную тему, держите в голове три метода:
- Переопределение стилей — нужно в любом случае для вашего основного приложения, дёшево и сердито, но убивает все исходные цвета.
- CSS-фильтр — прикольно, но работает так себе. Используйте только для непрозрачных (в смысле доступа) элементов вроде фреймов или веб-компонентов.
- Преобразование стилей — затемняет очень качественно, но сложнее других методов.
Даже если вы никогда не будете этим заниматься, надеюсь, вы интересно провели время!
P.S. Кстати, недавно мы рассказывали о решении другой проблемы пользователей почты — проблемы рассылок.