Как создать тёмную тему и не навредить. Опыт команды Яндекс.Почты

ji4qprfkpuotssmzabus4cjflrq.png

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

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


Простые способы

Прежде чем дойти до нашего волшебного «перекрашивателя», мы опробовали два простых, как пробка, варианта: навесить на элемент дополнительный тёмный стиль или 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 всё равно пролезут, ничего не поделать).

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

cu-1kav8ydvyjxlobshlklgio9u.png

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


  • 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);
}

dr3aix8opihd2nlq6oqzjfbdtvs.png

Остаются проблемы с контентными картинками, привязанными через background (знаем, так удобнее подстраивать соотношение сторон, но как же семантика?). Допустим, что мы сможем найти все такие элементы, явно их пометить и перекрасить обратно.

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

edqeh1dpkqv-w2gydobzojuaia0.png


  1. Тёмные страницы осветляются.
  2. Итоговыми цветами невозможно управлять — какой фильтр наложить, чтобы подстроить фон к вашему фирменному #bbbbb8? Загадка.
  3. После двух перекрасов картинки выцветают.
  4. Всё тормозит (особенно на телефонах) — логично, теперь вместо простой отрисовки браузеру нужно на каждом экране гонять обработку изображений.

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


Адаптивная тема

Настало время волшебства! Из недостатков первых двух подходов собираем чеклист:


  1. Делать фон тёмным, текст — светлым, границы — средними.
  2. Определять уже тёмные страницы и не перекрашивать их.
  3. Сохранять оригинальное соотношение яркостей и контрастов.
  4. Давать возможность настройки цветов.
  5. Оставлять тона такими, какими они были вначале.

Нам нужно изменить цвета стилей так, чтобы фон был тёмным. И почему бы не сделать это буквально? Просто берём все стили, ищем правила, связанные с цветами (color, background, border, box-shadow, их подсвойства), и заменяем его на «затемнённое» — затемняем фон, осветляем текст, границы затемняем меньше фона и т. д.

У такого метода есть одно невероятное достоинство, которое согреет душу любому разработчику. Каждому свойству можно настраивать (да, прямо кодом описывать!) свои правила преобразования цветов. При достаточном воображении можно интегрироваться с любой внешней темой, делать любую цветокоррекцию (например, вместо тёмной темы делать светлую или серо-буро-малиновую) и даже добавлять немного контекстности — скажем, по-разному обрабатывать широкие и узкие границы.

Недостатки — стандартные для «everything-in-js». Да, мы гоняем скрипты, ломаем инкапсуляцию стилей и парсим CSS регэкспами. Ну, в отличие от HTML, последнее не так уж позорно, потому что грамматика (нужного нам уровня) CSS всё-таки регулярная.

План перекраски такой:


  1. Нормализуем легаси-свойства стиля (bgcolor и друзей), перекладываем их в style="...".
  2. Находим все инлайн-стили.
  3. В каждом стиле находим все цветные правила (background-color, color, box-shadow и т. д.).
  4. Из всех цветных правил достаём цвета, находим нужный преобразователь (затемнитель для фона, осветлитель для текста).
  5. Вызываем преобразователь.
  6. Собираем преобразованные правила обратно в CSS.

Обвязка (нормализация, поиск стилей, парсинг) довольно простая. Разберёмся с тем, как именно работает наш волшебный преобразователь.


HSL-преобразования

«Затемнить цвет» — не такое простое действие, как может показаться, особенно если мы хотим сохранить тон (голубой становится тёмно-синим, а не оранжевым). Сделать это в нормальном RGB можно, но проблематично. Любители алгоритмического дизайна знают, что там даже градиенты получаются кривоватые. Зато работать с цветами в HSL — чистое наслаждение: вместо Red, Green и Blue, с которыми непонятно, что делать, у нас появляются другие три канала:


  • Hue — как раз тон, который мы хотим сохранить.
  • Saturaion — насыщенность, которая нам сейчас не очень важна.
  • Lightness — яркость, которую мы будем менять.

Такое пространство удобно представлять в виде цилиндра. А наша задача — перевернуть этот цилиндр с ног на голову. Функции цветокоррекции делают что-то вроде (h, s, l) => [h, s, 1 - l].


Цвета, с которыми и так всё хорошо

Иногда ситуация складывается удачно: эксклюзивный дизайн письма (или его части) уже тёмный. В таком случае не нужно ничего менять, лучше просто тихонько порадоваться — вероятно, дизайнер подобрал цвета не хуже нашего алгоритма. В HSL достаточно смотреть на L — яркость. Если она выше (для текста) или ниже (для фона) порога (который, конечно, настраивается) — ничего не делаем.


Динамический цирк

Хотя нам это и не понадобилось (ещё раз спасибо, санитайзер, ты спас меня от безумия!), но я всё же расскажу, какие допилы нужны адаптивной теме, чтобы затемнять полные страницы, а не только дурацкие статические письма из девяностых. Аккуратнее, это задача для тех, кто любит запах селекторов по утрам.


Динамические инлайн-стили

Самый простой кейс, который ломает нашу затемнённую страницу — изменение инлайн-стилей. Операция частая, но и фикс простой: добавляем MutationObserver и оперативно чиним инлайн-стили при изменениях.


Внешние стили

Работать со стилями из  изнутри страницы довольно мучительно из-за асинхронности и @import, да и от CORS не веселее. Кажется, эту проблему можно было бы довольно элегантно решить через веб-воркер (прокси для *.css).


Динамические стили

Наконец, собирая в кучу все наши проблемы, вспоминаем, что скрипт вообще может добавлять, удалять и переставлять (специфичность! каскад!)