Основания
Цвет
Два яруса. Внизу рампы — сырые шаги, они не меняются никогда. Сверху
семантика — для чего цвет нужен. Компонент видит только семантику: тот, кто
написал --n-3, только что захардкодил светлую тему.
Разметка
<div class="inst-panel">
<div class="inst-panel-header">
<span class="inst-panel-title">Прогон #4127</span>
<span class="inst-badge" data-tone="running"><span class="inst-dot"></span>идёт</span>
</div>
<div class="inst-panel-body inst-stack">
<input class="inst-input" type="text" value="terrain_chunk_04">
<div class="inst-note" data-tone="warn">Три теста упали после последнего прохода.</div>
</div>
</div>
Контракт
Компонент обращается к семантике, и только к ней. Рампа — сырьё, из которого семантика сделана, и в разметке ей делать нечего.
.my-thing {
color: var(--text-primary);
background: var(--surface-raised);
border: var(--hairline) solid var(--border);
}
| Что | Обязательно | Почему |
|---|---|---|
| Только ярус семантики | да | Тот, кто написал --n-3, только что захардкодил светлую тему: рампа между темами не меняется |
Тон через data-tone, а не свой цвет |
да | Пять значений, один словарь на всю библиотеку. Шестое не покрасится |
| Два передних плана по работе | да | --tone-ink для текста (4.5:1), --tone-mark для метки без подписи (3:1) |
| Цвет не единственный носитель | да | Состояние ходит со словом и знаком. Первый закон |
Доступность
| Проверка | go -C tools run ./cmd/contrast читает настоящий tokens.css и резолвит light-dark(), color-mix() и var() так же, как браузер |
| Порог текста | 4.5:1 во всех пяти темах. --text-faint из этого исключён и потому не носит текст |
| Порог метки | 3:1, но против двух фонов сразу: поверхности и дорожки. Отсюда --tone-mark отдельно от --tone-ink |
| Цвет не единственный носитель | Статус несёт точку и слово, сноска несёт иконку, строка дифа несёт знак |
| Тёмная тема | Не инверсия: контраст между соседними поверхностями расширяется, цветность опускается, рамки переходят с тёмной альфы на светлую |
| Режим принудительных цветов | Носители значения переживают сброс через forced-color-adjust: none |
Устройство
Нейтраль — 15 шагов
Разметка
<div class="ramp">
<span class="ramp-step" style="--c: var(--n-0)"></span>
<span class="ramp-step" style="--c: var(--n-1)"></span>
<span class="ramp-step" style="--c: var(--n-2)"></span>
<span class="ramp-step" style="--c: var(--n-3)"></span>
<span class="ramp-step" style="--c: var(--n-4)"></span>
<span class="ramp-step" style="--c: var(--n-5)"></span>
<span class="ramp-step" style="--c: var(--n-6)"></span>
<span class="ramp-step" style="--c: var(--n-7)"></span>
<span class="ramp-step" style="--c: var(--n-8)"></span>
<span class="ramp-step" style="--c: var(--n-9)"></span>
<span class="ramp-step" style="--c: var(--n-10)"></span>
<span class="ramp-step" style="--c: var(--n-11)"></span>
<span class="ramp-step" style="--c: var(--n-12)"></span>
<span class="ramp-step" style="--c: var(--n-13)"></span>
<span class="ramp-step" style="--c: var(--n-14)"></span>
</div>
<div class="ramp ramp-scale">
<span>0</span><span>1</span><span>2</span><span>3</span><span>4</span>
<span>5</span><span>6</span><span>7</span><span>8</span><span>9</span>
<span>10</span><span>11</span><span>12</span><span>13</span><span>14</span>
</div>
Переключите тему стола: рампа не изменится. Она сырая и не знает о темах — меняется только то, какие её шаги берёт семантика.
Одна ручка задаёт направление уклона: --hue-neutral: 75 — тёплый, 250 —
холодный. Цветность 0.002–0.006: ниже порога осознанного замечания, и в этом её
работа. Пятнадцатый шаг существует потому, что тёмному концу нужно четыре
различимые поверхности подряд, а на четырнадцати их помещалось три.
| Шаг | Светлота | Где занят |
|---|---|---|
--n-0 |
1.000 | --surface-raised, --surface-overlay (светлая), --accent-on |
--n-1 |
0.976 | --surface-page, --surface-field (светлая), --text-primary (тёмная) |
--n-2 |
0.952 | --surface-sunken (светлая) |
--n-3 |
0.922 | Резерв |
--n-4 |
0.878 | Резерв |
--n-5 |
0.806 | --text-secondary (тёмная) |
--n-6 |
0.706 | --text-muted (тёмная) |
--n-7 |
0.605 | --text-faint (обе темы) |
--n-8 |
0.500 | --text-muted (светлая) |
--n-9 |
0.400 | --text-secondary (светлая) |
--n-10 |
0.322 | --surface-overlay (dark-soft) |
--n-11 |
0.242 | --surface-overlay (тёмная), --surface-raised (dark-soft) |
--n-12 |
0.196 | --text-primary (светлая), --surface-raised (тёмная), --surface-page (dark-soft) |
--n-13 |
0.155 | --surface-page (тёмная), --surface-sunken и --surface-field (dark-soft) |
--n-14 |
0.120 | --surface-sunken, --surface-field (тёмная) |
Шаги без семантики — резерв под приложение и графики.
Акцент — один тон
Разметка
<div class="ramp">
<span class="ramp-step" style="--c: var(--a-1)"></span>
<span class="ramp-step" style="--c: var(--a-2)"></span>
<span class="ramp-step" style="--c: var(--a-3)"></span>
<span class="ramp-step" style="--c: var(--a-4)"></span>
<span class="ramp-step" style="--c: var(--a-5)"></span>
<span class="ramp-step" style="--c: var(--a-6)"></span>
</div>
<div class="ramp ramp-scale">
<span>1</span><span>2</span><span>3</span><span>4</span><span>5</span><span>6</span>
</div>
Второй акцентный тон не заводится. Это не запрос фичи, а сигнал, что смысл должно нести что-то другое.
| Шаг | Светлота | Где занят |
|---|---|---|
--a-1 |
0.960 | --accent-bg (светлая) |
--a-2 |
0.800 | --focus-ring (тёмная) |
--a-3 |
0.700 | --accent-text, --accent-mark, --accent-border (тёмная) |
--a-4 |
0.545 | --accent-solid, --accent-mark, --accent-border (светлая) |
--a-5 |
0.450 | --accent-text (светлая), --accent-hover |
--a-6 |
0.330 | --focus-ring (светлая) |
Тон — 215°. Светлота --accent-solid ограничена сверху белой подписью, и
предел ХЬЮ-ЗАВИСИМЫЙ: 0.545 на 215°, 0.568 на 250°, 0.576 на 275°, 0.580 на 45°.
Единого числа тут быть не может — «светлее 0.56 подпись не держит 4.5:1» это
измерение одного синего. Каждый набор держит свой предел сам, проверяет гейт.
Кольцо фокуса стоит на шагах 6 и 2, а не 4 и 3, и это не третий синий по
недосмотру: кольцо обводит заливку, и в светлых темах шаг 4 — это РОВНО
--accent-solid. Кольцо вокруг сплошной кнопки было того же цвета, что кнопка,
контраст 1.00.
Статусы — три зарезервированных тона
Разметка
<div class="ramp">
<span class="ramp-step" style="--c: var(--ok-1)"></span>
<span class="ramp-step" style="--c: var(--ok-2)"></span>
<span class="ramp-step" style="--c: var(--ok-3)"></span>
<span class="ramp-step" style="--c: var(--ok-4)"></span>
<span class="ramp-step" style="--c: var(--ok-5)"></span>
<span class="ramp-step" style="--c: var(--ok-6)"></span>
</div>
<div class="ramp">
<span class="ramp-step" style="--c: var(--warn-1)"></span>
<span class="ramp-step" style="--c: var(--warn-3)"></span>
<span class="ramp-step" style="--c: var(--warn-4)"></span>
<span class="ramp-step" style="--c: var(--warn-5)"></span>
<span class="ramp-step" style="--c: var(--warn-6)"></span>
</div>
<div class="ramp">
<span class="ramp-step" style="--c: var(--err-1)"></span>
<span class="ramp-step" style="--c: var(--err-3)"></span>
<span class="ramp-step" style="--c: var(--err-4)"></span>
<span class="ramp-step" style="--c: var(--err-5)"></span>
<span class="ramp-step" style="--c: var(--err-6)"></span>
</div>
<div class="ramp ramp-scale">
<span>1</span><span>3</span><span>4</span><span>5</span><span>6</span>
</div>
Шага 2 в статусных рядах нет — он не понадобился ни одной роли.
ok — 150°, warn — 85°, err — 25°. Никогда не используются как украшение
или как «четвёртая серия на графике».
| Шаг | Роль | Почему именно так |
|---|---|---|
| 1 | Тонированный фон в светлой теме | --ok-1 --warn-1 --err-1 |
| 2 | Текст на приподнятых тёмных поверхностях | Существует ради dark-soft: сквозь прозрачную тоновую заливку там просвечивает поднятая панель, и шаг 3 роняет подпись до 4.15 |
| 3 | Текст в тёмных темах на дне рампы | Светлее соседей по ряду |
| 4 | Заливка и индикатор в светлой | --ok-4 --warn-4 --err-4 |
| 5 | Текст в светлой, поверх шага 1 | Шаг 4 не вытягивает 4.5:1 на собственном тонированном фоне |
| 6 | Резерв под графики |
Шаг 5 существует именно потому, что шаг 4 не проходит порог на самом себе. Пока его не было, жёлтый бейдж жил на контрасте 3.02.
Шкала
Одно объявление на токен через light-dark(). Второй темы как отдельного блока
не существует — значит, и расходиться нечему.
Поверхности
Разметка
<div class="swatches">
<div class="sw"><div class="sw-chip" style="--c: var(--surface-sunken)"></div><div class="sw-cap">--surface-sunken</div></div>
<div class="sw"><div class="sw-chip" style="--c: var(--surface-page)"></div><div class="sw-cap">--surface-page</div></div>
<div class="sw"><div class="sw-chip" style="--c: var(--surface-raised)"></div><div class="sw-cap">--surface-raised</div></div>
<div class="sw"><div class="sw-chip" style="--c: var(--surface-overlay)"></div><div class="sw-cap">--surface-overlay</div></div>
<div class="sw"><div class="sw-chip" style="--c: var(--surface-field)"></div><div class="sw-cap">--surface-field</div></div>
</div>
Переключите тему стола — вот здесь всё и меняется. Рампа осталась прежней, а семантика взяла другие её шаги: в тёмной теме перепад между соседними поверхностями шире, потому что тёмная тема не инверсия, а авторская.
Читаются как стопка. Глубина передаётся порядком светлоты и никогда тенью.
| Токен | Работа |
|---|---|
--surface-sunken |
Дно: врез, дорожка, шапка таблицы |
--surface-page |
Фон документа |
--surface-raised |
Панель, карточка, шапка оболочки |
--surface-overlay |
Поповер, меню, тултип, модалка |
--surface-field |
Врез под ввод. Отдельная роль, а не переиспользование raised: пока поле красилось цветом панели, его единственной границей была рамка на 1.31:1 |
--surface-hover --surface-active |
Альфа поверх чего угодно |
--surface-selected |
Выделение. Тоже альфа — чтобы наведение по выделенной строке оставалось видно |
Текст
| Токен | Порог | Для чего |
|---|---|---|
--text-primary |
4.5:1 | Данные, заголовки, подписи контролов |
--text-secondary |
4.5:1 | Второстепенное: описания, единицы |
--text-muted |
4.5:1 | Метаданные и таймстемпы — это данные, им положен читаемый порог |
--text-faint |
3:1 | Не применяется к тексту, который читают. Порог декорации |
Рамки
Несущая и декоративная — разные токены, потому что к ним разные требования.
| Токен | Когда |
|---|---|
--border-subtle |
Разделитель внутри уже ограниченной области |
--border |
Панель, карточка, ячейка: рядом есть перепад поверхностей |
--border-strong |
Скроллбар, акцентированный шов |
--border-control |
Граница, которая и есть контрол: чекбокс, поле, дорожка свитча. Обязана держать 3:1 |
Тоны
У смыслового тона три токена: текст, метка и фон.
Текст и метка разошлись по той же причине, что у акцента. Шаг 5 рассчитан на чтение, и пока тон стоял в подписи бейджа, разницы не было. На заливке во всю строку — полоса истории, дуга кольца, лента меры — он читается темнее и глуше, чем акцентная метка рядом, и палитра распадается на два регистра. Метке довольно 3:1, и на шаге 4 все минимальные метки библиотеки стоят в одном.
| Тон | Текст (4.5:1) | Метка (3:1) | Фон |
|---|---|---|---|
| акцент | --accent-text |
--accent-mark |
--accent-bg |
| ok | --ok-text |
--ok-mark |
--ok-bg |
| warn | --warn-text |
--warn-mark |
--warn-bg |
| error | --err-text |
--err-mark |
--err-bg |
Третьего — «заливки» между текстом и фоном — нет. Он жил на шаге 4 и не проходил там, где под ним оказывалась дорожка: жёлтая заливка меры давала 2.49 при норме 3.0.
У акцента есть случай, которого нет у статусов, — заливка под белой подписью
(--accent-solid и --accent-on).
Тон как атрибут
data-tone ставится один раз на группу; вложенные элементы читают
--tone-ink, --tone-mark и --tone-bg и больше ничего о тоне не знают.
| Значение | Значит |
|---|---|
neutral |
Явно нейтральное. Оно же исполняет роль info |
running |
Идёт сейчас. Носитель — пульсация, тон вторичен |
ok |
Успешно завершено |
warn |
Завершено с замечаниями |
error |
Упало |
Словарь закрыт. Шестое значение не заводится.
Варианты
Тема — это три независимых ручки: тон нейтрали, сила уклона и глубина тёмных поверхностей. Седьмая тема стоит одну строку.
Светлые различаются уклоном, тёмные — глубиной, и это не небрежность. На светлом фоне глаз ловит температуру: тёплая бумага и холодная бумага читаются как разные. На тёмном температуру почти не видно, зато сразу заметно, насколько панель отделяется от страницы. Ручки разные, потому что в светлом и тёмном замечают разное.
| Атрибут | color-scheme |
--hue-neutral |
--tint |
Что ещё |
|---|---|---|---|---|
| нет | по системе | 75 | 0 | Слушает настройку ОС |
data-theme="light-neutral" |
light |
75 | 0 | Умолчание: чистый серый |
data-theme="light" |
light |
75 | 2 | Тёплая |
data-theme="light-cool" |
light |
250 | 2 | Холодная |
data-theme="dark-soft" |
dark |
75 | 0 | Стопка поднимается на ступень: переопределены ровно пять токенов |
data-theme="dark" |
dark |
75 | 0 | Дно рампы, без переопределений |
--tint — множитель, а не абсолютная величина: шаги рампы сохраняют свои доли
уклона (у светлого конца он слабее, у тёмного сильнее), и при нуле весь ряд
одновременно становится серым. Нейтральная тема — не третий тон, а его
отсутствие: любой --hue-neutral при нулевой цветности даёт один и тот же серый.
<html data-theme="dark"> <!-- принудительно -->
<html> <!-- по системной настройке -->
Светлая глубина не регулируется. Сверху рампы шаги идут плотно, и «менее светлая» светлая тема даёт просто серый фон.
Потолок глубины
Тёмных тем две, и третьей вверх не будет. Выше «серой» рампа перестаёт быть
тёмной: на --n-10 даже --text-muted даёт 3.29 при норме 4.5, а прозрачная
тоновая заливка, сквозь которую просвечивает панель, роняет подпись бейджа ещё
раньше. Ровно поэтому dark-soft кроме четырёх поверхностей переопределяет и
подписи статусов — они уходят на шаг 2 рампы.
Категориальная палитра
Разметка
<div class="ramp">
<span class="ramp-step" style="--c: var(--chart-1)"></span>
<span class="ramp-step" style="--c: var(--chart-2)"></span>
<span class="ramp-step" style="--c: var(--chart-3)"></span>
<span class="ramp-step" style="--c: var(--chart-4)"></span>
<span class="ramp-step" style="--c: var(--chart-5)"></span>
<span class="ramp-step" style="--c: var(--chart-6)"></span>
</div>
<div class="ramp ramp-scale">
<span>1</span><span>2</span><span>3</span><span>4</span><span>5</span><span>6</span>
</div>
Светлота внутри ряда разная. Так ряды различимы и при дальтонизме, и на чёрно-белой печати, где тон исчезает вовсе. Прищурьтесь — порядок по светлоте всё ещё читается.
Здесь цвет кодирует ряд, а не состояние, — единственное такое место в библиотеке. Отсюда и отдельные правила.
| Токен | Тон | Ряд |
|---|---|---|
--chart-1 |
292° | Первый |
--chart-2 |
320° | Второй |
--chart-3 |
355° | Третий |
--chart-4 |
55° | Четвёртый |
--chart-5 |
115° | Пятый |
--chart-6 |
190° | Шестой |
- Порядок — часть контракта. Ряд №1 всегда
--chart-1, иначе один и тот же показатель меняет цвет от экрана к экрану. - Минимум 25° от каждого статусного тона (25, 85, 150): категориальный цвет не должен прочитаться как «упало». С акцентом угловой гарантии нет — он ручка на четыре положения (215, 250, 275, 45), и глина стоит в 10° от ряда №4. Вместо неё разделение областей: акцент не заходит внутрь графика, категориальный цвет не выходит наружу.
- Светлота внутри палитры разная намеренно — ряды остаются различимыми при дальтонизме и на чёрно-белой печати, где тон исчезает вовсе.
- Шесть — потолок. Седьмой ряд означает, что это не график, а таблица.
- Вне графика и его легенды запрещена. В роли статуса, заливки кнопки или подсветки строки категориального цвета не бывает.
Прочее
| Токен | Работа |
|---|---|
--focus-ring |
Кольцо фокуса, одно на всю библиотеку |
--track |
Пустая дорожка меры и слайдера. Намеренно тихая: её работа — дать заливке отделиться, а не отделиться самой |
--scrim |
Подложка модалки. В тёмной теме гуще. Затемнение, а не размытие |
--shadow-color-near --shadow-color-far |
Цвета двух теней — см. высоту |
Правила
Что брать, а что нет
| Используйте | Возьмите другое |
|---|---|
Семантический токен: --text-primary, --surface-raised, --ok-text |
Шаг рампы напрямую (--n-3, --a-4) — это светлая тема, вписанная в компонент. Список семантики целиком — токены |
Состояние объекта — атрибут data-tone, один словарь на всю библиотеку |
Свой цвет под своё состояние — заведите тон и покажите его бейджем: цвет не имеет права быть единственным носителем |
Ряд графика — --chart-1 … --chart-6 по порядку |
Статусный тон как «ещё одна серия» — тона ok/warn/error зарезервированы. Ряды подписывает легенда |
| Глубина — перепад поверхностей | Тень ради глубины — тень означает «временное и сверху», см. высоту |
Информационное сообщение — тон neutral |
Синий как «info» — синий занят акцентом и состоянием «идёт», см. сноску в карточке |
API
| Имя | Что делает |
|---|---|
| класс | |
inst-theme | Корень области темы. Заново объявляет color и background, чтобы data-theme работал на поддереве |
| Имя | Значение | Что делает |
|---|---|---|
| атрибут | ||
data-theme | light-neutral · light · light-cool · dark-soft · dark | Тема. На корне документа или на любом поддереве вместе с inst-theme |
| Имя | Значение | Откуда |
|---|---|---|
| токен | ||
--text-primaryОсновной текст | light-dark(var(--n-12), var(--n-1)) | одно на все ячейки |
--text-secondaryВторичный текст. Порог чтения держит | light-dark(var(--n-9), var(--n-5)) | одно на все ячейки |
--text-mutedПодписи и вспомогательное | light-dark(var(--n-8), var(--n-6)) | одно на все ячейки |
--text-faintДекорация. Текстом не красится | light-dark(var(--n-7), var(--n-7)) | одно на все ячейки |
--surface-pageФон страницы | light-dark(var(--n-1), var(--n-13)) | база +1: тема |
--surface-sunkenВрез: утопленная поверхность | light-dark(var(--n-2), var(--n-14)) | база +1: тема |
--surface-raisedПоднятая поверхность: панель, карточка | light-dark(var(--n-0), var(--n-12)) | база +1: тема |
--surface-overlayВсплывающее: поповер, модалка | light-dark(var(--n-0), var(--n-11)) | база +1: тема |
--surface-fieldВрез поля ввода | var(--surface-raised) | одно на все ячейки |
--surface-hoverПод курсором | light-dark(oklch(0 0 0 / 0.035), oklch(1 0 0 / 0.045)) | одно на все ячейки |
--surface-activeПри нажатии | light-dark(oklch(0 0 0 / 0.065), oklch(1 0 0 / 0.080)) | одно на все ячейки |
--surface-selectedВыбранное | color-mix(in oklab, var(--a-4) 14%, transparent) | одно на все ячейки |
--borderОсновная граница | light-dark(oklch(0 0 0 / 0.12), oklch(1 0 0 / 0.11)) | одно на все ячейки |
--border-subtleРазделитель внутри блока | light-dark(oklch(0 0 0 / 0.07), oklch(1 0 0 / 0.06)) | одно на все ячейки |
--border-strongУсиленная граница | light-dark(oklch(0 0 0 / 0.22), oklch(1 0 0 / 0.20)) | одно на все ячейки |
--border-controlГраница контрола. Держит 3:1 | light-dark(oklch(0 0 0 / 0.46), oklch(1 0 0 / 0.38)) | одно на все ячейки |
--accent-textАкцент для текста, 4.5:1 | light-dark(var(--a-5), var(--a-3)) | база +1: тема |
--accent-markАкцент для метки без подписи, 3:1 | light-dark(var(--a-4), var(--a-3)) | одно на все ячейки |
--accent-solidСплошная заливка акцентом | var(--a-4) | одно на все ячейки |
--accent-onПередний план на сплошном акценте | var(--n-0) | одно на все ячейки |
--accent-bgТонированный фон акцента | light-dark(var(--a-1), color-mix(in oklab, var(--a-4) 15%, transparent)) | одно на все ячейки |
--accent-borderГраница акцентом | light-dark(var(--a-4), var(--a-3)) | одно на все ячейки |
--tone-inkПередний план тона для ТЕКСТА, 4.5:1 | var(--accent-text) | база +2: |
--tone-markПередний план тона для МЕТКИ, 3:1 | var(--text-secondary) | база +4: |
--tone-bgТонированный фон | var(--surface-sunken) | база +4: |
--ok-textТон успеха | light-dark(var(--ok-5), var(--ok-3)) | база +1: тема |
--warn-textТон замечания | light-dark(var(--warn-5), var(--warn-3)) | база +1: тема |
--err-textТон отказа | light-dark(var(--err-5), var(--err-3)) | база +1: тема |
--focus-ringКольцо фокуса | light-dark(var(--a-4), var(--a-3)) | одно на все ячейки |
--scrimПодложка модалки | light-dark(oklch(0 0 0 / 0.32), oklch(0 0 0 / 0.58)) | одно на все ячейки |
--trackДорожка меры, слайдера, кольца | light-dark(oklch(0 0 0 / 0.10), oklch(1 0 0 / 0.16)) | одно на все ячейки |
Почему кит устроен именно так — конституция · Открытый код под MIT, github.com/keshon/instrument