/* ── Система движения пульта ───────────────────────────────────────────────
   Единственный источник длительностей и кривых. Здесь же общие правила
   появления и микроотклика — чтобы движение оставалось системой, а не
   расползалось по 4700 строкам style.css.

   Файл обязателен, а не «дополнительный слой поверх»: style.css переведён на
   токены отсюда, и снятие строки <link> в index.html не откатит анимации к
   прежнему виду, а обнулит вообще все переходы пульта — var(--dur-quick) станет
   невычислимым, и браузер выбросит каждое объявление transition целиком. Это
   ровно тот случай, когда откат тише поломки, поэтому связку стережёт
   tests/motion-css.test.mjs.

   Правило номер один: анимируем только transform и opacity. Эти два свойства
   браузер считает отдельно, уже после того как расположил элементы, поэтому
   соседи физически не могут сдвинуться. Всё остальное — width, height, top,
   margin, padding — двигает раскладку, и именно от них вёрстка «плывёт».
   За этим следит tests/motion-css.test.mjs.
   Исключение одно и живёт не здесь, а рядом со своим элементом в style.css:
   ширина прогресс-бара, где движение ширины и есть смысл элемента. */

:root {
  --dur-quick: 120ms;   /* ховер, нажатие, подсветка строки */
  --dur-base:  200ms;   /* модалки, раскрытие, смена состояния */
  --dur-slow:  320ms;   /* каскад карточек, заполнение прогресс-баров */
  --dur-count: 600ms;   /* счётчик чисел */
  /* Перелив скелетона длиннее рабочих длительностей нарочно: это не отклик на
     действие человека, а фоновое дыхание экрана ожидания. */
  --dur-shimmer: 1400ms;
  /* Мерцание кнопки «Обновить даты»: заметно с другого конца экрана, но не
     дёргает глаз — потому и вдвое медленнее любого отклика на действие. */
  --dur-pulse: 2200ms;
  /* Кролик занятости (js/bunny.js): ритм чесания с блошками, дыхание, период
     моргания, капля пота, прыжок «ура» с вспышкой и искрами, облачко,
     разлёт конфетти, покачивание вскинутых лап. */
  --dur-bunny-chew: 1400ms;
  --dur-bunny-breathe: 2400ms;
  --dur-bunny-blink: 4200ms;
  --dur-bunny-jump: 1100ms;
  --dur-bunny-wave: 800ms;
  --dur-bunny-cloud: 500ms;
  --dur-bunny-confetti: 950ms;
  --dur-bunny-burst: 900ms;
  --dur-bunny-star: 1100ms;

  /* Быстрый старт, мягкое торможение — так двигаются физические предметы. */
  --ease-out:    cubic-bezier(.22, .61, .36, 1);
  /* Симметрично: для переходов, у которых нет «начала» и «конца». */
  --ease-in-out: cubic-bezier(.4, 0, .2, 1);
}

/* ── Модальные окна ────────────────────────────────────────────────────────
   Все 13 окон правятся одним правилом: они объявлены в разметке (динамических
   нет), поэтому общего селектора достаточно и ни одно не будет пропущено.

   Здесь animation, а не transition: окна показываются через style.display, а
   display не анимируется в принципе. Зато анимация запускается заново каждый
   раз, когда элемент выходит из display:none — это ровно то, что нужно. */

@keyframes overlay-in { from { opacity: 0; } to { opacity: 1; } }

@keyframes modal-in { from { opacity: 0; transform: translateY(8px) scale(.98); } to { opacity: 1; transform: none; } }

.modal-overlay {
  animation: overlay-in var(--dur-quick) var(--ease-out) both;
}
.modal-overlay > .modal {
  animation: modal-in var(--dur-base) var(--ease-out) both;
}

/* ── Микроотклик ───────────────────────────────────────────────────────────
   Быстрый и почти незаметный: он не должен читаться как анимация, он должен
   читаться как отзывчивость. Сжатие на нажатии даёт ощущение, что кнопку
   действительно продавили. */

.btn:active:not(:disabled),
.kb-btn:active:not(:disabled),
.tab:active {
  transform: scale(.97);
}

.btn,
.kb-btn,
.tab {
  transition: background var(--dur-quick) var(--ease-out),
              border-color var(--dur-quick) var(--ease-out),
              color var(--dur-quick) var(--ease-out),
              transform var(--dur-quick) var(--ease-out);
}

/* Строки таблиц: подсветка появляется мягко, уходит мгновенно — так курсор,
   быстро проезжающий по списку, не оставляет за собой шлейф. */
.rs-table tbody tr,
.modal__body table tbody tr {
  transition: background var(--dur-quick) var(--ease-out);
}

/* Стрелка сортировки поворачивается, а не перерисовывается другим символом. */
.sort-icon {
  display: inline-block;
  transition: transform var(--dur-quick) var(--ease-out),
              color var(--dur-quick) var(--ease-out);
}
.rs-table th.sortable:hover .sort-icon {
  transform: translateY(-1px);
}

/* ── Скелетоны загрузки ────────────────────────────────────────────────────
   Спиннер сообщает «идёт работа», скелетон — «вот что сейчас появится».
   Человек видит структуру экрана немедленно, и при том же времени ответа
   сервера ожидание субъективно короче.

   Высота плашки повторяет высоту карточки семинара: когда данные приедут,
   ничего не подпрыгнет. */

/* Сетка — зеркало .seminars из style.css (колонки, брейкпоинты, gap, padding).
   Ширины страницы сверху ничто не ограничивает, поэтому auto-fill на широком
   мониторе развёл бы скелетон и настоящую сетку по числу колонок — и высота
   блока подпрыгнула бы в момент, когда скелетон сменяют карточки. */
.skeleton-list {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 18px;
  padding: 24px;
}
@media (max-width: 1280px) { .skeleton-list { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 980px)  { .skeleton-list { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 640px)  { .skeleton-list { grid-template-columns: 1fr; padding: 12px; gap: 12px; } }

.skeleton-card {
  height: 180px;
  border-radius: 8px;
  background: linear-gradient(
    90deg,
    var(--b24-bg) 25%,
    var(--b24-white) 50%,
    var(--b24-bg) 75%
  );
  background-size: 200% 100%;
  animation: skeleton-shimmer var(--dur-shimmer) var(--ease-in-out) infinite;
}

/* Перелив идёт по background-position: раскладку это свойство не трогает. */
@keyframes skeleton-shimmer {
  from { background-position: 200% 0; }
  to   { background-position: -200% 0; }
}

/* ── Переходы разделов ─────────────────────────────────────────────────────
   Приходящий раздел выезжает снизу. Уходящий исчезает мгновенно — его прячет
   swapView() из js/utils.js, и гасить его нельзя: разделы стоят в потоке
   обычными соседями, и на время затухания их было бы видно двоих сразу —
   высота страницы удваивается, а приходящий уезжает под уходящий.

   Здесь animation, а не transition, и ровно по той же причине, что у модалок
   выше: разделы показываются через display, а анимация запускается заново
   каждый раз, когда элемент выходит из display:none. Поэтому правило висит на
   контейнерах статически — JS не навешивает и не снимает ни одного класса.

   Селекторы перечислены поимённо: это ровно те узлы, которые переключает
   swapView() — список и календарь «Набора», верхние вкладки и список
   плотности в Гео.

   Выход элемента из display:none перезапускает анимацию не только на нём
   самом, но и на всех потомках, у которых есть своё правило view-in, —
   движения складываются. Поэтому из .tab-content исключены две вкладки:

   #tab-geo — внутри него карта Leaflet, а она считает положение плиток от
   координат контейнера. Трансформация родителя во время появления сдвигает
   их и оставляет полосы непрогрузившихся квадратов, поэтому сама вкладка
   (сводка, переключатель режимов, карта) показывается без движения. Список
   плотности внутри неё — #geo-density-list — от этого исключения не зависит:
   он анимируется своим отдельным правилом (см. выше), которое существует не
   ради входа вкладки, а ради переключения под-видов список ↔ карта.

   #tab-seminars — внутри него #seminars и #calendarWrap, у обоих уже есть
   view-in. Не исключи вкладку — при переходе на «Набор на семинары»
   одновременно анимировались бы сам #tab-seminars, #seminars/#calendarWrap
   и каскад карточек внутри них: три накладывающихся движения вместо одного.
   Цена: плашка «Данные не загружены», спиннер и сообщение об ошибке — они
   лежат прямо в #tab-seminars, а не в #seminars/#calendarWrap — при входе на
   вкладку появляются без анимации. */

@keyframes view-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }

#seminars,
#calendarWrap,
#geo-density-list,
.tab-content:not(#tab-geo):not(#tab-seminars) {
  animation: view-in var(--dur-base) var(--ease-out) both;
}

/* ── База знаний ───────────────────────────────────────────────────────────
   Правила вкладки «Скрипт продаж» (кнопки, поля, диалоги, плашки блоков) —
   здесь, а не в kb.css, куда их по задаче просился первый черновик. kb.css
   общий с панелью открытых линий (lines.html) — у неё своя тема и свои законы
   движения, её не трогаем, — а токены длительностей и кривых (--dur-…,
   --ease-…), @keyframes overlay-in и modal-in (см. выше) и выключатель
   prefers-reduced-motion (см.
   ниже) объявлены только здесь. Попади эти правила в kb.css — часть отвалилась
   бы молча (нет токенов и кейфреймов), а часть протекла бы в lines.html без
   выключателя движения вообще. Здесь всё автоматически попадает под общую
   систему и общий выключатель.

   #tab-script уже получает свою анимацию появления через .tab-content выше
   (переход на вкладку) — ниже только то, что происходит внутри неё. Два слоя
   движения на одно переключение вкладки (сама вкладка + каскад плашек внизу)
   — та же мера, что уже принята для #tab-seminars: #seminars получает view-in,
   а карточки внутри него — свой каскад, и оба слоя остались. Лишней была бы
   именно третья, промежуточная прослойка, которой здесь и нет. */

/* Плашки блоков (.kb-tile, разметку собирает kb-view.js) появляются каскадом —
   тем же языком, что карточки семинаров (.card в style.css): пять шагов по
   40 мс, дальше разом — в базе бывает и три десятка блоков.

   В брифе для этого правила был указан класс .kb-card — он существует, но
   называет другую вещь: короб «Вариант N» внутри формы правки блока
   карточками (kb-cards.js), там вариантов обычно один-два, а не десятки.
   Настоящая плашка блока в общем списке — .kb-tile, её и берём.

   Каскад заперт на #kb-list[aria-labelledby]: этот атрибут kb.js сам снимает
   на время поиска и архива и возвращает в обычном просмотре — вместе с
   подсветкой активного раздела, одним и тем же кодом (см. renderSwitcher() в
   kb.js). Список пересобирается через innerHTML на каждый дебаунс поиска
   (250 мс, searchEl.addEventListener('input', …) там же) — без этого запора
   каскад переигрывался бы заново на каждую паузу в наборе, и вместо тихого
   появления менеджер видел бы, как весь список мигает при вводе слова. Клик
   по кнопке раздела через тот же атрибут проходит невредимым — это разовое
   действие человека, а не ввод текста, там каскад к месту. */
#kb-list[aria-labelledby] .kb-tile {
  animation: fadeInUp var(--dur-slow) var(--ease-out) both;
}
#kb-list[aria-labelledby] .kb-tile:nth-child(2)   { animation-delay: 40ms; }
#kb-list[aria-labelledby] .kb-tile:nth-child(3)   { animation-delay: 80ms; }
#kb-list[aria-labelledby] .kb-tile:nth-child(4)   { animation-delay: 120ms; }
#kb-list[aria-labelledby] .kb-tile:nth-child(5)   { animation-delay: 160ms; }
#kb-list[aria-labelledby] .kb-tile:nth-child(n+6) { animation-delay: 200ms; }

/* Тень и цвет рамки на наведении у .kb-tile уже заданы в kb.css — здесь только
   оживляем переход между обычным и наведённым состоянием, без своих цветов. */
.kb-tile {
  transition: box-shadow var(--dur-quick) var(--ease-out),
              border-color var(--dur-quick) var(--ease-out);
}

/* Кнопки в подвале плашки (стрелки порядка, «Копировать») — тот же класс носят
   плашки преподавателей и семинаров в боковой колонке (kb-seminars.js), отклик
   достаётся им заодно. Opacity — не только для ховера: flash() гасит кнопку на
   две секунды, пока показан результат копирования или причина отказа. */
.kb-tile-btn {
  transition: background var(--dur-quick) var(--ease-out),
              color var(--dur-quick) var(--ease-out),
              opacity var(--dur-quick) var(--ease-out);
}
.kb-tile-btn:active { transform: scale(.94); }

/* Поля и поиск: рамка меняется мягко — самый частый отклик в базе. Оба класса
   полей реальны и означают разные вещи: .kb-field — общие поля диалогов
   (index.html), .kb-card-field — поля внутри короба «Вариант N» (kb-cards.js,
   см. комментарий про .kb-card выше). */
.kb-search,
.kb-field input,
.kb-field textarea,
.kb-card-field input,
.kb-card-field textarea {
  transition: border-color var(--dur-quick) var(--ease-out),
              box-shadow var(--dur-quick) var(--ease-out);
}

/* Переключатель режима правки (Карточками / Текстом). .kb-btn такой же отклик
   уже получает в общем блоке «Микроотклик» выше — здесь не повторяем то, что
   у него есть, и добавляем только недостающий .kb-mode-btn. */
.kb-mode-btn {
  transition: background var(--dur-quick) var(--ease-out),
              border-color var(--dur-quick) var(--ease-out),
              color var(--dur-quick) var(--ease-out);
}

/* Диалоги базы знаний — тем же движением, что и модалки пульта (.modal-overlay
   выше): animation, а не transition, окна показываются сменой display. Класс
   общий на все диалоги базы, включая нарисованные kb-seminars.js (карточка
   преподавателя/семинара, предпросмотр) — см. в index.html комментарий про
   «третий стиль окна внутри одной вкладки». */
.kb-overlay {
  animation: overlay-in var(--dur-quick) var(--ease-out) both;
}
.kb-dialog {
  animation: modal-in var(--dur-base) var(--ease-out) both;
}

/* Записи истории появляются лесенкой — по ним видно, что список живой. Каскад
   здесь не запирают, как у .kb-tile выше: история открывается отдельным
   диалогом по клику и не пересобирается на лету — мигать при наборе нечему. */
.kb-hist-item {
  animation: fadeInUp var(--dur-base) var(--ease-out) both;
}
.kb-hist-item:nth-child(2)   { animation-delay: 40ms; }
.kb-hist-item:nth-child(3)   { animation-delay: 80ms; }
.kb-hist-item:nth-child(n+4) { animation-delay: 120ms; }

/* ── Массовое создание событий ─────────────────────────────────────────────
   Окно показывает полсотни строк, и каждая живёт своей жизнью: ждёт очереди,
   создаётся, оказывается созданной или дублем. Движение здесь несёт смысл —
   по нему видно, что работа идёт и где именно она идёт прямо сейчас. */

@keyframes bulkp-row-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }

/* Пульс работающей строки: кружок «дышит», пока портал заводит это событие. */
@keyframes bulkp-pulse { 0%, 100% { transform: scale(1); opacity: 1; } 50% { transform: scale(.76); opacity: .55; } }

/* Ответ пришёл — знак впечатывается. Перелёт за единицу нарочен: без него смена
   значка неотличима от смены цвета, а её надо заметить боковым зрением. */
@keyframes bulkp-pop { 0% { transform: scale(.4); } 60% { transform: scale(1.18); } 100% { transform: scale(1); } }

/* Дорожка кольца дышит, пока задача идёт: даже на этапе сверки с календарём,
   когда дуга ещё стоит на нуле, видно, что окно не замерло. */
@keyframes bulkp-track { 0%, 100% { opacity: 1; } 50% { opacity: .4; } }

.bulkp__row {
  animation: bulkp-row-in var(--dur-base) var(--ease-out) both;
}
/* Лесенка появления — как у карточек семинаров. Дальше десятой строки задержка
   общая: список бывает на полсотни строк, и ступенька на каждую растянула бы
   появление на секунды. */
.bulkp__row:nth-child(2)     { animation-delay: 25ms; }
.bulkp__row:nth-child(3)     { animation-delay: 50ms; }
.bulkp__row:nth-child(4)     { animation-delay: 75ms; }
.bulkp__row:nth-child(5)     { animation-delay: 100ms; }
.bulkp__row:nth-child(6)     { animation-delay: 125ms; }
.bulkp__row:nth-child(7)     { animation-delay: 150ms; }
.bulkp__row:nth-child(8)     { animation-delay: 175ms; }
.bulkp__row:nth-child(9)     { animation-delay: 200ms; }
.bulkp__row:nth-child(n+10)  { animation-delay: 225ms; }

.bulkp__row[data-state="run"] .bulkp__ic {
  animation: bulkp-pulse var(--dur-shimmer) var(--ease-in-out) infinite;
}
.bulkp__row[data-state="ok"] .bulkp__ic,
.bulkp__row[data-state="dup"] .bulkp__ic,
.bulkp__row[data-state="fail"] .bulkp__ic,
.bulkp__row[data-state="skip"] .bulkp__ic {
  animation: bulkp-pop var(--dur-base) var(--ease-out) both;
}

.bulkp--busy .bulkp__ringBg {
  animation: bulkp-track var(--dur-shimmer) var(--ease-in-out) infinite;
}

/* Полоса работающей строки. Долю выполнения по одному событию сервер не знает,
   поэтому отрезок бежит слева направо — это честное «идёт», а не выдуманный
   процент. Двигаем translateX, раскладку он не трогает; scaleX повторён в
   каждом кадре, иначе анимация затёрла бы ширину отрезка из style.css.

   При «уменьшить движение» общее правило ниже гасит бег (1 повтор, 1мс), и
   отрезок просто стоит в начале дорожки — строка всё равно отличается от
   пустой очереди фоном и подписью. */
@keyframes bulkp-bar-sweep {
  from { transform: translateX(-40%) scaleX(.35); }
  to   { transform: translateX(100%) scaleX(.35); }
}
.bulkp__row[data-state="run"] .bulkp__fill {
  animation: bulkp-bar-sweep var(--dur-shimmer) var(--ease-in-out) infinite;
}

/* ── Дашборд статистики семинара ───────────────────────────────────────────
   Окно открывается по кнопке на плашке, цифры приезжают запросом. Движение
   здесь работает как объяснение, а не как украшение: четыре главных числа
   проявляются слева направо — в том порядке, в каком их читают; кривая набора
   рисуется от далёкого прошлого к сегодня — в ту сторону, в какую идёт время;
   полосы вырастают из нуля, потому что ноль и есть их начало отсчёта.

   Раскладку ничего из этого не двигает: только transform, opacity и длина
   штриха. Ширину полос задаёт inline-стиль, движение даёт scaleX поверх неё. */

@keyframes stats-rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }

.stnum { animation: stats-rise var(--dur-base) var(--ease-out) both; }
.stnum:nth-child(2) { animation-delay: 45ms; }
.stnum:nth-child(3) { animation-delay: 90ms; }
.stnum:nth-child(4) { animation-delay: 135ms; }

/* Панель вкладки перерисовывается через innerHTML самого контейнера: сам
   .stats__panel из документа не выходит и анимацию не перезапустит, поэтому
   появление вешаем на детей — они каждый раз новые. */
.stats__panel > .stats__sec,
.stats__panel > .stchart,
.stats__panel > .stlegend,
.stats__panel > .stats__empty {
  animation: stats-rise var(--dur-base) var(--ease-out) both;
}
.stats__panel > .stats__sec:nth-child(2) { animation-delay: 50ms; }
.stats__panel > .stats__sec:nth-child(3) { animation-delay: 100ms; }
/* Легенда — после того, как кривая дорисовалась: сначала линия, потом её имя. */
.stats__panel > .stlegend { animation-delay: var(--dur-slow); }

/* Прорисовка кривой набора. Работает в паре с pathLength="1" на ломаной: длина
   линии равна единице, поэтому один штрих длиной 1 накрывает её целиком, а сдвиг
   штриха от 1 к 0 открывает линию слева направо. */
@keyframes stats-draw { from { stroke-dashoffset: 1; } to { stroke-dashoffset: 0; } }

.stchart__cur {
  stroke-dasharray: 1;
  animation: stats-draw var(--dur-slow) var(--ease-out) both;
}
/* Заливка, прогноз и точка «сегодня» ждут кривую: без задержки они висят в
   воздухе раньше линии, под которой должны лежать. */
.stchart__area,
.stchart__fc,
.stchart__dot {
  animation: stats-fade var(--dur-base) var(--ease-out) var(--dur-slow) both;
}
@keyframes stats-fade { from { opacity: 0; } }

/* Полосы: рост из нуля. transform-origin задан в style.css рядом с элементом. */
@keyframes stats-bar-grow { from { transform: scaleX(0); } to { transform: scaleX(1); } }

.stbar__fill { animation: stats-bar-grow var(--dur-slow) var(--ease-out) both; }

/* Скелет ожидания — то же дыхание, что и у карточек списка. */
.stats__skeleton { animation: skeleton-shimmer var(--dur-shimmer) var(--ease-in-out) infinite; }

/* ── Часы в шапке ──────────────────────────────────────────────────────────
   Четыре движения, и каждое отвечает на свой вопрос: часы живые (двоеточие
   дышит, волосок секунд едет), минута сменилась (цифра перекинулась), сутки
   сменились (подпись даты перевернулась). Пятого не нужно: часы — фон работы,
   а не её содержание.

   Всё держится на transform и opacity, поэтому шапка при каждом тике остаётся
   неподвижной: соседи не могут сдвинуться от того, чего браузер не считает
   заново. */

@keyframes clock-in { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: none; } }

/* Перекидное табло: новая цифра приходит снизу и доворачивается на место. */
@keyframes clock-flap { from { opacity: .2; transform: translateY(55%) rotateX(-70deg); } to { opacity: 1; transform: none; } }

/* Полночь: подпись даты уходит вверх и возвращается — новое число к этому
   моменту в ней уже стоит, так что перелистывание видно, а не угадывается. */
@keyframes clock-day {
  0% { opacity: 1; transform: none; }
  45% { opacity: 0; transform: translateY(-70%); }
  46% { opacity: 0; transform: translateY(70%); }
  100% { opacity: 1; transform: none; }
}

/* Двоеточие дышит, а не мигает: жёсткое включение-выключение в шапке читается
   как неисправность. Кадр заканчивается непрозрачным — при «уменьшить движение»
   анимация схлопывается в 1 мс, и двоеточие обязано остаться видимым. */
@keyframes clock-blink { 0%, 100% { opacity: 1; } 50% { opacity: .3; } }

.clock--live { animation: clock-in var(--dur-base) var(--ease-out) both; }
.clock__flap--tick { animation: clock-flap var(--dur-base) var(--ease-out) both; }
.clock__date--turn { animation: clock-day var(--dur-slow) var(--ease-in-out) both; }
.clock__sep { animation: clock-blink 2s var(--ease-in-out) infinite; }

/* Волосок секунд двигает не ширину, а масштаб: ширина пересчитала бы раскладку
   шестьдесят раз в минуту. Короткий переход сглаживает шаг секунды; на переходе
   через минуту он же откатывает полоску в ноль — это и читается как сброс. */
.clock__minute i { transition: transform var(--dur-quick) var(--ease-out); }

/* ── Кнопка «Обновить даты» в строке сверки ────────────────────────────────
   Единственное действие пульта, которое убирает саму причину расхождения:
   остальные кнопки строки пишут людям или заводят плашку. Мерцание — способ
   довести до неё глаз в списке из десятка одинаковых строк. Дышит только
   прозрачность: тень и фон двигали бы раскладку соседей. */

@keyframes fix-pulse { from { opacity: 1; } 50% { opacity: .55; } to { opacity: 1; } }

.recon__fix { animation: fix-pulse var(--dur-pulse) var(--ease-in-out) infinite; }
/* Навели мышь — мерцание ни к чему: кнопку уже нашли. */
.recon__fix:hover, .recon__fix:focus-visible, .recon__fix:disabled { animation: none; }

/* ── Кролик занятости ──────────────────────────────────────────────────────
   Движение поставлено по классике мультипликации. Ожидание: кролик грызёт
   морковку не метрономом, а тремя быстрыми укусами с передышкой — голова
   клюёт к морковке (bn-chew), морковка вздрагивает от зубов в противофазе
   (bn-carrot-nibble), ухо подрагивает от удовольствия в те же доли такта
   (bn-ear-flick) — от рассинхрона частей движение и читается живым. От места
   укуса дугой отлетают крошки (bn-crumb-fall) — видно, что кролик именно
   грызёт. Тело дышит, тень дышит в противофазе, глаза изредка моргают. Праздник: прыжок с приседом-замахом, растяжением на
   взлёте, зависанием и сквошем приземления (bn-jump), уши пружинят с
   запаздыванием (bn-ear-boing), позади лопается вспышка с кольцом
   (bn-burst-pop/bn-ring-out), вокруг мерцают искры (bn-star-pop), облачко
   «Ура!» выскакивает с перелётом (bn-cloud-pop), конфетти разлетается по
   inline-траекториям (--dx/--dy/--rot, задержка --cfd). Геометрия и цвета —
   в bunny.css; в кадрах только transform и opacity — оба не трогают раскладку,
   браузер ведёт их на композиторе. Эфемерные детали (крошки, вспышка,
   кольцо, искры, конфетти) в bunny.css рождаются с opacity:0 и кончают кадр
   прозрачными: при «уменьшить движение» (1 повтор, 1мс) их просто нет. */

.bunny-overlay {
  animation: overlay-in var(--dur-quick) var(--ease-out) backwards;
  transition: opacity var(--dur-slow) var(--ease-out);
}

/* Сам кролик выпархивает с лёгким увеличением — накладка не «включается», а
   появляется. */
.bunny {
  animation: bn-pop-in var(--dur-quick) var(--ease-out) backwards;
}

@keyframes bn-pop-in {
  from { opacity: 0; transform: scale(.85); }
  to { opacity: 1; transform: scale(1); }
}

/* Жевание: три быстрых клевка головой к морковке и отдых с полным ртом. */
@keyframes bn-chew {
  0% { transform: rotate(0deg) translateY(0); }
  8% { transform: rotate(-1.8deg) translateY(1.9px); }
  16% { transform: rotate(-.3deg) translateY(.4px); }
  24% { transform: rotate(-2deg) translateY(2.1px); }
  32% { transform: rotate(-.4deg) translateY(.4px); }
  40% { transform: rotate(-1.8deg) translateY(1.9px); }
  54% { transform: rotate(.4deg) translateY(0); }
  66% { transform: rotate(0deg) translateY(0); }
  100% { transform: rotate(0deg) translateY(0); }
}

/* Морковка вздрагивает от зубов в противофазе клевкам головы. */
@keyframes bn-carrot-nibble {
  0% { transform: rotate(0deg); }
  8% { transform: rotate(2.4deg); }
  16% { transform: rotate(.4deg); }
  24% { transform: rotate(2.6deg); }
  32% { transform: rotate(.5deg); }
  40% { transform: rotate(2.4deg); }
  54% { transform: rotate(-.5deg); }
  66% { transform: rotate(0deg); }
  100% { transform: rotate(0deg); }
}

/* Ухо подрагивает от удовольствия в такт укусам и обмякает на отдыхе. */
@keyframes bn-ear-flick {
  0% { transform: rotate(0deg); }
  8% { transform: rotate(7deg); }
  16% { transform: rotate(2deg); }
  24% { transform: rotate(8deg); }
  32% { transform: rotate(2.5deg); }
  40% { transform: rotate(7deg); }
  54% { transform: rotate(-3deg); }
  66% { transform: rotate(1deg); }
  100% { transform: rotate(0deg); }
}

/* Крошки моркови: отскакивают от места укуса, падают дугой, крутясь, и тают.
   Куда лететь — inline-переменные --fx/--fy, когда — --fd (лесенка в такт
   укусам: длительность у крошек та же, что у жующей головы). */
@keyframes bn-crumb-fall {
  0% { opacity: 0; transform: translate(0, 0) rotate(0deg); }
  12% { opacity: 1; transform: translate(calc(var(--fx) * .5), -5px) rotate(70deg); }
  60% { opacity: 1; transform: translate(var(--fx), var(--fy)) rotate(170deg); }
  100% { opacity: 0; transform: translate(calc(var(--fx) * 1.2), calc(var(--fy) + 9px)) rotate(230deg); }
}

@keyframes bn-breathe {
  0%, 100% { transform: scale(1); }
  50% { transform: scale(1.02, 1.035); }
}

@keyframes bn-shadow-pulse {
  0%, 100% { transform: scaleX(1); opacity: .8; }
  50% { transform: scaleX(.94); opacity: .6; }
}

@keyframes bn-blink {
  0%, 91%, 97%, 100% { transform: scaleY(1); }
  94% { transform: scaleY(.12); }
}

/* Прыжок: присед-замах, растяжение на взлёте, зависание на пике, сквош
   приземления, затухающий отскок. Сжатия парные (шире×ниже, уже×выше) —
   объём кролика на глаз сохраняется. */
@keyframes bn-jump {
  0% { transform: translateY(0) scale(1, 1); }
  10% { transform: translateY(3px) scale(1.16, .82); }
  26% { transform: translateY(-27px) scale(.9, 1.14); }
  38% { transform: translateY(-31px) scale(1, 1); }
  50% { transform: translateY(0) scale(1.12, .86); }
  64% { transform: translateY(-11px) scale(.96, 1.06); }
  76% { transform: translateY(0) scale(1.06, .94); }
  88% { transform: translateY(-3px) scale(1, 1); }
  100% { transform: translateY(0) scale(1, 1); }
}

/* Уши в прыжке пружинят с запаздыванием — без этого прыжок деревянный. */
@keyframes bn-ear-boing {
  0% { transform: rotate(0deg); }
  14% { transform: rotate(16deg); }
  32% { transform: rotate(-19deg); }
  48% { transform: rotate(11deg); }
  64% { transform: rotate(-6deg); }
  80% { transform: rotate(3deg); }
  100% { transform: rotate(0deg); }
}

@keyframes bn-ear-boing-r {
  0% { transform: rotate(0deg); }
  14% { transform: rotate(-16deg); }
  32% { transform: rotate(19deg); }
  48% { transform: rotate(-11deg); }
  64% { transform: rotate(6deg); }
  80% { transform: rotate(-3deg); }
  100% { transform: rotate(0deg); }
}

@keyframes bn-wave-l {
  0%, 100% { transform: rotate(0deg); }
  50% { transform: rotate(-14deg); }
}

@keyframes bn-wave-r {
  0%, 100% { transform: rotate(0deg); }
  50% { transform: rotate(14deg); }
}

/* Вспышка-звезда и кольцо позади кролика: короткий «кадр радости». */
@keyframes bn-burst-pop {
  0% { opacity: 0; transform: scale(.25) rotate(-10deg); }
  25% { opacity: .9; transform: scale(1) rotate(0deg); }
  100% { opacity: 0; transform: scale(1.3) rotate(14deg); }
}

@keyframes bn-ring-out {
  0% { opacity: .8; transform: scale(.5); }
  100% { opacity: 0; transform: scale(2); }
}

/* Искры вспыхивают с поворотом; --sd — сдвиг каждой, чтобы мерцали переливом. */
@keyframes bn-star-pop {
  0% { opacity: 0; transform: scale(0) rotate(0deg); }
  30% { opacity: 1; transform: scale(1.2) rotate(45deg); }
  55% { opacity: .9; transform: scale(.9) rotate(60deg); }
  100% { opacity: 0; transform: scale(0) rotate(90deg); }
}

/* Облачко «Ура!» выскакивает с перелётом и лёгким покачиванием. */
@keyframes bn-cloud-pop {
  from { opacity: 0; transform: translate(-50%, 12px) scale(.3) rotate(-8deg); }
  55% { opacity: 1; transform: translate(-50%, 0) scale(1.14) rotate(3deg); }
  75% { opacity: 1; transform: translate(-50%, 0) scale(.97) rotate(-1deg); }
  to { opacity: 1; transform: translate(-50%, 0) scale(1) rotate(0deg); }
}

@keyframes bn-confetti {
  from { opacity: 1; transform: translate(0, 0) rotate(0deg); }
  to { opacity: 0; transform: translate(var(--dx), var(--dy)) rotate(var(--rot)); }
}

.bunny--wait .bn-head { animation: bn-chew var(--dur-bunny-chew) var(--ease-in-out) infinite; }
.bunny--wait .bn-carrot { animation: bn-carrot-nibble var(--dur-bunny-chew) var(--ease-in-out) infinite; }
.bunny--wait .bn-ear { animation: bn-ear-flick var(--dur-bunny-chew) var(--ease-in-out) infinite; }
.bunny--wait .bn-crumb { animation: bn-crumb-fall var(--dur-bunny-chew) var(--ease-out) var(--fd, 0ms) infinite; }
.bunny--wait .bn-body-g { animation: bn-breathe var(--dur-bunny-breathe) var(--ease-in-out) infinite; }
.bunny--wait .bn-shadow { animation: bn-shadow-pulse var(--dur-bunny-breathe) var(--ease-in-out) infinite; }
.bunny--wait .bn-eyes { animation: bn-blink var(--dur-bunny-blink) var(--ease-in-out) infinite; }

.bunny--cheer .bn-jump { animation: bn-jump var(--dur-bunny-jump) var(--ease-in-out) both; }
.bunny--cheer .bn-ear2l { animation: bn-ear-boing var(--dur-bunny-jump) var(--ease-out) calc(var(--dur-bunny-jump) * .06) both; }
.bunny--cheer .bn-ear2r { animation: bn-ear-boing-r var(--dur-bunny-jump) var(--ease-out) calc(var(--dur-bunny-jump) * .06) both; }
.bunny--cheer .bn-arm-l { animation: bn-wave-l var(--dur-bunny-wave) var(--ease-in-out) infinite; }
.bunny--cheer .bn-arm-r { animation: bn-wave-r var(--dur-bunny-wave) var(--ease-in-out) infinite; }
.bunny--cheer .bn-burst { animation: bn-burst-pop var(--dur-bunny-burst) var(--ease-out) both; }
.bunny--cheer .bn-ring { animation: bn-ring-out var(--dur-bunny-burst) var(--ease-out) calc(var(--dur-bunny-jump) * .12) both; }
.bunny--cheer .bn-star { animation: bn-star-pop var(--dur-bunny-star) var(--ease-in-out) var(--sd, 0ms) infinite; }

.bunny__cloud { animation: bn-cloud-pop var(--dur-bunny-cloud) var(--ease-out) calc(var(--dur-bunny-jump) * .3) both; }
.bn-cf { animation: bn-confetti var(--dur-bunny-confetti) var(--ease-out) var(--cfd, 0ms) both; }

/* ── Выключатель ───────────────────────────────────────────────────────────
   «Уменьшить движение» — системная настройка, её включают люди, которым от
   анимаций физически плохо. Раз мы добавляем движение — обязаны добавить и
   выключатель.

   Не 0s, а 1ms: при нулевой длительности браузер не присылает события
   transitionend/animationend, и код, который их ждёт, встаёт навсегда. */
/* ── Встреча: появление шапки и вкладок при загрузке ──────────────────────
   Один раз на загрузку страницы. Animation, а не transition: элементы стоят
   в разметке с первого кадра, переходу не от чего оттолкнуться. В кадрах
   только transform/opacity — раскладка не шевелится. */
@keyframes header-in {
  from { opacity: 0; transform: translateY(-8px); }
  to   { opacity: 1; transform: translateY(0); }
}
.header { animation: header-in var(--dur-slow) var(--ease-out) both; }
.tabs   { animation: header-in var(--dur-slow) var(--ease-out) 80ms both; }

/* Дыхание градиентной линии шапки: только прозрачность, в такт --dur-pulse —
   заметно краем глаза, не дёргает. */
@keyframes accent-breathe {
  0%, 100% { opacity: 0.75; }
  50%      { opacity: 1; }
}
.header::before { animation: accent-breathe var(--dur-pulse) var(--ease-in-out) infinite; }

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 1ms !important;
    animation-delay: 0ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 1ms !important;
    transition-delay: 0ms !important;
    scroll-behavior: auto !important;
  }
  /* Спиннер — исключение: остановленный он врёт, будто загрузка замерла. */
  .loading__spinner {
    animation-duration: .8s !important;
    animation-iteration-count: infinite !important;
  }
}
