Темизация
Роли, а не хексы: как сменить бренд, не тронув ни одного компонента, и почему тему собирают, а не пишут руками.
Обещание дизайн-системы звучит так: смена бренда — это смена значений токенов, а исходники компонентов не открываются. Страница о том, как это устроено и где расставлены грабли.
Самое короткое, что работает
Поменять пару цветов встроенной темы можно, не заводя ни одного файла:
presetGranularNode({
providers: [granularityProvider],
themes: {
names: ['light', 'dark'],
tokenOverrides: {
dark: { '--gr-primary': '#4fd1e0' },
},
},
})Пересоберите — кнопки, ссылки, фокусные кольца и активные состояния переедут
сами: производные (-hover, -active) выведены формулой color-mix от роли, а
не записаны отдельными значениями.
Что такое тема
Тема — это набор семантических ролей (--gr-bg, --gr-primary,
--gr-danger-text, …), объявленных на одном селекторе. Больше ничего:
- примитивы —
--gr-space-*,--gr-radius-*,--gr-text-*,--gr-z-*— от темы не зависят: «тёмной версии отступа» не бывает; - производные состояния тема не объявляет: они выводятся от ролей и подстраиваются сами.
Практическое правило: меняется при переключении темы → роль; одинаково всегда → примитив.
Суффиксы ролей
Самая частая ошибка своей темы — перепутать роли и получить нечитаемый текст. У цветной роли до шести разновидностей, и они не взаимозаменяемы:
| Суффикс | Что это | Порог контраста |
|---|---|---|
| без суффикса | насыщенная заливка: фон бейджа, индикатор, граница | ≥ 3:1 к фону страницы |
-fg | текст на этой заливке | ≥ 4.5:1 к заливке и к её hover/active |
-solid | заливка кнопочного веса | ≥ 4.5:1 к своему -solid-fg |
-solid-fg | текст на -solid | ≥ 4.5:1 к -solid и его состояниям |
-light | мягкая тонированная подложка | ≥ 3:1 к тексту на ней |
-text | текст на подложке или на фоне страницы | ≥ 4.5:1 к -light и к фону |
Насыщенный тон нельзя использовать как цвет текста. Это не про «пару
неудачных» тонов: каждому найдётся тема, где он проваливает AA на обычной
подложке — --gr-success даёт 2.32 на светлой, --gr-primary — 3.70 на тёмной.
Парные -text держат AA везде, минимум 5.46. Цвет переднего плана берётся из
-text; тон остаётся заливкой, рамкой и индикатором.
Правило держится двумя гейтами библиотеки, а не памятью: статический ловит
text-[var(--gr-<тон>)] в исходниках, второй пересчитывает контраст по токенам
обеих тем — чтобы список запрещённых тонов не протух при первой же перекраске.
Отдельно стоят роли без тона: --gr-disabled-* для недоступного контрола и
--gr-invalid-* для не прошедшего валидацию. По умолчанию invalid ссылается
на danger, но это ссылка, а не копия: ошибка валидации и декоративный
state="danger" — разные сообщения, и тема вправе развести их по цвету.
Своя тема: собирается, а не пишется
Роль, которую тема не объявила, наследуется не от своей базы, а из :root —
то есть из светлой темы. Тёмная тема с одной забытой ролью получает светлое
пятно, и увидит это не сборка, а пользователь. Отсюда цена ручного файла: около
девяноста ролей и столько же внимания каждый раз, когда пакет заводит новую.
Поэтому тему собирает @feugene/granularity/theme:
import { extendTheme, tone } from '@feugene/granularity/theme'
const surfaces = {
'--gr-bg': '#041e2b',
'--gr-fg': '#e8f4fa',
'--gr-card': '#0a2f42',
}
export const ocean = extendTheme({
name: 'ocean', // селектор [data-theme='ocean']
base: 'dark',
tokens: {
...surfaces,
'--gr-primary': '#4fd1e0',
'--gr-primary-fg': '#041e2b',
...tone('azure', '#38bdf8', { base: surfaces }),
},
})
ocean.css // готовый CSS, включая фолбэк производных для браузеров без color-mixЧто это даёт помимо объёма:
- Тема не гниёт. Роль, добавленная пакетом завтра, приедет из базы на следующей сборке.
- Фолбэк считается от ваших значений, а не берётся из светлой темы, как у ручного файла.
- Тема проверяется. Контраст по WCAG и различимость тонов по ΔE считаются на сборке; не прошло — падение с именем роли и полученным отношением.
tone('success', '#3ddc97', { base }) выводит из одного цвета всю семью —
заливку, -fg, -solid, -solid-fg, -light, -text — по правилам из
таблицы выше. Перекрасить тон и оставить его семью от базы — классическая
ловушка: иконка success на --gr-success-light даёт 2.2:1.
createTheme — то же самое, но без базы: незаявленная роль роняет сборку со
списком. Брать его стоит там, где база мешает: высококонтрастная тема, чужой
брендбук, печать.
extendTheme | createTheme | |
|---|---|---|
| Незаявленная роль | приходит из базы | сборка падает |
| Пакет завёл новую роль | приедет сама | потребует решения автора темы |
| Когда брать | тема — вариация светлой или тёмной | у темы своя логика цвета |
Подключение
Своя тема — это обычный CSS приложения, подключённый после foundation-слоя:
import '@unocss/reset/tailwind-compat.css'
import 'virtual:uno:granular.css'
import './styles/theme-ocean.css'
import 'virtual:uno.css'Третью тему нельзя добавить через themes.themeFiles пресета. Вопреки
названию, это карта «имя темы → файл», которая заменяет CSS существующей темы;
темы приходят пересечением themes.names с тем, что объявил провайдер, и имени
ocean там взяться неоткуда. themeFiles уместен ровно тогда, когда тем
по-прежнему две, а поменять надо цвета — и тогда файл обязан объявлять все роли,
то есть быть собранным, а не написанным.
Тема, которая появляется только в браузере — редактор тем, тема из пользовательских настроек, — подключается рантайм-подпутём, который не тянет справочник токенов:
import { applyTheme } from '@feugene/granularity/theme/apply'
const remove = applyTheme(ocean.css, { name: 'ocean' })Переключение в рантайме
Канон — атрибут на корне документа:
document.documentElement.dataset.theme = 'ocean'useTheme() типизирован как 'light' | 'dark' и умеет ровно эти две: хранение
в localStorage под ключом gr-theme, синхронизацию между вкладками и слежение
за prefers-color-scheme. Третью тему он не переключит — ей нужен свой
контроллер, пишущий dataset.theme. Если тем ровно две, но с другими цветами,
берите имена light и dark, и композабл работает как есть.
В SSR состояние темы объявляется явно. Без плагина useTheme() держит его на
уровне модуля — на сервере это одно состояние на все запросы, и выбор одного
пользователя уехал бы в ответ следующему. Читать тему на сервере можно как есть,
менять — только через granularityThemePlugin. Он же нужен, когда на странице
живут несколько приложений с независимыми темами, и тогда ему обязателен
target: без него атрибут все пишут в один <html> и побеждает последний.
Ловушки, которые уже случались
Каждая ломала светлую или тёмную тему самого пакета:
- Вторичный текст выверен по фону страницы.
--gr-muted-fgживёт не на--gr-bg, а на--gr-mutedи--gr-secondary— считайте по самому тёмному из фонов, на которых он появляется. -fgпроверен только на базовой заливке. Hover и active уводят заливку темнее — проверяйте все три состояния.opacityдля disabled. Прозрачность разбавляет выверенные токены и роняет контраст. Гасите фоном, а не прозрачностью.- Два тона перепутаны на глаз.
--gr-infoбыл индиго в двух шагах от--gr-primary— ΔE 4.3 при пороге заметности 2.3, а текстовые роли совпадали значением полностью. Считайте ΔE между тонами, а не только контраст каждого. - Тень одна на обе темы. Полупрозрачный тёмный на тёмном фоне не даёт ничего: поверхность теряет один из двух каналов подъёма. Elevation объявляется в каждой теме отдельно.
- Мягкая подложка звучит громче в тёмной теме. Меряйте не абсолютное значение, а отход от своей поверхности: равные хексы равного результата не дают.
Покомпонентные токены
Глобальные роли покрывают не всё: у компонентов есть свои переменные, которыми
они перекрашиваются точечно. Источник — tokens.json рядом с кодом каждого
компонента, а список с природой и значением по умолчанию — на странице самого
компонента в каталоге и в
основах.