Темизация

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

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

Самое короткое, что работает

Поменять пару цветов встроенной темы можно, не заводя ни одного файла:

uno.config.ts
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:

theme.config.mjs
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

Что это даёт помимо объёма:

  1. Тема не гниёт. Роль, добавленная пакетом завтра, приедет из базы на следующей сборке.
  2. Фолбэк считается от ваших значений, а не берётся из светлой темы, как у ручного файла.
  3. Тема проверяется. Контраст по WCAG и различимость тонов по ΔE считаются на сборке; не прошло — падение с именем роли и полученным отношением.

tone('success', '#3ddc97', { base }) выводит из одного цвета всю семью — заливку, -fg, -solid, -solid-fg, -light, -text — по правилам из таблицы выше. Перекрасить тон и оставить его семью от базы — классическая ловушка: иконка success на --gr-success-light даёт 2.2:1.

createTheme — то же самое, но без базы: незаявленная роль роняет сборку со списком. Брать его стоит там, где база мешает: высококонтрастная тема, чужой брендбук, печать.

extendThemecreateTheme
Незаявленная рольприходит из базысборка падает
Пакет завёл новую рольприедет самапотребует решения автора темы
Когда братьтема — вариация светлой или тёмнойу темы своя логика цвета

Подключение

Своя тема — это обычный CSS приложения, подключённый после foundation-слоя:

src/main.ts
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 уместен ровно тогда, когда тем по-прежнему две, а поменять надо цвета — и тогда файл обязан объявлять все роли, то есть быть собранным, а не написанным.

Тема, которая появляется только в браузере — редактор тем, тема из пользовательских настроек, — подключается рантайм-подпутём, который не тянет справочник токенов:

ts
import { applyTheme } from '@feugene/granularity/theme/apply'

const remove = applyTheme(ocean.css, { name: 'ocean' })

Переключение в рантайме

Канон — атрибут на корне документа:

ts
document.documentElement.dataset.theme = 'ocean'

useTheme() типизирован как 'light' | 'dark' и умеет ровно эти две: хранение в localStorage под ключом gr-theme, синхронизацию между вкладками и слежение за prefers-color-scheme. Третью тему он не переключит — ей нужен свой контроллер, пишущий dataset.theme. Если тем ровно две, но с другими цветами, берите имена light и dark, и композабл работает как есть.

В SSR состояние темы объявляется явно. Без плагина useTheme() держит его на уровне модуля — на сервере это одно состояние на все запросы, и выбор одного пользователя уехал бы в ответ следующему. Читать тему на сервере можно как есть, менять — только через granularityThemePlugin. Он же нужен, когда на странице живут несколько приложений с независимыми темами, и тогда ему обязателен target: без него атрибут все пишут в один <html> и побеждает последний.

Ловушки, которые уже случались

Каждая ломала светлую или тёмную тему самого пакета:

  1. Вторичный текст выверен по фону страницы. --gr-muted-fg живёт не на --gr-bg, а на --gr-muted и --gr-secondary — считайте по самому тёмному из фонов, на которых он появляется.
  2. -fg проверен только на базовой заливке. Hover и active уводят заливку темнее — проверяйте все три состояния.
  3. opacity для disabled. Прозрачность разбавляет выверенные токены и роняет контраст. Гасите фоном, а не прозрачностью.
  4. Два тона перепутаны на глаз. --gr-info был индиго в двух шагах от --gr-primary — ΔE 4.3 при пороге заметности 2.3, а текстовые роли совпадали значением полностью. Считайте ΔE между тонами, а не только контраст каждого.
  5. Тень одна на обе темы. Полупрозрачный тёмный на тёмном фоне не даёт ничего: поверхность теряет один из двух каналов подъёма. Elevation объявляется в каждой теме отдельно.
  6. Мягкая подложка звучит громче в тёмной теме. Меряйте не абсолютное значение, а отход от своей поверхности: равные хексы равного результата не дают.

Покомпонентные токены

Глобальные роли покрывают не всё: у компонентов есть свои переменные, которыми они перекрашиваются точечно. Источник — tokens.json рядом с кодом каждого компонента, а список с природой и значением по умолчанию — на странице самого компонента в каталоге и в основах.

Последняя ревизия: 2026-09-01