Перейти к содержимому
Плотность
Тема

О проекте

Конституция

Кит для интерфейсов, которые показывают человеку работу машины: очереди задач, прогоны, логи, инспекторы, состояния и неопределённость. Дисциплина — тихий утилитарный минимализм: швейцарская сетка плюс Tufte, отрендеренные плоско.

Это не список компонентов, а список правил. Компоненты выводятся из правил; если правило и компонент разошлись — прав всегда правило.


Три закона

1. Цвет — это смысл. Если цвет не кодирует состояние, категорию или полярность, он удаляется. Четыре разных цвета на четырёх полосках, меряющих одну величину, — это не оформление, это ложное сообщение.

2. Компонент не знает о темах. Он обращается только к семантике и ролям (--text-primary, --pad-panel). Тот, кто написал --n-3 или #333, только что захардкодил светлую тему; тот, кто написал 14px, только что сломал плотность. Тёмная тема не инверсия — она авторская, и объявлена в тех же строках, что светлая, через light-dark().

3. Иерархия важнее украшения. Главное на экране — данные. Подписи, рамки, шапки и иконки обслуживают их и обязаны быть тише. Заголовок капсом с трекингом кричит громче числа, которое он подписывает, — значит, он неправ.


Словарь

Четыре яруса. Каждый параметр существует в единственном экземпляре; дубликат — это будущее расхождение, и оно наступает быстрее, чем кажется.

Ярус Где Правило
1. Рампы tokens.css 15 нейтральных шагов + акцент + 3 статуса. Не меняются между темами. Рампа непрерывна: шаг, не занятый семантикой, — резерв, а не мусор.
2. Семантика tokens.css Для чего цвет нужен. Одно объявление на токен через light-dark(). Второй темы как отдельного блока не существует.
3. Роли tokens.css Отступы, высоты, размеры глифов и жёлобов по назначению. Единственный ярус, который перенастраивает плотность.
4. Компонент components.css --btn-bg и подобные. 2–3 переменные, не больше.

Компонент имеет право видеть ярусы 2 и 3. Ярус 1 — никогда.

Правила ярусов

  • У смыслового тона ровно два токена: --*-text (метка: текст, точка, заливка меры, полоса сноски) и --*-bg (тонированный фон под ней). Третий, «заливка», между ними был и не проходил нигде, где под ним оказывалась дорожка. Исключение одно — акцент, у которого есть случай, которого нет у статусов: заливка под белой подписью (--accent-solid).
  • Несущая рамка и декоративная — разные токены. Если граница И ЕСТЬ контрол (чекбокс, поле, дорожка свитча), она обязана держать 3:1 и берёт --border-control. Если рядом есть перепад поверхностей (панель, карточка) — не обязана и берёт --border.
  • --text-faint не применяется к тексту, который читают. Это порог декорации (3:1), а не чтения (4.5:1). Таймстемп лога — данные, ему положен --text-muted.
  • Текст и метка — разные передние планы. У тона есть --tone-ink (текст, 4.5:1) и --tone-mark (точка, каретка, бегунок, заливка меры — 3:1, но ещё и против дорожки). У статусов они совпадают на шаге 5; у акцента расходятся, и обязаны: --accent-text берёт шаг 5, потому что иначе не возьмёт 4.5:1 на белом, а --accent-mark — шаг 4, тот же, что заливка кнопки. Метка и заливка обязаны совпадать, иначе на одном экране соседствуют два разных синих: чекбокс одного оттенка, бегунок рядом другого. В тёмной теме они расходятся неизбежно — заливке нельзя вверх из-за белой подписи, метке нельзя вниз из-за дорожки.
  • overscroll-behavior: contain не ставится «на всякий случай». Контейнер с overflow: auto, которому нечего прокручивать, при contain СЪЕДАЕТ колесо мыши: страница под ним не двигается. Цепочку прокрутки имеет смысл ограничивать только там, где прокрутка гарантированно есть, — то есть вместе с виртуализацией, а не заранее.
  • Псевдоэлемент в гриде размещается явно. ::before участвует в автопотоке наравне с элементами и идёт первым; одна явная grid-column у него ломает авторазмещение всех остальных ячеек. Либо все ячейки явные, либо ни одной.
  • overflow-wrap: anywhere живёт только на той ячейке, где нужен. Он уменьшает минимальную внутреннюю ширину до одного знака, и на контейнере схлопывает колонки грида: текст начинает переноситься по символу в столбик.
  • Дефолты объявляются через :where(). Дефолт с той же специфичностью, что и переопределение, — это не дефолт, а правило, объявленное позже.
  • Зазор ставится МЕЖДУ соседями, а не вокруг каждого. margin-block на каждом элементе складывается с внутренним отступом контейнера сверху и снизу, но не по бокам, — подсветка отступает от рамки по вертикали вдвое дальше, чем по горизонтали. Правильная форма — X + X { margin-block-start }.
  • Порядок состояний записывается селектором, а не порядком строк. отключено > ошибка > фокус > наведение. У :hover:not(:disabled) специфичность (0,3,0), а у :user-invalid всего (0,2,0) — курсор над невалидным полем прятал красную рамку, пока наведение не начало уступать явно через :not(:user-invalid).
  • Валидацию показывает платформа: :user-invalid, а не :invalid. :invalid горит красным ещё до того, как в поле что-то ввели, — форма встречает пользователя россыпью ошибок, которых он не совершал.
  • readonly и disabled — разные вещи. Одно нельзя изменить, но можно прочитать и скопировать (врез, полный цвет текста); другое недоступно целиком (прозрачность 0.5, как у кнопки). Пока они выглядели одинаково, пользователь не мог понять, ждать ему разблокировки или нет.
  • Хайрлайн включается по плотности пикселей. Браузер не рисует рамку тоньше физического пикселя: на 1x-дисплее 0.5px и 1px — одно и то же.

Запрещено

  • Вес 600 и 700. В шрифте они есть, здесь их нет.
  • ЗАГОЛОВКИ КАПСОМ и Title Case. Только обычное предложение.
  • Градиенты, свечения, размытия. Скелетон пульсирует прозрачностью, а не бликом.
  • Тень на карточке или панели. Тень означает «временное и сверху» — её получают только поповеры и модалки, и их ровно две (--shadow-popover, --shadow-modal).
  • Скругление на односторонней рамке. border-left + border-radius — всегда баг.
  • Второй акцентный тон. Если он понадобился, значение несёт что-то другое.
  • Размер шрифта меньше 11px.
  • Цвет как единственный носитель состояния — статус ходит с точкой и словом, сноска несёт иконку, строка дифа несёт знак.
  • Двойная ось Y на графике. Две шкалы — это два графика.
  • Число в компоненте. Новое значение заводится токеном яруса ролей или не заводится вовсе.
  • Цвет внутри data-URI. Форма рисуется маской, цвет приходит токеном.
  • !important. Ровно одно исключение, названное поимённо: [hidden] в base.css, потому что это корректность, а не оформление.

Категориальная палитра — оговорённое исключение из первого закона

Первый закон требует, чтобы цвет кодировал состояние, категорию или полярность. Ряд графика — это категория, поэтому исключение не нарушает закон, а уточняет его. Но правила у палитры свои:

  • Порядок — часть контракта. Ряд №1 всегда --chart-1. Иначе один и тот же показатель меняет цвет от экрана к экрану, и цвет перестаёт что-либо значить.
  • Тона отстоят минимум на 25° от каждого статусного (25, 85, 150) и от акцента (250). Категориальный цвет не должен прочитаться как «упало».
  • Светлота внутри палитры разная намеренно. Ряды остаются различимыми при дальтонизме и на чёрно-белой печати, где тон исчезает вовсе.
  • Категориальный цвет не появляется вне графика и его легенды. В роли статуса, заливки кнопки или подсветки строки он запрещён.
  • Шесть — потолок. Седьмой ряд означает, что это не график, а таблица.
  • Легенда обязательна при двух и более рядах. Без неё цвет ничего не сообщает.

Информационного тона нет, и это решение

Синий занят акцентом — интеракцией и состоянием «идёт». Третья роль сделала бы его нечитаемым, а второй синий запрещён вторым пунктом списка «запрещено». Информационное сообщение берёт тон neutral: приглушённая заливка и значок ⓘ. Оно и есть info — просто без синевы, которая в этом ките означает другое.

Одно совмещение, допущенное сознательно

Акцент несёт и интеракцию, и состояние «идёт». Формально это нарушение первого закона, и оно допущено потому, что носителем «идёт» является пульсация, а не тон: ничто другое в ките не пульсирует. Тон здесь — вторичный признак, а первичный уникален. Если однажды запульсирует что-то ещё, совмещение придётся разводить.


Приёмы, за счёт которых это выглядит продуманным

Ни один из них не заметен по отдельности. Заметно только их отсутствие.

Табличные цифры по умолчанию для всего. В инструментальном интерфейсе почти любое число либо стоит в колонке, либо обновляется на месте, и пропорциональные цифры дёргают оба случая. Проза отключает это через .inst-prose.

База документа — интерфейс, а не проза. 13/1.4 это частый случай. Когда базой была проза, каждый компонент был обязан переобъявить размер.

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

Нажатие — scale(0.985). Столько, чтобы почувствовать под пальцем.

Фокус с отступом 1px и без собственного радиуса. Кольцо впритык к контролу читается как изменение рамки. Радиус обводке задавать не надо: она и так следует радиусу элемента, а заданный — меняет форму самого элемента.

Заливка сплошной кнопки при наведении уходит от цвета подписи, а не к нему. Подпись белая — значит, в обеих темах вниз. Зеркальное «в тёмной светлее» роняет контраст подписи ниже порога.

Поле стоит во врезе, а не на цвете панели. Иначе его единственной границей остаётся рамка, и поля на панели не видно.

Подсказка и ошибка в одном слоте. Ошибка заменяет подсказку, а не сдвигает форму. Форма не перекладывается в момент заполнения.

Иконочная кнопка квадратная. 32×28 — самый частый признак самодельного кита.

Единица измерения мельче и тише числа. «42 с» одним кеглем читается как одно слово; разделив размер и цвет, глаз хватает число, а «с» уходит в фон.

Направление и полярность — два разных атрибута. Стрелка вниз у времени прогона это хорошо, стрелка вверх у предупреждений это плохо. Класс, названный стрелкой и означающий оценку, — гарантированная ошибка применения.

Ряд метрик без рамок. Четыре числа — это одна группа. Рамка вокруг каждого превращает их в четыре объекта.

Завершённые задачи отступают, а не гаснут. История остаётся читаемой, но перестаёт спорить с тем, что идёт сейчас. То же самое делает отвеченный запрос подтверждения.

Неопределённый прогресс — отдельный вид полосы. Определённая полоса, застрявшая на 90%, — это враньё пользователю. У неопределённой нет aria-valuenow, и это само по себе сообщение.

Свёрнутый вывод называет своё число словами. Молча обрезать — та же ложь.

Ширины колонок лога заданы в ch. Сетка объявлена на строке, значит, у каждой строки она своя, и колонка уровня иначе меняет ширину от слова к слову.

Отключённое состояние — прозрачность, а не серый. Кнопка сохраняет идентичность, и видно, какое именно действие недоступно.

Reduced-motion сжимает переходы, но замедляет, а не гасит индикаторы активности. Кит, вся работа которого — показывать, что машина занята, обязан показывать это всегда. Сжатая до 0.01ms бесконечная анимация — это остановка.


Разметочный контракт

Стили завязаны на ARIA, и это правильно: состояние живёт в одном месте. Но работает это, только если роли настоящие. Компонент без своей разметки считается неприменённым.

Компонент Обязательная разметка
.inst-segmented role="radiogroup" + role="radio" + aria-checked + бегущий tabindex
.inst-task контейнер role="listbox", строка role="option" + aria-selected + бегущий tabindex
.inst-tree-item контейнер role="tree", узел role="treeitem" + aria-level + aria-expanded + бегущий tabindex
.inst-meter role="progressbar" + aria-valuenow/min/max; у неопределённой aria-valuenow нет
.inst-step <details> / <summary> — раскрытие и клавиатура достаются от платформы
.inst-select обёртка .inst-select-wrap (в ней живёт шеврон)
.inst-num-field aria-label с осью на input; .inst-num-axis не является подписью
.inst-prop-label title с полным текстом, потому что подпись обрезается
.inst-failure role="alert" и хотя бы одно действие: без выхода это не блок отказа
.inst-popover popover на блоке + popovertarget на кнопке. Кнопка становится неявным якорем — имена якорей заводить не нужно
.inst-menu role="menu" + role="menuitem". Стрелки и бегущий tabindex выполняет kit.js
.inst-tooltip-text role="tooltip" + id, триггер несёт aria-describedby. Внутри панели с overflow: hidden обрежется — там нужен .inst-popover
.inst-dialog нативный <dialog>; открытие showModal(), закрытие — кнопки внутри <form method="dialog">
.inst-accordion-item <details>; общий name делает группу взаимоисключающей без JS
любая кнопка type="button", иначе внутри формы она её отправит
занятая кнопка aria-busy="true". Подпись остаётся в разметке — она держит ширину и озвучивается. disabled не ставится: он выбрасывает кнопку из порядка обхода прямо под руками у того, кто пришёл с клавиатуры. Кит снимает мышь (pointer-events), защиту от повторного нажатия ставит обработчик

Бегущий tabindex и стрелки — это JS, и его берёт на себя kit.js. Роль обещает клавиатуру, значит клавиатура обязана работать; отдельный файл — чтобы CSS оставался пригодным без скрипта.

Состояние и вариант

  • Вариант — то, что выбирает автор разметки: модификатор -- (.inst-btn--primary, .inst-panel-body--flush).
  • Состояние — то, что меняется в течение жизни: атрибут.

Атрибутных словарей четыре, и все они закрыты. Класс вида .is-* для состояния запрещён. Новое значение заводится вместе со строкой в таблице ниже, иначе оно молча не сработает.

Проверка, отличающая ось от дубликата: если два атрибута дают один и тот же цвет, это один атрибут, у которого два имени. Ровно так исчез data-polarity — его good/bad/warn были третьим именем для ok/error/warn, причём последнее совпадало буквально.

1. data-tone — смысловой регистр. Один словарь на весь кит.

neutral · running · ok · warn · error

Значит одно и то же везде: на бейдже, сноске, баннере, точке, строке лога, дельте метрики. Ставит --tone-ink, --tone-mark, --tone-bg; компонент их читает и больше ничего о тоне не знает. Информационного тона нет намеренно — его роль исполняет neutral.

2. data-state — фаза жизни. Словарь СВОЙ у каждого компонента.

У строки очереди и у шага мастера разные фазы, и общий словарь на всех был бы либо неполон, либо бессмыслен. Но каждый набор обязан быть перечислен здесь целиком, включая базовое значение — то, у которого нет своего оформления.

Компонент Словарь База
.inst-task queued running done warn failed skipped queued
.inst-step running ok failed атрибута нет
.inst-approval pending approved denied pending — единственная, где есть действия
.inst-stepper-item todo current done todo
.inst-meter indeterminate атрибута нет = определённая

Базовое значение пишется в разметке, хотя правил под него нет. Причина: data-state="queued" читается, а его отсутствие — нет, и опечатка вроде quued вместо queued выглядит как база.

3. data-kind — категория, а не состояние. Диф: add · del. Добавленная строка не находится в состоянии «ok» — она относится к виду «добавление». Тон здесь соврал бы. Один и тот же data-kind несут и строки дифа, и числа в его шапке: одна ось — одна запись.

4. data-when — кто управляет показом. .inst-field-error[data-when="invalid"] показывается платформой по :user-invalid; тот же блок без атрибута показывает приложение, например по ответу сервера. Это не состояние поля, а разделение ответственности.

Занятость — на aria-busy, а не в этих словарях. У кнопки нет фазы жизни, есть переходный флаг, и слово для него платформа уже придумала. Стили кита завязаны на ARIA именно затем, чтобы состояние жило в одном месте.


Как добавить компонент

  1. Сначала ответьте, какую работу делает элемент. Панель — область приложения. Карточка — объект, который можно было бы перетащить. Строка очереди плоская, шаг имеет тело. Если ответа нет, компонент не нужен: нужен существующий с другим содержимым.

  2. Возьмите высоту, отступ и размер из яруса ролей. Новое значение заводится только тогда, когда ни одно существующее не подходит, и тогда оно становится ролью, а не константой в компоненте. Константа переживёт вас и сломает плотность.

  3. Опишите варианты через 2–3 внутренние переменные (--btn-fg, --btn-bg), а не переписыванием всего блока. Вариант должен быть двумя строками. Если базовое правило пишет background напрямую, переменная не читается — и вариант перестаёт быть двумя строками.

  4. Состояния — только поверхность и рамка. Ховер не двигает layout, не меняет размер шрифта и не добавляет тень.

  5. Прогоните go -C tools run ./cmd/contrast и добавьте туда свои пары. Проверка читает настоящий tokens.css и резолвит light-dark(), color-mix() и var() так же, как браузер, поэтому разойтись с китом не может. Что из этих токенов сложилось на экране, покажет замер по пикселям — уже в браузере.

  6. Проверьте в трёх плотностях. Если компонент ломается в compact, у него зашита константа, которая должна быть ролью.

  7. Проверьте, что комментарий описывает код. Комментарий, разошедшийся с кодом, — такой же баг, как расхождение токенов, и в ките-как-спецификации он дороже обычного.


Структура

src/
  tokens.css       рампы + семантика + роли + плотность
  base.css         сброс, дефолты элементов, фокус, скроллбары, проза
  layout.css       оболочка, контейнер, поток, шапка экрана, навигация
  components.css   кнопки, поверхности, данные, состояния
  forms.css        поля, валидация, раскладка формы (слой тот же)
  data.css         аватар, тег, лента, легенда, спарклайн, кольцо, календарь
  overlay.css      поповер, меню, тултип, модалка, шторка
  agent.css        агентный слой + forced-colors
  motion.css       prefers-reduced-motion, отдельный слой
  print.css        @media print, отдельный последний слой
  kit.css          единая точка входа (@layer + @import)
tools/
  cmd/contrast/    контраст по настоящему tokens.css
  cmd/targets/     цели нажатия по трём плотностям
  cmd/docscheck/   сверка справочника с китом, в обе стороны
  cmd/dist/        сборка поставки и проверка, что она не отстала
  audit.js         замер по отрисованным пикселям, в консоли браузера
docs/              справочник: markdown, из которого собирается сайт
site/              генератор сайта
dist/              поставка: собранный CSS и JS

Подключение — одна строка, без сборки:

<link rel="stylesheet" href="src/kit.css">

Слои

kit.tokens → kit.base → kit.layout → kit.components → kit.overlay
  → kit.agent → kit.motion → kit.print

Порядок объявлен явно, поэтому фиксирован независимо от порядка импортов. Стили приложения лежат вне слоёв и всегда выигрывают без !important.

kit.motion и kit.print идут последними намеренно: правила prefers-reduced-motion и @media print обязаны перебивать компоненты (у .inst-btn специфичность выше, чем у *) и при этом не перебивать приложение. Порядок слоёв даёт это — без единого !important.

Адаптивность

Три уровня, и порядок между ними обязателен.

  1. Интринсик. Раскладка перестраивается сама, без единого запроса: auto-fit у сетки, flex-wrap с порогом у сплита. Работает всегда, в том числе там, где контейнера-предка нет. Пробуется первым.
  2. @container. Компонент отвечает на ширину СВОЕЙ области, а не окна. Для панельного кита это единственный правильный ответ: одна и та же панель стоит и в узкой колонке инспектора, и во всю ширину дашборда. Контейнерами объявлены .inst-panel, .inst-container и .inst-shell-main — области, ширина которых всегда приходит снаружи. Объявлять контейнером то, что получает ширину от содержимого, нельзя: container-type отключает интринсик по инлайновой оси и схлопнет элемент.
  3. @media. Один порог на весь кит — 60rem у оболочки приложения. Она и есть то единственное, что действительно зависит от размера окна.

Отступы задаются примитивами

Утилит вида mt-3, p-2, pt-0d25 в ките нет и не будет. Причина не в эстетике: шкала нарочно разрежена сверху, чтобы «чуть побольше» не было доступным решением, а набор утилит отступов возвращает его первым классом и переносит решение о ритме из кита в разметку каждого экрана.

Вместо них — три примитива потока (.inst-stack, .inst-cluster, .inst-grid) с тремя шагами зазора, названными намерением, а не числом: обычный, --tight, --loose. Плотность перенастраивает все три разом.

Темы и плотность

<html data-theme="dark">              <!-- принудительно -->
<html>                                 <!-- по системной настройке -->
<section data-density="compact">       <!-- плотность как атрибут контейнера -->

Переключение темы меняет color-scheme, а light-dark() в семантике сама начинает отдавать вторую ветку. Второго блока токенов не существует.

Живая спецификация

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

В примерах запрещён инлайновый стиль как канал оформления; инлайн допустим только как канал данных — ширина заполнения, глубина узла, границы отрезка на дорожке, число проверок в группе штрихов. Если для демонстрации понадобилось оформление инлайном, значит, в ките дыра, и чинить надо кит.


Почему кит устроен именно так — конституция · Открытый код под MIT, github.com/keshon/instrument