Griffincss / темы
тема действует доступность система хранилище

Темы

Что здесь происходит

Страница ничего не знает о том, какая тема включена. Она написана на семантических токенах — «поверхность», «чернила», «граница», — а тема меняет только их значения. Переключатель ставит один атрибут на <html>, и этого достаточно: цвета пересчитываются каскадом, разметка не трогается.

Замеры контраста ниже — не подписи, а показания: скрипт читает вычисленные значения токенов прямо из документа и считает коэффициент по WCAG. Переключите тему — числа пересчитаются.

Поверхности

Из чего сделан фон. Замер — контраст основного текста к этой поверхности.

Чернила

Чем написан текст. Замер — контраст к фону страницы.

Акцент и статусы

Литеральные утилиты вроде .gr-bg-primary темой не трогаются — белое обязано остаться белым. Для цвета, живущего по теме, есть эти токены.

accent success warning danger info

Образцы

Обычные элементы страницы. Ни одного цвета в разметке.

Карточка

Фон — поверхность первой ступени, граница и тень идут за темой: в тёмной теме плотность тени выше, геометрия та же.

Обычная ссылка и посещённая после перехода.

Форма

ТокенРоль
--gr-color-bgфон страницы
--gr-color-surfaceкарточка
--gr-color-borderразделитель
xs
sm
md
lg
xl
2xl

Тема на любом элементе

Селекторы темы написаны без привязки к корню, поэтому атрибут работает на любой секции. Эти два острова держат свою тему независимо от переключателя наверху.

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 в утилитах.