В рамках работы над open-source проектом работал над задачкой по генерации тем. То есть в проекте было несколько тем: одни темы имели светлый и темный режим, другие - только светлый или только темный. И задача сводилась к тому, чтобы сделать для всех тем светлый и темный режим.

Вот на скриншоте показаны несколько темных тем: Dracula, Solarized, Gruvbox, Monokai, Moonlight. В теории можно взять и инвертировать светлоту, получив тем самым светлую тему. В теории теория и практика не отличаются. На практике всё совсем наоборот 😀. Чтобы объяснить почему, полезно сначала посмотреть на то, как устроена работа с цветом в принципе.
В 2011 году Этан Шунуовер опубликовал Solarized — палитру из шестнадцати цветов для терминалов и редакторов. Она стала популярной потому что в основе проектирования цвета была конкретная методология.
Шунуовер проектировал схему с точными соотношениями светлоты в пространстве CIELAB и набором оттенков, основанных на фиксированных отношениях на цветовом круге. Палитра тестировалась на откалиброванных и намеренно некалиброванных дисплеях, в различных условиях освещения.
Ключевое свойство Solarized в том, что монотонные цвета имеют симметричные разницы светлоты в CIELAB, поэтому переключение между тёмным и светлым режимами сохраняет одинаковую воспринимаемую контрастность между каждым значением. Это достигается точным зеркалированием Lab-координат: base03 и base3 симметричны относительно средней точки шкалы, как и все остальные пары.
Solarized снижает контраст яркости, но, в отличие от многих низкоконтрастных схем, сохраняет контрастирующие оттенки для читаемости синтаксической подсветки. Это важно, потому что необходимо уменьшить «слепящий» эффект белого на чёрном, не потеряв при этом различимость элементов.
Именно поэтому я считаю, что Solarized — очень хороший пример темы, хотя и сам ей не пользуюсь. В схему вложена хорошая идея и такая же хорошая реализация — оба режима (темный и светлый) должны восприниматься как части единой системы.
Разные цветовые модели
Когда мы говорим о цвете, мы часто думаем о HEX или HSL. HEX — это просто RGB в другой записи, а HSL хоть и интуитивен, но математически нечестен. Два цвета с одинаковым L в HSL воспринимаются глазом совершенно по-разному. Зелёный при hsl(120 100% 50%) — ослепительно яркий, синий при hsl(240 100% 50%) — значительно темнее, хотя формально у них одна «светлота».
Шунуовер работал в CIELAB именно потому, что это пространство перцептивно равномерное: одинаковое числовое расстояние между точками соответствует одинаковому воспринимаемому различию для глаза. Отсюда и возможность строить симметрию: если base03 имеет L=15, а base3 имеет L=97, то их среднее — L=56, и вся палитра строится как зеркало вокруг этой оси.
Современные системы дизайна, переходят на OKLCH. Это улучшенная версия того же принципа. В нём L (lightness) означает одно и то же для любого оттенка. Поэтому цвета с L = 0.55 воспринимаются одинаково яркими. Это позволяет строить палитры более системно, а не на глаз.
В нашем случае темы хранятся именно в OKLCH, а в браузере они рендерятся как lab().
Проблема контраста
Solarized решал контраст вручную, через Lab-симметрию. В нашем же случае мы не можем всё выверять вручную. В текущей реализации есть лишь автоматическая генерации и нет симметрии. У нас есть только числа, которые нужно проверять.
Для проверки контрастности можно использовать относительную яркость (relative luminance). Она используется в WCAG. Но яркость относительна, поэтому мы не можем полагаться на нее полностью как на L в OKLCH. Синие и фиолетовые цвета при L ≈ 0.55 дают относительную яркость около 0.22–0.25, что обеспечивает контраст ~5:1 с белым текстом. Этого достаточно, для того, чтобы комфортно читать текст. Но жёлтый и оранжевый при той же OKLCH-светлоте имеют яркость 0.35–0.45, и контраст с белым падает до 2.5–3:1. А такого контраста уже недостаточно для читаемости.
Первая попытка генерации светлых тем для Gruvbox и Monokai дала золотистый primary с тёмным текстом поверх него. Визуально это выглядело плохо. У меня не сохранилось скриншотов, поверьте на слово, было так себе. Поэтому для последующей генерации я решил для тёплых хроматических primary-color (жёлтый, оранжевый, золотой) нужно опускать L до 0.45–0.50 и использовать белый foreground-color. Для холодных (синий, фиолетовый) можно оставлять L ≈ 0.50–0.58. Это нельзя вычислить автоматически без анализа конкретного цвета.
Проблема сохранения визуальной идентичности
Любая тема, которую мы генерируем в дополнение к уже существующей должна удовлетворять как минимум двум условиям:
— иметь правильный контраст
— быть узнаваемой
Solarized — это крайний случай. Тут идентичность задокументирована точными Lab-значениями. Но есть темы, у которых нет такой хорошей документации.
Например, Dracula узнаваемы по пурпурно-розовому оттенку из #bd93f9. Но этот цвет воспроизводится по-разному в разных редакторах. Более того, для многих тем существуют еще и различные вариации, то есть нет только темной или светлой темы. Есть наборы из нескольких темных и нескольких светлых вариантов.
Первая автоматически сгенерированная версия Dracula использовала --primary: oklch(0.550 0.178 295°). Цвет синевато-фиолетовый, контраст с белым нормальный. Вроде технически верно. Но полученный оттенок был скорее пурпурно-розовый. При 295° тема начинает напоминать Moonlight. В результате смещение на 10 градусов по оттенку разрушало идентичность.
Инвертированная логика акцентов
В качестве компонентов в проекте используются shadcn. Одна из неочевидных конвенций shadcn это то, как работает accent в светлых и тёмных темах. В тёмной теме accent — это насыщенный цвет. Он используется для подсветки активных элементов. В светлой теме accent — это очень бледная, почти белая поверхность для hover-состояний, а accent-foreground — тёмный текст поверх неё. При автоматической генерации мы копируем тёмные значения accent в светлые варианты. В результате получаются элементы с насыщенным фоном и светлым текстом. Все это можно описать некрасивым эмодзи, но не буду этого делать 😁.
В общем, задачка прлучилась довольно интересной. Просто скопировать и сделать вжух не вышло. Пришлось подумать и поэкспериментировать. Для каждой новой темы необходимо понимать ее идентичность, проверять контраст и accent-инверсию, корректировать и проверять.
Результат работы можно посмотреть ниже или открыть пулл реквест и исследовать код. Конечно, некоторые моменты еще можно докрутить и улучшить. Хотя у меня и нет года, как у Шунуовера, я считаю, что результат получился вполне достойным.










































