# Темизация

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

- Page: https://granularity.tech/ru/docs/theming
- Last reviewed: 2026-10-16

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

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

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

```js title="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` и к фону |

:::callout{type="warning"}
**Насыщенный тон нельзя использовать как цвет текста.** Это не про «пару
неудачных» тонов: каждому найдётся тема, где он проваливает 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`:

```js title="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
```

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

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

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

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

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

## Подключение

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

```ts title="src/main.ts"
import './reset.css'
import 'virtual:granum.css'
import './styles/theme-ocean.css'
```

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

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

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

```js title="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' },
      },
    },
  },
})
```

:::callout{type="warning"}
Токены в `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`, и композабл работает как есть.

:::callout{type="info"}
**В 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` рядом с кодом
компонента, у которого такие переменные есть, а список с природой и значением по умолчанию — на странице самого
компонента в [каталоге](/ru/components) и в
[основах](/ru/foundations/tokens).
