Темизация
Тема выбирается на сборке, режим переключается в рантайме: как сменить бренд, не тронув ни одного компонента, и почему режим собирают, а не пишут руками.
Обещание дизайн-системы звучит так: смена бренда — это смена значений токенов, а исходники компонентов не открываются. Страница о том, как это устроено и где расставлены грабли.
Тема и режим
Это два разных слова, и путать их дорого:
- тема — дизайн-язык целиком: примитивы, роли и производные состояния. Темы выбираются на сборке: основная у приложения одна, дополнительные едут своими листами и включаются в рантайме. Встроенная тема — «Сланец»,
slate, из пакета@feugene/granularity-tokens; он приезжает зависимостью ядра, ставить его отдельно не нужно; - режим — вариант внутри темы:
light,darkили свой. Набор режимов задаётся на сборке, а переключается между ними в рантайме: все режимы сборки уже лежат в CSS.
Режим включается парой «тема-режим» в атрибуте корня — <html data-theme="slate-dark">.
Без атрибута действует режим по умолчанию основной темы: его блок висит ещё и на :root.
Классов .light и .dark нет.
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: тёмный и остальные называются явно. Какие темы есть в каталоге и чем они отличаются — в
разделе «Темы».
Самое короткое, что работает
Поменять пару цветов режима можно, не заводя ни одного файла:
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 приложения: зависимостью
ядра он приезжает, но строгий менеджер пакетов чужую зависимость импортировать не даст.
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Что это даёт помимо объёма:
- Режим не гниёт. Роль, добавленная пакетом завтра, приедет из базы на следующей сборке.
- Режим проверяется. Контраст по WCAG и различимость тонов по ΔE считаются на сборке; не прошло — падение с именем роли и полученным отношением.
- База — любой режим любой темы:
'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
рядом с режимами темы и получит токены компонентов от базы:
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, здесь нет:
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): у каждой пары своё значение.
Режим, который появляется только в браузере — редактор тем, режим из пользовательских настроек, — подключается рантайм-подпутём, который не тянет справочник токенов:
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 с ролями, — и объявляет только отличия от базы.
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; она
лежит в общем листе. Каждая дополнительная — своим листом с полными блоками своих пар:
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; его
передают один раз, там, где создаётся состояние:
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')<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 ставит пару на своё поддерево:
<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, который в тёмном виде выглядит иначе, средство выбирается в таком порядке — от того, что не знает ни темы, ни режима, к тому, что знает о них всё:
- Роли
--gr-*и формулы от них. Меняются с режимом сами и правильно работают в островах — покрывают почти всё. - Вариант
dark:в разметке. granum строит его по схеме режимов сборки: в него входят все режимы с тёмной схемой, свои тоже. light-dark(светлое, тёмное). Сборка объявляетcolor-schemeв блоке каждого режима, и функция берёт значение по схеме ближайшего — в том числе в острове. Только цвета.- Суффикс пары —
[data-theme$='-dark']: по имени режима, от темы не зависит. Однозначен, потому что в имени режима нет дефиса. - Пара целиком —
[data-theme='slate-dark']: только для правки, которая правда относится к одной теме.
.hero {
background: light-dark(#f8fafc, #0b1120);
}
[data-theme$='-dark'] .hero__art {
display: none;
}Селектор предка — dark:, суффикс, пара — совпадает и внутри светлого острова
на тёмной странице: тёмный предок остаётся предком. Роли и light-dark() этой
ловушки не знают — значение берётся у ближайшего режима.
Ловушки, которые уже случались
Каждая ломала светлый или тёмный режим самого пакета:
- Вторичный текст выверен по фону страницы.
--gr-muted-fgживёт не на--gr-bg, а на--gr-mutedи--gr-secondary— считайте по фону с наименьшим контрастом: в светлом режиме это самый тёмный из них, в тёмном — самый светлый. -fgпроверен только на базовой заливке. Hover и active подмешивают к заливке--gr-fg— в светлом режиме она темнеет, в тёмном светлеет. Проверяйте все три состояния.opacityдля disabled. Прозрачность разбавляет выверенные токены и роняет контраст. Гасите фоном, а не прозрачностью.- Два тона перепутаны на глаз.
--gr-infoбыл индиго в двух шагах от--gr-primary— ΔE 4.3 при пороге заметности 2.3, а текстовые роли совпадали значением полностью. Считайте ΔE между тонами, а не только контраст каждого. - Тень одна на оба режима. Полупрозрачный тёмный на тёмном фоне не даёт ничего: поверхность теряет один из двух каналов подъёма. Elevation объявляется в каждом режиме отдельно.
- Мягкая подложка звучит громче в тёмном режиме. Меряйте не абсолютное значение, а отход от своей поверхности: равные хексы равного результата не дают.
Покомпонентные токены
Глобальные роли покрывают не всё: у компонентов есть свои переменные. Их имена и
природа — в tokens.json рядом с кодом компонента, а список со значением по
умолчанию — на странице самого компонента в каталоге и в
основах. Природ две, и они отвечают на вопрос «чьё это
решение»:
- токен темы — дизайн-решение: цвет, тень, радиус, длительность. Значение даёт
каждая тема, приложение перекрывает его
modes.tokenOverridesили своим CSS; - хук — ручка приложения или экземпляра. Тема его не задаёт, компонент читает его с запасным значением, а задать его можно инлайн-стилем на самом компоненте.
Перекрасить кнопки можно и без них: достаточно задать режиму --gr-{tone}-solid и
-solid-fg, а --gr-button-* остаются точкой для случая «кнопка обязана
отличаться от общей роли».