# Темизация

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

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

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

## Тема и режим

Это два разных слова, и путать их дорого:

- **тема** — дизайн-язык целиком: примитивы, роли и производные состояния. Темы выбираются **на сборке**: основная у приложения одна, дополнительные едут своими листами и включаются в рантайме. Встроенная тема — [«Сланец»](/ru/themes/slate), `slate`, из пакета `@feugene/granularity-tokens`; он приезжает зависимостью ядра, ставить его отдельно не нужно;
- **режим** — вариант внутри темы: `light`, `dark` или свой. Набор режимов задаётся на сборке, а переключается между ними **в рантайме**: все режимы сборки уже лежат в CSS.

Режим включается парой «тема-режим» в атрибуте корня — `<html data-theme="slate-dark">`.
Без атрибута действует режим по умолчанию основной темы: его блок висит ещё и на `:root`.
Классов `.light` и `.dark` нет.

```js title="granum.config.mjs"
import { windEngine } from '@feugene/granum-engine-wind'
import { defineGranumConfig } from '@feugene/granum/vite'

export default defineGranumConfig({
  engine: windEngine(),
  providers: ['@feugene/granularity'],
  themes: [{ theme: 'slate', modes: ['light', 'dark'] }],
})
```

Без `themes` собирается `slate`. Запись без `modes` даёт теме один режим — режим
по умолчанию, `light`: тёмный и остальные называются явно. Какие темы есть в каталоге и чем они отличаются — в
разделе [«Темы»](/ru/themes).

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

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

```js title="granum.config.mjs"
defineGranumConfig({
  engine: windEngine(),
  providers: ['@feugene/granularity'],
  themes: [{
    theme: 'slate',
    modes: {
      names: ['light', 'dark'],
      tokenOverrides: {
        dark: { 'gr-primary': '#4fd1e0' },
      },
    },
  }],
})
```

Пересоберите. Значение ляжет в блок пары `slate-dark` после данных темы и
перекроет их. За ролью переедут её производные `-hover` и `-active` — они
выведены формулой `color-mix`. Остальные роли тона — `-solid` с его
состояниями, `-fg`, `-light`, `-text` — свои значения режима: solid-кнопки красит
`-solid`, ссылки — `-text`. Перекрашивая тон, выводите семью целиком через
`tone()`. Ключ `root` вместо имени режима меняет примитивы — то, что одинаково
во всех режимах.

## Из чего состоит тема

Тема — полный набор значений одних и тех же токенов. Имена у всех тем общие, их
задаёт реестр пакета токенов, поэтому компоненты о темах не знают. В тему входят:

- **метаданные** — id, название, описание, режим по умолчанию и список режимов с
  их `colorScheme`; тема может наследовать другую (`extends: 'slate'`) и объявлять
  только отличия;
- **примитивы** — `--gr-space-*`, `--gr-radius-*`, `--gr-text-*`, `--gr-z-*` — от
  режима не зависят: «тёмной версии отступа» не бывает;
- **роли и тени каждого режима** (`--gr-bg`, `--gr-primary`, `--gr-danger-text`, …):
  поверхности, текст, действия, статусы, сплошные заливки;
- **производные состояния** заливок (`-hover`, `-active`) тема не объявляет —
  они выводятся от ролей, а для браузеров без `color-mix` сборка кладёт
  посчитанные значения; состояния кнопочной заливки `-solid-hover` и
  `-solid-active` — роли режима;
- **значения компонентов** — раздел `components/@feugene/granularity/<GrX>/`:
  имена токенов компонента — контракт ядра, значения — решение темы (цвет кнопки,
  радиус метки, толщина линии). Компонент читает их без запасного значения, и
  нехватку называет `granum doctor`;
- **пропсы по умолчанию** — то, что токеном не выразить: вид индикатора загрузки,
  форма группы кнопок;
- **README** — для чего тема и правила, которых нет в токенах; его показывает
  страница темы в разделе [«Темы»](/ru/themes).

Практическое правило: меняется при переключении режима → роль; одинаково всегда →
примитив.

### Что есть у режима

- **имя** — латиница и цифры без дефиса: дефис отделяет тему от режима в паре;
- **`colorScheme`** — `light` или `dark`: по нему сборка объявляет `color-scheme`
  для нативных контролов, а приложение выбирает режим, когда следует системной
  схеме, и считает `isDark`;
- **токены** — роли и тени; режим может наследовать другой режим той же темы
  (`extends`) и хранить только отличия;
- **режим по умолчанию** — `defaultMode` темы; у основной темы его блок лежит и
  на `:root`, поэтому страница без атрибута уже оформлена.

Режим можно добавить и со стороны приложения — `modes.define`, об этом ниже: для
`useTheme` он ничем не отличается от режима темы.

## Суффиксы ролей

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

| Суффикс | Что это | Порог контраста |
|---|---|---|
| без суффикса | насыщенная заливка: фон бейджа, индикатор, граница | ≥ 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-tokens/theme`. Импортировать пакет
токенов напрямую можно, только объявив его в `package.json` приложения: зависимостью
ядра он приезжает, но строгий менеджер пакетов чужую зависимость импортировать не даст.

```js title="theme.config.mjs"
import { extendMode, tone } from '@feugene/granularity-tokens/theme'

const surfaces = {
  '--gr-bg': '#041e2b',
  '--gr-fg': '#e8f4fa',
  '--gr-card': '#0a2f42',
}

export const ocean = extendMode({
  name: 'ocean', // пара slate-ocean
  title: 'Ocean',
  base: 'dark',
  tokens: {
    ...surfaces,
    '--gr-primary': '#4fd1e0',
    '--gr-primary-fg': '#041e2b',
    ...tone('azure', '#38bdf8', { base: surfaces }),
  },
})

ocean.definition // вход modes.define: extends, colorScheme и только отличия
ocean.css // CSS под парой, с фолбэком производных для браузеров без color-mix
```

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

:::steps
1. **Режим не гниёт.** Роль, добавленная пакетом завтра, приедет из базы на следующей сборке.
2. **Режим проверяется.** Контраст по WCAG и различимость тонов по ΔE считаются на сборке; не прошло — падение с именем роли и полученным отношением.
3. **База — любой режим любой темы:** `'dark'` встроенной, `{ theme, mode }` своей темы или другой собранный режим. Пара и `colorScheme` берутся у базы.
:::

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

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

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

## Режим в конфиге

Собранный режим отдают конфигу granum. Он встанет под свою пару `slate-ocean`
рядом с режимами темы и получит токены компонентов от базы:

```js title="granum.config.mjs"
import { windEngine } from '@feugene/granum-engine-wind'
import { defineGranumConfig } from '@feugene/granum/vite'
import { ocean } from './theme.config.mjs'

export default defineGranumConfig({
  engine: windEngine(),
  providers: ['@feugene/granularity'],
  themes: [{
    theme: 'slate',
    modes: {
      names: ['light', 'dark', 'ocean'],
      define: { ocean: ocean.definition },
    },
  }],
})
```

Режим можно объявить и прямо в конфиге — `extends`, `colorScheme` и только
отличия. Так короче, но проверки контраста, которую даёт `extendMode`, здесь нет:

```js title="granum.config.mjs"
defineGranumConfig({
  engine: windEngine(),
  providers: ['@feugene/granularity'],
  themes: [{
    theme: 'slate',
    modes: {
      names: ['light', 'dark', 'ocean'],
      define: {
        ocean: { extends: 'dark', colorScheme: 'dark', tokens: { 'gr-primary': '#4fd1e0', 'gr-bg': '#062b32' } },
      },
    },
  }],
})
```

:::callout{type="warning"}
Токены в `define` и `tokenOverrides` пишутся **без префикса `--`**. Генератор
дописывает его сам, а ключ с `--` granum отклоняет ошибкой конфига
`InvalidConfigError` с путём до поля. Режим с именем, которое уже есть у темы,
отвергается тоже (`activation-collision`): у каждой пары своё значение.
:::

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

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

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

Переключатель режимов сборки о таком режиме не знает: `setMode` принимает только
режимы из манифеста, поэтому пару `slate-ocean` редактор ставит сам.

## Своя тема

Режим — вариант той же дизайн-системы. Тема — другой дизайн-язык: свои
примитивы (скругления, шрифты), свои режимы. Своя тема лежит папкой той же
раскладки, что встроенные: `theme.json` с `"extends": "slate"`, `root.json` с
примитивами и `modes/<режим>.json` с ролями, — и объявляет только отличия от базы.

```js title="granum.config.mjs"
import { loadTheme } from '@feugene/granularity-tokens/theme/node'

const brand = loadTheme(new URL('./src/themes/brand/', import.meta.url))

export default defineGranumConfig({
  engine: windEngine(),
  providers: ['@feugene/granularity'],
  themes: [{ theme: brand, modes: ['light', 'dark'] }],
})
```

`loadTheme` проверяет полноту по реестру, контраст и различимость тонов в каждом
режиме и падает с именем токена и режима: неполная или нечитаемая тема до
пользователя не доезжает. Значения компонентов наследник берёт у темы из
`extends` — и получает её правки на следующей сборке. Самостоятельный язык
начинается с копии: `gr-theme-copy slate brand` кладёт папку со всеми значениями
«Сланца», и дальше тема живёт сама — новый токен ядра приходит в неё не молча, а
находкой `granum doctor`.

Тем в сборке может быть несколько. Основная — одна, запись с `default: true`; она
лежит в общем листе. Каждая дополнительная — своим листом с полными блоками своих пар:

```js title="granum.config.mjs"
defineGranumConfig({
  engine: windEngine(),
  providers: ['@feugene/granularity'],
  themes: [
    { theme: 'slate', default: true, modes: ['light', 'dark'] },
    { theme: brand, modes: ['light', 'dark'] },
  ],
})
```

Лист дополнительной темы Vite выпускает ассетом, а адрес кладёт в манифест
`virtual:granum/themes`: включает тему `setTheme` или остров `GrConfigProvider` —
ниже, и лист грузится только тогда. Страница, которая показывает тему всегда,
импортирует его статически — `virtual:granum/themes/brand.css` или
`virtual:granum/themes.css` для всех дополнительных сразу.

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

Тему и режим переключает `useTheme()` поверх контроллера granum. Темы сборки, их
режимы и пары знает сборка и отдаёт манифестом `virtual:granum/themes`; его
передают один раз, там, где создаётся состояние:

```ts title="src/main.ts"
import { initThemeEarly } from '@feugene/granularity'
import manifest from 'virtual:granum/themes'
import { createApp } from 'vue'
import App from './App.vue'

initThemeEarly({ manifest }) // до монтирования: без мигания режима
createApp(App).mount('#app')
```

```vue title="src/components/ModeToggle.vue"
<script setup lang="ts">
import { useTheme } from '@feugene/granularity'

const { isDark, toggleMode } = useTheme()
</script>

<template>
  <button type="button" @click="toggleMode()">
    {{ isDark ? 'Light mode' : 'Dark mode' }}
  </button>
</template>
```

- `theme` — текущая тема и `mode` — текущий режим (оба `ComputedRef`); `themes` — темы сборки; `modes` — режимы текущей темы с `colorScheme` и парами — реактивный массив, который `setTheme` меняет на месте, поэтому деструктуризация не теряет смену темы.
- `setTheme(id, { mode? })` сначала загружает лист дополнительной темы, потом ставит пару: значения темы без листа были бы чужими. Режим — заданный, иначе одноимённый текущему, иначе той же схемы, иначе по умолчанию.
- `isDark` смотрит на `colorScheme` режима, а не на имя: `ocean` поверх тёмного — тёмный.
- `toggleMode()` переключает между первым светлым и первым тёмным режимом текущей темы, `setMode()` — на любой её режим, свои из `modes.define` тоже. Режима нет в сборке — ошибка.
- Под ключом `gr-theme` хранится не пара, а выбор: у основной темы — имя режима, у дополнительной — `<тема>-<режим>`. Режим основной темы переживает смену темы на сборке. Пока выбор не сохранён, режим следует системной `prefers-color-scheme`; выбор в другой вкладке применяется и здесь.
- Пара, уже стоящая на корне (серверный HTML, скрипт в `<head>`), побеждает хранилище: контроллер её узнаёт и не перезаписывает.

Без `manifest` действует `slate` с режимами `light` и `dark`. Приложению с другой
темой, несколькими темами или своими режимами манифест передавать обязательно —
иначе пары `slate-*` не совпадут ни с одним селектором его CSS.

Атрибут руками не пишут: `data-theme="dark"` не совпадёт ни с одним селектором —
нужна пара, а знает её сборка. Свой CSS под тёмные режимы пишут селектором
`[data-theme$='-dark']` или парой целиком; цвета из токенов `--gr-*` селектора не
требуют вовсе. Вариант `dark:` в утилитах работает без правок: granum строит его
по парам тёмных режимов.

:::callout{type="info"}
**В SSR состояние режима объявляется явно.** На сервере без плагина `useTheme()`
отдаёт состояние только для чтения: `mode` и `isDark` читаются, а
`setMode`/`toggleMode` бросают — общее модульное состояние унесло бы выбор
одного пользователя в ответ следующему. Поэтому там ставят
`granularityThemePlugin` с тем же `manifest`. Он же нужен, когда на странице живут
несколько приложений с независимыми режимами, и тогда ему обязателен `target`:
без него пару все пишут в один `<html>` и побеждает последний. Как поставить
режим до первого кадра — на странице [SSR](/ru/docs/ssr).
:::

## Остров режима и темы

`GrConfigProvider` ставит пару на своё поддерево:

```vue
<GrConfigProvider mode="dark">
  <SettingsPanel />
</GrConfigProvider>

<GrConfigProvider theme="brand" mode="dark">
  <BrandPreview />
</GrConfigProvider>
```

`mode="dark"` — остров режима текущей темы: `data-theme="slate-dark"` на обёртке.
`theme` вместе с `mode` — остров другой темы сборки: лист дополнительной темы он
подгружает сам по адресу из манифеста, а пропсы по умолчанию берёт её. Темы, которой
нет в `themes` конфига, в CSS нет вовсе — остров не найдёт ни одного селектора.
Панели оверлеев внутри переносят пару в `body` сами. Режим **документа** — работа
`useTheme`, а не провайдера.

Проп `dark` у `GrBadge` и `GrChip` — не режим: это тёмная плашка, плотная метка
и на светлой странице.

## Свой CSS под режим

Компоненты переключаются ролями сами. Своему CSS, который в тёмном виде выглядит
иначе, средство выбирается в таком порядке — от того, что не знает ни темы, ни
режима, к тому, что знает о них всё:

1. **Роли `--gr-*`** и формулы от них. Меняются с режимом сами и правильно
   работают в островах — покрывают почти всё.
2. **Вариант `dark:` в разметке.** granum строит его по схеме режимов сборки: в
   него входят все режимы с тёмной схемой, свои тоже.
3. **`light-dark(светлое, тёмное)`.** Сборка объявляет `color-scheme` в блоке
   каждого режима, и функция берёт значение по схеме ближайшего — в том числе в
   острове. Только цвета.
4. **Суффикс пары** — `[data-theme$='-dark']`: по имени режима, от темы не
   зависит. Однозначен, потому что в имени режима нет дефиса.
5. **Пара целиком** — `[data-theme='slate-dark']`: только для правки, которая
   правда относится к одной теме.

```css
.hero {
  background: light-dark(#f8fafc, #0b1120);
}

[data-theme$='-dark'] .hero__art {
  display: none;
}
```

Селектор предка — `dark:`, суффикс, пара — совпадает и внутри светлого острова
на тёмной странице: тёмный предок остаётся предком. Роли и `light-dark()` этой
ловушки не знают — значение берётся у ближайшего режима.

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

Каждая ломала светлый или тёмный режим самого пакета:

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). Природ две, и они отвечают на вопрос «чьё это
решение»:

- **токен темы** — дизайн-решение: цвет, тень, радиус, длительность. Значение даёт
  каждая тема, приложение перекрывает его `modes.tokenOverrides` или своим CSS;
- **хук** — ручка приложения или экземпляра. Тема его не задаёт, компонент читает
  его с запасным значением, а задать его можно инлайн-стилем на самом компоненте.

Перекрасить кнопки можно и без них: достаточно задать режиму `--gr-{tone}-solid` и
`-solid-fg`, а `--gr-button-*` остаются точкой для случая «кнопка обязана
отличаться от общей роли».
