Темизация

  • Устройство
  • 9 мин
  • кода: 6

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

Ревизия 2026-10-16

На этой странице

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

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

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

granum.config.mjs
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:

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 './reset.css'
import 'virtual:granum.css'
import './styles/theme-ocean.css'

Тема приложения идёт после листа: virtual:granum.css кладёт своё в слои granum.*, а нелейерный CSS приложения бьёт любой слой независимо от специфичности. Побеждает он не порядком, а тем, что вне слоёв, — но порядок всё равно держите: так читается намерение.

Третья тема — объявлением, а не файлом

Своя тема может быть частью конфига, и тогда её значения проходят ту же резолюцию, что и встроенные:

granum.config.mjs
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. Файл рядом с листом по-прежнему рабочий путь и описан выше; разница в том, что он перестал быть единственным.

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

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() отдаёт состояние только для чтения: theme и isDark читаются, а setTheme/toggleTheme бросают — общее модульное состояние унесло бы выбор одного пользователя в ответ следующему. Менять тему на сервере — только через granularityThemePlugin. Он же нужен, когда на странице живут несколько приложений с независимыми темами, и тогда ему обязателен target: без него атрибут все пишут в один <html> и побеждает последний.

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

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

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

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

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