/* ==================================================================
   «Рядом» · система движения (FIXES7 Д7)

   Четвёртый и последний css-файл. Подключается после deal.css:
   движение — это надстройка над готовым видом, и оно обязано иметь
   право поправить чужой переход, не переписывая чужое правило.

   Словарь взят с lectorium.bytewell.ru (animations.css, tokens.css)
   и переложен на наши классы: кривые, «поднимается из размытия»,
   дрейф пятен фона, дыхание, блик, скелет, появление по словам,
   экономный профиль. Кривые живут в theme.css рядом с --ease —
   ими пользуется не только этот файл.

   Пять правил, которым подчинён файл:

   1. Двигаются только transform (в том числе отдельные translate и
      scale) и opacity. Размытие — исключение, и только на входе
      элемента: filter создаёт слой и требует перерисовки.
   2. Ни одна анимация не задерживает действие. Нажал — сработало,
      движение идёт поверх.
   3. Ни одно появление не оставляет элемент невидимым, если анимация
      почему-то не проиграется. Поэтому здесь НЕТ animation-fill-mode:
      backwards — пауза перед началом вписана в сами кадры (см. раздел 2).
      Правило то же, по которому в screens.css у screen-in нет заполнения.
   4. prefers-reduced-motion: reduce выключает всё. Это не украшение,
      а требование доступности.
   5. :root[data-perf="lite"] — профиль для слабых устройств: без
      размытия и без дрейфа. Включает его js
      (navigator.hardwareConcurrency <= 4 или deviceMemory <= 4),
      здесь описано только то, как выглядит результат.

   Контракт разметки (что этот файл ждёт от html/js):
     .will-reveal    — элемент поднимается из размытия сам, без js
     .word-in        — класс на слове живой расшифровки:
                       <span class="word-in">слово </span>
     .skeleton       — плашка на месте того, что грузится
     .sheen          — блик по поверхности (нужен position и overflow,
                       правило их ставит само)
     .breathe        — медленное дыхание свечения
     .capsule        — плита-капсула: парит, пружинит
     .capsule.open   — развёрнутое состояние (один разворот на класс)
     .capsule-glow   — сиренево-голубое свечение вокруг капсулы
   ================================================================== */

/* ------------------------------------------------------------------
   1. Величины этого файла

   Кривых здесь нет намеренно: они лежат в theme.css рядом с --ease,
   потому что ими пользуются и screens.css, и deal.css. Ниже — только
   то, что нужно самому движению и больше нигде не встречается.
   ------------------------------------------------------------------ */

:root {
  /* Подъём при появлении. В rem — вместе с «крупным текстом» растёт
     и расстояние, иначе на увеличенном интерфейсе движение выглядит
     мельче содержимого. */
  --m-rise: 0.75rem;            /* 12px */

  /* Размытие на входе. В px, как --hair и кольцо фокуса: это не
     размер элемента, а свойство самого эффекта, и расти вместе с
     текстом ему незачем — зато дорожать при этом было бы за что. */
  --m-haze: 8px;
}

/* ------------------------------------------------------------------
   2. Появление содержимого экрана лесенкой

   Смена класса on — это «переход между страницами» для человека.
   Раньше плавным был весь экран целиком (screen-in в screens.css);
   теперь поднимается содержимое, по элементам, с задержкой около
   40 мс между соседями. Разница на глаз большая: экран перестаёт
   быть картинкой, которая проявилась, и становится набором вещей,
   которые встали на места.

   Ступеней шесть. Дальше человек уже не читает «одно за другим»,
   а ждёт, поэтому седьмой и последующие элементы приходят вместе
   с шестым.

   Почему пауза вписана в кадры (0%, 8% { скрыто }), а не задана
   через animation-delay: задержка требует fill-mode: backwards,
   а он оставляет элемент прозрачным и сдвинутым в любой обстановке,
   где анимация не проиграется. Кадры с паузой дают ровно тот же
   результат, но худшее, что может случиться, — содержимое появится
   сразу, без лесенки. При длительности 0.5s ступени выходят
   0 / 40 / 80 / 120 / 160 / 200 мс.
   ------------------------------------------------------------------ */

/* Экран целиком больше не двигается — двигается его содержимое.
   Экран разговора исключён: там сцена во весь кадр, поднимать
   из размытия видеокартинку и панель управления незачем и дорого,
   и своё движение у него есть — капсула шапки (раздел 8). */
.screen.on:not(#s-call) {
  animation: none;
}

.screen.on:not(#s-call) > * {
  animation: reveal-1 0.5s var(--e-out);
}

.screen.on:not(#s-call) > *:nth-child(2) { animation-name: reveal-2; }
.screen.on:not(#s-call) > *:nth-child(3) { animation-name: reveal-3; }
.screen.on:not(#s-call) > *:nth-child(4) { animation-name: reveal-4; }
.screen.on:not(#s-call) > *:nth-child(5) { animation-name: reveal-5; }
.screen.on:not(#s-call) > *:nth-child(n + 6) { animation-name: reveal-6; }

/* Отдельный элемент, который просят появиться так же. В источнике
   этот класс снимал js после вызова reveal(); у нас js в движение не
   вмешивается, поэтому класс работает сам — и элемент с ним не может
   остаться невидимым навсегда, если сценарий не дошёл до вызова. */
.will-reveal {
  animation: reveal-1 0.5s var(--e-out);
}

@keyframes reveal-1 {
  0% { opacity: 0; translate: 0 var(--m-rise); filter: blur(var(--m-haze)); }
  100% { opacity: 1; translate: 0 0; filter: blur(0); }
}

@keyframes reveal-2 {
  0%, 8% { opacity: 0; translate: 0 var(--m-rise); filter: blur(var(--m-haze)); }
  100% { opacity: 1; translate: 0 0; filter: blur(0); }
}

@keyframes reveal-3 {
  0%, 16% { opacity: 0; translate: 0 var(--m-rise); filter: blur(var(--m-haze)); }
  100% { opacity: 1; translate: 0 0; filter: blur(0); }
}

@keyframes reveal-4 {
  0%, 24% { opacity: 0; translate: 0 var(--m-rise); filter: blur(var(--m-haze)); }
  100% { opacity: 1; translate: 0 0; filter: blur(0); }
}

@keyframes reveal-5 {
  0%, 32% { opacity: 0; translate: 0 var(--m-rise); filter: blur(var(--m-haze)); }
  100% { opacity: 1; translate: 0 0; filter: blur(0); }
}

@keyframes reveal-6 {
  0%, 40% { opacity: 0; translate: 0 var(--m-rise); filter: blur(var(--m-haze)); }
  100% { opacity: 1; translate: 0 0; filter: blur(0); }
}

/* Переход между разделами нижней панели (`data-enter="tab"` ставит
   роутер): это не новая страница, а смена содержимого под той же панелью.
   Поэтому движение тише — без размытия, подъём вдвое меньше, лёгкое
   приближение из 0.99, короче и с шагом 30 мс, — а сама панель не
   анимируется вовсе и остаётся на месте, как одна на все разделы.
   Пауза, как и выше, вписана в кадры, а не в animation-delay. */
.screen.on[data-enter='tab']:not(#s-call) > * {
  animation: tab-in-1 0.36s var(--e-out);
}
.screen.on[data-enter='tab']:not(#s-call) > *:nth-child(2) { animation-name: tab-in-2; }
.screen.on[data-enter='tab']:not(#s-call) > *:nth-child(3) { animation-name: tab-in-3; }
.screen.on[data-enter='tab']:not(#s-call) > *:nth-child(4) { animation-name: tab-in-4; }
.screen.on[data-enter='tab']:not(#s-call) > *:nth-child(5) { animation-name: tab-in-5; }
.screen.on[data-enter='tab']:not(#s-call) > *:nth-child(n + 6) { animation-name: tab-in-6; }

/* Панель разделов не появляется и не исчезает ни при каком переходе:
   на соседнем экране стоит такая же, на том же месте, и любое её
   движение выглядело бы как мигание. !important — потому что правила
   лесенки выше и экономного профиля ниже специфичнее любого селектора
   без id, а исключение здесь одно и безусловное. */
.screen.on > .tabbar {
  animation: none !important;
}

@keyframes tab-in-1 {
  0% { opacity: 0; translate: 0 0.375rem; scale: 0.99; }
  100% { opacity: 1; translate: 0 0; scale: 1; }
}
@keyframes tab-in-2 {
  0%, 8% { opacity: 0; translate: 0 0.375rem; scale: 0.99; }
  100% { opacity: 1; translate: 0 0; scale: 1; }
}
@keyframes tab-in-3 {
  0%, 16% { opacity: 0; translate: 0 0.375rem; scale: 0.99; }
  100% { opacity: 1; translate: 0 0; scale: 1; }
}
@keyframes tab-in-4 {
  0%, 24% { opacity: 0; translate: 0 0.375rem; scale: 0.99; }
  100% { opacity: 1; translate: 0 0; scale: 1; }
}
@keyframes tab-in-5 {
  0%, 32% { opacity: 0; translate: 0 0.375rem; scale: 0.99; }
  100% { opacity: 1; translate: 0 0; scale: 1; }
}
@keyframes tab-in-6 {
  0%, 40% { opacity: 0; translate: 0 0.375rem; scale: 0.99; }
  100% { opacity: 1; translate: 0 0; scale: 1; }
}

/* Экономный профиль: одно движение вместо лесенки и ни одного
   размытия. Лесенка в нём не нужна не из экономии кадров, а из
   честности: на слабом телефоне шесть слоёв с blur приходят
   рывками, и «дорого» превращается в «сломано». */
@keyframes reveal-lite {
  from { opacity: 0; translate: 0 var(--m-rise); }
  to { opacity: 1; translate: 0 0; }
}

/* ------------------------------------------------------------------
   3. Дрейф фона

   Фон приложения — три радиальных пятна и вертикальный градиент,
   собранные в один слой body::before (theme.css, раздел 3). Разложить
   их на три слоя, чтобы каждое пятно ходило само по себе, значило бы
   переписать фон целиком и увести цвета из theme.css. Поэтому сделано
   иначе, в два слоя:

   1. Сам фон медленно ходит целиком. Чтобы сдвиг не открыл край,
      слой заранее увеличен на 14% — этого хватает на ход до 7% в
      любую сторону, а картинка от такого приближения не меняется:
      вместе с пятнами растут и их радиусы.
   2. Поверх идёт одно самостоятельное пятно из той же палитры со
      своим, другим по длине периодом. Оно и даёт то, ради чего всё
      затевалось: свет на фоне движется не как сдвинутая картинка,
      а сам по себе, и два периода никогда не сходятся.

   Анимируется только transform. Никакого background-position:
   сдвиг готового слоя композитор делает почти бесплатно, а сдвиг
   градиента внутри слоя заставляет его растрировать каждый кадр —
   на телефоне это ровно та причина, по которой «красивый фон»
   съедает батарею.
   ------------------------------------------------------------------ */

body::before {
  /* Статическое приближение — оно же запас под ход. Записано и вне
     кадров: если анимация не проиграется, слой всё равно закрывает
     экран с запасом, и края не видно. */
  transform: scale(1.14);
  animation: drift-a 68s var(--e-in-out) infinite;
  will-change: transform;
}

/* Второе пятно. Живёт на псевдоэлементе #app, потому что оба
   псевдоэлемента body заняты (фон и виньетка), а новых элементов
   в разметку движение не добавляет.

   z-index: -1, как у слоёв фона. Ниже поставить нельзя: под
   body::before лежит непрозрачный вертикальный градиент, и оттуда
   пятна не видно вовсе. Значит, оно идёт поверх виньетки — при
   доле 0.16 это незаметно, а сдвинуть виньетку было бы заметно
   сразу: у неё есть углы. */
#app::before {
  content: '';
  position: fixed;
  inset: -20%;                  /* запас под ход, край гасится самим градиентом */
  z-index: -1;
  pointer-events: none;
  background: radial-gradient(34% 26% at 64% 30%, var(--glow-2) 0%, transparent 72%);
  opacity: 0.16;
  animation: drift-b 53s var(--e-in-out) infinite;
  will-change: transform;
}

@keyframes drift-a {
  0%, 100% { transform: scale(1.14) translate3d(0, 0, 0); }
  33% { transform: scale(1.14) translate3d(2.5%, 2%, 0); }
  66% { transform: scale(1.14) translate3d(-2%, 3%, 0); }
}

@keyframes drift-b {
  0%, 100% { transform: translate3d(0, 0, 0); }
  40% { transform: translate3d(-6%, 4%, 0); }
  70% { transform: translate3d(4%, -5%, 0); }
}

/* ------------------------------------------------------------------
   4. Дыхание, блик, скелет
   ------------------------------------------------------------------ */

/* Дыхание свечения. Медленное и без остановки: пульс читается как
   «идёт», вращение — как «зависло». */
.breathe {
  animation: breathe 2.6s var(--e-in-out) infinite;
}

/* Знак приложения на экране загрузки. Пока /api/me не ответил,
   на экране нет ничего, кроме него, и дыхание здесь — единственный
   признак, что приложение живо, а не замерло.

   Только на s-boot: тот же .brand стоит и на экране помощи, а там
   он подпись, а не показание прибора, и дышать ему незачем.

   Дыхание здесь ЗАМЕНЯЕТ появление лесенкой, а не добавляется к
   нему: у элемента одно свойство animation, и два движения на нём
   спорили бы за прозрачность — знак сначала проявился бы до конца,
   а потом провалился к 0.55, с которых начинается вдох. Правило
   стоит ниже раздела 2 и при равной силе побеждает его. */
#s-boot.on > .brand {
  animation: breathe 2.6s var(--e-in-out) infinite;
}

@keyframes breathe {
  0%, 100% { scale: 1; opacity: 0.55; }
  50% { scale: 1.09; opacity: 0.95; }
}

/* Блик, проходящий по поверхности. Вешается на то, что сейчас в
   работе: полосу загрузки модели, плашку ожидания. Правило само
   ставит position и overflow — иначе блик уедет за пределы блока
   и придётся об этом помнить в каждом месте. */
.sheen {
  position: relative;
  overflow: hidden;
}

.sheen::after {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: linear-gradient(100deg, transparent 20%, var(--glass-2) 48%, transparent 76%);
  animation: sheen 1.6s var(--e-in-out) infinite;
}

@keyframes sheen {
  from { transform: translateX(-120%); }
  to { transform: translateX(120%); }
}

/* Плашка на месте того, что грузится. В источнике она мерцает
   сдвигом background-position; здесь — тем же бликом на отдельном
   слое, потому что правило 1 этого файла не делает исключений:
   background-position перерисовывает плашку каждый кадр, а список
   договоров — это десяток таких плашек сразу. */
.skeleton {
  position: relative;
  overflow: hidden;
  border-radius: var(--r-card-s);
  background: var(--glass);
  color: transparent !important;
}

.skeleton::after {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: linear-gradient(100deg, transparent 20%, var(--glass-2) 48%, transparent 76%);
  animation: sheen 1.5s linear infinite;
}

/* ------------------------------------------------------------------
   5. Живая расшифровка: слова появляются по одному

   Класс вешается на слово, которое только что распозналось:
   <span class="word-in">слово </span>. Пробел внутри span, а не
   между ними, — иначе inline-block съедает перенос строки.

   inline-block обязателен: к строчному боксу transform не применяется
   вовсе, и слово просто проявится без подъёма.
   ------------------------------------------------------------------ */

.word-in {
  display: inline-block;
  animation: word-in 0.42s var(--e-out);
}

@keyframes word-in {
  from { opacity: 0; translate: 0 7px; filter: blur(5px); }
  to { opacity: 1; translate: 0 0; filter: blur(0); }
}

/* Размытие на каждом слове — дорогое удовольствие: filter создаёт
   слой и требует перерисовки, а слов в минуту приходит полторы
   сотни. В экономном профиле остаются прозрачность и сдвиг — их
   считает композитор. */
@keyframes word-in-lite {
  from { opacity: 0; translate: 0 5px; }
  to { opacity: 1; translate: 0 0; }
}

/* ------------------------------------------------------------------
   6. Пружина на нажатие, переключатели и галочки

   Нажатие — резкое и без задержки (0.09s по --e-snap): палец уже
   на кнопке, и любое смягчение здесь читается как задумчивость.
   Отпускание — пружина с перелётом (--e-overshoot): кнопка
   возвращается не «до» размера, а чуть «за» него и успокаивается.
   Величины самого сжатия остаются в theme.css — это её вид,
   а не её движение.
   ------------------------------------------------------------------ */

.btn,
.back,
.top-home,
.round,
.key {
  transition: transform 0.34s var(--e-overshoot), background 0.16s var(--e-out);
}

.btn:active,
.back:active,
.top-home:active,
.round:active,
.key:active {
  transition: transform 0.09s var(--e-snap), background 0.09s;
}

/* Круг в панели звонка: у него в переходе есть ещё и цвет значка —
   он переключается вместе с состоянием «включено/выключено». */
.ctl > :first-child {
  transition: transform 0.34s var(--e-overshoot), background 0.16s var(--e-out),
    color 0.16s var(--e-out);
}

.ctl:active > :first-child {
  transition: transform 0.09s var(--e-snap), background 0.09s, color 0.09s;
}

/* Переключатель. Ползунок доезжает до края и слегка перелетает —
   это и есть ощущение щелчка, которого нет у равномерного хода.
   Дорожка красится спокойнее ползунка: цвет не должен обгонять
   движение, иначе переключатель выглядит уже переключённым до
   того, как ползунок доехал. */
.switch::after {
  transition: transform 0.42s var(--e-overshoot);
}

.switch::before {
  transition: background 0.26s var(--e-out);
}

/* Галочка. Появляется с перелётом из точки — так видно, что она
   именно поставлена, а не была там всегда. Анимация без заполнения:
   в конце знак и так в своём обычном виде. */
input[type='checkbox']:checked::after,
input[type='radio']:checked::after {
  animation: tick-in 0.34s var(--e-overshoot);
}

@keyframes tick-in {
  from { transform: scale(0.2); opacity: 0; }
  to { transform: scale(1); opacity: 1; }
}

/* ------------------------------------------------------------------
   7. Капсула

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

   Приём сильный и от повтора тускнеет, поэтому мест ровно два:

   1. Шапка звонка .callTop — состояние разговора. Разворачивается
      при появлении собеседника: до него в шапке одно «Соединяемся…»,
      после — состояние и имя. Это её родное место, и другого
      состояния, которое стоило бы такого движения, на экране нет.
   2. Подсказка .hint-pop — выезжает из капсулы и схлопывается
      обратно, когда уходит.

   Разворот у .callTop привязан к :has(): имя собеседника появилось —
   капсула развернулась. Ни один js для этого не нужен, а там, где
   :has() не поддержан, шапка просто останется на месте — она и без
   движения полностью рабочая, и это главное свойство всей развязки:
   базовое состояние капсулы совпадает с обычным видом элемента, а
   класс open (или его подобие через :has) включает разовый разворот,
   а не постоянно сжатую форму.
   ------------------------------------------------------------------ */

.capsule {
  position: relative;
}

/* Парит: мягкая тень под плитой. У .hint-pop своя тень уже есть
   (screens.css) — вторую не назначаем. */
.capsule,
.callTop {
  box-shadow: 0 0.75rem 1.75rem rgba(0, 0, 0, 0.45);
}

/*
 * Своё центрирование шапки и подсказки переезжает с transform на
 * отдельное свойство translate. Причина: разворот капсулы — это
 * scale, и если оставить центрирование в transform, любой кадр с
 * масштабом стёр бы его и капсула уехала бы к левому краю.
 *
 * Всё завёрнуто в @supports: где отдельных translate/scale нет,
 * transform остаётся прежним, центрирование работает, а разворота
 * просто не происходит. Разворот — украшение, центрирование — нет.
 */
@supports (translate: 0) {
  .callTop {
    transform: none;
    translate: -50% 0;
    animation: capsule-in 0.5s var(--e-out);
  }

  /* Собеседник появился — капсула развернулась. */
  .callTop:has(.who:not(:empty)) {
    animation: capsule-open 0.62s var(--e-out);
  }

  .hint-pop {
    transform: none;
    translate: -50% var(--m-rise);
    scale: 0.92;
    transition: opacity 0.24s var(--e-out), translate 0.42s var(--e-overshoot),
      scale 0.42s var(--e-overshoot);
  }

  /* transform снимается и здесь. Своё центрирование у подсказки
     записано дважды — в закрытом состоянии и в открытом (.hint-pop.on
     в screens.css), — и правило с классом специфичнее нашего. Пока оно
     действовало, к translate прибавлялся ещё один сдвиг на -50%, и
     открытая подсказка уезжала к левому краю. */
  .hint-pop.on {
    transform: none;
    translate: -50% 0;
    scale: 1;
  }
}

@keyframes capsule-in {
  0% { scale: 0.8 0.9; opacity: 0; }
  60% { scale: 1.03 1.01; opacity: 1; }
  100% { scale: 1; opacity: 1; }
}

@keyframes capsule-open {
  0% { scale: 0.66 0.86; opacity: 0.7; }
  60% { scale: 1.05 1.03; opacity: 1; }
  100% { scale: 1; opacity: 1; }
}

/*
 * Свечение капсулы.
 *
 * Слой лежит ПОД содержимым и НАД собственной подложкой плиты
 * (z-index: -1 внутри самой капсулы), поэтому свет виден и вокруг
 * плиты, и мягким налётом на ней — ровно как на видео. Чтобы налёт
 * не съел контраст белого текста, середина слоя приглушена маской
 * до трети: край светится в полную силу, центр — вполсилы.
 *
 * Размыт именно слой свечения, а не плита: в референсе плита
 * остаётся чёткой, размыт свет. Это заодно и дешевле — размывается
 * одна заливка, а не текст с подложкой.
 */
.capsule-glow::before,
.callTop::before,
.hint-pop:not(.ok)::before {
  content: '';
  position: absolute;
  inset: -0.5rem;
  z-index: -1;
  pointer-events: none;
  border-radius: var(--r-pill);
  background: linear-gradient(
    100deg,
    var(--capsule-lilac) 0%,
    var(--white) 46%,
    var(--capsule-sky) 100%
  );
  filter: blur(0.75rem);
  opacity: 0;
  -webkit-mask-image: radial-gradient(
    ellipse closest-side,
    rgba(0, 0, 0, 0.32) 0%,
    rgba(0, 0, 0, 0.32) 55%,
    rgba(0, 0, 0, 1) 100%
  );
  mask-image: radial-gradient(
    ellipse closest-side,
    rgba(0, 0, 0, 0.32) 0%,
    rgba(0, 0, 0, 0.32) 55%,
    rgba(0, 0, 0, 1) 100%
  );
}

/* Вспышка идёт вместе с разворотом и гаснет сама. Свечение —
   событие, а не состояние: постоянно горящая капсула перестала бы
   что-либо значить. У лаймовой подсказки (.ok) свечения нет вовсе —
   сиреневый свет на лайме читается как ошибка цвета. */
.capsule.open::before,
.callTop:has(.who:not(:empty))::before,
.hint-pop:not(.ok).on::before {
  animation: capsule-flash 0.72s var(--e-out);
}

@keyframes capsule-flash {
  0% { opacity: 0; scale: 0.7 0.8; }
  35% { opacity: 0.9; scale: 1.08 1.12; }
  100% { opacity: 0; scale: 1.16 1.2; }
}

/* Разворот капсулы там, где состояние переключает js. */
.capsule.open {
  animation: capsule-open 0.62s var(--e-out);
}

/* ------------------------------------------------------------------
   8. Экономный профиль: без размытия и без дрейфа

   Рисунок сохраняется, цена падает. Включается атрибутом
   data-perf="lite" на <html>.
   ------------------------------------------------------------------ */

:root[data-perf='lite'] body::before {
  animation: none;
}

/* Второе пятно фона в экономном профиле не рисуется вовсе: без
   дрейфа это просто ещё один полноэкранный слой, за который платят
   памятью и ничего не получают. */
:root[data-perf='lite'] #app::before {
  display: none;
}

:root[data-perf='lite'] .screen.on:not(#s-call) > *,
:root[data-perf='lite'] .screen.on:not(#s-call) > *:nth-child(2),
:root[data-perf='lite'] .screen.on:not(#s-call) > *:nth-child(3),
:root[data-perf='lite'] .screen.on:not(#s-call) > *:nth-child(4),
:root[data-perf='lite'] .screen.on:not(#s-call) > *:nth-child(5),
:root[data-perf='lite'] .screen.on:not(#s-call) > *:nth-child(n + 6),
:root[data-perf='lite'] .will-reveal {
  animation-name: reveal-lite;
  animation-duration: 0.3s;
}

:root[data-perf='lite'] .word-in {
  animation-name: word-in-lite;
  animation-duration: 0.26s;
}

/* Свечение капсулы держится на размытии: без него вместо света
   получается цветная пилюля с резким краем. Поэтому в экономном
   профиле капсула остаётся — пружина и тень никуда не деваются, —
   а свет убирается целиком. */
:root[data-perf='lite'] .capsule-glow::before,
:root[data-perf='lite'] .callTop::before,
:root[data-perf='lite'] .hint-pop::before {
  display: none;
}

/* Что в экономном профиле ещё оставалось дорогим (docs/FIXES16.md, п. 3).

   Размытие подложки под шапкой звонка: телефон размывает всё, что лежит
   под плашкой, заново на каждом кадре, пока под ней что-то движется, — а
   под ней кольцо голоса. Плашка остаётся, фон у неё плотнее, чтобы текст
   читался без размытия. */
:root[data-perf='lite'] .callTop {
  -webkit-backdrop-filter: none;
  backdrop-filter: none;
  background: rgba(11, 15, 12, 0.86);
}

/* Тень, которая растёт и гаснет, браузер перерисовывает главным потоком
   на каждом кадре. Кольца вокруг кнопки диктовки расходятся ровно тогда,
   когда телефон занят больше всего — слушает и распознаёт речь. О том, что
   запись идёт, говорит сама кнопка: она лаймовая и крупнее, пока её держат
   (deal.css, .ptt.held .ptt-mic). Кольца в экономном профиле не рисуются. */
:root[data-perf='lite'] .ptt.held .ptt-ring {
  animation: none;
  opacity: 0;
}

/* Слой фона в экономном профиле не двигается — значит, и держать его
   отдельным слоем видеопамяти незачем. */
:root[data-perf='lite'] body::before {
  will-change: auto;
}

/* ------------------------------------------------------------------
   9. Отключение движения

   theme.css уже гасит длительности по prefers-reduced-motion, и
   этого достаточно, чтобы всё описанное выше закончилось мгновенно
   в своём обычном виде — ни одно появление здесь не оставляет за
   собой скрытого состояния. Ниже — только то, чего theme.css знать
   не может: непрерывные анимации этого файла и лишний слой фона.
   ------------------------------------------------------------------ */

@media (prefers-reduced-motion: reduce) {
  body::before,
  #s-boot .brand,
  .breathe,
  .sheen::after,
  .skeleton::after,
  .capsule,
  .capsule-glow::before,
  .callTop,
  .callTop::before,
  .hint-pop::before,
  .will-reveal,
  .word-in,
  .screen.on > *,
  input[type='checkbox']:checked::after,
  input[type='radio']:checked::after {
    animation: none !important;
  }

  /* Фон остаётся ровно тем кадром, на котором стоял: приближение
     нужно самому дрейфу, но без него оно ничему не мешает и не
     видно. Обнулять его отдельно — значит менять картинку тем,
     кто попросил убрать движение, а просили не об этом. */
  body::before {
    transform: scale(1.14);
  }

  #app::before {
    display: none;
  }

  /* Подсказка обязана оставаться видимой и на месте: она несёт
     текст, а не эффект. */
  .hint-pop {
    translate: -50% 0;
    scale: 1;
    transition: none;
  }
}

/* ------------------------------------------------------------------
   10. Пять этапов сделки

   Движение там, где что-то зафиксировано или сдвинулось: этап сменился,
   сумма принята, суммы совпали или разошлись, лицо распознано, подпись
   поставлена. Всё — transform и opacity, пружина --e-spring для
   «щёлкнуло», --e-overshoot для «поставлено», --e-out для появления.
   Правила файла (шапка) действуют: ни одно движение не задерживает
   действие и не оставляет элемент невидимым.
   ------------------------------------------------------------------ */

/* Лента этапов: сменился этап — строка коротко подаётся вперёд. */
.stage-line.bump {
  animation: stage-bump 0.5s var(--e-spring);
}

@keyframes stage-bump {
  0% { transform: translateX(0); }
  35% { transform: translateX(0.375rem); }
  100% { transform: translateX(0); }
}

/* Точка текущего этапа дышит вместе с кольцом вокруг неё. */
.stage-dots > i.now::after {
  animation: stage-halo 2.2s var(--e-in-out) infinite;
}

@keyframes stage-halo {
  0%, 100% { transform: scale(1); opacity: 0.45; }
  50% { transform: scale(1.35); opacity: 0.12; }
}

/* Знак записи на экране ожидания: три волны поочерёдно, пока пульс живой. */
.record-orb.live > span {
  animation: orb-wave 1.1s var(--e-in-out) infinite;
}

.record-orb.live > span:nth-child(2) { animation-delay: 0.15s; }
.record-orb.live > span:nth-child(3) { animation-delay: 0.3s; }

@keyframes orb-wave {
  0%, 100% { transform: scaleY(1); }
  50% { transform: scaleY(2.1); }
}

/* Отметка стороны: галочка ставится с перелётом — именно поставлена. */
.parties .party.just::after,
.step.just .step-mark::after {
  animation: tick-in 0.38s var(--e-overshoot);
}

.parties .party.just::before,
.step.just .step-mark {
  animation: mark-pop 0.5s var(--e-spring);
}

@keyframes mark-pop {
  0% { transform: scale(0.85); }
  45% { transform: scale(1.12); }
  100% { transform: scale(1); }
}

/* Кнопка «принято»: пружина при переходе в серое — как выключатель,
   который довели до щелчка. Слова сменяются растворением. */
.accept-btn.accept-pop {
  animation: accept-pop 0.55s var(--e-spring);
}

@keyframes accept-pop {
  0% { transform: scale(1); }
  30% { transform: scale(0.955); }
  70% { transform: scale(1.015); }
  100% { transform: scale(1); }
}

.accept-btn.accepted .btn-label {
  animation: label-in 0.4s var(--e-out);
}

.accept-btn.accepted .ic {
  animation: tick-in 0.45s var(--e-overshoot);
}

@keyframes label-in {
  from { transform: translateY(0.35rem); opacity: 0; filter: blur(3px); }
  to { transform: translateY(0); opacity: 1; filter: blur(0); }
}

/* Квитанция под кнопкой поднимается с той же пружиной. */
.receipt.receipt-in {
  animation: receipt-in 0.6s var(--e-spring);
}

.receipt.receipt-in > .ic {
  animation: tick-in 0.5s 0.12s var(--e-overshoot) backwards;
}

@keyframes receipt-in {
  0% { transform: translateY(0.75rem) scale(0.96); opacity: 0; }
  60% { transform: translateY(-0.125rem) scale(1.005); opacity: 1; }
  100% { transform: translateY(0) scale(1); opacity: 1; }
}

/* Печать: круг раскрывается с перелётом, галочка проступает следом,
   кольцо расходится и тает. Запускается, когда экран показан
   (.screen.on) или когда печать появляется поверх лица (.face-stage.sealed).
   Без заполнения: в конце всё в своём обычном виде. */
.screen.on .seal,
.face-stage.sealed .face-seal .seal {
  animation: seal-in 0.62s var(--e-overshoot);
}

.screen.on .seal .ic,
.screen.on .seal .seal-cross,
.face-stage.sealed .face-seal .ic {
  animation: tick-in 0.45s 0.16s var(--e-overshoot) backwards;
}

.screen.on .seal .seal-ring,
.face-stage.sealed .face-seal .seal-ring {
  animation: seal-ring 1s 0.12s var(--e-out);
}

@keyframes seal-in {
  from { transform: scale(0.4); opacity: 0; }
  to { transform: scale(1); opacity: 1; }
}

@keyframes seal-ring {
  0% { transform: scale(1); opacity: 0.8; }
  100% { transform: scale(1.9); opacity: 0; }
}

/* Печать подписи на s-signed чуть дольше и с паузой: это итог. */
.screen.on .signed-card .seal {
  animation-duration: 0.8s;
  animation-delay: 0.1s;
}

/* Суммы не совпали: карточка коротко вздрагивает — «стоп», не поломка. */
.screen.on .mismatch-card {
  animation: shake-x 0.55s 0.2s var(--e-out);
}

/* Промах в поле суммы — та же дрожь на самом поле. */
.field.shake {
  animation: shake-x 0.45s var(--e-out);
}

@keyframes shake-x {
  0%, 100% { transform: translateX(0); }
  20% { transform: translateX(-0.375rem); }
  40% { transform: translateX(0.3125rem); }
  60% { transform: translateX(-0.1875rem); }
  80% { transform: translateX(0.125rem); }
}

/* Сцена проверки лица.
   Пока распознаватель и камера поднимаются — кольцо крутится
   неопределённо; идёт шаг — кольцо заполняется по --p (deal.css), а
   лаймовая обводка дышит, пока лицо в круге; шаг выполнен — вспышка;
   не вышло — дрожь. */
.face-stage.loading .face-ring {
  animation: ring-spin 1.6s linear infinite;
}

.face-stage.loading .face-ring-fill {
  stroke: var(--lime);
  stroke-dashoffset: 236;   /* пятая часть окружности бегает по кругу */
}

@keyframes ring-spin {
  from { transform: rotate(-90deg); }
  to { transform: rotate(270deg); }
}

.face-stage.live.face-ok .face {
  animation: face-breathe 2.4s var(--e-in-out) infinite;
}

@keyframes face-breathe {
  0%, 100% { box-shadow: 0 0 0 0 rgba(205, 245, 111, 0.28); }
  50% { box-shadow: 0 0 0 0.5rem rgba(205, 245, 111, 0); }
}

.face-stage.flash .face-ring-fill {
  animation: ring-flash 0.6s var(--e-out);
}

@keyframes ring-flash {
  0% { stroke-width: 3; filter: drop-shadow(0 0 0 rgba(205, 245, 111, 0)); }
  30% { stroke-width: 6; filter: drop-shadow(0 0 0.5rem rgba(205, 245, 111, 0.9)); }
  100% { stroke-width: 3; filter: drop-shadow(0 0 0 rgba(205, 245, 111, 0)); }
}

.face-stage.shake {
  animation: shake-x 0.5s var(--e-out);
}

/* Смена подписи действия — растворением, не подменой. */
.hero-step.swap,
#identityStep.swap {
  animation: label-in 0.38s var(--e-out);
}

/* Экономный профиль: без размытия в смене слов, без дыхания вокруг лица. */
:root[data-perf='lite'] .accept-btn.accepted .btn-label,
:root[data-perf='lite'] .hero-step.swap {
  animation-name: reveal-lite;
  animation-duration: 0.26s;
}

:root[data-perf='lite'] .face-stage.live.face-ok .face,
:root[data-perf='lite'] .stage-dots > i.now::after {
  animation: none;
}
