Темизация
Роли, а не хексы: как сменить бренд, не тронув ни одного компонента, и почему тему собирают, а не пишут руками.
Обещание дизайн-системы звучит так: смена бренда — это смена значений токенов, а исходники компонентов не открываются. Страница о том, как это устроено и где расставлены грабли.
Самое короткое, что работает
Поменять пару цветов встроенной темы можно, не заводя ни одного файла:
defineGranumConfig({
engine: windEngine(),
providers: ['@feugene/granularity'],
themes: {
names: ['light', 'dark'],
tokenOverrides: {
dark: { 'gr-primary': '#4fd1e0' },
},
},
})Пересоберите. Значение встанет после файла тёмной темы пакета под её селектор и
перекроет его. За ролью переедут её производные -hover и -active — они
выведены формулой color-mix. Остальные роли тона — -solid с его
состояниями, -fg, -light, -text — свои значения темы: solid-кнопки красит
-solid, ссылки — -text. Перекрашивая тон, выводите семью целиком через
tone().
Что такое тема
Тема — это набор семантических ролей (--gr-bg, --gr-primary,
--gr-danger-text, …), объявленных на одном селекторе. Больше ничего:
- примитивы —
--gr-space-*,--gr-radius-*,--gr-text-*,--gr-z-*— от темы не зависят: «тёмной версии отступа» не бывает; - производные состояния заливок (
-hover,-active) тема не объявляет — они выводятся от ролей; состояния кнопочной заливки-solid-hoverи-solid-active— роли темы.
Практическое правило: меняется при переключении темы → роль; одинаково всегда → примитив.
Суффиксы ролей
Самая частая ошибка своей темы — перепутать роли и получить нечитаемый текст. У цветной роли до шести разновидностей, и они не взаимозаменяемы:
| Суффикс | Что это | Порог контраста |
|---|---|---|
| без суффикса | насыщенная заливка: фон бейджа, индикатор, граница | ≥ 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 './reset.css'
import 'virtual:granum.css'
import './styles/theme-ocean.css'Тема приложения идёт после листа: virtual:granum.css кладёт своё в слои
granum.*, а нелейерный CSS приложения бьёт любой слой независимо от
специфичности. Побеждает он не порядком, а тем, что вне слоёв, — но порядок
всё равно держите: так читается намерение.
Третья тема — объявлением, а не файлом
Своя тема может быть частью конфига, и тогда её значения проходят ту же резолюцию, что и встроенные:
defineGranumConfig({
engine: windEngine(),
providers: ['@feugene/granularity'],
themes: {
names: ['light', 'dark', 'ocean'],
define: {
ocean: {
// Структура берётся у существующей темы, переопределяются только цвета.
extends: 'dark',
selector: '[data-theme="ocean"]',
tokens: { 'gr-primary': '#4fd1e0', 'gr-bg': '#062b32' },
},
},
},
})Токены в define и tokenOverrides пишутся без префикса --. Генератор
дописывает его сам, а ключ с -- granum отклоняет ошибкой конфига
InvalidConfigError с путём до поля.
Ключа themes.themeFiles пресета v1 в granum нет: конфиг с ним падает с
InvalidConfigError. Файл рядом с листом по-прежнему рабочий путь и описан
выше; разница в том, что он перестал быть единственным.
Тема, которая появляется только в браузере — редактор тем, тема из пользовательских настроек, — подключается рантайм-подпутём, который не тянет справочник токенов:
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()
отдаёт состояние только для чтения: theme и isDark читаются, а
setTheme/toggleTheme бросают — общее модульное состояние унесло бы выбор
одного пользователя в ответ следующему. Менять тему на сервере — только через
granularityThemePlugin. Он же нужен, когда на странице живут несколько
приложений с независимыми темами, и тогда ему обязателен target: без него
атрибут все пишут в один <html> и побеждает последний.
Ловушки, которые уже случались
Каждая ломала светлую или тёмную тему самого пакета:
- Вторичный текст выверен по фону страницы.
--gr-muted-fgживёт не на--gr-bg, а на--gr-mutedи--gr-secondary— считайте по фону с наименьшим контрастом: в светлой теме это самый тёмный из них, в тёмной — самый светлый. -fgпроверен только на базовой заливке. Hover и active подмешивают к заливке--gr-fg— в светлой теме она темнеет, в тёмной светлеет. Проверяйте все три состояния.opacityдля disabled. Прозрачность разбавляет выверенные токены и роняет контраст. Гасите фоном, а не прозрачностью.- Два тона перепутаны на глаз.
--gr-infoбыл индиго в двух шагах от--gr-primary— ΔE 4.3 при пороге заметности 2.3, а текстовые роли совпадали значением полностью. Считайте ΔE между тонами, а не только контраст каждого. - Тень одна на обе темы. Полупрозрачный тёмный на тёмном фоне не даёт ничего: поверхность теряет один из двух каналов подъёма. Elevation объявляется в каждой теме отдельно.
- Мягкая подложка звучит громче в тёмной теме. Меряйте не абсолютное значение, а отход от своей поверхности: равные хексы равного результата не дают.
Покомпонентные токены
Глобальные роли покрывают не всё: у компонентов есть свои переменные, которыми
они перекрашиваются точечно. Источник — tokens.json рядом с кодом
компонента, у которого такие переменные есть, а список с природой и значением по умолчанию — на странице самого
компонента в каталоге и в
основах.