Раскладка
Оболочка приложения
Каркас экрана: боковая колонка, шапка, рабочая область. Каждая часть прокручивается сама — боковая навигация не уезжает, пока читают таблицу.
Прогоны
Разметка
<div class="inst-shell">
<aside class="inst-shell-aside">
<a class="inst-shell-brand" href="#"><span class="inst-dot" data-tone="ok"></span>instrument</a>
<nav class="inst-nav" aria-label="Разделы">
<div class="inst-nav-group">
<span class="inst-nav-label">Работа</span>
<a class="inst-nav-item" href="#" aria-current="page">Прогоны
<span class="inst-badge inst-nav-count">7</span></a>
<a class="inst-nav-item" href="#">Очередь</a>
</div>
</nav>
</aside>
<header class="inst-shell-header">
<nav aria-label="Хлебные крошки">
<ol class="inst-crumbs">
<li><a href="#">Проекты</a></li>
<li><span aria-current="page">Прогон #4127</span></li>
</ol>
</nav>
<span class="inst-cluster-spacer"></span>
<button class="inst-btn inst-btn--sm" type="button">Экспорт</button>
<button class="inst-btn inst-btn--sm inst-btn--primary" type="button">Запустить</button>
</header>
<main class="inst-shell-main" id="main">
<div class="inst-container inst-container--full inst-stack">
<div class="inst-page-header">
<div class="inst-page-header-main">
<h1 class="inst-page-title">Прогоны</h1>
</div>
</div>
</div>
</main>
</div>
Контракт
<a class="inst-skip-link" href="#main">К содержанию</a>
<div class="inst-shell">
<aside class="inst-shell-aside">
<a class="inst-shell-brand" href="/">Приложение</a>
…
</aside>
<header class="inst-shell-header">…</header>
<main class="inst-shell-main" id="main">…</main>
</div>
Порядок в разметке — aside, header, main; на экране их расставляет
grid-template-areas, поэтому шапка стоит справа от колонки, а не над ней. На
узком экране порядок областей меняется тем же свойством — без второго набора
разметки.
Пропуск к содержимому стоит до оболочки: это первое, что встречает
клавиатура, и href обязан указывать на id рабочей области.
| Что | Почему |
|---|---|
inst-skip-link первым в <body> |
Иначе клавиатура каждый раз проходит всю навигацию заново |
id на inst-shell-main |
Цель пропуска |
<aside> / <header> / <main> |
Ориентиры страницы. <div> их не даёт |
aria-label у каждой <nav> внутри |
В оболочке их обычно три: разделы, крошки, вкладки |
Ширина колонки перенастраивается одной строкой:
.my-app { --aside-w: 280px; }
Доступность
| Пропуск к содержимому | Первый элемент в порядке обхода. Скрыт до фокуса clip-path, а не display: none: скрытое так остаётся фокусируемым |
| Ориентиры | <aside>, <header>, <main> дают навигацию по ориентирам без единого role |
| Прокрутка | Три независимые области с overflow: auto. overscroll-behavior не ставится: контейнеру, которому нечего прокручивать, contain съедает колесо мыши |
| Высота | 100dvh, а не 100vh — на мобильных панель браузера иначе срезает нижнюю часть рабочей области |
| Перенос в шапке | Обязателен: без него узкий экран получает горизонтальную прокрутку документа, и 100dvh перестаёт помещаться по высоте |
| Печать | Боковая колонка скрывается, оболочка разворачивается в обычный поток, прокручиваемые области печатаются целиком |
Устройство
| Область | Что делает |
|---|---|
inst-shell-aside |
Боковая колонка шириной --aside-w. Прокручивается сама, лежит на --surface-sunken |
inst-shell-header |
Шапка. Ряд с обязательным переносом: крошки и контролы на узком экране уходят на вторую строку, а не выталкивают документ за вьюпорт |
inst-shell-main |
Рабочая область. Прокручивается сама, даёт вертикальный воздух и объявлена контейнером app |
Горизонтальный воздух даёт не рабочая область, а контейнер внутри неё: у них разная работа — один ограничивает меру чтения, другая держит прокрутку.
Заголовок колонки
Имя приложения — первый ребёнок колонки, до навигации.
Класс живёт в библиотеке из-за одной строки — отбивки снизу. В колонке она обязательна: без неё имя встаёт в ряд с пунктами и делается первым из них. В ряду (узкий экран) она же смещает имя вверх на половину себя, и логотип с пунктами перестают стоять на одной линии. Расхождение в шесть пикселей, которое видно сразу и не находится никогда — потому что искать его идут в выравнивании, а лежит оно в отбивке.
Липкая полоса действий
Рабочая область объявляет свой нижний отступ переменной --scroll-pad-end.
position: sticky закрепляется относительно поля паддинга области
прокрутки, а не её границы. Полоса действий, закреплённая по inset-block-end: 0, останавливается на высоту отступа выше видимого низа — и под ней остаётся
живая щель, сквозь которую видно, как едет содержимое. Полоса перестаёт быть
дном.
Знает про этот отступ только тот, кто его поставил. Отсюда переменная:
полоса действий вычитает её сама. Своя область
прокрутки — объявите --scroll-pad-end на ней.
Поведение
@media (width < 60rem) { … }
Единственный порог во всей библиотеке. Оболочка и есть то единственное, что
действительно зависит от размера окна: всё остальное перестраивается интринсиком
или отвечает на ширину своей области через @container — см.
принципы дизайна.
Рабочая область объявлена @container-областью под именем app: её ширина
приходит от сетки оболочки, а не от содержимого, поэтому объявлять её
контейнером безопасно.
/* Своё правило под узкую рабочую область */
@container app (inline-size < 50rem) {
.my-board { grid-template-columns: 1fr; }
}
Ниже 60rem есть два поведения, и выбор между ними — по длине навигации.
Умолчание: горизонтальная полоса
Колонка становится лентой над содержимым:
| Что меняется | Как |
|---|---|
| Порядок областей | header, aside, main — колонкой |
| Колонка | Ряд с горизонтальной прокруткой, нижняя линия вместо боковой |
inst-nav, inst-nav-group |
Разворачиваются в ряд |
inst-nav-label |
Скрывается: подписи групп в полосе не помещаются |
inst-nav-item |
Перестаёт переносить подпись |
Работает без единой строки JS и потому не может «не открыться»: у приложения, которое ничего не подключило, навигация на телефоне всё равно есть. Годится примерно до восьми пунктов.
Вариант: выезжающий ящик
<aside class="inst-shell-aside inst-shell-aside--drawer">…</aside>
<button class="inst-btn inst-btn--icon inst-shell-nav-toggle" type="button"
aria-label="Разделы" aria-expanded="false" aria-controls="sidebar">…</button>
Как только пунктов больше восьми, лента перестаёт работать: семьдесят один пункт — это лента шириной почти восемь тысяч пикселей без заголовков групп. Это уже не «неудобно», это отсутствие навигации.
Ящик сохраняет вертикаль и подписи групп, выезжает из-за начального края и
гасит фон той же подложкой --scrim, что и модалка.
| Класс | Работа |
|---|---|
inst-shell-aside--drawer |
Колонка уходит в фиксированный слой и выезжает по требованию |
inst-shell-nav-toggle |
Кнопка вызова. На широком экране скрыта: колонка и так на месте, и кнопка была бы обманом |
Что даёт приложение. Одну строку — переключить aria-expanded на кнопке.
Ровно тот же контракт, что у модалки с
showModal(). Ловушка фокуса, Escape и клик по подложке — тоже слой
приложения: библиотека рисует состояние, но не вычисляет его.
Сценарии
Оболочка сама по себе — три пустые области. Работу видно, когда в них что-то стоит, и главное в ней не видно на картинке вовсе: каждая область прокручивается сама. Прокрутите таблицу — колонка и шапка останутся на месте.
Собранный на этой оболочке экран лежит в «Сборках»: консоль прогонов. Там же видно, как в неё садятся навигация, шапка экрана, сплит и состояния — пустое, загрузка, отказ.
API
| Имя | Что делает |
|---|---|
| класс | |
inst-shell | Грид на всю высоту окна (100dvh) |
inst-shell-aside | Боковая колонка |
inst-shell-brand | Имя приложения над разделами. Первым ребёнком колонки |
inst-shell-header | Шапка |
inst-shell-main | Рабочая область. Контейнер app |
inst-skip-link | Пропуск к содержимому. Виден только по :focus-visible |
| Имя | Значение | Откуда |
|---|---|---|
| токен | ||
--scroll-pad-endНижний отступ области прокрутки. Объявляет оболочка, вычитает липкая полоса действий | var(--pad-panel) | одно на все ячейки |
--aside-w | 232px | база +4: масштаб |
--pad-panel | var(--space-5) | база +14: плотность · масштаб |
--space-3 | 6px | одно на все ячейки |
--space-4 | 8px | одно на все ячейки |
--space-5 | 12px | одно на все ячейки |
--gap-inline | var(--space-4) | база +14: плотность · масштаб |
--hairline | 1px | база +1: экран |
--border | light-dark(oklch(0 0 0 / 0.12), oklch(1 0 0 / 0.11)) | одно на все ячейки |
--surface-sunken | light-dark(var(--n-2), var(--n-14)) | база +1: тема |
--surface-page | light-dark(var(--n-1), var(--n-13)) | база +1: тема |
--surface-raised | light-dark(var(--n-0), var(--n-12)) | база +1: тема |
--radius-md | 8px | база +4: масштаб |
--text-sm | 0.875rem | база +4: масштаб |
--text-lg | 1.125rem | база +4: масштаб |
--control-h-md | 32px | база +14: плотность · масштаб |
--tracking-tight | -0.01em | одно на все ячейки |
--text-primary | light-dark(var(--n-12), var(--n-1)) | одно на все ячейки |
--z-modal | 1000 | одно на все ячейки |
Почему кит устроен именно так — конституция · Открытый код под MIT, github.com/keshon/instrument