Перейти к содержимому
EN
Масштаб
Плотность
Тема
Акцент

Раскладка

Оболочка приложения

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

Компонент

Прогоны

Разметка
<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-w232pxбаза +4: масштаб
--pad-panelvar(--space-5)база +14: плотность · масштаб
--space-36pxодно на все ячейки
--space-48pxодно на все ячейки
--space-512pxодно на все ячейки
--gap-inlinevar(--space-4)база +14: плотность · масштаб
--hairline1pxбаза +1: экран
--borderlight-dark(oklch(0 0 0 / 0.12), oklch(1 0 0 / 0.11))одно на все ячейки
--surface-sunkenlight-dark(var(--n-2), var(--n-14))база +1: тема
--surface-pagelight-dark(var(--n-1), var(--n-13))база +1: тема
--surface-raisedlight-dark(var(--n-0), var(--n-12))база +1: тема
--radius-md8pxбаза +4: масштаб
--text-sm0.875remбаза +4: масштаб
--text-lg1.125remбаза +4: масштаб
--control-h-md32pxбаза +14: плотность · масштаб
--tracking-tight-0.01emодно на все ячейки
--text-primarylight-dark(var(--n-12), var(--n-1))одно на все ячейки
--z-modal1000одно на все ячейки

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