Темизация

  • Устройство
  • 17 мин
  • кода: 12

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

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

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

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

Тема и режим

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

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

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

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: тёмный и остальные называются явно. Какие темы есть в каталоге и чем они отличаются — в разделе «Темы».

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

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

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 — для чего тема и правила, которых нет в токенах; его показывает страница темы в разделе «Темы».

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

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

  • имя — латиница и цифры без дефиса: дефис отделяет тему от режима в паре;
  • 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 и к фону

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

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

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

  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 — то же самое, но без базы: незаявленная роль роняет сборку со списком. Брать его стоит там, где база мешает: высококонтрастный режим, чужой брендбук, печать.

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

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

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

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, здесь нет:

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' } },
      },
    },
  }],
})

Токены в 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 с ролями, — и объявляет только отличия от базы.

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; она лежит в общем листе. Каждая дополнительная — своим листом с полными блоками своих пар:

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; его передают один раз, там, где создаётся состояние:

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')
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 строит его по парам тёмных режимов.

В SSR состояние режима объявляется явно. На сервере без плагина useTheme() отдаёт состояние только для чтения: mode и isDark читаются, а setMode/toggleMode бросают — общее модульное состояние унесло бы выбор одного пользователя в ответ следующему. Поэтому там ставят granularityThemePlugin с тем же manifest. Он же нужен, когда на странице живут несколько приложений с независимыми режимами, и тогда ему обязателен target: без него пару все пишут в один <html> и побеждает последний. Как поставить режим до первого кадра — на странице 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 рядом с кодом компонента, а список со значением по умолчанию — на странице самого компонента в каталоге и в основах. Природ две, и они отвечают на вопрос «чьё это решение»:

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

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