Темы
Что здесь происходит
Страница ничего не знает о том, какая тема включена. Она написана на
семантических токенах — «поверхность», «чернила», «граница», — а тема
меняет только их значения. Переключатель ставит один атрибут на
<html>, и этого достаточно: цвета
пересчитываются каскадом, разметка не трогается.
Замеры контраста ниже — не подписи, а показания: скрипт читает вычисленные значения токенов прямо из документа и считает коэффициент по WCAG. Переключите тему — числа пересчитаются.
Поверхности
Из чего сделан фон. Замер — контраст основного текста к этой поверхности.
Чернила
Чем написан текст. Замер — контраст к фону страницы.
Акцент и статусы
Литеральные утилиты вроде .gr-bg-primary темой
не трогаются — белое обязано остаться белым. Для цвета, живущего по теме,
есть эти токены.
Образцы
Обычные элементы страницы. Ни одного цвета в разметке.
Карточка
Фон — поверхность первой ступени, граница и тень идут за темой: в тёмной теме плотность тени выше, геометрия та же.
Форма
| Токен | Роль |
|---|---|
| --gr-color-bg | фон страницы |
| --gr-color-surface | карточка |
| --gr-color-border | разделитель |
Тема на любом элементе
Селекторы темы написаны без привязки к корню, поэтому атрибут работает на любой секции. Эти два острова держат свою тему независимо от переключателя наверху.
data-gr-theme="light"
Светлый остров.
data-gr-theme="dark"
Тёмный остров.
Подключение
<!-- CSS: токены темы входят во все пакеты, достаточно любого.
Правила режима для слабовидящих со свойствами — кегль,
подчёркивание ссылок, кольцо фокуса — едут с ядром и компонентами,
но не с утилитами -->
<link rel="stylesheet" href="griffincss-core.css">
<!-- JS нужен только для переключателя. Синхронно, в <head>:
атрибут ставится до первой отрисовки и страница не мигает -->
<script src="griffincss-theme.js"></script>
Griffincss.theme.set('dark'); // 'light' | 'dark' | 'auto'
Griffincss.theme.toggle(); // светлая ⇄ тёмная
Griffincss.theme.a11y(true); // true | false | 'auto'
Griffincss.theme.get(); // {theme, resolved, a11y, resolvedA11y}
document.addEventListener('griffincss:themechange', function (e) {
console.log(e.detail.resolved);
});
Без JS тема тоже работает: без атрибута страница следует
prefers-color-scheme, а режим повышенного
контраста — prefers-contrast. Скрипт нужен
ровно затем, чтобы дать пользователю выбор и запомнить его.
Смена — за один кадр
У компонентов переходы цвета 0,2 с, и без помощи рантайма при
переключении каждый ехал бы к новому цвету сам по себе: шапка уже
тёмная, карточки ещё нет, страница 200 мс идёт пятнами, а замер
контраста сразу после переключения читает промежуточное значение.
Поэтому set(), toggle(),
a11y() и style()
на время переключения ставят на <html>
класс gr-theme-switching и вставляют
в <head> безслойный
<style> с одним правилом —
transition: none для всего под этим
классом, — а через два кадра (requestAnimationFrame
× 2) снимают оба. Страница меняется целиком за один кадр; цвет,
прочитанный getComputedStyle сразу после
вызова, — уже итоговый.
Гасятся переходы цвета, а не всё, что движется. Элемент с классом
gr-theme-keep и всё под ним остаются вне
правила: плашка, которая скользит под выбранный пункт переключателя,
значок кнопки режима, любой переход, которым тема отвечает на само
переключение, идут своим ходом. Событие
griffincss:themechange приходит после
снятия гашения, когда переходы уже вернулись, — слушатель двигает
своё без оговорок; set() при этом
возвращает новое состояние сразу. Два переключения подряд в одном
окне гашения дают одно событие с итоговым состоянием.
<!-- Плашка едет под выбранный пункт переходом translate 0.35s;
без класса она прыгала бы: кадр щелчка попадает в окно гашения -->
<nav class="theme-switch gr-theme-keep">
<span class="theme-switch-thumb"></span>
…
</nav>
Правило намеренно не лежит ни в одном CSS-файле библиотеки: переходы
компонентов (griffincss.ui), утилит
(griffincss.utils) и вашей темы стоят
в разных слоях, и в каком бы слое оно ни лежало, кто-то из них
его перебил бы. Безслойное объявление старше всех слоёв без
!important. Класс на
<html> — ваш крючок: что ещё гасить
на время переключения, решает тема (.gr-theme-switching
.my-hero { animation: none }). Под строгим CSP листу нужен
nonce — рантайм берёт его со своего тега
<script>, как рантайм раскладок
(«Строгий CSP»); без него лист
блокируется, и тема переключается как прежде — переходами.
Токены для дизайнера
Сборка кладёт рядом с CSS файл design-tokens.json:
переменные темы в формате DTCG — том самом, который читают плагины
Figma Variables. Файл собирается из готового
griffincss-core.css, а не из исходников,
поэтому разойтись с библиотекой не может: изменилась переменная —
изменился токен, а забытая пересборка роняет проверку сборки.
npm i griffincss-core
<!-- файл -->
node_modules/griffincss-core/dist/design-tokens.json
<!-- или точкой входа пакета -->
require.resolve('griffincss-core/tokens')
Это экспорт переменных, а не библиотека компонентов. Figma-кита у Griffincss не было и не будет: кит требует постоянного сопровождения, и это записанный отказ. Но половина пользы кита — как раз переменные, а их можно отдать файлом, который ничего не стоит держать в актуальном состоянии. Классов, макетов, готовых кнопок и автолэйаутов в файле нет, и появиться им там неоткуда.
Как это ложится в Figma
Три набора верхнего уровня — light,
dark, low-vision —
это одна коллекция с тремя режимами. Составы наборов совпадают токен
в токен: Figma требует, чтобы переменная существовала одна и имела
значение в каждом режиме, и набор с дыркой импортировался бы частично
и молча. В каждом наборе 141 токен; имя токена повторяет имя переменной
CSS без префикса — color-bg это
--gr-color-bg, обратный путь всегда виден.
| Тип DTCG | Сколько | Что это | Чем становится в Figma |
|---|---|---|---|
color |
101 | палитра, семантические цвета, якоря контраста | переменная COLOR |
dimension |
19 | зазоры, скругления, толщины, высоты контролов, брейкпоинты | переменная FLOAT |
number |
8 | множители плотности и теней, порядок наложения, интерлиньяж | переменная FLOAT |
fontFamily |
3 | стеки шрифтов — без засечек, с засечками, моноширинный | переменная STRING |
shadow |
7 | готовые тени, разложенные на смещение, размытие и цвет | эффект, а не переменная: у Figma нет типа «тень» среди Variables |
transition |
1 | длительность и кривая перехода | ничем — читается глазами |
| без типа | 2 | tracking-base и rating-symbol |
строка |
Значения резолвнуты до конца: ни var(), ни
calc() в файле нет — множители темы и плотности уже
применены. Цвета переведены из HSL в hex по нормативному алгоритму
CSS Color 4. Совпадения байт-в-байт со всеми браузерами не бывает:
на значениях, попадающих ровно на половину младшего разряда, Chrome,
Firefox и WebKit расходятся друг с другом на 1/255 — экспорт держится
внутри этого разброса, а не выбирает себе движок.
Двум типам DTCG соответствия нет, и подобрать его было бы враньём:
tracking-base — ключевое слово
normal, а не размер, а
rating-symbol — знак «★», а не цвет.
Они приезжают без $type, со значением как есть.
Плагин их пропустит — это громкий отказ вместо тихого неверного ответа.
Чего в файле нет — и почему
Оси оформления. data-gr-style —
возможность opt-in. Стиль двигает геометрию и движение — скругление,
плотность, высоту теней, отклик — и переводит часть палитры
на свои якоря: воздушный уводит акцент в пастель, а поверхности
и линии подкрашивает, строгий и журнальный уводят поверхности к краю
шкалы. Что именно переведено и на какой якорь — таблица на странице
Стили
оформления. В плоский экспорт это не ложится: три стиля на две
темы дали бы шесть наборов ради нескольких десятков значений.
Условий. DTCG плоский: он не знает ни каскада, ни медиазапросов.
Там, где у переменной есть второе значение в условном блоке, которому
не досталось своего набора, условие сказано словами в
$description токена. Таких четыре: зазоры
gap, gap-sm,
gap-lg экспортируются мобильной базой
и со средних экранов становятся крупнее, а
shadow-strength на печати обнуляется.
Берёте значения под макет рабочего стола — читайте подпись.
Комбинации «тёмная + для слабовидящих». Оси независимы, и в CSS
они складываются; в плоском файле пришлось бы выкладывать все сочетания.
Набор low-vision — это режим поверх светлой
темы; тот же режим поверх тёмной собирается из
dark по тем же правилам контраста, что описаны
выше на этой странице.
Примеры использования
Вся страница выше — живой пример: прибор, образцы и острова уже работают на токенах. Здесь — два рецепта, которые чаще всего переносят к себе в проект.
Переключатель темы в шапке сайта
Кнопка-тумблер: клик — Griffincss.theme.toggle(),
подпись обновляется по событию
griffincss:themechange, поэтому она честна
и тогда, когда тему сменили в другом месте — хоть из этого прибора,
хоть из системных настроек в режиме auto.
<button id="theme-toggle" type="button">☾ Тёмная</button>
<script>
var btn = document.getElementById('theme-toggle');
btn.addEventListener('click', function () { Griffincss.theme.toggle(); });
document.addEventListener('griffincss:themechange', function (e) {
btn.textContent = e.detail.resolved === 'dark' ? '☀ Светлая' : '☾ Тёмная';
});
</script>
— рабочая кнопка: переключает всю страницу.
Свой бренд — три пары на тему
У акцента три якоря на каждую тему, и каждую пару берёт своя ось:
тема — повседневную --gr-hsl-accent-base,
режим для слабовидящих — контрастную
--gr-hsl-accent-max, воздушный стиль —
пастельную --gr-hsl-accent-soft вместе
с чернилами на ней --gr-hsl-on-accent-soft.
Бренд задаётся якорями, а какую пару взять — забота оси: кнопки,
ссылки и выделение перекрашиваются вместе, и режим по-прежнему
переводит акцент на контрастный, а стиль — на пастельный.
/* Светлая тема. Пороги: -max — не ниже 7 : 1 на белом (поверхность режима),
-soft с on-accent-soft — не ниже 4,5 : 1. */
:root {
--gr-hsl-accent-base: 152, 60%, 32%;
--gr-hsl-accent-base-hover: 152, 60%, 26%;
--gr-hsl-accent-max: 152, 100%, 20%;
--gr-hsl-accent-max-hover: 152, 100%, 14%;
--gr-hsl-accent-soft: 152, 45%, 55%;
--gr-hsl-accent-soft-hover: 152, 45%, 47%;
--gr-hsl-on-accent-soft: 152, 60%, 10%;
}
/* Тёмная тема — те же три пары, светлее; -max здесь на чёрном.
on-accent-soft общий: тёмные чернила на пастели читаются в обеих. */
[data-gr-theme="dark"] {
--gr-hsl-accent-base: 152, 45%, 60%;
--gr-hsl-accent-base-hover: 152, 45%, 70%;
--gr-hsl-accent-max: 152, 80%, 75%;
--gr-hsl-accent-max-hover: 152, 80%, 85%;
--gr-hsl-accent-soft: 152, 40%, 65%;
--gr-hsl-accent-soft-hover: 152, 40%, 73%;
}
/* Страница следует системной теме (auto): тёмный блок повторяется
под медиазапросом — в носитель, который выбирает система, из
пользовательского CSS иначе не попасть. */
@media (prefers-color-scheme: dark) {
:root:not([data-gr-theme]),
[data-gr-theme="auto"] {
--gr-hsl-accent-base: 152, 45%, 60%;
--gr-hsl-accent-base-hover: 152, 45%, 70%;
--gr-hsl-accent-max: 152, 80%, 75%;
--gr-hsl-accent-max-hover: 152, 80%, 85%;
--gr-hsl-accent-soft: 152, 40%, 65%;
--gr-hsl-accent-soft-hover: 152, 40%, 73%;
}
}
Не переопределяйте --gr-hsl-accent
напрямую. Объявление вне слоёв выигрывает у любого правила
внутри слоя, какой бы ни была специфичность, — в том числе у блока
[data-gr-a11y="low-vision"], который
переводит акцент на accent-max. Режим
для слабовидящих включится, а акцент останется повседневным: именно
так ломался прежний рецепт этой страницы. Якоря лежат ниже
производных токенов, и режим забирает своё поверх них.
Кольцо фокуса --gr-hsl-focus к бренду
не привязано: это сигнал платформы, а не фирменный цвет, и в режиме
оно уходит на ink-max. Журнальный стиль
берёт свою, приглушённую пару — --gr-hsl-accent-press
и -press-hover — по той же схеме, если он
у вас подключён.
С 0.26.0 те же объявления можно держать и в своём подслое
griffincss.app, а не только вне слоёв:
токены всех пакетов лежат в одном слое griffincss.tokens,
и подслой после него перебивает их — раньше копия :root
из компонентов и утилит лежала выше подслоя и выигрывала
(скелет страницы, порядок слоёв в README).
Одному блоку — локально и сознательно — переопределяют сам
семантический токен: var() подставляется
в месте объявления, и HSL-якоря на острове не сработали бы. Цена
понятна: внутри такого острова режим для слабовидящих акцент
не переводит.
/* один блок */
.promo { --gr-color-accent: hsl(152, 60%, 32%); --gr-color-accent-hover: hsl(152, 60%, 26%); }
Бренд — зелёный
Семантический токен переопределён на этом острове: ссылка и кнопка уже в фирменном цвете.
Радиус контейнера: «радиус + отступ»
Визуальное правило: контейнер с отступом P вокруг контролов
скруглён на radius + P, иначе кнопка с радиусом 8 px в карточке
с радиусом 8 px и отступом 16 px читается как «углы не совпадают».
Библиотека этого правила не навязывает — вид всех карточек по мнению
менять нельзя, — но даёт ему ручку: --gr-radius-container
читают карточка, окно, меню и сообщение, а без объявления он равен
--gr-radius. Задать правило — одна строка
в своём CSS; считается она на элементе, поэтому островок с другим
--gr-radius получит свои углы.
/* Контейнеры скруглены на радиус контрола плюс зазор */
:root { --gr-radius-container: calc(var(--gr-radius) + var(--gr-gap)); }
/* Строгий стиль: и контролы, и контейнеры прямые — правило само даёт 0 + зазор,
поэтому для него радиус контейнера возвращается к --gr-radius */
:root[data-gr-style="strict"], [data-gr-style="strict"] { --gr-radius-container: var(--gr-radius); }
Токен читают четыре контейнера сразу: карточка, окно, меню
выпадающей панели и сообщение. Если по замыслу темы правило
«радиус + отступ» нужно окнам и панелям, а выпадашки и сообщения
остаются на радиусе контролов, объявляйте токен не на корне,
а на тех контейнерах, которым он нужен, — или на корне,
а внутри остальных возвращайте --gr-radius.
/* Только окна и панели кабинета; меню и сообщения — на радиусе контрола */
.gr-modal, .account .gr-card { --gr-radius-container: calc(var(--gr-radius) + var(--gr-gap)); }
Обратный расчёт «внутрь» — от контейнера к контролу — тем же токеном
не выразить: --gr-radius на самом контейнере
сослался бы сам на себя. Для вложенных скруглений есть каскад
.gr-radius в утилитах.