/* J Sapron Club — клиентское приложение. Дизайн: холодный сливово-золотой "luxury beauty" —
   стеклянные карточки, мягкие плавающие цветовые пятна на фоне, пружинистые появления,
   сегментированный ввод SMS-кода, счётчик баллов, который "докручивается" при изменении. */

:root{
  /* Минимальная ширина холста приложения. Уже неё ничего не ужимаем — включается
     горизонтальная прокрутка (см. @media в разделе про минимальный размер). */
  --app-min-w:320px;
  /* ---- Размер косметолога на видео ----
     Ролики в приложении три (телефон в руках, планшет для записи, подарок), стоят они на
     четырёх экранах, и раньше каждое место задавало свой размер: вход — 27svh, приглашение
     — 24svh, поздравление — 20vh. Фигура из-за этого выглядела то крупнее, то мельче, а на
     приглашении вдвое мельче остальных: там широкому кадру 640x488 доставалась коробка с
     пропорциями узкого (480x638), и кадр вписывался в неё по ширине.
     Теперь размер один на всё приложение и живёт здесь. Одинаковой делается высота КАДРА:
     фигура занимает у всех трёх роликов одну и ту же долю его высоты (95%, измерено по
     постерам), поэтому равные кадры дают и равный рост косметолога.
     Потолок 216px — не круглое число, а самое большое, при котором широкий кадр (его
     ширина в 1.311 раза больше высоты) ещё влезает в окно поздравления: 340px карточки
     минус 26px полей с каждой стороны. Выше — и кадр начал бы ужиматься по ширине, то есть
     на этом экране фигура снова стала бы меньше, чем на остальных.
     Средняя часть clamp — то же ограничение для узких экранов, где по ширине зажимает
     раньше, чем по высоте. 88px — поля карточки и окна вместе.
     Первая строка — запасной вариант для браузеров без svh и без min(). */
  --ck-h:clamp(130px, 26vh, 216px);
  --ck-h:clamp(130px, min(26svh, (100vw - 88px) / 1.311), 216px);
  /* ---- Шрифты ----
     Rubik — весь интерфейс. Гротеск со скруглёнными углами, и один из немногих
     бесплатных, кто закрывает разом латиницу, кириллицу И иврит — а приложение
     трёхъязычное (см. i18n.js: ru / en / he). Прежняя пара Fraunces + Libre Franklin
     иврита не знала вовсе: ивритские надписи уезжали в системный запасной шрифт и
     выглядели чужими рядом с остальным экраном.
     Manrope — цифры интерфейса: суммы в истории, ячейки SMS-кода. Он намеренно не
     круглый: на фоне мягкого Rubik прямые жирные цифры читаются как вес, а не как пузыри.
     Pacifico — только имя клиента на карте (--font-name). Рукописное перо с нажимом:
     имя обязано выглядеть подписанным от руки, а не набранным. Кириллицу знает, иврит —
     нет, как и Cormorant ниже.
     Cormorant Garamond — цифры на карте клиента (--font-card в .points-card). Антиква с
     сильным контрастом штриха: на карте у цифр другая работа, чем в истории операций, —
     там это данные, а здесь номинал подарочного сертификата, и гротеск превращал карту в
     финтех-плитку. Иврита Cormorant не знает, поэтому на иврите карта набирается тем же
     Rubik (правило в конце блока про карту).
     Все три шрифта приезжают одной таблицей Google Fonts (см. index.html), и адрес у
     клиентского приложения и у админки буквально один и тот же — второе из них берёт
     шрифты уже из кэша браузера, не скачивая заново. Cormorant в админке не используется
     нигде, но из её адреса не убран намеренно: разойдись адреса хоть одним знаком, общий
     кэш пропадёт, а сам файл шрифта браузер качает только под текст, который им набран. */
  --font-ui:"Rubik", system-ui, -apple-system, "Segoe UI", sans-serif;
  --font-num:"Manrope", var(--font-ui);
  /* Фон приложения — белый жемчуг: почти чистый белый с еле уловимым холодным отливом.
     Раньше здесь была тёплая розово-кремовая подложка (#FBF3EF / #F5E2DA); перламутр
     оставляет белого столько же, сколько его было бы на голом #FFF, а цвет приходит
     только пятнами-переливами (--pearl-* ниже) и фирменными акцентами. */
  --bg:#F8FAFD;
  --bg-2:#EEF2F8;
  --surface:#FFFFFF;
  --surface-glass:rgba(255,255,255,0.74);
  /* Передний план пересобран под перламутр: холодный белый фон и тёплый металл на нём —
     та же пара, что украшение на жемчужном подносе. Отсюда три правила палитры:
       • тёмное — не чёрное и не бурое, а глубокий баклажан (--ink): на холодном белом он
         читается «чернилами», а не сажей;
       • акцент — глубокая слива (--plum). До неё здесь побывали малиновый (#B8456A) и
         гранат (#A32F55), и оба спотыкались об одно и то же: они ТЁПЛЫЕ, а перламутр
         ХОЛОДНЫЙ — винное пятно на голубоватом белом читалось чужим. Слива того же рода
         (тёмный цветной акцент), но в холодной половине круга: она родня чернилам-баклажану
         (--ink) и сиреневому отливу (--pearl-lilac), так что кнопка выглядит не наклейкой,
         а сгущённым фоном. Контраст к фону вырос с 6.5:1 до ~9.5:1. Тот же акцент стоит
         в admin-app (#5B3A63) — два приложения наконец одной палитры;
       • гранат не выброшен, а переведён в --danger. Раньше он был сразу двумя вещами — и
         фирменным цветом, и цветом ошибки; если бы акцент уехал в сливу целиком, ошибки и
         списания стали бы лиловыми и перестали читаться как «что-то не так»;
       • золото раздвоено. --gold остаётся МЕТАЛЛОМ (рамки, значки, градиенты) и стал
         светлее — шампанское, а не латунь: на холодном белом латунь отдавала в оливковый.
         Для текста золотом есть отдельная бронза --gold-ink: прежний единый #C79A4B давал
         на белом контраст 2.8:1, надпись про паузу читалась с трудом. */
  --ink:#1E1729;
  --ink-soft:#6E6579;
  --border:#E6E4EF;
  --plum:#4E2A63;
  --plum-deep:#33184A;
  --danger:#A32F55;
  --gold:#C9A566;
  --gold-ink:#8A6A2E;
  --gold-soft:#F5E8D0;
  --success:#2E7D63;
  /* Тени холодные и лиловатые — под перламутром, а не под кремом. Их две: --shadow-color
     для тонкой близкой тени (она даёт предмету край) и --shadow-deep для широкой дальней
     (она даёт ему вес). Белая карточка на белом фоне держится только на этой паре. */
  --shadow-color:rgba(41,33,66,0.13);
  --shadow-deep:rgba(41,33,66,0.20);
  --accent:var(--plum);
  --accent-soft:var(--gold-soft);
  --disabled-bg:#E8E7F0;
  --disabled-ink:#6E6A80;
  /* Перламутр — три холодных отлива, которыми переливается белый фон: голубой,
     сиреневый и мятный. Держим их отдельными токенами, а не вписываем в пятна: тем же
     набором пользуются и пятна на экране входа (.blob), и фон кабинета (.dash-bg),
     так что весь белый переливается одинаково, на каком бы экране человек ни стоял. */
  --pearl-blue:#CFE0FF;
  --pearl-lilac:#E2DCFB;
  --pearl-aqua:#D6EFF1;
}
@media (prefers-color-scheme: dark){
  :root{
    /* Тёмная тема — тот же баклажан, только здесь он фон, а не чернила. Тёмный акцент в
       темноте не читается (9.5:1 к белому означает около 1.5:1 к чёрному), поэтому слива
       отражается в светлую орхидею, гранат --danger — в розовый кварц, а бронза --gold-ink —
       в светлое шампанское: роль у токена та же, значение обратное. Орхидея взята не самая
       светлая: этим же токеном заливается кнопка с белой надписью, так что цвет держит
       баланс между двумя ролями — текстом на фоне и фоном под текстом. */
    --bg:#150E1C; --bg-2:#231834; --surface:#241B31; --surface-glass:rgba(36,27,49,0.66);
    --ink:#F3EDF7; --ink-soft:#B3A8C0; --border:#362B45;
    --plum:#BE94D6; --plum-deep:#9E70BC; --danger:#E58EA8;
    --gold:#E0C289; --gold-ink:#E8CE97;
    --gold-soft:#4B3C24;
    --success:#7FC9A6; --shadow-color:rgba(0,0,0,0.45); --shadow-deep:rgba(0,0,0,0.6);
    --disabled-bg:#332B3E; --disabled-ink:#A79DB4;
    /* В тёмной теме перламутр не светлеет, а густеет: те же голубой, сиреневый и мятный,
       но глубокие. Светлые оттенки на тёмном фоне светили бы прожекторами вместо отлива. */
    --pearl-blue:#2C3E6B; --pearl-lilac:#372B5E; --pearl-aqua:#20444E;
  }
}
*{ box-sizing:border-box; }
/* Телефон — это базовый, а не «уменьшенный» случай: приложение занимает весь экран
   целиком, без карточки-рамки посреди страницы. Рамка с закруглениями, тенью и полями
   вокруг — это отдельный, ДОПОЛНИТЕЛЬНЫЙ вид для большого экрана, он включается только
   в @media в конце файла. Раньше было наоборот (карточка всегда), поэтому на телефоне
   приложение выглядело как страница сайта, открытая на широком мониторе.

   Приложение, а не страница: сам документ НЕ прокручивается — ни вверх-вниз, ни
   вбок. Раньше это была обычная длинная страница, и любой элемент, не поместившийся в
   экран (в первую очередь — окно проверки reCAPTCHA, которое Google вставляет в <body>
   размером под настольный экран), превращал приложение в прокручиваемый сайт: экран
   уезжал вбок и вниз. Теперь <body> — фиксированный слой ровно в видимую область, а
   прокрутка живёт ВНУТРИ экранов (.view) — как в нативном приложении, где скроллится
   список, а не окно.

   position:fixed + inset:0 вместо прежних 100vh/100dvh — не просто другой способ задать
   высоту: у фиксированного слоя низ привязан к нижнему краю ВИДИМОЙ области, поэтому
   он сам сжимается и растёт вместе с адресной строкой браузера — та же задача, что решала
   dvh, но без возможности для страницы вообще стать длиннее экрана.

   overscroll-behavior:none по обеим осям. Жест вбок не должен ни оттягивать край, ни
   листать историю браузера «назад/вперёд»; жест вниз — не должен подсвечивать край.
   По вертикали одно время стояло auto — как лазейка для штатного жеста браузера
   «потянуть сверху вниз, чтобы обновить страницу»: без него перезагрузить приложение
   с телефона было нечем. Лазейка не помогала: жест выполняет браузер, и только над
   прокручиваемым ДОКУМЕНТОМ, а здесь документ фиксированный — оттягивать браузеру
   нечего, сколько ему ни разрешай. Обновление по жесту теперь своё — pull-refresh.js. */
/* touch-action: pan-x pan-y — разрешены только прокрутки, щипок и двойное касание
   масштаба не дают. Это второй из трёх запретов масштабирования (первый — метатега
   viewport в index.html, третий — no-zoom.js): именно он снимает увеличение по двойному
   касанию в Safari, где метатегу не верят. pan-x нужен наравне с pan-y: ниже --app-min-w
   появляется горизонтальная прокрутка, и manipulation её бы оставил, но заодно оставил
   бы и щипок. */
html{
  height:100%; overflow:hidden; overscroll-behavior-x:none; overscroll-behavior-y:none;
  touch-action:pan-x pan-y;
}
/* overflow:auto, а не hidden — это страховка на случай экрана меньше минимального
   (см. --app-min-w и блок @media в конце этого раздела). На нормальном экране
   прокручивать здесь нечего: .view сжимается под видимую область сам, и полоса
   прокрутки не появляется. Но когда содержимому уже некуда сжиматься, лучше отдать
   его прокрутке, чем дожать в оставшиеся пиксели поверх соседей. */
body{
  margin:0; position:fixed; inset:0; overflow:auto;
  overscroll-behavior-x:none; overscroll-behavior-y:none;
  background:var(--bg); color:var(--ink);
  font-family:var(--font-ui);
  display:flex; flex-direction:column; align-items:stretch; justify-content:flex-start;
  -webkit-tap-highlight-color:transparent; touch-action:pan-x pan-y;
}
/* Прокрутка внутри экрана. min-height:0 здесь обязателен: без него flex-элемент не даёт
   сжать себя ниже собственного содержимого и вылезает за экран вместо того, чтобы
   прокручиваться. contain по обеим осям: жест не должен «протекать» на окно браузера —
   ни вбок (это листание истории «назад/вперёд»), ни вниз. Раньше по вертикали стояло
   auto — как лазейка для штатного жеста браузера «потянуть, чтобы обновить». Лазейка
   не работала (документ здесь не прокручивается, оттягивать браузеру нечего — см.
   комментарий к html/body выше), зато оставляла подсветку края у начала списка. Жест
   обновления теперь свой, в pull-refresh.js. */
.view{
  flex:1 1 auto; min-height:0; min-width:var(--app-min-w);
  overflow-y:auto; overflow-x:hidden;
  overscroll-behavior-x:contain; overscroll-behavior-y:contain;
  -webkit-overflow-scrolling:touch;
}
.hidden{ display:none !important; }

/* ---- Жест «потянуть вниз, чтобы обновить»: значок ---- */
/* Стоит над содержимым, но ниже заставки (z-index:5) — на заставке жест выключен.
   top отсчитывается от безопасной зоны, иначе на iPhone значок выезжает из-под «чёлки».
   Смещение и прозрачность ставит JS: transform двигает значок ровно за пальцем, поэтому
   постоянного transition здесь нет — он включается классом is-settling только на время
   возврата, когда палец уже отпущен. */
#pull-refresh{
  position:fixed; left:50%; top:calc(env(safe-area-inset-top) + 6px); z-index:4;
  width:38px; height:38px; margin-top:-52px;
  display:flex; align-items:center; justify-content:center;
  border-radius:50%;
  background:var(--surface-glass);
  backdrop-filter:blur(10px); -webkit-backdrop-filter:blur(10px);
  border:1px solid var(--border); box-shadow:0 6px 18px var(--shadow-color);
  color:var(--ink-soft);
  transform:translate3d(-50%,0,0); opacity:0;
  visibility:hidden; pointer-events:none;
}
#pull-refresh.is-active{ visibility:visible; }
/* Порог пройден — отпускай. Цвет меняется вместе с почти довёрнутой стрелкой: два
   разных сигнала об одном и том же, чтобы «сработает» читалось и краем глаза. */
#pull-refresh.is-armed{ color:var(--plum); border-color:var(--plum); }
#pull-refresh.is-settling{ transition:transform .26s ease, opacity .26s ease; }
.pull-icon{ width:20px; height:20px; }
#pull-refresh.is-refreshing .pull-icon{ animation:spin .8s linear infinite; }
@media (prefers-reduced-motion: reduce){
  #pull-refresh.is-settling{ transition:none; }
  #pull-refresh.is-refreshing .pull-icon{ animation:none; }
}


/* ---- Перламутровые переливы: несколько почти прозрачных пятен медленно "дышат" ---- */
/* Пятна не КРАСЯТ белый фон, а переливаются по нему. Отсюда три отличия от прежних
   плотных розового и золотого кругов:
   1) цвет — холодный перламутр (--pearl-*), а не фирменные розовый с золотом;
   2) заливка — радиальный градиент, прозрачный уже к 70% радиуса, а не сплошной круг:
      цвет живёт только в середине пятна, и даже большое пятно почти не съедает белого;
   3) прозрачность — вдвое ниже прежней (.55 → ~.28): перелив должен угадываться, а не
      читаться пятном.
   Дорожка у каждого пятна своя (drift-1…drift-4), длительности взаимно не кратны
   (37/29/43/31 с) — поэтому общий рисунок не повторяется и движение выглядит случайным,
   а не строевым, как было у одной общей drift на всех. */
.ambient-bg{
  position:fixed; inset:0; z-index:0; overflow:hidden; pointer-events:none;
}
.blob{
  position:absolute; border-radius:50%; filter:blur(58px);
}
.blob-1{
  width:320px; height:320px; left:-80px; top:-60px; opacity:.3;
  background:radial-gradient(circle, var(--pearl-blue) 0%, transparent 70%);
  animation:drift-1 37s ease-in-out infinite alternate;
}
.blob-2{
  width:280px; height:280px; right:-90px; top:22%; opacity:.26;
  background:radial-gradient(circle, var(--pearl-lilac) 0%, transparent 70%);
  animation:drift-2 29s ease-in-out infinite alternate; animation-delay:-7s;
}
.blob-3{
  width:360px; height:360px; left:14%; bottom:-170px; opacity:.24;
  background:radial-gradient(circle, var(--pearl-aqua) 0%, transparent 68%);
  animation:drift-3 43s ease-in-out infinite alternate; animation-delay:-13s;
}
.blob-4{
  width:220px; height:220px; right:12%; bottom:26%; opacity:.22;
  background:radial-gradient(circle, var(--pearl-blue) 0%, transparent 66%);
  animation:drift-4 31s ease-in-out infinite alternate; animation-delay:-19s;
}
@keyframes drift-1{
  0%{ transform:translate(0,0) scale(1); }
  38%{ transform:translate(26px,22px) scale(1.1); }
  71%{ transform:translate(-18px,38px) scale(.94); }
  100%{ transform:translate(16px,-14px) scale(1.05); }
}
@keyframes drift-2{
  0%{ transform:translate(0,0) scale(1.04); }
  44%{ transform:translate(-30px,26px) scale(.92); }
  78%{ transform:translate(-12px,-22px) scale(1.12); }
  100%{ transform:translate(-34px,8px) scale(1); }
}
@keyframes drift-3{
  0%{ transform:translate(0,0) scale(.96); }
  33%{ transform:translate(34px,-26px) scale(1.08); }
  67%{ transform:translate(-22px,-12px) scale(1); }
  100%{ transform:translate(12px,-34px) scale(1.1); }
}
@keyframes drift-4{
  0%{ transform:translate(0,0) scale(1.08); }
  41%{ transform:translate(-24px,-30px) scale(.9); }
  74%{ transform:translate(18px,-16px) scale(1.06); }
  100%{ transform:translate(-8px,24px) scale(.98); }
}
@media (prefers-reduced-motion: reduce){ .blob{ animation:none; } }

.brand{ font-family:var(--font-ui); font-weight:600; font-size:22px; display:flex; align-items:center; gap:8px; }
.brand.small{ font-size:18px; }
.brand-logo{ width:3.3em; height:3.3em; border-radius:50%; object-fit:cover; flex:none; position:relative; z-index:1; }
/* Обёртка логотипа. Раньше держала вокруг него венок из приборов (logo2.png фоном
   в ::before), сейчас только центрирует содержимое и задаёт ему размер шрифтом:
   всё внутри — круг, ролик — считается в em от неё. */
.logo-halo{ position:relative; display:inline-flex; align-items:center; justify-content:center; flex:none; }

/* ---- Видео с зелёным фоном, из которого фон убирается прямо в браузере ---- */
/* Разметка виджета: контейнер [data-chromakey], а внутри три слоя друг на друге —
   неподвижная картинка (она же запасной вариант), невидимое <video> и <canvas>, куда
   chromakey.js перерисовывает каждый кадр уже без зелёного. Само видео обязано остаться
   в отрисовке (opacity, а не display:none), иначе Safari на iPhone не даёт кадров. */
.ck{ position:relative; display:block; }
.ck > .ck-poster,
.ck > .ck-video,
.ck > .ck-canvas{
  position:absolute; left:0; top:0; width:100%; height:100%;
  object-fit:contain; pointer-events:none;
}
.ck > .ck-video{ opacity:0; }
/* Холст проявляется, когда на нём появился первый обработанный кадр (класс ck-live
   ставит chromakey.js). До этого виден poster — иначе на месте виджета была бы дыра
   на всё время, пока видео качается. */
.ck > .ck-canvas{ opacity:0; transition:opacity .35s ease; }
.ck > .ck-poster{ opacity:1; transition:opacity .35s ease; }
.ck.ck-live > .ck-canvas{ opacity:1; }
.ck.ck-live > .ck-poster{ opacity:0; }
@media (prefers-reduced-motion: reduce){
  .ck > .ck-canvas, .ck > .ck-poster{ transition:none; }
}

/* Косметолог вместо круглого логотипа на экране входа. Пропорции 480x638 — размер
   готового файла; ширину считает aspect-ratio по высоте, так что фигура не выпирает за
   карточку на узком экране. Высоту задаёт --ck-h — она же у роликов на приглашении и в
   поздравлении, поэтому косметолог везде одного роста (см. :root).
   Первые две строки — запасной вариант для браузеров без svh и без aspect-ratio. */
.auth-card .brand .ck{ width:163px; height:216px; z-index:1; }
@media (max-height: 700px){
  .auth-card .brand .ck{ width:135px; height:180px; }
}
@supports (aspect-ratio: 480 / 638){
  .auth-card .brand .ck{
    height:calc(var(--ck-h) * var(--kbd-ck, 1));
    width:auto; aspect-ratio:480 / 638; max-width:100%;
  }
}
/* Уменьшение и возврат — движением, а не подменой кадра. Только в режиме ввода: в
   остальное время высота ролика меняется от поворота экрана, и вести её туда незачем. */
body.kbd-fit .auth-card .brand .ck{ transition:height .26s cubic-bezier(.22,.61,.36,1); }
@media (prefers-reduced-motion: reduce){
  body.kbd-fit .auth-card .brand .ck{ transition:none; }
}

/* Открытая клавиатура и размер ролика. Правило одно: ролик уменьшается ПОСЛЕДНИМ — и
   только тогда, когда без этого поле с кнопкой над клавиатурой уже не помещаются.
   Раньше он уменьшался ПЕРВЫМ: стоило коснуться поля — ролик сжимался почти вдвое, вместе
   с ним пропадали название клуба, приветствие и подсказка шага, а
   стоило клавиатуре закрыться, всё возвращалось. То есть самый заметный элемент экрана
   дёргался при каждом касании поля, а это на экране входа происходит по десять раз подряд
   (шесть клеток кода, номер, имя, фамилия). От такого ответа на клавиатуру отказались и не
   возвращаемся: --ck-h считается только от размера экрана и о клавиатуре не знает.
   Но на телефоне, где клавиатура забирает больше половины экрана (Samsung с рядом
   подсказок и крупным шрифтом), места не остаётся даже после того, как убрано всё
   остальное, — и там лучше показать косметолога чуть мельче, чем не показать кнопку.
   Множитель --kbd-ck ставит app.js (fitAboveKbd) — ровно на недостающее и не мельче
   KBD_CK_MIN; едет он через transition, а не прыжком, и возвращается сам, как только
   клавиатура закрылась.
   Место в карточке ролик держит своё: на клавиатуру отвечает пустое место между шапкой и
   формой, а не он. См. «Открытая клавиатура на шагах входа» ниже, рядом с двумя
   auto-промежутками, которые это пустое место и создают. */


/* Ролик под шаг входа: телефон в руках — пока человек вводит что-то со своего телефона
   (номер, код из СМС), планшет для записи — на анкете, где заводится
   карточка клиента. Видно всегда ровно один; спрятанный лежит в display:none, и это не
   только про внешний вид — по нему chromakey.js понимает, что виджет не на экране, и не
   качает, не декодирует и не рисует его вовсе.
   Правило написано «от телефона»: без data-step (карточка ещё не дошла до showStep, или
   app.js не выполнился из-за сорвавшейся загрузки Firebase) видно ролик с телефоном.
   Пустого места на экране не будет ни при каком раскладе. */
.auth-card .ck-step{ display:none; }
.auth-card .ck-step[data-step="phone"]{ display:block; }
.auth-card[data-step="profile"] .ck-step[data-step="phone"]{ display:none; }
.auth-card[data-step="profile"] .ck-step[data-step="reg"]{ display:block; }

/* ---- Заставка (пока Firebase вспоминает вошедшего клиента) ---- */
/* Живёт ровно до первого ответа onAuthStateChanged, после чего app.js плавно гасит её и
   удаляет из разметки — обратно она не возвращается даже при выходе из аккаунта. */
/* Слой поверх приложения, а не ещё один экран в потоке: нужный экран под ней уже
   показан и просто проявляется, когда заставка гаснет, — иначе на время гашения оба
   экрана стояли бы друг под другом и интерфейс дёргался. */
/* Цвета — ТОКЕНЫ темы, как и на всех остальных экранах: заставка обязана быть той же
   темы, что и приложение под ней. Раньше здесь стояли жёсткие светлые цвета (#F8FAFD /
   #1E1729) ради стыка с системным экраном запуска: на телефоне по значку сначала
   показывается не наша страница, а экран, который рисует сам Android по манифесту —
   значок на фоне background_color (#F8FAFD), и тему устройства манифест не знает. Стык
   получался незаметным, но платой за него было худшее: человек с тёмной темой открывал
   приложение и первым делом получал в глаза светлый экран на всё время, пока Firebase
   вспоминает вошедшего, — и только потом приложение становилось тёмным.
   Выбор сделан в пользу темы человека. Светлый кадр системного экрана в тёмной теме
   остаётся (убрать его нельзя — цвет в манифесте один), но он короткий и принадлежит
   системе, а наша заставка с первого кадра уже тёмная и переходит в приложение без
   единой смены яркости. В светлой теме не меняется ничего: var(--bg) там и есть
   #F8FAFD, то есть ровно цвет системного экрана. */
#view-splash{
  position:fixed; inset:0; z-index:5;
  /* Высота — 100svh, а не растяжка от inset:0. У position:fixed нижний край считается
     по БОЛЬШОМУ видимому окну — тому, каким экран становится, когда браузер прячет свою
     адресную строку. Пока строка на месте, заставка снизу уходит под неё, и середина
     нашего блока оказывается ниже середины того, что человек реально видит: логотип с
     подписью выглядят сползшими вниз, хотя в разметке они ровно по центру. 100svh — это
     высота окна в его самом маленьком состоянии (со строкой), поэтому центр заставки
     совпадает с центром видимой области всегда. В установленном приложении адресной
     строки нет, svh там равен всей высоте — ничего не меняется. Старые браузеры без svh
     просто пропустят строку и останутся на прежнем поведении. */
  height:100svh;
  display:flex; align-items:center; justify-content:center; padding:24px;
  background:var(--bg); color:var(--ink);
  /* Фон гаснет НЕ сразу, а после содержимого — задержка здесь и есть вторая доля такта
     (см. блок про уход в два такта ниже). */
  transition:opacity .24s ease .2s;
}

/* ---- Уход заставки: два такта, а не один ----
   Раньше заставка просто гасла целиком, а следующий экран в тот же кадр начинал свой
   каскад появления — и полсекунды на экране честно стояли два экрана сразу: сквозь
   полупрозрачную заставку проступали логотип входа и поля. Заставка при этом ещё не
   ушла, а кабинет уже приходил, и глазу не за что было зацепиться.

   Теперь такт первый: содержимое заставки (логотип с названием, подпись, полоска)
   уезжает вверх и гаснет тем же движением, что и всё остальное в приложении
   (cascadeOut — см. «Уход экрана» ниже). Такт второй: когда содержимого уже нет,
   гаснет фон — под ним пусто, потому что следующий экран ещё стоит на паузе
   (.auth-hold / .dash-hold). Каскад появления начинается только после того, как
   заставку убрали из разметки, — это делает hideSplash в app.js.

   Порядок ухода — сверху вниз, как и везде: сначала логотип, потом подпись, потом
   полоска. Шаг маленький (.05s): объектов всего три, растягивать не на чем. */
#view-splash.splash-out{ opacity:0; pointer-events:none; }
#view-splash.splash-out .splash-brand,
#view-splash.splash-out .splash-line,
#view-splash.splash-out .splash-bar{
  animation-name:cascadeOut;
  animation-duration:.18s;
  animation-timing-function:ease-in;
  animation-fill-mode:both;
}
#view-splash.splash-out .splash-line{ animation-delay:.05s; }
#view-splash.splash-out .splash-bar{ animation-delay:.09s; }
/* Полоска на уходе перестаёт бегать: блик, скользящий внутри уезжающей полоски, — второе
   движение поверх первого, и оба читаются хуже. */
#view-splash.splash-out .splash-bar span{ animation:none; }
@media (prefers-reduced-motion: reduce){
  /* Без анимаций заставка просто гаснет, и ждать содержимое фону незачем. */
  #view-splash{ transition-delay:0s; }
  #view-splash.splash-out .splash-brand,
  #view-splash.splash-out .splash-line,
  #view-splash.splash-out .splash-bar{ animation:none; }
}
/* Сдвиг вниз — чтобы по центру экрана оказался ЛОГОТИП, а не карточка целиком. Под
   логотипом в карточке ещё название, подпись и полоска, поэтому центрирование карточки
   поднимает сам логотип выше середины (на 375×812 — на 44 px). Системный экран запуска
   Android ставит значок ровно по центру, и при переходе логотип заметно прыгал вверх.
   Число равно половине высоты всего, что лежит под логотипом; на layout не влияет —
   transform не двигает соседей и не меняет размеры карточки.

   НО этот сдвиг нужен только там, где системный экран запуска вообще есть, — в
   установленном приложении. В браузере ему предшествует не значок по центру, а вкладка
   с адресной строкой, и опущенный логотип просто читается как «съехал вниз». Поэтому по
   умолчанию сдвига нет (карточка стоит по центру, логотип — чуть выше середины, как
   глаз и ждёт), а 44 px возвращаются только в standalone/fullscreen, то есть когда
   приложение открыто с рабочего стола. */
.splash-card{ display:flex; flex-direction:column; align-items:center; gap:16px; transform:translateY(var(--splash-shift, 0px)); }
@media (display-mode: standalone), (display-mode: fullscreen){
  .splash-card{ --splash-shift:44px; }
}
/* Название, подпись и полоска проявляются НЕ сразу, а через четверть секунды после
   логотипа. Причина — тот самый системный экран запуска: на нём есть только значок,
   без текста. Если наша заставка появляется целиком и разом, глаз читает это как
   «открылась вторая страница». Когда же первый кадр — это один логотип, а остальное
   всплывает следом, переход читается как продолжение того же экрана. */
/* backwards, а НЕ both: у #splash-line прозрачностью управляет i18n.js — он гасит и
   зажигает строку, когда меняет фразу. Анимация с both удержала бы за собой opacity
   насовсем и перебила бы inline-стиль из JS: фразы перестали бы сменяться. При backwards
   анимация владеет прозрачностью только до своего конца, дальше строка живёт своей
   жизнью. Первая смена фразы — на 2.4 с, анимация заканчивается на 0.7 с, не пересекаются. */
.splash-title, .splash-line, .splash-bar{ animation:splashFadeIn .45s ease-out .25s backwards; }
@keyframes splashFadeIn{
  from{ opacity:0; transform:translateY(6px); }
  to{ opacity:1; transform:translateY(0); }
}
@media (prefers-reduced-motion: reduce){
  .splash-title, .splash-line, .splash-bar{ animation:none; }
}
.splash-brand{ flex-direction:column; gap:10px; font-size:22px; }
.splash-brand .logo-halo{ font-size:48px; }
.splash-line{
  margin:0; min-height:1.3em; text-align:center;
  font-family:var(--font-ui); font-style:italic; font-size:14px; color:var(--ink-soft);
  transition:opacity .22s ease;
}
/* Тонкий блик, скользящий по полоске, — тот же жест, что и «наводим блеск» в подписи. */
.splash-bar{ width:130px; height:3px; border-radius:2px; background:var(--border); overflow:hidden; }
.splash-bar span{
  display:block; width:42%; height:100%; border-radius:2px;
  /* Слива и золото — те же токены, что у кнопок: в тёмной теме оба отражаются в светлые
     (орхидея и шампанское), иначе блик не читался бы на тёмной полоске. */
  background:linear-gradient(90deg, var(--plum), var(--gold));
  animation:splashSweep 1.3s ease-in-out infinite;
}
@keyframes splashSweep{
  from{ transform:translateX(-115%); }
  to{ transform:translateX(255%); }
}
@media (prefers-reduced-motion: reduce){
  .splash-bar span{ animation:none; width:100%; }
}

/* ---- Заставка, с которой не выходит уйти ----
   Этих правил в обычном запуске не видит никто: заставка живёт доли секунды и уходит сама.
   Они для того случая, когда ответа, ради которого она стоит, нет и, похоже, не будет
   (SPLASH_STUCK_MS и readClientOnce в app.js).

   Бегущая полоска тогда пропадает совсем, а не замирает: движение обещает, что дело идёт,
   и с неподвижным экраном человек просидит ещё десять минут, прежде чем поймёт, что ждать
   нечего. На её месте встаёт кнопка. Строчка над кнопкой из шёпота курсивом становится
   обычной строкой основного цвета — это уже не «наводим красоту», а новость, которую надо
   прочитать. Сам текст подменяет app.js. */
#view-splash.is-stalled .splash-bar{ display:none; }
#view-splash.is-stalled .splash-line{ color:var(--ink); font-style:normal; }
/* Кнопка стоит одна посреди пустого экрана, тянуть её во всю ширину не за что: у
   .btn-primary своей ширины нет, поэтому размер ей задают поля. Отступ сверху свой, а не
   те 4px от .btn-primary: там кнопка идёт следом за полем ввода, здесь — за подписью на
   пустом экране, и воздуха ей нужно больше. */
.splash-retry{ margin-top:10px; padding-left:26px; padding-right:26px; }

/* ---- Страница-приглашение от мастера ---- */
/* Единственный экран приложения, который читают, а не заполняют: по ссылке от мастера
   сюда попадает человек, который про клуб ещё ничего не знает. Отсюда все его отличия
   от экрана входа — он длиннее экрана телефона и прокручивается как обычная страница,
   а не ужимается под видимую область: сокращать рассказ, чтобы он влез в один экран,
   значит не рассказать ничего.

   Прокрутка тут своя, внутренняя (как у общего .view выше), а не общая со страницей,
   как у экрана входа: под приглашением нет клавиатуры, ради которой экран входа отдаёт
   прокрутку странице, зато есть подвал с переключателем языка — и он должен стоять на
   месте, пока текст едет.  */
/* Матовая подложка — та же, что у экрана входа, и по той же причине: сквозь неё
   продолжают просвечивать цветные пятна фона, и получается «морозное стекло» на весь
   экран, а не белый лист поверх перламутра. Стоит на самом экране, а не на .invite-card:
   карточка ограничена по ширине (её ширина — про длину строки), и стекло превратилось
   бы в полосу посередине. */
#view-invite{
  position:relative; z-index:1;
  background:var(--surface-glass);
  backdrop-filter:blur(22px) saturate(160%); -webkit-backdrop-filter:blur(22px) saturate(160%);
}
.invite-card{
  display:flex; flex-direction:column; align-items:center; text-align:center;
  width:100%; max-width:460px; margin:0 auto;
  padding:calc(env(safe-area-inset-top) + clamp(14px, 3vh, 28px)) 22px clamp(18px, 3vh, 30px);
  animation:cardFade .45s ease-out both;
}
@media (prefers-reduced-motion: reduce){ .invite-card{ animation:none; } }

/* От кого приглашение. Стоит выше названия клуба и набрано мелко и в разрядку: это не
   заголовок страницы, а подпись к ней — «вам это прислал ваш мастер», — и в первую
   секунду человек должен узнать именно это, а не название незнакомого клуба. */
.invite-from{
  margin:0 0 10px; font-size:11.5px; letter-spacing:.1em; text-transform:uppercase;
  color:var(--gold-ink);
}

/* Косметолог с подарком — тот же ролик, что в поздравлении после регистрации, и высота
   кадра у него та же, что у ролика на входе (--ck-h): фигура одного роста на всех экранах.
   Пропорции здесь ДРУГИЕ — 640x488 против 480x638 на входе: кадр с подарком шире, руки
   разведены. Раньше коробке доставались пропорции узкого ролика, широкий кадр вписывался
   в неё по ширине и фигура выходила вдвое мельче, чем на входе. */
.invite-brand{
  flex-direction:column; gap:8px; font-size:22px; margin-bottom:6px;
}
.invite-brand .logo-halo{ font-size:56px; }
.invite-ck{ width:283px; height:216px; max-width:100%; }
@supports (aspect-ratio: 640 / 488){
  .invite-ck{
    height:var(--ck-h);
    width:auto; aspect-ratio:640 / 488; max-width:100%;
  }
}

.invite-title{
  margin:6px 0 0; font-family:var(--font-ui); font-weight:600;
  font-size:clamp(20px, 5.4vw, 25px); line-height:1.25; color:var(--ink);
}
.invite-lead{
  margin:10px 0 0; font-size:14.5px; line-height:1.55; color:var(--ink-soft);
  max-width:36em;
}
/* Три шага — нумерованный список, но цифры рисуем сами: у стандартного маркера номер
   висит слева от текста и на узком экране уводит первую строку вправо. Здесь номер —
   кружок в начале строки, и текст под ним не «уступает место», а начинается от края.
   Список выровнен по левому краю, а не по центру, как всё остальное на странице: три
   разных по длине абзаца, поставленные по центру, читаются как стихотворение. */
.invite-steps{
  list-style:none; counter-reset:invite-step;
  margin:18px 0 0; padding:0; width:100%;
  display:flex; flex-direction:column; gap:12px; text-align:start;
}
.invite-steps li{
  counter-increment:invite-step;
  position:relative; padding-inline-start:42px; min-height:30px;
}
.invite-steps li::before{
  content:counter(invite-step);
  position:absolute; inset-inline-start:0; top:0;
  width:30px; height:30px; border-radius:50%;
  display:flex; align-items:center; justify-content:center;
  font-family:var(--font-num); font-weight:800; font-size:14px;
  color:var(--plum-deep);
  background:color-mix(in srgb, var(--gold) 22%, var(--surface));
  border:1px solid color-mix(in srgb, var(--gold) 45%, transparent);
}
.invite-steps b{ display:block; font-family:var(--font-ui); font-size:15px; color:var(--ink); }
.invite-steps span{ display:block; margin-top:2px; font-size:13.5px; line-height:1.5; color:var(--ink-soft); }

/* Подарок за регистрацию — единственная строка на странице с золотой подложкой: это то,
   что человек получает прямо сейчас, и оно не должно теряться среди трёх шагов. */
.invite-gift{
  margin:18px 0 0; padding:12px 16px; width:100%;
  border-radius:16px; font-size:14px; line-height:1.45; color:var(--plum-deep);
  background:linear-gradient(180deg,
    color-mix(in srgb, var(--gold-soft) 82%, var(--surface)) 0%,
    color-mix(in srgb, var(--gold-soft) 55%, var(--surface)) 100%);
  border:1px solid color-mix(in srgb, var(--gold) 40%, transparent);
}
/* Тёмная тема: --gold-soft здесь тёмная бронза, и сливовые чернила --plum-deep на ней
   тонут (~2.8:1), а номера шагов — на своей золотистой плашке (~2.9:1). Текст подарка
   ведём обычными чернилами, номера — светлым шампанским --gold-ink: роль золотого
   акцента сохраняется, контраст выходит за 4.5:1. */
@media (prefers-color-scheme: dark){
  .invite-gift{ color:var(--ink); }
  .invite-steps li::before{ color:var(--gold-ink); }
}

.invite-cta{ margin-top:20px; width:100%; }

/* Мелкие строки под кнопкой: срок жизни баллов и то, что приложение можно поставить на
   телефон. Обе — честные оговорки, и стоять они должны там, где стоят: после решения,
   а не вместо него. */
.invite-fine{
  margin:12px 0 0; font-size:12px; line-height:1.45; color:var(--ink-soft); max-width:34em;
}
.invite-install-note{ margin-top:6px; }

/* Появление — тот же каскад сверху вниз, что у экрана входа и кабинета: объекты
   выходят по очереди, а не всей страницей разом. Приглашение показывает invite.js, не
   дожидаясь Firebase, поэтому паузы (auth-hold) здесь нет — ждать нечего. */
.invite-card > *{
  opacity:0; animation:fadeInUp .42s cubic-bezier(.2,.9,.25,1) both;
}
.invite-card > *:nth-child(1){ animation-delay:.04s; }
.invite-card > *:nth-child(2){ animation-delay:.10s; }
.invite-card > *:nth-child(3){ animation-delay:.17s; }
.invite-card > *:nth-child(4){ animation-delay:.24s; }
.invite-card > *:nth-child(5){ animation-delay:.31s; }
.invite-card > *:nth-child(6){ animation-delay:.38s; }
.invite-card > *:nth-child(7){ animation-delay:.45s; }
.invite-card > *:nth-child(n+8){ animation-delay:.52s; }
@media (prefers-reduced-motion: reduce){
  .invite-card > *{ animation:none; opacity:1; }
}

/* Иврит: страница симметричная, зеркалить нечего — кроме списка шагов, у которого есть
   начало строки. Оно уже задано логическими свойствами (padding-inline-start,
   inset-inline-start), поэтому достаточно объявить направление самой страницы. */
[lang="he"] .invite-card{ direction:rtl; }

/* ---- Auth screen ---- */
/* На телефоне карточка входа — это и есть экран: она тянется на всю ширину и высоту,
   без границы, скруглений и тени (им неоткуда быть видимыми — вокруг нет «страницы»).
   Матовая подложка (backdrop-filter) остаётся: сквозь неё продолжают просвечивать
   цветные пятна фона, и получается «морозное стекло» на весь экран, а не белый лист.
   Нижний отступ считается от безопасной области — под вырезом камеры и полосой-жестом
   телефона содержимое иначе оказывается срезанным. Место под переключатель языка здесь
   больше не резервируется: он лежит отдельным подвалом в потоке и занимает своё. */
/* Экран входа прокручивается ВМЕСТЕ со страницей, а не внутри себя.
   Общий .view выше устроен наоборот — он сжимается под видимую область и прокручивает
   содержимое у себя внутри, а подвал стоит под ним неподвижно. Ни входу, ни кабинету
   это не подходит (кабинет отсюда своё правило и получил — см. #view-dashboard).
   Подвал занимает 145-165px (переключатель языка, строка про reCAPTCHA и две строки
   ссылок), и эту высоту он забирал у формы ВСЕГДА, сколько бы её ни просила форма. На
   телефоне пониже (360x640 — обычный Android, у которого экран в CSS-пикселях меньше,
   чем у «большого» телефона) формы входа не хватало ровно настолько, что кнопка
   «Получить код по SMS» оказывалась ниже нижнего края области прокрутки — под флагами
   подвала. Со стороны это выглядит как подвал, наехавший на поле ввода номера.

   flex:1 0 auto — обе половины сразу, и в этом весь смысл:
     • grow:1 — когда форма короче экрана (обычный случай), экран входа растягивается на
       всё свободное место, и подвал стоит внизу экрана, как и раньше;
     • shrink:0 + min-height:auto — когда форма ВЫШЕ экрана (анкета из семи строк,
       открытая клавиатура, телефон лёжа), экран входа не ужимается под видимую область:
       он берёт свою настоящую высоту, подвал уезжает под него, и прокручивается вся
       страница целиком — одним привычным движением, без обрезанного края.
   Порога по высоте здесь нет намеренно: любой порог означает, что по одну его сторону
   что-то не помещается (см. прежний @media max-height:599px в конце файла — именно на
   нём приложение и ломалось на чужом телефоне). */
#view-auth{
  flex:1 0 auto; min-height:auto; width:100%; display:flex; padding:0;
  overflow:visible; position:relative; z-index:1;
}
/* Содержимое прижато к верху, а не центрировано по вертикали. Центрирование означает, что
   положение шапки считается от высоты ВСЕГО содержимого: форма кода короче формы анкеты,
   значит при переходе между шагами ролик с косметологом переезжал бы вверх или вниз — и то
   же самое при открытой клавиатуре. Прижатый верх даёт одно и то же место шапки на всех
   шагах и в любом состоянии, а свободное место, если оно есть, остаётся под формой.
   Заодно это снимает старую проблему центрирования: когда содержимое выше экрана, center
   выталкивает верх формы ВЫШЕ начала прокрутки, и до него уже не добраться. */
.auth-card{
  flex:1; width:100%; display:flex; flex-direction:column;
  justify-content:flex-start;
  background:var(--surface-glass);
  backdrop-filter:blur(22px) saturate(160%); -webkit-backdrop-filter:blur(22px) saturate(160%);
  /* Поля сверху и снизу — доля высоты экрана, а не 34px на любом телефоне: на низком
     экране эти два отступа вместе стоили 68px из тех, которых не хватало форме.
     Верхнее поле вдвое меньше нижнего, и это не описка: свободное место карточка делит
     поровну между промежутком над шапкой и промежутком под ней, а верхнее поле кладётся
     СВЕРХУ этого дележа — то есть целиком идёт в верхнюю пустоту и перекашивает её.
     8-16px — то, что остаётся ради воздуха у самого края (и ради вырезов камеры,
     которые добавляет к нему env(safe-area-inset-top)); всё остальное считает дележ.
     Обе строки нужны: вторая — для браузеров с svh, первая остаётся запасной. */
  padding:calc(env(safe-area-inset-top) + clamp(8px, 1.6vh, 16px)) 22px clamp(16px, 3.4vh, 34px);
  padding:calc(env(safe-area-inset-top) + clamp(8px, 1.6svh, 16px)) 22px clamp(16px, 3.4svh, 34px);
  animation:cardFade .45s ease-out both;
}

/* Формы стоят внизу карточки, а шапка — вверху: между ними пустое место, если оно есть.
   Поле с номером и кнопка «Получить код по SMS» из-за этого оказываются у подвала, где
   их и ждёт большой палец, а не в середине экрана под роликом.
   margin-top:auto, а не отдельная раскладка: карточка и так flex-колонка во всю высоту
   экрана, и auto просто отдаёт весь остаток места одному промежутку — тому, что выше форм.
   Ролик при этом не сдвигается ни на пиксель: он прижат к верху, и высота формы на его
   место больше не влияет — ни при смене шага, ни при открытой клавиатуре.
   Отсчёт паузы (#sms-lock-note) лежит внутри этой же коробки, сразу после форм: запас
   высоты (min-height ниже) больше высоты коротких форм, и снаружи коробки заметка
   оказывалась под пустой полосой — далеко от кнопки, о которой она говорит. Внутри она
   встаёт вплотную к видимой форме, а запас остаётся под ней. Ниже коробки в карточке —
   только невидимая коробка reCAPTCHA.

   min-height — ради того, чтобы шапка стояла на месте при смене шага. Свободное место
   делится поровну между промежутком над шапкой и промежутком под ней (см. margin-top:auto
   у .brand), а значит зависит от высоты формы: короткая форма — больше места на двоих,
   и шапка съезжает вниз. 220px — высота самой высокой из форм входа (код из СМС 218,
   номер 157), поэтому на обеих коробка одна и та же
   и шапка не двигается вовсе. Выбивается только анкета: она выше (295), и на ней шапка
   поднимается примерно на 37 пикселей — но анкету человек видит один раз в жизни, при
   регистрации, а первые шаги проходит каждый раз.
   На невысоком экране запаса нет вовсе: 220px там съедают весь остаток, оба промежутка
   становятся нулевыми, и шапка просто стоит вверху — как и раньше. */
.auth-forms{ margin-top:auto; min-height:220px; }

/* ---- Открытая клавиатура на шагах входа: режим ввода ----
   Клавиатура забирает почти половину экрана, а на телефонах Samsung — с рядом подсказок и
   крупным шрифтом — и больше половины. Человек в этот момент смотрит ровно на две вещи:
   на косметолога (это единственное, чем экран входа отличается от любой другой формы с
   полем) и на то, что заполняет, — либо шесть клеток кода, либо поле с кнопкой под ним:
   номер телефона и «Получить код по SMS», анкету и
   «Далее». Всё остальное на время набора не нужно.
   Раскладку клавиатура при этом НЕ ужимает: за это отвечает interactive-widget в метатеге
   viewport (см. index.html). Страница остаётся во весь экран, ничего не пересчитывается и
   не сплющивается, а поднимает нужное над клавиатурой само приложение — правилами ниже.

   Что и в каком порядке убирается, решает app.js (fitAboveKbd), и порядок этот — от
   наименее нужного к самому нужному, по одному шагу и ровно столько, сколько не хватает
   места:
     • kbd-fit  — режим ввода включён. Держит верхний промежуток числом (--kbd-brand-top)
       и разрешает движение: и промежуток, и рост ролика едут через transition. Снимается
       последним, когда всё уже приехало на место, — иначе шапка вернулась бы к auto одним
       кадром, то есть прыжком (подробности в releaseKbdFit).
     • kbd-tight — форма поднята к шапке. Обнулить нижний auto-промежуток мало: весь
       остаток тут же ушёл бы в верхний, и ролик поехал бы ВНИЗ. Поэтому app.js перед
       сжатием запоминает верхний промежуток числом и подставляет его сюда, а нижний уходит
       в ноль: форма поднимается к шапке, ролик остаётся ровно там же, а всё свободное
       место собирается под формой — там, где его и закроет клавиатура. Остальные правила
       класса — отбивки: на невысоком телефоне одного промежутка не хватает, и строки
       шапки встают теснее.
     • kbd-min  — уходит приветствие. Оно стоит НИЖЕ ролика, поэтому
       форма поднимается на всю его высоту, а ролик не двигается ни на пиксель.
     • kbd-solo — уходят название клуба и подсказка шага («Код отправлен на …», «Номер
       подтверждён — расскажите немного о себе»). Остаётся то, что человек прямо сейчас
       заполняет, и косметолог над ним. Подпись под отдельным полем (.field-hint) не
       трогаем: она отвечает на только что введённое (принят ли код подруги), и убрать её
       — значит спрятать ответ ровно в тот момент, когда его ждут.
   Дальше app.js двигает сам ролик вверх (уменьшая --kbd-brand-top, но не выше верхнего
   края карточки — он остаётся на экране целиком), и только в самом конце уменьшает его
   (--kbd-ck, см. «Открытая клавиатура и размер ролика» выше).

   Что уезжает под клавиатуру и почему это не страшно: на шаге с кодом — кнопка
   «Подтвердить» и «изменить номер»/«отправить ещё раз» (код из шести цифр форма
   отправляет сама, fillOtpFrom в app.js), на остальных шагах — строка ошибки, отсчёт
   паузы и подвал. До них человек достаёт прокруткой: нижнее поле карточки на время набора
   растёт ровно на высоту клавиатуры (--kbd-inset), поэтому под ней ничего не пропадает
   насовсем — страница становится длиннее ровно настолько, сколько клавиатура закрыла. */
body.kbd-fit .auth-card .brand{
  margin-top:var(--kbd-brand-top, 0px);
  transition:margin-top .26s cubic-bezier(.22,.61,.36,1),
             margin-bottom .26s cubic-bezier(.22,.61,.36,1);
}
body.kbd-tight .auth-card .brand{ margin-bottom:clamp(2px, .7svh, 6px); }
body.kbd-tight .auth-forms{ margin-top:0; }
body.kbd-tight .welcome{ margin-bottom:clamp(4px, 1svh, 8px); }
/* Промежуток между полями формы — у любой из трёх, а не только у формы кода: под
   клавиатурой стоят и номер, и подшаги анкеты (у них своя колонка .form-step с тем же
   промежутком). */
body.kbd-tight .auth-forms .form,
body.kbd-tight .auth-forms .form-step{ gap:clamp(8px, 1.4svh, 11px); }
/* Место, которое забрала клавиатура, карточка добавляет себе нижним полем. Раскладку
   клавиатура больше не ужимает, а значит, всё, что оказалось под ней, само не поднимется:
   страница ровно в экран, прокручивать нечего. С этим полем страница становится длиннее
   ровно на высоту клавиатуры — и до строки ошибки под кнопкой можно доехать пальцем, а
   браузеру есть что прокрутить, когда он сам подводит поле к фокусу. На положение ролика
   и формы поле не влияет: в этом режиме оба стоят на числах, а не на auto-промежутках. */
body.kbd-tight .auth-card{
  padding-bottom:calc(clamp(16px, 3.4svh, 34px) + var(--kbd-inset, 0px));
}
/* Скрытые строки не заменяются пустотой: display:none убирает их из раскладки целиком,
   иначе смысла в этом не было бы. */
body.kbd-min .auth-card .welcome{ display:none; }
body.kbd-solo .auth-card .brand-name,
body.kbd-solo .auth-card .code-hint{ display:none; }
@media (prefers-reduced-motion: reduce){
  body.kbd-fit .auth-card .brand{ transition:none; }
}

/* ---- Открытая клавиатура на остальных экранах ----
   Раскладку клавиатура не ужимает (см. метатег), поэтому то, что оказалось под ней, само
   не поднимется. На экране входа этим занят режим ввода выше; в кабинете под клавиатуру
   попадает ровно одно — форма на обороте карты (имя и почта). Ей хватает нижнего поля во
   всю высоту клавиатуры: колонка кабинета укладывается в то, что осталось видимым (поля
   считаются внутрь высоты — box-sizing:border-box), карта с формой оказывается над
   клавиатурой, а если и этого мало, экран становится прокручиваемым, и остаток человек
   доводит пальцем. Класс и --kbd-inset ставит app.js. */
body.kbd-up #view-dashboard{ overflow-y:auto; }
body.kbd-up .dash-content{ padding-bottom:var(--kbd-inset, 0px); }
/* И окна поверх экрана. Поля есть ровно у одного — подтверждение почты (шесть ячеек кода,
   .vmail-*), но правило общее: у fixed-окна с inset:0 и margin:auto свободное место делится
   поровну сверху и снизу, то есть окно стоит в середине СТРАНИЦЫ. Страницу клавиатура не
   ужимает — значит середина осталась прежней, и нижняя половина окна вместе с ячейками
   ушла бы под клавиатуру. Нижнее поле во всю её высоту возвращает окно в середину того,
   что видно. */
body.kbd-up .confirm-modal{ margin-bottom:var(--kbd-inset, 0px); }
/* Появление на телефоне — просто проявление. Пружинистый «вылет со скейлом» (cardIn)
   уместен для карточки, лежащей на странице, но когда элемент занимает весь экран,
   тот же эффект читается как рывок всего интерфейса и на время анимации вылезает
   за края экрана. */
@keyframes cardFade{ from{ opacity:0; } to{ opacity:1; } }
@keyframes cardIn{
  from{ opacity:0; transform:translateY(18px) scale(.97); }
  to{ opacity:1; transform:translateY(0) scale(1); }
}
.auth-card.shake{ animation:shake .45s; }
@keyframes shake{
  10%,90%{ transform:translateX(-1px); } 20%,80%{ transform:translateX(3px); }
  30%,50%,70%{ transform:translateX(-6px); } 40%,60%{ transform:translateX(6px); }
}
@media (prefers-reduced-motion: reduce){ .auth-card{ animation:none; } .auth-card.shake{ animation:none; } }

.auth-card .brand{
  text-align:center; justify-content:center; flex-direction:column; gap:10px;
  font-size:24px; margin-bottom:10px;
  /* Второй auto-промежуток в карточке (первый — над формами). Два auto делят свободное
     место поровну, поэтому шапка — ролик, название клуба и приветствие — встаёт ровно
     посередине между верхом экрана и формой, а не липнет к верхнему краю. */
  margin-top:auto;
  /* Название клуба и его отбивки — тоже от высоты экрана: на низком телефоне 24px
     выглядели крупнее, чем задумано (экран меньше, а кегль тот же), и забирали строку,
     которой не хватало форме. */
  gap:clamp(4px, 1.1svh, 10px);
  font-size:clamp(19px, 2.9svh, 24px);
  margin-bottom:clamp(4px, 1.1svh, 10px);
}
.auth-card .logo-halo{ font-size:56px; }
.welcome{ text-align:center; color:var(--ink-soft); font-size:14px; margin:0 0 20px; font-style:italic; font-family:var(--font-ui); }
.welcome{ margin-bottom:clamp(10px, 2.4svh, 20px); }

/* ---- Появление экрана входа: всё по очереди сверху вниз ----
   Порядок ровно тот, в котором элементы лежат на экране: косметолог с названием клуба,
   приветствие, поля формы с кнопкой. Логотип сюда ниоткуда не
   прилетает — заставка просто гаснет на месте (см. hideSplash в app.js), — так что он
   появляется вместе со всеми, первым в очереди.

   --step — шаг между соседями, --form-start — когда до формы доходит очередь. Считать
   через переменные, а не расписывать задержки числами, нужно из-за второго значения:
   при первом появлении экрана (.auth-intro) форма ждёт шапку, а при смене шага внутри
   уже открытого экрана шапка на месте, ждать нечего, и поля выходят почти сразу. */
@keyframes fadeInUp{
  from{ opacity:0; transform:translateY(12px); }
  to{ opacity:1; transform:translateY(0); }
}
.auth-card{ --step:.07s; --form-start:.10s; }
.auth-card.auth-intro{ --form-start:.44s; }

.auth-card .brand,
.auth-card .welcome,
.auth-card .form > *{
  opacity:0; animation:fadeInUp .42s cubic-bezier(.22,.61,.36,1) both;
}
.auth-card .brand{ animation-delay:.04s; }
.auth-card .welcome{ animation-delay:calc(.04s + var(--step)*2); }

.auth-card .form > *{ animation-delay:var(--form-start); }
.auth-card .form > *:nth-child(2){ animation-delay:calc(var(--form-start) + var(--step)); }
.auth-card .form > *:nth-child(3){ animation-delay:calc(var(--form-start) + var(--step)*2); }
.auth-card .form > *:nth-child(4){ animation-delay:calc(var(--form-start) + var(--step)*3); }
.auth-card .form > *:nth-child(5){ animation-delay:calc(var(--form-start) + var(--step)*4); }
.auth-card .form > *:nth-child(6){ animation-delay:calc(var(--form-start) + var(--step)*5); }
.auth-card .form > *:nth-child(7){ animation-delay:calc(var(--form-start) + var(--step)*6); }
.auth-card .form > *:nth-child(8){ animation-delay:calc(var(--form-start) + var(--step)*7); }
.auth-card .form > *:nth-child(9){ animation-delay:calc(var(--form-start) + var(--step)*8); }

/* Анкета разбита на подшаги, и её прямые дети — не поля, а три секции (см. index.html).
   Появляться по очереди должны поля ВНУТРИ видимой секции, а сама секция — просто
   коробка: без этого правила весь подшаг выезжал бы одним куском.

   Хвост формы (точки прогресса, кнопка, кнопки выхода, строка ошибки) остаётся прямым
   ребёнком и держит свои задержки из правил выше — позиции у него постоянные, потому что
   секций всегда три, сколько бы из них ни было спрятано. */
.auth-card .form > .form-step{ animation:none; opacity:1; }
.auth-card .form .form-step > *{
  opacity:0; animation:fadeInUp .42s cubic-bezier(.22,.61,.36,1) both;
  animation-delay:var(--form-start);
}
.auth-card .form .form-step > *:nth-child(2){ animation-delay:calc(var(--form-start) + var(--step)); }
.auth-card .form .form-step > *:nth-child(3){ animation-delay:calc(var(--form-start) + var(--step)*2); }
.auth-card .form .form-step > *:nth-child(4){ animation-delay:calc(var(--form-start) + var(--step)*3); }
.auth-card .form .form-step > *:nth-child(5){ animation-delay:calc(var(--form-start) + var(--step)*4); }
.auth-card .form .form-step > *:nth-child(6){ animation-delay:calc(var(--form-start) + var(--step)*5); }

/* Пауза, пока сверху лежит заставка: под непрозрачным экраном каскад проиграл бы вхолостую.
   Класс снимает app.js в тот кадр, когда заставка начинает гаснуть (releaseAuthCascade).
   Именно play-state, а не отмена анимации: приостановленная анимация с fill both держит
   свой первый кадр, то есть все элементы честно ждут в невидимом состоянии. */
.auth-card.auth-hold .brand,
.auth-card.auth-hold .welcome,
.auth-card.auth-hold .form > *,
.auth-card.auth-hold .form .form-step > *{ animation-play-state:paused; }

@media (prefers-reduced-motion: reduce){
  .auth-card .brand, .auth-card .welcome,
  .auth-card .form > *, .auth-card .form .form-step > *{
    animation:none; opacity:1;
  }
}

/* ---- Уход экрана: та же очередь, только наоборот ----
   Появление ведёт объекты снизу вверх и проявляет их; уход продолжает то же движение —
   объект уезжает ещё чуть выше и гаснет, будто его «сдуло» со страницы вверх. Так между
   двумя половинами перехода нет смены направления, и они читаются как одно движение.

   Очередь и задержки расставляет transitions.js: сколько объектов уходит, знает только он
   (у каждого шага своя форма, у кабинета свои карточки), а значит и шаг между соседями
   можно посчитать только там — в анкете полей вдвое больше, чем в поле для номера, и
   одинаковый для всех шаг растянул бы её уход на полсекунды.

   Имя класса продублировано (.cascade-out.cascade-out) вместе с .view нарочно: рядом
   стоят правила появления вроде «.auth-card .form > *» и «.dash-below > *», и без запаса
   по весу они бы пересилили уход. !important тут не годится — он в придачу обнулил бы
   animation-delay, который transitions.js ставит каждому объекту прямо в style.
   Поэтому и свойства расписаны по одному, а не одной строкой animation: сокращённая
   запись тоже сбрасывает задержку. */
@keyframes cascadeOut{
  from{ opacity:1; transform:translateY(0); }
  to{ opacity:0; transform:translateY(-10px); }
}
.view .cascade-out.cascade-out{
  animation-name:cascadeOut;
  animation-duration:.2s;                       /* то же число в OUT_TIME в transitions.js */
  animation-timing-function:ease-in;
  animation-fill-mode:both;
  /* Уходящий объект больше не кликается: без этого можно успеть нажать кнопку экрана,
     которого уже нет, — например отправить форму второй раз, пока она гаснет. */
  pointer-events:none;
}
@media (prefers-reduced-motion: reduce){
  .view .cascade-out.cascade-out{ animation:none; }
}
/* Промежуток между полями — от высоты экрана: у анкеты семь строк, и на низком телефоне
   14px между каждой стоили почти сотню пикселей на пустом месте. */
.form{ display:flex; flex-direction:column; gap:14px; }
.form{ gap:clamp(10px, 1.9svh, 14px); }

/* Подшаг анкеты. Внутри — та же колонка с тем же промежутком, что и у формы: секция
   существует только ради того, чтобы её содержимое можно было показать и спрятать целиком,
   и в раскладке она обязана быть незаметной. Промежуток повторён числами, а не унаследован:
   gap:inherit взял бы значение у .form, но в браузерах без поддержки inherit для gap это
   молча дало бы нулевой отступ — на анкете это самая заметная поломка из возможных. */
.form-step{ display:flex; flex-direction:column; gap:14px; }
.form-step{ gap:clamp(10px, 1.9svh, 14px); }

/* Точки прогресса анкеты. Три штуки: пройденные и текущая — сливовые, будущие — бледные;
   текущая крупнее, чтобы её было видно и на беглый взгляд, и в чёрно-белом режиме
   экономии батареи, где цвета почти нет. */
.step-dots{ display:flex; justify-content:center; align-items:center; gap:7px; }
.step-dot{
  width:6px; height:6px; border-radius:50%; background:var(--border);
  transition:background-color .25s ease, transform .25s ease;
}
.step-dot.is-done{ background:color-mix(in srgb, var(--plum) 45%, transparent); }
.step-dot.is-current{ background:var(--plum); transform:scale(1.5); }
@media (prefers-reduced-motion: reduce){ .step-dot{ transition:none; } }

/* Иврит: точки идут справа налево. Это тот редкий случай, когда раскладку зеркалить
   НАДО: точки — не украшение, а шкала «сколько пройдено», и читается она в ту же
   сторону, что и текст. Слева направо на иврите закрашенная часть оказывается в конце
   шкалы, и движение вперёд выглядит как возврат назад. Разметку и порядок классов
   (is-done/is-current раздаёт app.js по индексу) это не трогает — переворачивается
   только картинка. */
[lang="he"] .step-dots{ flex-direction:row-reverse; }

/* Сводка на последнем подшаге: имя и подтверждённый номер одной строкой. Набрана как
   введённое значение, а не как подпись, — человек должен узнать в ней то, что сам
   написал двумя экранами раньше. */
.profile-summary{
  margin:0; text-align:center; font-size:14px; font-weight:600; color:var(--ink);
  overflow-wrap:anywhere;
}
.profile-summary:empty{ display:none; }

/* Ссылка «меня пригласила подруга» — отдельной строкой в потоке формы, а не внутри
   текста, поэтому её надо прижать к краю: .link-btn растянулся бы на всю ширину колонки,
   и промах мимо неё открывал бы поле кода. */
.form > .form-step > .link-btn{ align-self:flex-start; padding:2px 0; font-size:13px; }
[lang="he"] .form > .form-step > .link-btn{ align-self:flex-end; }
.form label{ display:flex; flex-direction:column; gap:6px; font-size:13px; color:var(--ink-soft); font-weight:500; }
.form input[type="text"], .form input[type="email"], .form input[type="tel"]{
  /* appearance:none — не косметика, а снятие СОБСТВЕННОЙ отделки поля, которую браузер
     рисует поверх наших стилей. Safari на iPhone под textfield подкладывает свой фон со
     светлой внутренней тенью по верхней кромке, и убрать её через border/background
     нельзя — она приходит из appearance. На обычных полях это давало еле заметную
     вторую черту вдоль верхнего края, а на телефоне (.phone-row ниже) — целую вторую
     рамку внутри первой, потому что там поле занимает не всю видимую рамку, а только
     часть справа от «+972». */
  -webkit-appearance:none; appearance:none;
  border:1px solid var(--border); border-radius:12px; padding:11px 13px; font-size:15px;
  background:var(--bg); color:var(--ink); font-family:inherit;
  transition:border-color .15s ease, box-shadow .15s ease;
}
/* Подсказки в полях. Браузер рисует placeholder почти в цвет ввода, и в поле телефона
   «50 123 4567» рядом с полновесным «+972» читалось как уже введённый номер — люди
   пытались его стереть. Держим подсказку заметно бледнее текста: видно, что это образец,
   а не значение. opacity:1 обязателен — Firefox иначе приглушает placeholder ещё раз,
   поверх нашего цвета. */
.form input::placeholder{
  color:color-mix(in srgb, var(--ink-soft) 45%, transparent);
  opacity:1;
}

/* Автозаполнение. Подставив сохранённый номер или имя, браузер заливает поле СВОИМ цветом
   (бледно-жёлтым или голубым) и держит его сильнее любого background — перебить можно
   только внутренней тенью в сто пикселей. Без этого поле после автозаполнения выглядит
   чужой заливкой в чужой теме: в тёмной теме — белым прямоугольником с тёмным текстом.
   На телефоне это к тому же читалось вторым полем внутри рамки: заливка ложится на input,
   то есть на часть рамки правее «+972», а не на всю. */
.form input:-webkit-autofill,
.form input:-webkit-autofill:hover,
.form input:-webkit-autofill:focus{
  -webkit-box-shadow:0 0 0 100px var(--bg) inset;
  box-shadow:0 0 0 100px var(--bg) inset;
  -webkit-text-fill-color:var(--ink);
  caret-color:var(--ink);
}
.form input:focus{
  outline:none; border-color:var(--plum);
  box-shadow:0 0 0 4px color-mix(in srgb, var(--plum) 18%, transparent);
}

/* Иврит: имя и фамилия — единственные поля анкеты, куда пишут словами, а не номером,
   адресом или кодом, и набирают их справа налево. Направление приходится ставить явно:
   text-align:right с карточки в поле НЕ наследуется — браузер держит на input свой
   text-align:start (проверено: computed style поля остаётся ltr/start). Поэтому пустое
   поле было левосторонним: курсор вставал слева, а набранный иврит уползал от него
   вправо. С direction курсор стоит справа и буквы идут оттуда же; латинское имя внутри
   (Sara) браузер сам разворачивает обратно — этим занимается двунаправленный алгоритм.
   Остальные поля остаются левосторонними намеренно: email, код подруги и телефон
   набирают латиницей и цифрами. */
[lang="he"] .form input[name="firstName"],
[lang="he"] .form input[name="lastName"]{ direction:rtl; text-align:right; }

/* ---- Телефон: только Израиль, код зафиксирован ----
   Одно поле, а не два: рамку, фон и фокусное кольцо держит .phone-row,
   а флаг с +972 и сам input лежат внутри прозрачными. Префикс набран тем же
   кеглем и цветом, что и ввод — читается как уже введённое начало номера. */
.phone-row{
  display:flex; align-items:stretch;
  border:1px solid var(--border); border-radius:12px;
  background:var(--bg); color:var(--ink);
  transition:border-color .15s ease, box-shadow .15s ease;
}
.phone-row:focus-within{
  border-color:var(--plum);
  box-shadow:0 0 0 4px color-mix(in srgb, var(--plum) 18%, transparent);
}
.phone-prefix{
  flex:none; display:flex; align-items:center; gap:6px;
  padding:11px 0 11px 13px; font-size:15px; font-weight:400; white-space:nowrap; color:var(--ink);
  /* По клику по префиксу курсор должен попасть в поле, а не выделить текст. */
  pointer-events:none; user-select:none;
}
.phone-row input[type="tel"]{
  flex:1; min-width:0;
  border:none; background:none; box-shadow:none;
  /* Скруглены только ПРАВЫЕ углы — те, что совпадают с углами самой рамки. Слева поле
     упирается в «+972» посреди рамки, и там любое скругление обводит начало поля
     отдельной дужкой: получается вторая рамка внутри первой, а префикс оказывается за
     её пределами — будто это два разных поля. Само по себе скругление не видно (фона у
     поля нет), но по нему обрезается всё, что поле когда-либо зальёт, — в первую очередь
     заливка автозаполнения выше. */
  border-radius:0 12px 12px 0;
  padding:11px 13px 11px 7px;
}
.phone-row input[type="tel"]:focus{ outline:none; border:none; box-shadow:none; }
/* Повтор правила про автозаполнение — ровно из-за строки выше. Она снимает с поля любую
   тень (иначе фокусное кольцо рисовалось бы дважды: на рамке и внутри неё), и общее
   правило .form input:-webkit-autofill проигрывает ей по порядку, стоит человеку тронуть
   поле: заливка возвращалась бы в тот самый миг, когда на неё смотрят. Внутренняя тень
   обрезается по полю, до префикса не достаёт — но там и так фон рамки того же цвета. */
.phone-row input[type="tel"]:-webkit-autofill,
.phone-row input[type="tel"]:-webkit-autofill:hover,
.phone-row input[type="tel"]:-webkit-autofill:focus{
  -webkit-box-shadow:0 0 0 100px var(--bg) inset;
  box-shadow:0 0 0 100px var(--bg) inset;
}

/* Селектор обязательно с .form: правило перебивает .form label (display:flex;
   flex-direction:column), а у того специфичность выше, чем у одного класса, —
   с простым .checkbox-row галочка вставала ОТДЕЛЬНОЙ строкой над текстом. */
.form .checkbox-row{ flex-direction:row; align-items:flex-start; gap:9px; font-size:13px; line-height:1.4; }
/* Оба согласия умещаются в одну строку, поэтому галочка почти по центру строки
   (16px квадрат против 18px высоты строки). Выравнивание по верху оставлено на случай
   переноса — на узком экране строка может стать двухстрочной. */
.form .checkbox-row input{ margin-top:1px; flex:none; width:16px; height:16px; accent-color:var(--plum); }
.form .checkbox-row a{ color:var(--plum); }
/* Иврит: галочка справа от текста. Общее правило приложения — раскладку не зеркалить
   (см. блок [lang="he"] .view ниже), но здесь зеркалить обязательно: квадратик слева от
   правостороннего текста читается как относящийся к предыдущей строке, а не к своей.
   row-reverse ставит input первым от правого края, текст — левее него; justify-content
   не трогаем: в row-reverse flex-start и есть правый край. */
[lang="he"] .form .checkbox-row{ flex-direction:row-reverse; }

.btn-primary{
  position:relative; margin-top:4px; border:none; border-radius:14px; padding:13px;
  font-size:15px; font-weight:600; font-family:inherit; cursor:pointer; color:#fff;
  background:linear-gradient(135deg, var(--plum) 0%, var(--plum-deep) 100%);
  /* Две тени вместо одной: наружная сливовая даёт кнопке вес, внутренняя светлая полоска
     по верхней кромке — блик, будто на выпуклой поверхности. Без блика заливка градиентом
     читается плоской наклейкой. */
  box-shadow:0 10px 24px -10px color-mix(in srgb, var(--plum-deep) 70%, transparent),
             inset 0 1px 0 rgba(255,255,255,.17);
  transition:transform .15s ease, box-shadow .15s ease, filter .15s ease;
}
.btn-primary:hover:not(:disabled){ transform:translateY(-1px); filter:brightness(1.05); }
.btn-primary:active:not(:disabled){ transform:translateY(0) scale(.97); }
/* Кнопка выключена на время отправки (со спиннером) — тогда она остаётся сливовой, просто
   чуть приглушённой: это рабочее состояние, «идёт отправка». */
.btn-primary:disabled{ cursor:default; opacity:.85; }
/* А вот когда выключена БЕЗ спиннера — значит, идёт пауза после «слишком много попыток»,
   и кнопка должна выглядеть неактивной по-настоящему: серая, без тени и градиента.
   Приглушённой сливовой было мало — по ней продолжали жать. */
.btn-primary:disabled:not(.is-loading){
  background:var(--disabled-bg); color:var(--disabled-ink);
  box-shadow:none; opacity:1; cursor:not-allowed;
}
/* Во время паузы обе кнопки отправки показывают вместо подписи отсчёт (setCountdownLabel
   и renderResendBtn в app.js). Цифры здесь моноширинные: у пропорциональных единица уже
   остальных, и на каждой секунде с ней текст на кнопке дёргался бы вправо-влево. */
.btn-primary .btn-label, #btn-resend-code{ font-variant-numeric:tabular-nums; }
/* ---- Ожидание: хоровод звёзд ----
   Стоит на КАЖДОЙ кнопке приложения, у которой есть состояние отправки, — от
   номера телефона до анкеты. Раньше здесь крутилось белое колечко из границы, и оно было
   единственной деталью на всём экране, у которой ничего общего с остальным светом.

   Теперь по кругу идут восемь звёзд. Они не мигают поодиночке: по кольцу убывает и
   яркость, и РАЗМЕР — от крупной белой головы к почти невидимому хвосту, а последняя
   звезда пустая совсем (нулевой размер). Кольцо проворачивается на 45° восемь раз за
   оборот, и мимо каждой позиции проходит сначала голова, потом всё более тусклый хвост:
   звезда как будто вспыхивает во всю величину и тут же ужимается в ничто. Отсюда
   steps(8): плавное вращение размазало бы звёзды в пунктирный круг, а шаг ровно в одну
   позицию читается как «зажглась следующая».

   Как это сделано. Восемь звёзд — это восемь теней ОДНОГО элемента: лишней разметки под
   спиннер в формах нет, а восемь пустых span'ов в каждую кнопку — плохая цена за
   украшение. Отсюда все приёмы этого блока:
     • сам элемент — точка 3×3 в центре кнопки, её саму не видно нигде;
     • по окружности звёзды расставлены смещением тени (радиус 9 пикселей; пары ±6.4 —
       тот же радиус, разложенный по диагоналям);
     • размер каждой звезды задаёт РАСТЯЖЕНИЕ тени (четвёртое число): +1.7 у головы даёт
       точку 6.4 пикселя, -1.5 у последней — ровно ноль, то есть пустое место. Это
       единственный способ дать восьми теням разный размер: ширина у них общая;
     • размытие тоже убывает: горячая точка светит ореолом, погасшая — уже нет;
     • девятая тень — широкий мягкий ореол ПОД головой (в списке он последний, а тени
       рисуются сверху вниз, поэтому он оказывается позади). Без него голова просто
       белая точка; с ним она светится.

   Тот же хоровод стоит в админке (admin-app/style.css). Общего файла стилей у двух
   приложений нет, поэтому блок продублирован намеренно — правку нужно вносить в оба. */
.btn-primary .btn-spinner{
  --star:color-mix(in srgb, var(--gold) 42%, #fff);
  display:none; position:absolute; left:50%; top:50%;
  width:3px; height:3px; margin:-1.5px 0 0 -1.5px; border-radius:50%;
  box-shadow:
    0      -9px   5px    1.7px var(--star-head, rgba(255,255,255,.98)),
    6.4px  -6.4px 4px    1.1px color-mix(in srgb, var(--star) 80%, transparent),
    9px     0     3.5px   .6px color-mix(in srgb, var(--star) 60%, transparent),
    6.4px   6.4px 3px     .1px color-mix(in srgb, var(--star) 44%, transparent),
    0       9px   2.5px  -.4px color-mix(in srgb, var(--star) 30%, transparent),
   -6.4px   6.4px 2px    -.9px color-mix(in srgb, var(--star) 18%, transparent),
   -9px     0     1.5px -1.3px color-mix(in srgb, var(--star) 8%,  transparent),
   -6.4px  -6.4px 0     -1.5px transparent,
    0      -9px   7px     1px  color-mix(in srgb, var(--star) 30%, transparent);
  animation:btn-orbit .8s steps(8, end) infinite;
}
@keyframes btn-orbit{ to{ transform:rotate(360deg); } }
.btn-primary.is-loading .btn-label{ visibility:hidden; }
.btn-primary.is-loading .btn-spinner{ display:inline-block; }
/* spin остался ради стрелки «потянуть, чтобы обновить» (#pull-refresh) — кнопки им
   больше не пользуются. */
@keyframes spin{ to{ transform:rotate(360deg); } }
/* Тёмная тема: слива отражается в светлую орхидею, и белая надпись на ней перестаёт
   читаться — 2.5:1 в светлом конце градиента при требуемых 4.5:1 (15px/600 — обычный
   текст, не крупный). Обычный приём для светлого акцента на тёмном фоне — тёмные
   чернила на самой кнопке: почти чёрный баклажан даёт ~6.5:1. Серое выключенное
   состояние (:disabled:not(.is-loading)) сильнее по специфичности и остаётся со своим
   --disabled-ink. Звёзды ожидания меняют полярность вместе с надписью: белая голова
   на светлой орхидее пропадала бы, поэтому у хоровода тёмная голова и тёмный шлейф. */
@media (prefers-color-scheme: dark){
  .btn-primary{ color:#1A0F22; }
  .btn-primary .btn-spinner{
    --star-head:rgba(26,15,34,.95);
    --star:#33184A;
  }
}
/* При выключенных анимациях хоровод не вертится, а дышит — тот же приём, что у кружка
   ожидания установки (installPulse, объявлен в своём @media ниже). */
@media (prefers-reduced-motion: reduce){
  .btn-primary .btn-spinner{ animation:installPulse 1.4s ease-in-out infinite; }
}

/* ---- «Крем и сияние»: .btn-shine ----
   Модификатор главной кнопки, стоит на «Получить код по SMS» — единственном действии,
   ради которого человек вообще открыл этот экран. Задача — не «подсветить кнопку», а
   сделать её похожей на предмет с косметической полки: густой крем в тёмном сливовом
   флаконе с золотым тиснением.

   Здесь БЫЛА бегущая по кнопке полоса света, и её пришлось убрать. Причина не в
   настройках, а в самой идее: полоса — это блик от источника, который движется мимо
   предмета, а кнопка в руке никуда не едет. Плюс полоса всегда рисуется ПОВЕРХ заливки
   отдельным светлым прямоугольником со своими краями — на тёмной сливе она читалась
   наклейкой, а не отсветом. Свет здесь не бегает. Он лежит там, где ему положено лежать
   на выпуклой глянцевой поверхности, и медленно дышит:

     • глянцевая шапка — широкий мягкий свет сверху, как на выпуклой крышке баночки.
       Он неподвижен: именно неподвижный верхний блик и делает поверхность выпуклой;
     • тёплый золотой подпал у нижнего правого угла — отражённый свет, «металл рядом»;
     • золотой волосок по кромке (вторая тень в box-shadow). Постоянный, тонкий:
       он даёт кнопке край раньше, чем человек её коснётся;
     • ::before — свечение ИЗНУТРИ: большое размытое золотое пятно без единого края,
       наложенное режимом screen. Screen — ключевая часть: он не кладёт светлое пятно
       на сливу, а осветляет саму сливу, поэтому свет не «плохо сливается» — он и есть
       та же слива, только раскалённая. Пятно очень медленно (5.5 с) набирает и отдаёт
       яркость, чуть разрастаясь: кнопка не мигает, она тлеет;
     • ::after — четыре искры, вспыхивающие по очереди, вразнобой. Точечная вспышка —
       единственное быстрое движение, которое тёмной глянцевой поверхности идёт: она
       читается как блеск крупинки, а не как подсветка. Подробности — в блоке ниже.

   Нажатие: кнопка оседает и на миг вспыхивает изнутри тёплым (inset-тень золотом) —
   «крем откликнулся». */
.btn-shine{
  /* isolation обязателен: без него screen у ::before смешивался бы не с заливкой кнопки,
     а со всем, что под ней на экране, — и свечение уезжало бы вместе с фоном страницы. */
  overflow:hidden; isolation:isolate;
  background:
    radial-gradient(150% 105% at 50% -60%, rgba(255,255,255,.24) 0%, rgba(255,255,255,.09) 44%, rgba(255,255,255,0) 72%),
    radial-gradient(95% 130% at 94% 130%, color-mix(in srgb, var(--gold) 30%, transparent) 0%, transparent 62%),
    linear-gradient(135deg, var(--plum) 0%, var(--plum-deep) 100%);
  box-shadow:0 10px 24px -10px color-mix(in srgb, var(--plum-deep) 70%, transparent),
             0 0 0 1px color-mix(in srgb, var(--gold) 24%, transparent),
             inset 0 1px 0 rgba(255,255,255,.22);
}
/* Надпись и спиннер — над свечением. Без этого screen осветляет и буквы тоже, и надпись
   на полсекунды теряет плотность.

   Надписи position:relative нужен (она обычный inline-span, и без него z-index на неё не
   действует), а спиннеру — категорически нет, и это стоило целого пропавшего ожидания:
   у .btn-primary .btn-spinner стоит position:absolute вместе с left/top:50%, и стоит
   заменить absolute на relative, как те же 50% превращаются из центрирования в сдвиг —
   спиннер уезжает вправо-вниз за край кнопки, где его срезает overflow:hidden. Кнопка
   нажималась, код уходил, а на экране не двигалось ничего. Поэтому здесь у спиннера
   только z-index. */
.btn-shine .btn-label{ position:relative; z-index:2; }
.btn-shine .btn-spinner{ z-index:2; }

/* Пятно заведомо больше кнопки (inset:-35%) и размыто на 16 пикселей: любой край,
   попавший внутрь, тут же превратил бы свечение обратно в фигуру. Обрезает лишнее
   overflow:hidden выше. */
.btn-shine::before{
  content:""; position:absolute; inset:-35%; z-index:1; pointer-events:none;
  background:radial-gradient(closest-side ellipse at 50% 58%,
    color-mix(in srgb, var(--gold) 78%, #fff) 0%,
    color-mix(in srgb, var(--gold) 48%, transparent) 46%,
    transparent 76%);
  filter:blur(16px);
  mix-blend-mode:screen;
  opacity:.24;
  animation:btn-bloom 5.5s ease-in-out infinite;
}
@keyframes btn-bloom{
  0%, 100% { opacity:.20; transform:scale(.94); }
  50%      { opacity:.42; transform:scale(1.05); }
}

/* ---- Звёзды (::after) ----
   Четыре искры, вспыхивающие по очереди, — единственное быстрое движение на кнопке, и
   оно намеренно точечное: свет не едет через всю плоскость, а на долю секунды загорается
   в одной точке и гаснет. Так блестит крупинка перламутра, а не бежит подсветка.

   Все четыре нарисованы фоновыми слоями ОДНОГО псевдоэлемента (свободных больше нет), и
   отсюда единственная хитрость этого блока: мигать вразнобой они не могут через opacity —
   она у псевдоэлемента одна на всех. Зато background-size — список, по слою на искру, и
   каждый его элемент анимируется отдельно. Поэтому искра «зажигается» не прозрачностью, а
   размером: 0 → 6px → 0. Растущая точка света и выглядит живее ровного проступания.
   В ключевых кадрах нули перед каждой вспышкой проставлены явно — без них соседняя искра
   начинала бы разгораться сразу после предыдущей и висела бы на кнопке полцикла.

   Режим screen тот же, что у свечения: искра осветляет сливу под собой, а не кладётся
   на неё белой точкой. Лежат искры ПОД надписью (z-index) — пробегающая за буквами
   искра читается как блеск поверхности, а поверх букв читалась бы как сор на экране. */
.btn-shine::after{
  content:""; position:absolute; inset:0; z-index:1; pointer-events:none;
  border-radius:inherit;
  background-image:
    radial-gradient(circle closest-side, #fff 0%, color-mix(in srgb, var(--gold) 70%, #fff) 38%, transparent 100%),
    radial-gradient(circle closest-side, #fff 0%, color-mix(in srgb, var(--gold) 70%, #fff) 38%, transparent 100%),
    radial-gradient(circle closest-side, #fff 0%, color-mix(in srgb, var(--gold) 70%, #fff) 38%, transparent 100%),
    radial-gradient(circle closest-side, #fff 0%, color-mix(in srgb, var(--gold) 70%, #fff) 38%, transparent 100%);
  background-repeat:no-repeat;
  background-position:21% 32%, 79% 28%, 63% 74%, 36% 71%;
  background-size:0 0, 0 0, 0 0, 0 0;
  mix-blend-mode:screen;
  animation:btn-stars 5.5s linear infinite;
}
@keyframes btn-stars{
  0%,  5%  { background-size:0 0,     0 0,     0 0,     0 0; }
  10%      { background-size:7px 7px, 0 0,     0 0,     0 0; }
  16%, 26% { background-size:0 0,     0 0,     0 0,     0 0; }
  31%      { background-size:0 0,     6px 6px, 0 0,     0 0; }
  37%, 52% { background-size:0 0,     0 0,     0 0,     0 0; }
  57%      { background-size:0 0,     0 0,     5px 5px, 0 0; }
  63%, 76% { background-size:0 0,     0 0,     0 0,     0 0; }
  81%      { background-size:0 0,     0 0,     0 0,     6px 6px; }
  87%,100% { background-size:0 0,     0 0,     0 0,     0 0; }
}

/* Наведение — ореол: тёплое золотое свечение вокруг кнопки поверх обычного подъёма.
   :focus-visible получает то же самое: с клавиатуры кнопка должна выглядеть так же
   «горячей», как под курсором. */
.btn-shine:hover:not(:disabled),
.btn-shine:focus-visible:not(:disabled){
  box-shadow:0 14px 30px -12px color-mix(in srgb, var(--plum-deep) 78%, transparent),
             0 0 0 1px color-mix(in srgb, var(--gold) 48%, transparent),
             0 0 26px -6px color-mix(in srgb, var(--gold) 55%, transparent),
             inset 0 1px 0 rgba(255,255,255,.28);
}
/* Нажатие — вспышка изнутри. Главное состояние на телефоне: hover там не бывает,
   и единственный отклик, который человек увидит, — этот. */
.btn-shine:active:not(:disabled){
  box-shadow:0 4px 12px -8px color-mix(in srgb, var(--plum-deep) 70%, transparent),
             0 0 0 1px color-mix(in srgb, var(--gold) 55%, transparent),
             inset 0 0 24px color-mix(in srgb, var(--gold) 38%, transparent),
             inset 0 1px 0 rgba(255,255,255,.30);
}
/* Пока держит пауза «слишком много попыток» — света нет: сияющая кнопка, на которую
   нельзя нажать, — обещание, которого приложение не выполнит. Отправка сюда НЕ попадает
   (:not(.is-loading)) — там кнопка занята делом, и свет как раз уместен.
   Серую заливку выключенного состояния (.btn-primary:disabled:not(.is-loading)) здесь
   же приходится повторить: у .btn-shine своя многослойная заливка, и без этой строки
   она перебивала бы серую. */
.btn-shine:disabled:not(.is-loading)::before,
.btn-shine:disabled:not(.is-loading)::after{ animation:none; opacity:0; }
.btn-shine:disabled:not(.is-loading){ background:var(--disabled-bg); box-shadow:none; }

/* Пока идёт отправка, свет самой кнопки дышит чаще (1.6 с вместо 5.5): тот же сигнал
   «принято», только боковым зрением. Хоровод звёзд в спиннере — общий для всех кнопок,
   он описан выше. */
.btn-shine.is-loading::before{ animation-duration:1.6s; }
.btn-shine.is-loading::after{ animation-duration:1.8s; }

/* Свечение не выключается целиком, а замирает на средней яркости: «дышит» здесь именно
   движение, а сам тёплый свет — это уже не анимация, а цвет кнопки. */
@media (prefers-reduced-motion: reduce){
  .btn-shine::before{ animation:none; opacity:.30; transform:none; }
  .btn-shine::after{ animation:none; background-size:0 0, 0 0, 0 0, 0 0; }
}

.btn-ghost{
  background:transparent; border:1px solid var(--border); color:var(--ink-soft);
  border-radius:10px; padding:7px 14px; font-size:13px; cursor:pointer; font-family:inherit;
  transition:border-color .15s ease, color .15s ease;
}
.btn-ghost:hover:not(:disabled){ border-color:var(--plum); color:var(--plum); }
.form-error{
  color:var(--danger); font-size:13px; min-height:16px; margin:0;
  opacity:0; transform:translateY(-2px); transition:opacity .15s ease, transform .15s ease;
}
.form-error:not(:empty){ opacity:1; transform:translateY(0); }
/* На анкете пустая строка ошибки места не занимает. Везде она держит свои 16 пикселей
   (min-height выше), чтобы форма не дёргалась в момент появления текста, — но у анкеты
   ровно этих 16 пикселей вместе с отступом и не хватало, чтобы подшаг помещался на
   телефоне 375x667 без прокрутки. Ошибку здесь и так сопровождает встряска карточки, так
   что её появление незаметным не будет и без плавного проявления. */
#form-profile .form-error:empty{ display:none; }
/* Заметка про паузу — не ошибка: это подсказка «сколько ждать», поэтому спокойный
   золотой, а не красный тон, и своя строка под кнопкой. */
.form-note{
  color:var(--gold-ink); font-size:13px; margin:10px 0 0; text-align:center; line-height:1.35;
  font-variant-numeric:tabular-nums;
  opacity:0; transform:translateY(-2px); transition:opacity .15s ease, transform .15s ease;
}
.form-note:not(:empty){ opacity:1; transform:translateY(0); }
.code-hint{ font-size:13px; color:var(--ink-soft); margin:0; text-align:center; }
/* Подпись под отдельным полем анкеты (сейчас — под кодом подруги). В отличие от .code-hint
   она прижата к левому краю поля, над которым стоит, и набрана мельче: это пояснение к
   одной строке формы, а не обращение ко всему экрану. Скрытая (hidden) не занимает места
   вовсе — иначе между полем и согласиями оставалась бы пустая полоса. */
.field-hint{
  margin:-6px 0 0; font-size:12px; line-height:1.4;
  color:var(--gold-ink);
}
.field-hint.hidden{ display:none; }
.code-hint span{ font-weight:600; color:var(--ink); }
/* Номер в подсказке всегда «+972…». В ивритской фразе bidi-алгоритм уносил бы ведущий
   «+» в конец номера — изолируем номер и жёстко держим его слева направо. */
#code-phone-display{ direction:ltr; unicode-bidi:isolate; }

/* ---- SMS-код: 6 отдельных ячеек вместо одного поля ----
   Селекторы намеренно длиннее, чем хотелось бы: ячейки — это .form input[type="text"],
   а у того правила выше (border, padding, font-size, background) специфичность 0-2-1
   против 0-1-0 у голого .otp-digit. Голый класс проигрывал: цифры рисовались общими
   15px вместо 22px, а рамка на фокусе и вовсе не меняла цвет. С `.form input` перед
   классом специфичность сравнивается, и дальше решает порядок в файле — а этот блок
   стоит ниже общего. */
.otp-row{ display:flex; gap:8px; justify-content:center; }
.form input.otp-digit{
  width:44px; height:54px; text-align:center; font-size:22px;
  border-radius:12px; border:1px solid var(--border); background:var(--bg); color:var(--ink);
  font-family:var(--font-num); font-weight:800; font-variant-numeric:tabular-nums;
  transition:border-color .15s ease, box-shadow .15s ease, transform .1s ease;
}
.form input.otp-digit:focus{
  outline:none; border-color:var(--plum); transform:translateY(-2px);
  box-shadow:0 0 0 4px color-mix(in srgb, var(--plum) 18%, transparent);
}
/* Заполненная ячейка обведена шампанским и подсвечена мягким кольцом того же металла:
   одной сменой цвета рамки на белом фоне «готово» почти не читалось. */
.form input.otp-digit:not(:placeholder-shown){
  border-color:var(--gold);
  box-shadow:0 0 0 3px color-mix(in srgb, var(--gold) 15%, transparent);
}

.code-actions{ display:flex; justify-content:space-between; align-items:center; gap:8px; flex-wrap:wrap; }
.code-actions .btn-ghost{ font-size:12.5px; padding:6px 10px; }
.code-actions .btn-ghost:disabled{ opacity:0.5; cursor:default; }
/* Анкета отправляется — выходов с неё нет (app.js, lockProfileExits): человек не должен
   успеть нажать «Назад» или «Изменить номер» между нажатием «Завершить регистрацию» и
   ответом сервера. visibility, а не display: место под кнопками остаётся, и карточка не
   прыгает вверх в тот самый момент, когда на неё смотрят. visibility в transition — чтобы
   кнопки не пропадали рывком раньше, чем закончится затухание. */
.code-actions{ transition:opacity .2s ease, visibility .2s ease; }
.code-actions.is-busy{ opacity:0; visibility:hidden; pointer-events:none; }

/* ---- Dashboard ---- */
/* Кабинет устроен как витрина: шапка и карта клиента стоят на месте всегда, а
   ездит только то, что лежит под картой (.dash-below). Карта — главное в кабинете, и уезжать
   ей за верхний край экрана незачем: баланс должен быть перед глазами и тогда, когда
   человек читает историю начислений. Строки прокрутки уходят вверх и пропадают ровно
   там, где кончается карта, — будто заезжают под неё (см. .dash-below ниже).

   До этого кабинет ехал вместе со страницей целиком — вместе с картой, шапкой и подвалом;
   а ещё раньше — прокручивался внутри себя весь, вместе с шапкой. От второго варианта в своё
   время отказались из-за подвала: он тогда был полосой, приклеенной ПОВЕРХ нижнего края,
   и нижние блоки уходили под него. Сейчас этой беды нет: подвал — обычный сосед по колонке
   <body>, он стоит ПОД областью прокрутки, а не над ней, и накрывать ему нечего.

   flex:1 1 auto + min-height:0 — экран занимает всё, что осталось от <body> после подвала,
   и разрешает сжать себя ниже собственного содержимого: без min-height:0 flex-элемент
   вырастает под содержимое, и внутренней прокрутке взяться неоткуда. У самого экрана
   прокрутки нет вовсе (overflow:hidden) — она одна и лежит ниже, у .dash-below.
   display:flex — чтобы высоту экрана унаследовала колонка внутри (.dash-content). */
#view-dashboard{
  flex:1 1 auto; min-height:0; width:100%;
  display:flex; flex-direction:column;
  overflow:hidden; position:relative; z-index:1;
}
/* Фон кабинета — тот же белый жемчуг, что и на экране входа, и теми же перламутровыми
   отливами, только не пятнами, а очень широкими градиентами во весь экран. Раньше здесь
   был тёмный градиент до почти чёрного (#3D2A38), из-за которого кабинет выглядел
   «другим приложением» рядом со светлым входом, а белые карточки на нём читались серыми;
   потом — тёплая бело-розовая подложка с золотым и розовым пятнами. Цвета берутся из
   токенов, поэтому тёмная тема (prefers-color-scheme: dark) получает свой тёмный вариант
   этого же градиента сама. Доли перламутра держим низкими (не выше половины): фон обязан
   оставаться белым, отлив — только угадываться на нём.

   Линейная подложка светлеет сверху вниз только до 42% высоты и дальше держит ровный
   белый жемчуг (--bg) до самого низа. Раньше она густела к нижнему краю до --bg-2
   (#EEF2F8), и нижняя полоса экрана — как раз та, где стоит подвал, — выходила заметно
   серее содержимого: подвал читался отдельной серой панелью, хотя своего фона у него
   нет вовсе. Верх при этом остался прежним: там подложка по-прежнему начинается с
   чистого белого (--surface), и объём у фона никуда не делся. */
.dash-bg{
  position:fixed; inset:0; z-index:0;
  background:
    radial-gradient(ellipse at 15% 8%, color-mix(in srgb, var(--pearl-blue) 50%, transparent), transparent 46%),
    radial-gradient(ellipse at 88% 20%, color-mix(in srgb, var(--pearl-aqua) 40%, transparent), transparent 42%),
    radial-gradient(ellipse at 82% 88%, color-mix(in srgb, var(--pearl-lilac) 45%, transparent), transparent 50%),
    linear-gradient(165deg, var(--surface) 0%, var(--bg) 42%, var(--bg) 100%);
  animation:bgShift 30s ease-in-out infinite alternate;
  background-size:140% 140%;
}
@keyframes bgShift{
  from{ background-position:0% 0%, 100% 0%, 100% 100%, 0% 0%; }
  to{ background-position:10% 10%, 88% 6%, 90% 80%, 8% 8%; }
}
@media (prefers-reduced-motion: reduce){ .dash-bg{ animation:none; } }
/* Поля кабинета — общие для всего его содержимого: и для шапки, и для карты, и для того,
   что под картой. Раньше их не было вовсе, а боковые поля жили на .dash-below: карта тогда
   шла во всю ширину экрана. Верхний отступ считается от безопасной области — под ним стоит
   шапка, а она первая на экране и иначе попала бы под вырез камеры и системную строку. */
.dash-content{
  position:relative; z-index:1; width:100%; max-width:420px; margin:0 auto;
  padding:calc(env(safe-area-inset-top) + 14px) 20px 0;
  /* Колонка во всю высоту экрана: шапка и карта берут ровно свою высоту (flex:none ниже),
     а весь остаток целиком достаётся области прокрутки под картой. min-height:0 — та же
     поправка, что и у самого экрана: без неё колонка не сожмётся ниже содержимого и
     прокручивать будет нечего.
     Нижнего поля здесь больше нет — оно переехало ВНУТРЬ .dash-below: снаружи области
     прокрутки оно превратилось бы в пустую полосу под ней, а последняя строка истории
     упиралась бы в невидимую границу, не доезжая до низа. */
  flex:1 1 auto; display:flex; flex-direction:column; min-height:0;
}
/* Шапка и карта в колонке не тянутся и не жмутся — берут ровно свою высоту, остальное
   отдают прокрутке. width:100% карте обязателен: её ширину задают max-width и поля
   margin:0 auto, а элемент flex-колонки с ДВУМЯ автоматическими полями по бокам
   растягиваться перестаёт и сжимается по содержимому — а содержимое у карты absolute,
   то есть нулевое, и карта схлопнулась бы в нитку. */
.dash-content .dash-header, .dash-content .card-flip{ flex:none; }
.dash-content .card-flip{ width:100%; }
/* Появление кабинета — тем же порядком, что и на экране входа: объекты выходят по
   очереди сверху вниз, сначала шапка, потом карта клиента, потом всё, что лежит под ней.
   Раньше кабинет проявлялся одним куском; теперь оба экрана собираются одинаково.

   У самой карты — только прозрачность, без fadeInUp. Дело в transform: элемент с
   transform становится точкой отсчёта для всех потомков, а поверх карты летят баллы
   при начислении (celebrate.js кладёт их в общий слой поверх страницы и считает
   координаты от карты) — сдвигать её под ними незачем. */
.dash-content{ --step:.07s; }
.dash-content .dash-header{ opacity:0; animation:fadeInUp .42s cubic-bezier(.22,.61,.36,1) both; animation-delay:.04s; }
.dash-content .card-flip{ opacity:0; animation:cardFade .42s ease-out both; animation-delay:calc(.04s + var(--step)); }
/* .dash-content в селекторах не для красоты: «.dash-below > *» весит ровно один класс
   (звёздочка не весит ничего) — столько же, сколько любой блок под картой со своим именем
   класса, и такой блок, стоя позже в файле, пересилил бы этот каскад собственной
   анимацией появления. Так уже было с карточкой ждущего бонуса, которой здесь больше нет. */
.dash-content .dash-below > *{ opacity:0; animation:fadeInUp .42s cubic-bezier(.22,.61,.36,1) both; }
.dash-content .dash-below > *:nth-child(1){ animation-delay:calc(.04s + var(--step)*2); }
.dash-content .dash-below > *:nth-child(2){ animation-delay:calc(.04s + var(--step)*3); }
.dash-content .dash-below > *:nth-child(3){ animation-delay:calc(.04s + var(--step)*4); }
.dash-content .dash-below > *:nth-child(n+4){ animation-delay:calc(.04s + var(--step)*5); }
/* Пауза, пока сверху лежит заставка — точно такая же, как .auth-hold у экрана входа
   (там же и подробности). Без неё кабинет собирался под заставкой вхолостую: к тому
   кадру, когда заставка начинала гаснуть, карта с баллами уже стояла готовая, и человек видел
   её просвечивающей сквозь заставку, а не приходящей. Снимает паузу releaseScreenCascade в app.js,
   когда заставки на экране уже нет совсем. */
.dash-content.dash-hold .dash-header,
.dash-content.dash-hold .card-flip,
.dash-content.dash-hold .dash-below > *{ animation-play-state:paused; }
@media (prefers-reduced-motion: reduce){
  .dash-content .dash-header, .dash-content .card-flip, .dash-content .dash-below > *{ animation:none; opacity:1; }
}
/* Единственное прокручиваемое место кабинета: всё, что лежит под картой. Боковых полей
   у него нет — они общие, на .dash-content, иначе получились бы двойные: у карты одни, у
   текста под ней другие. Своё здесь только нижнее, и оно внутри прокрутки (см. выше).

   КАК СОДЕРЖИМОЕ ПРОПАДАЕТ ПОД КАРТОЙ. Верхний край области прокрутки заведён ПОД карту
   отрицательным margin-top на --dash-fade, и на столько же отодвинуто содержимое внутри
   (padding-top): промежуток между картой и первой строкой от этого не меняется, но строка,
   уходящая вверх, теперь доезжает до нижнего края карты и скрывается за ним — карта
   рисуется поверх (perspective у .card-flip делает её отдельным слоем).

   Одного только «уехало под карту» мало: у карты скруглённые углы, и в вырезах под ними
   строка выглядывала бы сбоку от карты, уже перевалив за её нижний край. Поэтому те же
   пиксели гасит маска — к нижнему краю карты содержимое подходит уже почти прозрачным, и
   в вырезах угла показывать нечего. Заодно это и есть само движение «под карту»: обрыв по
   прямой читался бы срезом, а растворение — уходом за край.

   Числа согласованы так, чтобы В ПОКОЕ маска не трогала ничего: полностью непрозрачной
   она становится ровно там, где начинается содержимое (padding-top), то есть на 8px ниже
   карты. Иначе первая строка — а когда подсказка про оборот отжила своё, это карточка
   приглашения без собственного верхнего поля — стояла бы приглушённой просто так.

   overscroll-behavior:contain — жест не протекает наружу: докрутив список до конца, человек
   не утягивает следом всю страницу. Прокрутку вверх дальше нуля это не запрещает — на ней
   стоит свой жест обновления (pull-refresh.js смотрит на scrollTop как раз этого элемента). */
.dash-below{
  --dash-fade:14px;                                 /* сколько ушло под карту */
  --dash-gap:8px;                                   /* и сколько видно до первой строки */
  flex:1 1 auto; min-height:0;
  overflow-y:auto; overflow-x:hidden;
  overscroll-behavior-x:contain; overscroll-behavior-y:contain;
  -webkit-overflow-scrolling:touch;
  margin-top:calc(-1 * var(--dash-fade));
  padding:calc(var(--dash-fade) + var(--dash-gap)) 0 28px;
  -webkit-mask-image:linear-gradient(to bottom, #0000 0, #0000 6px, #000 calc(var(--dash-fade) + var(--dash-gap)));
  mask-image:linear-gradient(to bottom, #0000 0, #0000 6px, #000 calc(var(--dash-fade) + var(--dash-gap)));
}
/* Низкий экран — телефон лёжа, открытая клавиатура, маленькое окно на компьютере. Карта
   в габаритах настоящей (ISO 7810) занимает там почти всю высоту, и под ней не остаётся
   полосы, которую имело бы смысл прокручивать: список в две строки — это не список, а
   щель. Поэтому ниже порога кабинет возвращается к прежнему устройству: не закреплено
   ничего, прокручивается вся страница целиком, одним движением, вместе с подвалом.
   Порог тот же (599px), что и у остальных экранов в конце файла. */
@media (max-height:599px){
  #view-dashboard{ flex:1 0 auto; min-height:auto; display:block; overflow:visible; }
  .dash-content{ display:block; padding-bottom:28px; }
  .dash-below{
    overflow:visible; margin-top:0; padding:0;
    -webkit-mask-image:none; mask-image:none;
  }
}
/* Шапка кабинета: только название клуба и кнопка выхода. Знака здесь больше нет —
   он ушёл внутрь карты золотым тиснением (.card-logo), поэтому от font-size надписи
   теперь ничего, кроме самой надписи, не зависит. */
.dash-header{ display:flex; align-items:center; justify-content:space-between; gap:10px; margin:0 0 14px; }
.dash-header .brand.small{ font-size:16px; }

/* ---- Карта клиента ----
   Это подарочная карта косметолога, а не блок с числом, поэтому у неё габариты настоящей
   карты: aspect-ratio:1.586 — те самые 85.6 × 54 мм из ISO 7810, по которым скроены
   банковская карта, права и клубная карта салона. Раньше высота набиралась содержимым, и
   карта выходила почти квадратной — предмет, которого не существует в жизни. Фиксированная
   пропорция заодно навела порядок внутри: в карту физически перестало помещаться всё
   подряд, и длинная строка про первый визит переехала под карту (см. .points-sub).

   Внутри всё выстроено в столбец по левому краю, а правая половина отдана узору: знак
   клуба, под ним ИМЯ клиента крупно и от руки, под именем баллы. Имя стоит ВЫШЕ баланса
   и набрано рукописным шрифтом намеренно. Карта именная, а не «экран с числом»: своё имя
   человек находит на предмете раньше любой цифры и по нему же узнаёт, что предмет его.
   Разрядка в верхнем регистре, как на банковской карте, тут была ровно наоборот — она
   превращает имя в служебную строчку, набитую тиснением на пластике.

   Тёмный фон выбран не «для красоты вообще»: цифры залиты светлым кремом с золотой
   крошкой (см. .points-value), и на белой карточке их бы попросту не было видно. Светлым
   остаётся весь остальной кабинет — карта и должна отличаться от того, что под ней.
   Оттенок — глубокий баклажан, почти чёрный в середине (#1A0D21 рядом с --ink #1E1729):
   карта выглядит сгущённым до предела фирменным цветом, а не отдельной краской. Поверх
   него два цветных отблеска: тёплое золото снизу слева и еле уловимый румянец сверху
   справа (#E4B4C7) — розовый на баклажане и есть цвет косметологии, но он взят светом,
   а не заливкой, иначе карта снова станет тёплой целиком. Самый верхний слой фона —
   косая штриховка в 3% белого: она даёт бархат, на котором золото выглядит золотом.

   Переменные --r, --inset и оба шрифта объявлены не здесь, а на сцене (.card-flip ниже):
   радиусы считаются друг от друга сразу в трёх слоях лицевой стороны (сама карта, золотая
   нить по краю и внутренняя рамка), а оборот карты — сосед лицевой стороны, а не её
   потомок, и до объявленного здесь он бы не дотянулся. */
.points-card{
  position:relative; z-index:1;
  padding:clamp(13px, 4.2vw, 19px) clamp(15px, 4.8vw, 22px);
  /* Две строки, а не три: верх карты (имя и знак клуба) и баллы, прижатые к низу
     оставшимся 1fr. Раньше строк было три — монограмма, имя посередине, баллы; имя
     переехало наверх, к знаку клуба, и средняя строка стала лишней. */
  display:grid; grid-template-rows:auto 1fr;
  border-radius:var(--r);
  text-align:left; overflow:hidden; color:#FFF6EE;
  background:
    repeating-linear-gradient(112deg, rgba(255,255,255,.026) 0 1px, transparent 1px 6px),
    radial-gradient(88% 74% at 94% -18%, rgba(228,180,199,.26), transparent 60%),
    radial-gradient(96% 82% at 0% 114%, rgba(201,165,102,.32), transparent 62%),
    linear-gradient(147deg, #43254D 0%, #2A1531 42%, #1A0D21 68%, #381D3C 100%);
  box-shadow:0 2px 6px rgba(28,14,36,.22), 0 22px 46px -24px rgba(28,14,36,.92);
}
/* Золотая нить по самому краю карты: рамка нарисована фоном и вырезана маской — так её
   можно вести градиентом, чего обычному border не позволить. Второй конец нити — светлая
   орхидея (#C6A8E4): нить обязана уходить в цвет самой карты, иначе по краю остаётся
   след чужой палитры. */
.points-card::before{
  content:""; position:absolute; inset:0; border-radius:inherit; padding:1px;
  background:linear-gradient(135deg, var(--gold), transparent 42%, #C6A8E4 100%);
  -webkit-mask:linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
  -webkit-mask-composite:xor; mask-composite:exclude; opacity:.6; pointer-events:none;
}
/* Всё содержимое карты стоит над её фоном и над подсветкой начисления (::after).
   Цвета внутри заданы прозрачным белым, а не токенами темы: токены рассчитаны на светлую
   подложку кабинета, на тёмной карте они не читаются. */
.points-card > *{ position:relative; z-index:1; }
/* Три декоративных слоя ниже — исключение из правила выше: они лежат ПОД содержимым.
   Отдельными элементами, а не псевдоэлементами карты, потому что ::before уже занят
   золотой нитью, а ::after — вспышкой в момент начисления (.points-card.celebrating). */
.card-foil, .card-sheen, .card-decor, .card-stars{ z-index:0; pointer-events:none; }

/* Внутренняя рамка в волос толщиной, отступя от края, — приём бумажного сертификата:
   именно она, а не золочёный край, делает из прямоугольника «документ». Радиус считается
   от радиуса карты: у вложенного скругления центр общий с внешним, поэтому вычитается
   отступ, иначе углы рамки пойдут вразрез с углами карты.

   ОГОНЁК ПО РАМКЕ. По этой же линии медленно бежит тёплый огонёк — так свет идёт по
   золотому тиснению, когда карту поворачивают в руке. Это второе, «частое» движение карты
   рядом с бликом (.card-sheen), и роли у них разные: блик проходит редко и через всю
   карту, огонёк идёт непрерывно, но только по волосяной линии и почти не поднимает её
   яркость — увидеть его можно, отвлечься на него нельзя.

   Рамка ради него нарисована не border'ом, а фоном под маской: у border нет ни точки,
   которую можно двигать, ни градиента вдоль. Приём тот же, что у золотой нити по краю
   карты (.points-card::before), — две маски, внешняя минус внутренняя, между ними остаётся
   ровно один пиксель, и он-то и есть линия. Слоёв фона два: пятно света и под ним ровная
   золотая заливка прежнего цвета, поэтому в любой момент цикла рамка на месте целиком.

   Ход огонька задан background-position, и значения в ключах — -50% и 150%, а не 0% и
   100%, потому что процент в background-position двигает не центр картинки, а её край:
   при 0% пятно только прижимается углом к углу карты, и на саму линию попадает лишь его
   бледная кромка. Крайние значения считаются от размера пятна, и размер выбран ровно в
   половину карты (background-size:50% 50%) как раз ради того, чтобы они вышли круглыми:
   при половине -50% ставит центр пятна на левый край, 150% — на правый. Меняя размер
   пятна, пересчитывать придётся и ключи.

   Проценты самих ключей неровные (30.7 / 50 / 80.7) тоже не по недосмотру: карта вытянута
   1.586:1, длинная сторона занимает 30.7% периметра, короткая — 19.3%, и только при таких
   ключах огонёк идёт по всем четырём сторонам с одной скоростью, а не пролетает короткие
   рывком. */
.card-foil{
  position:absolute; inset:var(--inset);
  border-radius:calc(var(--r) - var(--inset) * .72);
  padding:1px;
  background:
    radial-gradient(circle closest-side, rgba(255,240,212,.72), rgba(255,224,170,.3) 45%, rgba(255,224,170,0) 78%),
    linear-gradient(rgba(233,205,157,.24) 0 0);
  background-repeat:no-repeat;
  background-size:50% 50%, 100% 100%;
  -webkit-mask:linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
  mask:linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
  -webkit-mask-composite:xor; mask-composite:exclude;
  animation:cardFoilTrace 7s linear infinite;
}
@keyframes cardFoilTrace{
  0%{ background-position:-50% -50%, 0 0; }
  30.7%{ background-position:150% -50%, 0 0; }
  50%{ background-position:150% 150%, 0 0; }
  80.7%{ background-position:-50% 150%, 0 0; }
  100%{ background-position:-50% -50%, 0 0; }
}
/* Блик, раз в девять секунд проходящий по карте наискось: пластик и фольга ловят свет,
   и без этого движения карта остаётся картинкой карты. Держится у левого края почти весь
   цикл (0%–70% — одно и то же значение transform) и проходит экран за оставшуюся треть. */
.card-sheen{
  position:absolute; inset:-40% -70%;
  background:linear-gradient(100deg,
    transparent 42%, rgba(255,241,222,.09) 47%, rgba(255,255,255,.16) 51%,
    rgba(255,241,222,.07) 56%, transparent 62%);
  animation:cardSheen 9s ease-in-out infinite;
}
@keyframes cardSheen{
  0%, 70%{ transform:translateX(-42%); }
  100%{ transform:translateX(42%); }
}
/* Набор косметолога тонкой золотой линией в нижнем углу карты — банка крема, флакон
   сыворотки с пипеткой и кисть (сам рисунок и почему именно эти три предмета — в
   index.html). Прежде здесь была веточка эвкалипта: линия красивая, но у клиента она не
   говорила, чья это карта, — такой узор бывает в любом приложении. Уведён в 17%
   прозрачности: это тиснение на подарочном сертификате, а не иконки в интерфейсе.

   Две величины отступов держат рисунок между цифрой баллов и значком «перевернуть»,
   которые стоят по краям того же низа карты:
     right — не 0 и не отрицательный, как было у веточки, а ширина значка с его полем:
       иначе предметы уходят под кнопку, и в углу получается каша из двух рисунков;
     bottom — отрицательный: предметы срезаны нижним краем карты. Именно срез, а не
       поля со всех сторон, отличает тиснение от наклейки, а веточка ради этого уходила
       за правый край, который здесь занят. */
.card-decor{
  position:absolute; right:clamp(30px, 9.5vw, 40px); bottom:-12px;
  width:clamp(140px, 44%, 190px); height:auto;
  color:var(--gold); opacity:.17;
}

/* Верхняя строка карты: слева приветствие с именем владельца, справа — назначение карты
   мелкой разрядкой и знак клуба. Монограммы «JS» в золотом кольце здесь больше нет: она
   занимала самое заметное место карты двумя служебными буквами, а имя из-за этого
   уезжало в середину. Теперь верхнюю строку держит имя, а место «чипа» отдано знаку.
   align-items:flex-start — правый столбец обязан стоять по верхнему краю вместе со
   строкой «Здравствуйте,», а не съезжать к середине высокого имени. */
.card-top{ display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
/* Правый столбец: подпись и под ней знак клуба. Столбцом, а не строкой, потому что
   разряженная подпись длинная, а карта узкая: в строку они вдвоём отняли бы у имени
   половину ширины. */
.card-kind-wrap{
  flex:none; display:flex; flex-direction:column; align-items:flex-end; gap:6px;
  padding-top:2px;
}
.card-kind{
  font-size:9px; letter-spacing:.26em; text-transform:uppercase;
  color:rgba(255,246,238,.44);
  /* Подпись стоит в две строки — «КАРТА» над «КЛУБА»: в одну строку разряженные
     одиннадцать знаков отъедали у имени владельца заметный кусок узкой карты.
     max-width подобрана по самому длинному слову с этим трекингом (≈5 знаков),
     чтобы перенос всегда шёл по пробелу, а не рвал слово. */
  white-space:normal; max-width:6em; line-height:1.4; text-align:right;
}
/* Знак клуба внутри карты — золотым тиснением, а не картинкой как она есть. Цветной
   портрет на баклажановом фоне читался бы наклейкой поверх карты; фильтр сводит его в
   один тон (grayscale → sepia) и докручивает насыщенность до золота карты (--gold
   #C9A566, тон ~38°, поэтому hue-rotate почти не нужен). Получается тот же приём, что у
   набора косметолога в углу: одна золотая краска на всю декорацию карты.
   Из шапки кабинета логотип при этом убран — см. комментарий в index.html. */
.card-logo{
  display:block; width:clamp(34px, 10.4vw, 44px); height:auto;
  /* grayscale сводит портрет в один тон, sepia красит его в тёплый, saturate доводит до
     золота, а hue-rotate уводит от лимонного в янтарь: без последних двух шагов один
     sepia даёт блёклую сепию, а один saturate — кислую желтизну. Значения подобраны по
     цвету карты (--gold #C9A566), а не «на глаз посветлее». */
  filter:grayscale(1) sepia(1) saturate(1.9) hue-rotate(-10deg) brightness(.98)
         drop-shadow(0 1px 6px rgba(201,165,102,.32));
  opacity:.85;
}

/* ---- УРОВНИ КАРТЫ ----
   Три наряда одной карты: data-tier ставит app.js (renderPointsInfo) из документа
   клиента. Меняется не макет, а материал — металл декора, свет и глубина фона: карта
   выше уровнем должна узнаваться через стол, как золотая банковская карта узнаётся
   раньше, чем прочитано имя банка.

   Три уровня различаются ТЕМПЕРАТУРОЙ МАТЕРИАЛА, а не только яркостью: тёплая матовая
   медь → холодное полированное серебро → чёрное золото. Раньше бронза была «выцветшей
   сливой» — серо-фиолетовой, то есть той же холодной гаммы, что серебро, и через стол
   уровни не различались. Тепло против холода глаз ловит мгновенно, «чуть меньше
   фиолетового» — нет.

   Бронза — МЕДЬ: тёмный шоколад с медным светом снизу и розоватым отблеском сверху.
   Наряд явно тёплый и явно скромный: металл матовый, движения медленные, внешнего
   свечения нет вовсе — сияние покупается уровнем. Базовая карта (без data-tier, пока
   данные едут) остаётся насыщенной сливой.
   Серебро остывает: гвоздь наряда — луна вместо свечи. Металл декора уходит в светлое
   серебро, тёплая орхидея фона — в холодную сирень, и у карты появляется первый внешний
   свет — холодное свечение по контуру. Золото — предельный наряд: фон глубже и теплее,
   золотая нить по краю сплошная, блик и огонёк ходят чаще, а внешнее свечение тёплое и
   ДЫШИТ (cardGoldBreath). Дыхание — единственная новая анимация всего блока, остальное —
   перекраска и смена темпа уже описанных движений: карта и так живая, четвёртое
   одновременное движение превратило бы её в витрину игрового автомата.
   Роскошь золота — в СДЕРЖАННОСТИ фона и чёткости металла: фон почти чёрный, тёплый,
   но не рыжий (бордово-рыжая заливка и разлитое оранжевое пятно делали карту
   любительской), золото живёт в деталях — сплошная нить, вызолоченная внутренняя
   рамка, искры, тиснение. Так устроены дорогие чёрные карты: тёмный предмет, на
   котором металл лежит тонко и резко.

   Чип с именем уровня стоит в правом столбце под подписью «Карта клуба» — там, где на
   настоящей карте печатают слово Gold. Он есть и у бронзы: уровень — не награда за рост,
   а свойство карты, и прятать его от новичка значило бы прятать саму лестницу. */
.card-tier{
  font-size:8px; letter-spacing:.2em; text-transform:uppercase; line-height:1;
  padding:3px 8px; border-radius:999px;
  border:1px solid rgba(255,246,238,.2); color:rgba(255,246,238,.5);
}
/* Пустой чип (данные ещё едут) не должен висеть пустой таблеткой. */
.card-tier:empty{ display:none; }
.card-tier[data-tier="bronze"]{ border-color:rgba(198,144,98,.5); color:#D8A879; }
.card-tier[data-tier="silver"]{ border-color:rgba(206,216,231,.55); color:#DCE4EF; }
.card-tier[data-tier="gold"]{
  border-color:rgba(236,202,132,.75); color:#F6E0AC;
  background:linear-gradient(120deg, rgba(236,202,132,.22), rgba(236,202,132,.05));
  text-shadow:0 0 10px rgba(236,202,132,.55);
}

/* ---- ИСКРЫ УРОВНЯ ----
   Пустой слой .card-stars (index.html, рядом с .card-foil и .card-sheen). Бронза его не
   видит вовсе, серебро зажигает редкие холодные искры, золото — россыпь тёплых: рост
   уровня читается уже по одному этому слою, «пусто → несколько → россыпь». Точки — две
   группы в ::before/::after с разной длительностью и сдвигом фазы, поэтому искры
   загораются вразнобой; каждая точка — кружок с мягким ореолом, на глаз это блёстка в
   лаке, а не пиксель. Позиции расставлены по пустым местам карты: центр (там цифра и
   имя) обе группы обходят стороной. */
.card-stars{ position:absolute; inset:0; display:none; }
.card-stars::before, .card-stars::after{
  content:""; position:absolute; inset:0;
  animation:starTwinkle 4.6s ease-in-out infinite;
}
.card-stars::after{ animation-duration:6.2s; animation-delay:1.7s; }
@keyframes starTwinkle{
  0%, 100%{ opacity:.15; }
  35%{ opacity:.95; }
  60%{ opacity:.35; }
  80%{ opacity:.8; }
}
/* БРОНЗА (МЕДЬ). Тёмный шоколад с медным светом: розоватый отблеск сверху (медь и
   косметология сходятся в розовом), медное пятно снизу, штриховка чуть тёплая. Нить,
   огонёк, набор косметолога и знак клуба — матовой медью, медленнее и прозрачнее
   базовых: металл нижней ступени не полирован. Тёплым в полную силу остаётся крем
   цифры — баланс обязан читаться на любом уровне. Свечения у бронзы нет вовсе:
   сиять здесь нечему, сияние покупается уровнем. */
.points-card[data-tier="bronze"]{
  background:
    repeating-linear-gradient(112deg, rgba(255,240,228,.028) 0 1px, transparent 1px 6px),
    radial-gradient(88% 74% at 94% -18%, rgba(218,164,148,.24), transparent 60%),
    radial-gradient(96% 82% at 0% 114%, rgba(198,134,86,.32), transparent 62%),
    linear-gradient(147deg, #4C3130 0%, #37211F 42%, #241413 68%, #3E2721 100%);
}
/* Нить — медь, уходящая в пыльную розу: тёплый край на тёплой карте, но с памятью о
   сливовой палитре клуба. */
.points-card[data-tier="bronze"]::before{
  background:linear-gradient(135deg, #C08A5E, transparent 42%, #A57B82 100%);
  opacity:.5;
}
/* background-размеры и repeat повторены из базового правила намеренно — см. комментарий
   к лунному огоньку серебра ниже. */
.points-card[data-tier="bronze"] .card-foil{
  background:
    radial-gradient(circle closest-side, rgba(244,212,184,.55), rgba(208,158,116,.22) 45%, rgba(208,158,116,0) 78%),
    linear-gradient(rgba(200,162,130,.18) 0 0);
  background-repeat:no-repeat;
  background-size:50% 50%, 100% 100%;
  animation-duration:9s;
}
.points-card[data-tier="bronze"] .card-sheen{
  background:linear-gradient(100deg,
    transparent 42%, rgba(244,226,210,.05) 47%, rgba(255,244,232,.1) 51%,
    rgba(244,226,210,.04) 56%, transparent 62%);
  animation-duration:11s;
}
.points-card[data-tier="bronze"] .card-decor{ color:#B98A5F; opacity:.17; }
.points-card[data-tier="bronze"] .card-logo{
  filter:grayscale(1) sepia(.9) saturate(1.5) hue-rotate(-16deg) brightness(.95)
         drop-shadow(0 1px 5px rgba(198,134,86,.3));
  opacity:.75;
}
/* Ореол цифры — почти погашен и тёплый: крем остаётся, светиться ему не положено. */
.points-card[data-tier="bronze"] .points-shine{ animation-name:pointsGlowBronze; }
@keyframes pointsGlowBronze{
  0%, 100%{ filter:drop-shadow(0 0 8px rgba(236,206,180,.16)); }
  50%{ filter:drop-shadow(0 0 14px rgba(242,214,188,.32)); }
}

/* СЕРЕБРО. Фон — те же четыре слоя и те же геометрии, что у базовой карты, перекрашенные
   в холод: штриховка чуть ярче (холодный свет жёстче тёплого), орхидея — в сирень,
   золотое пятно снизу — в лунное серебро, слива основы — на полтона синее. Внешний свет
   в box-shadow добавлен ЧЕТВЁРТЫМ слоем к двум базовым теням и ДЫШИТ (cardSilverBreath):
   у серебра, как и у золота, свечение живое, только холодное, реже и тише — луна, а не
   очаг. Статичное свечение прописано и здесь: при prefers-reduced-motion дыхание
   выключается, а серебряная карта обязана светиться и неподвижной. */
.points-card[data-tier="silver"]{
  background:
    repeating-linear-gradient(112deg, rgba(255,255,255,.035) 0 1px, transparent 1px 6px),
    radial-gradient(88% 74% at 94% -18%, rgba(220,208,244,.38), transparent 60%),
    radial-gradient(96% 82% at 0% 114%, rgba(208,220,238,.46), transparent 62%),
    linear-gradient(147deg, #493A63 0%, #2E2148 42%, #1B1430 68%, #3A2E54 100%);
  box-shadow:0 2px 6px rgba(28,14,36,.22), 0 22px 46px -24px rgba(28,14,36,.92),
    0 0 30px -6px rgba(200,214,236,.48);
  animation:cardSilverBreath 7s ease-in-out infinite;
}
@keyframes cardSilverBreath{
  0%, 100%{ box-shadow:0 2px 6px rgba(28,14,36,.22), 0 22px 46px -24px rgba(28,14,36,.92),
    0 0 26px -6px rgba(200,214,236,.4); }
  50%{ box-shadow:0 2px 6px rgba(28,14,36,.22), 0 22px 46px -24px rgba(28,14,36,.92),
    0 0 40px -2px rgba(214,228,246,.62); }
}
/* Редкие холодные искры серебра: по три точки на группу, у краёв карты. */
.points-card[data-tier="silver"] .card-stars{ display:block; }
.points-card[data-tier="silver"] .card-stars::before{
  background:
    radial-gradient(circle at 9% 78%, rgba(240,246,255,.9) 0 1.2px, rgba(214,228,246,.35) 2.4px, transparent 4px),
    radial-gradient(circle at 88% 30%, rgba(240,246,255,.8) 0 1px, rgba(214,228,246,.3) 2px, transparent 3.6px),
    radial-gradient(circle at 42% 12%, rgba(240,246,255,.75) 0 1px, rgba(214,228,246,.28) 2px, transparent 3.4px);
}
.points-card[data-tier="silver"] .card-stars::after{
  background:
    radial-gradient(circle at 72% 86%, rgba(240,246,255,.85) 0 1.1px, rgba(214,228,246,.32) 2.2px, transparent 3.8px),
    radial-gradient(circle at 22% 40%, rgba(240,246,255,.7) 0 1px, rgba(214,228,246,.26) 2px, transparent 3.2px),
    radial-gradient(circle at 60% 30%, rgba(240,246,255,.65) 0 .9px, rgba(214,228,246,.24) 1.8px, transparent 3px);
}
/* Нить по краю — сплошная серебряная, как у золота золотая: настоящий металл не тает
   в фоне на середине пути, и именно сплошная нить отличает металл от намёка на него.
   Три точки серебра, а не заливка одним цветом, — металл живёт переливом. */
.points-card[data-tier="silver"]::before{
  background:linear-gradient(135deg, #F0F4FA, #B9C6D8 46%, #E4EBF4 100%);
  opacity:.9;
}
/* Чип уровня отвечает наряду: у серебра — та же лунная подложка и свечение, что у
   золота, только холодные. Бронзовый чип остаётся тихим — как и вся бронза. */
.card-tier[data-tier="silver"]{
  background:linear-gradient(120deg, rgba(206,216,231,.14), rgba(206,216,231,.03));
  text-shadow:0 0 8px rgba(206,216,231,.38);
}
/* Ореол цифры — в холод: тёплый крем в серебряном наряде светился бы чужим светом. */
.points-card[data-tier="silver"] .points-shine{ animation-name:pointsGlowSilver; }
@keyframes pointsGlowSilver{
  0%, 100%{ filter:drop-shadow(0 0 10px rgba(214,228,246,.24)); }
  50%{ filter:drop-shadow(0 0 24px rgba(224,236,252,.55)); }
}
/* Огонёк по внутренней рамке — лунный. background-размеры и repeat повторены из базового
   правила намеренно: короткая запись background их сбрасывает, а без size 50% ключи
   анимации cardFoilTrace перестают попадать в углы (см. комментарий к .card-foil). */
.points-card[data-tier="silver"] .card-foil{
  background:
    radial-gradient(circle closest-side, rgba(240,246,255,.78), rgba(205,216,232,.32) 45%, rgba(205,216,232,0) 78%),
    linear-gradient(rgba(214,222,234,.26) 0 0);
  background-repeat:no-repeat;
  background-size:50% 50%, 100% 100%;
}
.points-card[data-tier="silver"] .card-sheen{
  animation-duration:7.5s;
  background:linear-gradient(100deg,
    transparent 42%, rgba(228,238,250,.12) 47%, rgba(244,248,254,.2) 51%,
    rgba(228,238,250,.09) 56%, transparent 62%);
}
.points-card[data-tier="silver"] .card-decor{ color:#D5DEEC; opacity:.24; }
/* Знак клуба — серебряным тиснением: тот же приём одного металла на всю декорацию, что
   у золота, только без sepia — grayscale и есть серебро, осталось поднять свет. */
.points-card[data-tier="silver"] .card-logo{
  filter:grayscale(1) brightness(1.24) contrast(.9)
         drop-shadow(0 1px 8px rgba(214,226,244,.55));
}

/* ЗОЛОТО (ЧЁРНОЕ ЗОЛОТО). Фон — почти чёрная тёплая слива, ГЛУБЖЕ базового, а не рыжее:
   прежняя бордово-рыжая заливка с разлитым оранжевым пятном превращала карту в янтарную
   картинку, дорогая карта устроена наоборот — тёмный предмет, золото в чётких деталях.
   Золотые пятна фона узкие и прижаты к углам (свет от металла на краю, а не залитая
   середина), янтарный подсвет снизу тонкий. Статичное свечение в box-shadow задано и
   здесь, а не только в кадрах cardGoldBreath: при prefers-reduced-motion дыхание
   выключается, а золотая карта обязана светиться и неподвижной. */
.points-card[data-tier="gold"]{
  background:
    repeating-linear-gradient(112deg, rgba(255,236,200,.045) 0 1px, transparent 1px 6px),
    radial-gradient(70% 58% at 100% -12%, rgba(240,206,138,.38), transparent 62%),
    radial-gradient(78% 66% at -6% 112%, rgba(226,184,104,.44), transparent 62%),
    radial-gradient(130% 70% at 50% 122%, rgba(168,116,56,.26), transparent 55%),
    linear-gradient(147deg, #3E2330 0%, #2A121E 42%, #180B12 68%, #351D28 100%);
  box-shadow:0 2px 6px rgba(28,14,36,.22), 0 22px 46px -24px rgba(28,14,36,.92),
    0 0 38px -4px rgba(236,202,132,.55);
  animation:cardGoldBreath 6s ease-in-out infinite;
}
/* Дышит только внешнее свечение — третий слой тени; два базовых слоя переписаны в оба
   кадра без изменений, потому что box-shadow анимируется целиком, по слоям его не
   разъять. Размах дыхания шире серебряного — высший уровень виден через стол, — но
   верхняя точка укрощена: прежние 72px/.85 горели вывеской, а не тёплым очагом.
   Вспышка начисления сильнее дыхания: правило .points-card.celebrating стоит ниже по
   файлу и при равной специфичности выигрывает — на время праздника дыхание замирает
   и возвращается само. */
@keyframes cardGoldBreath{
  0%, 100%{ box-shadow:0 2px 6px rgba(28,14,36,.22), 0 22px 46px -24px rgba(28,14,36,.92),
    0 0 30px -4px rgba(236,202,132,.48); }
  50%{ box-shadow:0 2px 6px rgba(28,14,36,.22), 0 22px 46px -24px rgba(28,14,36,.92),
    0 0 56px 0 rgba(244,216,150,.72); }
}
/* Россыпь тёплых искр — по пять на группу, гуще серебряных и с одной крупной у правого
   верха: золото играет светом даже неподвижное. */
.points-card[data-tier="gold"] .card-stars{ display:block; }
.points-card[data-tier="gold"] .card-stars::before{
  background:
    radial-gradient(circle at 80% 18%, rgba(255,244,214,.95) 0 1.6px, rgba(240,208,140,.4) 3.2px, transparent 5.2px),
    radial-gradient(circle at 10% 72%, rgba(255,240,206,.9) 0 1.2px, rgba(240,208,140,.34) 2.4px, transparent 4px),
    radial-gradient(circle at 38% 10%, rgba(255,240,206,.8) 0 1px, rgba(240,208,140,.3) 2px, transparent 3.6px),
    radial-gradient(circle at 64% 84%, rgba(255,240,206,.85) 0 1.1px, rgba(240,208,140,.32) 2.2px, transparent 3.8px),
    radial-gradient(circle at 27% 52%, rgba(255,240,206,.7) 0 .9px, rgba(240,208,140,.26) 1.8px, transparent 3px);
}
.points-card[data-tier="gold"] .card-stars::after{
  background:
    radial-gradient(circle at 90% 62%, rgba(255,244,214,.9) 0 1.3px, rgba(240,208,140,.36) 2.6px, transparent 4.4px),
    radial-gradient(circle at 18% 24%, rgba(255,240,206,.85) 0 1.1px, rgba(240,208,140,.32) 2.2px, transparent 3.8px),
    radial-gradient(circle at 52% 68%, rgba(255,240,206,.75) 0 1px, rgba(240,208,140,.28) 2px, transparent 3.4px),
    radial-gradient(circle at 70% 40%, rgba(255,240,206,.7) 0 .9px, rgba(240,208,140,.26) 1.8px, transparent 3px),
    radial-gradient(circle at 6% 46%, rgba(255,240,206,.65) 0 .9px, rgba(240,208,140,.24) 1.8px, transparent 3px);
}
/* Ореол цифры — теплее и шире базового: цифра на золотой карте налита светом. */
.points-card[data-tier="gold"] .points-shine{ animation-name:pointsGlowGold; }
@keyframes pointsGlowGold{
  0%, 100%{ filter:drop-shadow(0 0 14px rgba(255,214,160,.36)); }
  50%{ filter:drop-shadow(0 0 32px rgba(255,228,182,.7)); }
}
/* Нить по краю сплошная и вся золотая: у высшего уровня она не тает в фоне на середине
   пути. Три точки золота, а не заливка одним цветом, — металл живёт переливом. */
.points-card[data-tier="gold"]::before{
  background:linear-gradient(135deg, #F6E2B4, #CFAC6E 46%, #F0D8A4 100%);
  opacity:1;
}
/* Внутренняя рамка вызолочена целиком: заливка плотнее и жёлтее базовой, огонёк ярче
   и ходит заметно чаще — золото ловит свет любым движением руки. На почти чёрном фоне
   именно эта резкая тонкая рамка и делает карту «золотой», а не заливка.
   background-размеры и repeat повторены из базового правила намеренно — короткая
   запись background их сбрасывает (см. комментарий к лунному огоньку серебра). */
.points-card[data-tier="gold"] .card-foil{
  background:
    radial-gradient(circle closest-side, rgba(255,238,198,.9), rgba(240,204,134,.4) 45%, rgba(240,204,134,0) 78%),
    linear-gradient(rgba(228,192,128,.38) 0 0);
  background-repeat:no-repeat;
  background-size:50% 50%, 100% 100%;
  animation-duration:5s;
}
.points-card[data-tier="gold"] .card-sheen{
  animation-duration:6.5s;
  background:linear-gradient(100deg,
    transparent 42%, rgba(255,232,196,.18) 47%, rgba(255,246,228,.3) 51%,
    rgba(255,232,196,.14) 56%, transparent 62%);
}
.points-card[data-tier="gold"] .card-decor{ opacity:.34; }
.points-card[data-tier="gold"] .card-logo{
  opacity:1;
  filter:grayscale(1) sepia(1) saturate(2.4) hue-rotate(-10deg) brightness(1.1)
         drop-shadow(0 1px 10px rgba(236,202,132,.62));
}
@media (prefers-reduced-motion: reduce){
  /* Дыхание и мерцание выключаются, свет остаётся: статичные свечения прописаны в самих
     правилах карт, а искры замирают на средней яркости — блёстки в лаке, не гирлянда. */
  .points-card[data-tier="gold"], .points-card[data-tier="silver"]{ animation:none; }
  .card-stars::before, .card-stars::after{ animation:none; opacity:.55; }
  /* Статичный ореол цифры (общее правило ниже по файлу гасит анимацию .points-shine и
     оставляет тёплый свет) — в серебряном наряде обязан остыть вместе со всем нарядом. */
  .points-card[data-tier="silver"] .points-shine{ filter:drop-shadow(0 0 12px rgba(214,228,246,.32)); }
}

/* ---- Имя владельца ----
   Рукописное и крупное — так подписывают подарочный сертификат от руки. Это единственное
   на карте, что принадлежит одному человеку, и работает оно ровно потому, что своё имя
   человек выхватывает взглядом раньше всего остального.
   Pacifico — округлое перо с нажимом; из бесплатных рукописных он один из немногих, кто
   знает кириллицу, а приложение трёхъязычное. Иврита он не знает, как и антиква карты, —
   на иврите обе переменные подменяются на Rubik (правило в конце блока про карту).
   Кегль сбавляют классы .name-long / .name-xlong: их ставит app.js по длине имени. Именно
   ступеньками, а не подгонкой по ширине: измерять текст пришлось бы после того, как шрифт
   приедет с сети, а до этого момента метрики у запасного шрифта совсем другие. Перенести
   имя на вторую строку нельзя — карта фиксированных габаритов. */
/* min-width:0 — обязательный для flex-элемента с nowrap-текстом внутри: без него
   длинное имя не сжимается, а раздвигает строку и выталкивает знак клуба за край карты. */
.card-who{ flex:1 1 auto; min-width:0; }
.hello{ margin:0 0 1px; font-size:10.5px; color:rgba(255,246,238,.52); }
.card-name{
  margin:0; font-family:var(--font-name); font-weight:400;
  font-size:clamp(27px, 8.4vw, 36px); line-height:1.18;
  color:#FFEFDF; text-shadow:0 1px 11px rgba(255,198,146,.26);
  /* У Pacifico хвосты прописных букв уходят заметно ниже базовой линии — без этого
     запаса «у» и «р» срезало бы нижней строкой. */
  padding-bottom:.1em;
  white-space:nowrap; overflow:hidden;
}
.card-name.name-long{ font-size:clamp(22px, 6.9vw, 29px); }
.card-name.name-xlong{ font-size:clamp(18px, 5.5vw, 24px); }

/* ---- Баллы ---- Прижаты к низу карты: строка занимает последнюю строку сетки (1fr),
   а align-self:end сажает содержимое на её нижний край — раньше низ держала третья
   строка сетки (auto), теперь строк две. */
.card-mid{ text-align:left; align-self:end; }
.points-label{
  font-size:10px; letter-spacing:.26em; text-transform:uppercase;
  color:rgba(255,246,238,.55);
}
/* Строка под картой — срок сгорания баллов или условие подарка. Раньше она стояла ВНУТРИ
   карты; в габаритах банковской карты для двух строк мелкого текста места нет, да и на
   настоящей карте такого текста не бывает — он живёт на обороте или в письме. Здесь она
   лежит на светлой подложке кабинета, поэтому и цвет у неё из токенов темы, а не белый. */
.points-sub{
  margin:12px auto 0; max-width:392px; text-align:center;
  font-size:12.5px; line-height:1.45; color:var(--ink-soft);
}
.points-sub:empty{ display:none; }

/* ---- СЦЕНА: У КАРТЫ ДВЕ СТОРОНЫ ----
   Лицевая сторона (.points-card) и оборот (.card-back) лежат друг на друге двумя
   абсолютными слоями внутри .card-3d, и вся эта конструкция поворачивается вокруг
   вертикальной оси. Зачем именно поворот, а не второй экран: имя правят на обороте карты,
   и человек должен видеть, что перед ним ОБРАТНАЯ сторона того же предмета. Смена двух
   блоков этого не говорит — она говорит «открылась другая страница».

   Про то, как человек вообще узнаёт, что карту можно перевернуть, — см. комментарий к
   разметке карты в index.html: подсказок три, и .peek ниже одна из них.

   Габариты и внешние поля стоят здесь, а не на самой карте: обе стороны — absolute,
   собственной высоты у них нет, и задать её обязана коробка снаружи. Здесь же объявлены
   --r/--inset и шрифты карты: ими пользуются обе стороны, а стороны друг другу соседи. */
.card-flip{
  --r:clamp(16px, 5.2vw, 22px);
  --inset:clamp(7px, 2.4vw, 11px);
  /* Шрифт карты. Cormorant Garamond — антиква с сильным контрастом штриха: тонкие цифры
     с засечками читаются как гравировка на сертификате, а не как цена в приложении. Прежний
     Manrope (жирный гротеск) держал цифры «весом», и карта выходила финтех-плиткой. Иврита
     Cormorant не знает — на иврите переменная подменяется на Rubik (правило в конце блока
     про карту), и от этого страдают только цифры, которые в иврите всё равно латинские. */
  --font-card:"Cormorant Garamond", Georgia, "Times New Roman", serif;
  /* Рукописный шрифт имени — см. .card-name. Отдельной переменной, а не значением в самом
     правиле: на иврите обе антиквы подменяются разом, и подмена должна быть в одном месте.
     Им же набрано имя в полосе для подписи на обороте — одно перо на обеих сторонах. */
  --font-name:"Pacifico", var(--font-card);
  /* Габариты настоящей карты (ISO 7810, 85.6 × 54 мм) — переехали сюда с .points-card. */
  aspect-ratio:1.586; max-width:392px; margin:0 auto;
  /* Перспектива — то, чем поворот предмета отличается от «страница сменилась». Без неё
     дальний край грани не сходится к горизонту, и вместо карты в руке по экрану ездит
     сжимающийся по ширине прямоугольник. Значение примерно втрое больше ширины карты:
     ближе — поворот превращается в аттракцион, дальше — в плоскую подмену. */
  perspective:1100px;
}
.card-3d{
  position:relative; width:100%; height:100%;
  transform-style:preserve-3d;
  /* Полсекунды с небольшим — столько поворачивают карту в руке. Быстрее выходит щелчок
     переключателя, медленнее — человек успевает решить, что приложение задумалось. */
  transition:transform .62s cubic-bezier(.2,.72,.24,1);
}
.card-flip.is-flipped .card-3d{ transform:rotateY(180deg); }

/* Приоткрытая карта — вторая из трёх подсказок про оборот (см. index.html и cardHintLeft
   в app.js). Угол нарочно маленький: этого хватает, чтобы стало видно, что грань куда-то
   уходит и за ней что-то есть, и мало, чтобы принять движение за начавшийся сам собой
   поворот, который надо остановить. */
.card-flip.peek .card-3d{ animation:cardPeek 1.9s cubic-bezier(.34,.72,.28,1); }
@keyframes cardPeek{
  0%, 16%   { transform:rotateY(0deg); }
  44%       { transform:rotateY(-16deg); }
  80%, 100% { transform:rotateY(0deg); }
}

.card-face{
  position:absolute; inset:0;
  /* Изнанка грани не рисуется. Без этого обе стороны видны одновременно и просвечивают
     друг сквозь друга зеркально — вместо карты получается стеклянная пластинка. */
  -webkit-backface-visibility:hidden; backface-visibility:hidden;
}
/* Площадь карты нажатий больше не ловит — переворачивает только значок в углу (см.
   обработчик в app.js). Раньше ловила вся лицевая сторона, и «указательный палец» стоял
   здесь же; теперь его нет намеренно: курсор-рука на всей карте обещал бы действие,
   которого у карты целиком не осталось. */
/* Карта — предмет, а не текст: выделять на ней имя и баллы незачем, а при нажатии по
   площади выделение появляется само и мешает. */
.points-card{ -webkit-user-select:none; user-select:none; }

/* Значок «перевернуть» в нижнем углу карты. Селектор с родителем не для красоты: правило
   «.points-card > *» выше возвращает прямым детям position:relative, и с одним классом
   значок зависел бы от того, кто в файле стоит ниже. */
.points-card > .card-turn{
  position:absolute; z-index:2;
  right:clamp(11px, 3.4vw, 15px); bottom:clamp(11px, 3.4vw, 15px);
  display:flex; align-items:center; justify-content:center;
  width:30px; height:30px; padding:0;
  border-radius:50%; cursor:pointer;
  border:1px solid rgba(233,205,157,.34);
  background:rgba(255,246,238,.07);
  color:var(--gold);
  transition:background .15s ease, border-color .15s ease, transform .15s ease;
}
.card-turn svg{ width:15px; height:15px; }
.card-turn:hover{ background:rgba(255,246,238,.14); border-color:rgba(233,205,157,.62); }
.card-turn:active{ transform:scale(.92); }

/* Зона нажатия шире рисунка. Значок рисуют маленьким не по недосмотру: на карте он —
   пометка на углу, а не кнопка приложения, и вырасти ему некуда. Но с тех пор как площадь
   карты нажатий не ловит, промах по значку значит «ничего не произошло», а это худший ответ
   из возможных. Невидимое поле вокруг доводит цель до пальца, не трогая рисунок.
   Внутрь не растём (одни только внешние отступы): вплотную к значку лежит узор карты. */
.card-turn::before{
  content:""; position:absolute; inset:-11px; border-radius:50%;
}

/* ПРИГЛАШЕНИЕ НАЖАТЬ. Значок нарисован, но нарисованное на карте по умолчанию читается как
   часть картинки — тиснение, а не орган управления. Отличает их одно: живое ведёт себя не
   как краска. Отсюда три слоя, и все три сложены в один такт длиной 4.4 с — так это читается
   одним движением, а не тремя разными украшениями в одном углу:
     1. САМ ЗНАЧОК ПОВОРАЧИВАЕТСЯ — на долю секунды, вокруг той самой пунктирной оси, что на
        нём нарисована. Это главное здесь и единственное, что не просто «мигает, значит
        нажимается», а показывает, ЧТО случится после нажатия: карта повернётся точно так же.
        Прежний вариант был тише — один ореол на box-shadow, — и его на узорчатой карте просто
        не замечали.
     2. Вспышка кружка — фон и рамка на миг набирают света вместе с поворотом. Она и делает
        поворот заметным боковым зрением: тёмный контур в углу карты сам по себе слишком мал.
     3. Кольцо, уходящее наружу (::after) — только пока живы подсказки про оборот, класс
        invite ставит app.js. Это единственная из подсказок, которая показывает не «у карты
        есть оборот», а «нажимать сюда»; после первого переворота человек уже знает, где
        кнопка, и кольцо гаснет навсегда вместе с остальными подсказками.
   Три четверти такта значок неподвижен: зовут паузой не меньше, чем движением, и без неё
   угол карты превращается в мигающую лампочку, которую хочется закрыть пальцем. */
.card-turn{ animation:turnBreath 4.4s ease-in-out infinite; }
@keyframes turnBreath{
  0%, 58%, 100%{
    background-color:rgba(255,246,238,.07); border-color:rgba(233,205,157,.34);
    box-shadow:0 0 0 0 rgba(233,205,157,0);
  }
  72%{
    background-color:rgba(255,246,238,.22); border-color:rgba(233,205,157,.9);
    box-shadow:0 0 16px 2px rgba(233,205,157,.45);
  }
}
/* Поворот значка. perspective прямо в transform, потому что переносить его на .card-turn
   нельзя: у кнопки есть своё нажатие (scale), и общая на двоих перспектива увела бы его
   в глубину. Угол больше, чем у приоткрывающейся карты (там 16°): значок мелкий, и на нём
   малый угол — это просто дрожание. */
.card-turn svg{ animation:turnHintSpin 4.4s cubic-bezier(.34,.72,.28,1) infinite; }
@keyframes turnHintSpin{
  0%, 58%, 100%{ transform:perspective(70px) rotateY(0deg); }
  72%          { transform:perspective(70px) rotateY(-62deg); }
  86%          { transform:perspective(70px) rotateY(7deg); }
}
.card-turn.invite::after{
  content:""; position:absolute; inset:0; border-radius:50%;
  border:1.5px solid rgba(233,205,157,.9);
  opacity:0; pointer-events:none;
  animation:turnHalo 4.4s ease-out infinite;
}
/* Волна уходит от значка и растворяется, а не бьётся об него обратно: круг на воде
   расходится один раз. Дальше 2.15 радиуса не растёт — на этом краю она ещё внутри
   скруглённого угла карты, а карта режет всё, что вышло за край (overflow:hidden), и
   кольцо превратилось бы в обрубленную дугу. */
@keyframes turnHalo{
  0%, 58%  { transform:scale(1);    opacity:0; }
  66%      { opacity:.85; }
  100%     { transform:scale(2.15); opacity:0; }
}
/* Наведение и нажатие отменяют зов целиком: человек уже здесь, звать его некуда, — а заодно
   освобождают фон и рамку, которые иначе остались бы за анимацией, и наведение не отвечало
   бы ничем. */
.card-turn:hover, .card-turn:active, .card-turn:focus-visible{ animation:none; }
.card-turn:hover svg, .card-turn:active svg, .card-turn:focus-visible svg{ animation:none; }
.card-turn:hover::after, .card-turn:active::after{ animation:none; opacity:0; }

/* ---- ОБОРОТ КАРТЫ ----
   Тот же предмет с другой стороны, поэтому фон набран из тех же красок, что и лицевая
   сторона, но развёрнут: тёплое золото ушло вверх, румянец вниз. Так и бывает с картой,
   которую повернули, — свет ложится с другой стороны, а материал остаётся тем же.

   Строк четыре: магнитная полоса, слой с полями, строка ошибки и кнопки. Остаток высоты
   (1fr) отдан слою с полями, потому что расти в габаритах карты может только он; строки
   ошибки в обычном состоянии нет вовсе (:empty ниже). */
.card-back{
  --pad-x:clamp(13px, 4.2vw, 18px);
  transform:rotateY(180deg);
  display:grid; grid-template-rows:auto 1fr auto auto;
  /* Промежутки между строками мельче, чем где-либо в приложении, и это не стиль, а
     арифметика: в 54 миллиметрах высоты стоят полоса, четыре поля, строка ошибки и две
     кнопки. Каждый лишний пиксель здесь — это пиксель, отнятый у чего-то из перечисленного. */
  gap:clamp(3px, 1vw, 5px);
  padding:clamp(9px, 3.2vw, 14px) var(--pad-x) clamp(9px, 3.4vw, 15px);
  border-radius:var(--r); overflow:hidden;
  color:#FFF6EE; text-align:left;
  background:
    repeating-linear-gradient(112deg, rgba(255,255,255,.026) 0 1px, transparent 1px 6px),
    radial-gradient(88% 74% at 6% -18%, rgba(228,180,199,.22), transparent 60%),
    radial-gradient(96% 82% at 100% 114%, rgba(201,165,102,.28), transparent 62%),
    linear-gradient(213deg, #43254D 0%, #2A1531 42%, #1A0D21 68%, #381D3C 100%);
  box-shadow:0 2px 6px rgba(28,14,36,.22), 0 22px 46px -24px rgba(28,14,36,.92);
}
/* Золотая нить по краю — та же, что у лицевой стороны: край у предмета один. */
.card-back::before{
  content:""; position:absolute; inset:0; border-radius:inherit; padding:1px;
  background:linear-gradient(225deg, var(--gold), transparent 42%, #C6A8E4 100%);
  -webkit-mask:linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
  -webkit-mask-composite:xor; mask-composite:exclude; opacity:.6; pointer-events:none;
}
.card-back > *{ position:relative; z-index:1; }

/* ---- Оборот по уровням ----
   Карта — один предмет: перевернув золотую, нельзя увидеть сливовую изнанку. data-tier
   стоит на сцене переворота (#card-flip, ставит renderPointsInfo в app.js), и оборот
   собран из тех же красок, что лицевая сторона уровня, только развёрнутых на 213°/225°,
   как у базового оборота. Дыхания и искр на обороте нет: это сторона с формой и
   клавиатурой, свет здесь статичный — материал виден, а работать не мешает. */
.card-flip[data-tier="bronze"] .card-back{
  background:
    repeating-linear-gradient(112deg, rgba(255,240,228,.024) 0 1px, transparent 1px 6px),
    radial-gradient(88% 74% at 6% -18%, rgba(218,164,148,.2), transparent 60%),
    radial-gradient(96% 82% at 100% 114%, rgba(198,134,86,.28), transparent 62%),
    linear-gradient(213deg, #4C3130 0%, #37211F 42%, #241413 68%, #3E2721 100%);
}
.card-flip[data-tier="bronze"] .card-back::before{
  background:linear-gradient(225deg, #C08A5E, transparent 42%, #A57B82 100%);
  opacity:.5;
}
.card-flip[data-tier="silver"] .card-back{
  background:
    repeating-linear-gradient(112deg, rgba(255,255,255,.035) 0 1px, transparent 1px 6px),
    radial-gradient(88% 74% at 6% -18%, rgba(220,208,244,.34), transparent 60%),
    radial-gradient(96% 82% at 100% 114%, rgba(208,220,238,.4), transparent 62%),
    linear-gradient(213deg, #493A63 0%, #2E2148 42%, #1B1430 68%, #3A2E54 100%);
  box-shadow:0 2px 6px rgba(28,14,36,.22), 0 22px 46px -24px rgba(28,14,36,.92),
    0 0 26px -6px rgba(200,214,236,.42);
}
.card-flip[data-tier="silver"] .card-back::before{
  background:linear-gradient(225deg, #F0F4FA, #B9C6D8 46%, #E4EBF4 100%);
  opacity:.9;
}
.card-flip[data-tier="gold"] .card-back{
  background:
    repeating-linear-gradient(112deg, rgba(255,236,200,.045) 0 1px, transparent 1px 6px),
    radial-gradient(70% 58% at 0% -12%, rgba(240,206,138,.32), transparent 62%),
    radial-gradient(78% 66% at 106% 112%, rgba(226,184,104,.38), transparent 62%),
    linear-gradient(213deg, #3E2330 0%, #2A121E 42%, #180B12 68%, #351D28 100%);
  box-shadow:0 2px 6px rgba(28,14,36,.22), 0 22px 46px -24px rgba(28,14,36,.92),
    0 0 30px -6px rgba(236,202,132,.45);
}
.card-flip[data-tier="gold"] .card-back::before{
  background:linear-gradient(225deg, #F6E2B4, #CFAC6E 46%, #F0D8A4 100%);
  opacity:1;
}
/* Магнитная полоса. Во всю ширину — то есть за поля карты, отсюда отрицательные боковые
   поля ровно в padding (--pad-x): полоса, не доходящая до краёв, выглядит наклейкой. */
.card-stripe{
  height:clamp(17px, 5vw, 22px); margin-inline:calc(var(--pad-x) * -1);
  background:linear-gradient(180deg, #120A17 0%, #241429 55%, #150C1B 100%);
  box-shadow:inset 0 1px 0 rgba(255,255,255,.05), inset 0 -1px 0 rgba(0,0,0,.4);
}
/* Слой с полями между полосой и кнопками. Своей высоты не просит (1fr и центрирование по
   вертикали), а min-height:0 нужен на самый плохой случай: когда над кнопками встаёт ещё и
   строка ошибки, на маленькой карте лишнюю высоту должен отдать этот слой, а не кнопки —
   уехавшие за край кнопки означали бы форму, которую нечем ни отправить, ни закрыть. */
.card-back-body{
  align-self:center; min-height:0;
  display:grid; gap:clamp(4px, 1.3vw, 7px);
}

/* Полоса для подписи — единственное светлое место на тёмном предмете, и единственное, куда
   на настоящей карте пишут от руки. Тем она и объясняет, зачем человек сюда попал: не
   «форма редактирования профиля», а строка, в которой стоит его подпись.

   Имя и фамилия стоят в два столбца, а не в две строки, с тех пор как ниже появилась ещё
   и почта: в габаритах карты четыре строки подряд не помещаются. Столбцы здесь не уступка
   тесноте — подпись на карте одна и всегда в одну строку. */
.sign-panel{
  display:grid; grid-template-columns:1fr 1fr; gap:clamp(6px, 2.4vw, 11px);
  border-radius:7px; padding:clamp(3px, 1.1vw, 5px) clamp(8px, 2.6vw, 11px);
  background:linear-gradient(180deg, #FBF6EE 0%, #F1E7DA 100%);
  box-shadow:inset 0 1px 0 rgba(255,255,255,.75), 0 1px 0 rgba(0,0,0,.28);
}
.sign-label{
  display:block; font-size:clamp(7.5px, 2.3vw, 8.5px); letter-spacing:.22em;
  text-transform:uppercase; color:#8C7B6B;
}
/* Поле подписи. Ни рамки, ни фона — только линейка снизу, как на бумаге: рамка превратила
   бы полосу для подписи обратно в анкету. Перо то же, что и у имени на лицевой стороне
   (--font-name), и это единственное, что связывает две стороны в один предмет.
   Кегль убавлен против прежнего: в столбце шириной в пол-карты рукописным пером помещается
   вдвое меньше букв, а имена вроде «Александра» обязаны влезать целиком. */
.sign-panel input{
  -webkit-appearance:none; appearance:none;
  width:100%; padding:0 0 1px; border:none; border-bottom:1px solid rgba(90,70,55,.22);
  background:none; border-radius:0;
  font-family:var(--font-name); font-weight:400;
  font-size:clamp(12px, 3.6vw, 16px); line-height:1.45;
  color:#2A1B14; caret-color:var(--plum);
  /* Длинное имя в узком столбце не растягивает полосу, а сжимается само. */
  min-width:0;
}
.sign-panel input:focus{ outline:none; border-bottom-color:var(--plum); }
/* Автозаполнение красит поле в свой голубой — на полосе для подписи это чужая краска. */
.sign-panel input:-webkit-autofill,
.sign-panel input:-webkit-autofill:hover,
.sign-panel input:-webkit-autofill:focus{
  -webkit-box-shadow:0 0 0 100px #F6F0E6 inset;
  box-shadow:0 0 0 100px #F6F0E6 inset;
  -webkit-text-fill-color:#2A1B14;
  caret-color:var(--plum);
}

/* ПОЧТА. Не в полосе для подписи, а под ней, прямо по тёмному телу карты: на обороте
   настоящей карты так печатают служебное — сайт, телефон поддержки. Разница смысловая,
   а не только по краскам: в полосе стоит подпись владельца, здесь — способ связи с салоном.
   Отсюда и шрифт: интерфейсный, а не рукописный. Адрес почты пером с сильным контрастом
   штриха — это набор палочек, в котором не отличить l от I, а точку от запятой. */
.card-line{ display:grid; gap:1px; }
.card-line-label{
  font-size:clamp(7.5px, 2.3vw, 8.5px); letter-spacing:.22em; text-transform:uppercase;
  color:rgba(233,205,157,.74);
}
.card-line input{
  -webkit-appearance:none; appearance:none;
  width:100%; padding:0 0 2px; border:none;
  border-bottom:1px solid rgba(233,205,157,.32);
  background:none; border-radius:0;
  font-family:inherit; font-size:clamp(11.5px, 3.4vw, 13.5px); line-height:1.35;
  color:#FFF6EE; caret-color:var(--gold);
}
.card-line input::placeholder{ color:rgba(255,246,238,.32); }
.card-line input:focus{ outline:none; border-bottom-color:var(--gold); }
/* Тот же спор с автозаполнением, что и в полосе для подписи, только подложка тёмная. */
.card-line input:-webkit-autofill,
.card-line input:-webkit-autofill:hover,
.card-line input:-webkit-autofill:focus{
  -webkit-box-shadow:0 0 0 100px #2A1531 inset;
  box-shadow:0 0 0 100px #2A1531 inset;
  -webkit-text-fill-color:#FFF6EE;
  caret-color:var(--gold);
}

/* Согласие на письма. Строка появляется только вместе с адресом (показывает app.js) и
   набрана мельче всего на карте намеренно: это не то, ради чего человек сюда пришёл, и
   занимать собой оборот она не должна. Но и прятать её в мелкий шрифт «чтобы не мешала»
   нельзя — под ней согласие на рассылку, и человек обязан видеть, что именно он отмечает. */
.card-consent{
  display:flex; align-items:center; gap:7px; cursor:pointer;
  font-size:clamp(9.5px, 2.9vw, 11px); line-height:1.25;
  color:rgba(255,246,238,.8);
}
.card-consent.hidden{ display:none; }

/* Состояние адреса под полем почты. Строка мелкая намеренно — это не то, ради чего
   переворачивают карту, — но она единственная, кто говорит человеку, доходят ли до него
   письма клуба вообще. Неподтверждённый адрес ещё и нажимается (открывает окно ввода кода
   из письма), поэтому это кнопка, а не строка: «не подтверждён» без способа исправить —
   упрёк, а не сообщение.
   Сброс внешности кнопки полный: на тёмном теле карты системная кнопка со своей рамкой и
   фоном выглядит заплатой. */
.card-verify{
  -webkit-appearance:none; appearance:none;
  display:flex; align-items:center; gap:6px;
  margin:0; padding:0; border:none; background:none; cursor:pointer;
  font-family:inherit; font-size:clamp(8.5px, 2.6vw, 10px); line-height:1.25;
  text-align:start; color:#FFC9A8;
}
.card-verify.hidden{ display:none; }
/* Подтверждённый адрес — уже не кнопка: нажимать не на что. Цвет шампанского, тот же, что
   у подписей на обороте: это спокойная отметка «всё в порядке», а не новость. */
.card-verify:disabled{ cursor:default; color:rgba(233,205,157,.78); }
.card-verify-mark{
  flex:none; width:12px; height:12px; border-radius:50%;
  display:flex; align-items:center; justify-content:center;
  font-size:8px; font-weight:700; line-height:1;
  border:1px solid currentColor;
}
.card-verify-mark::before{ content:"!"; }
.card-verify.is-verified .card-verify-mark::before{ content:"¹3"; }
.card-verify:not(:disabled):hover .card-verify-text,
.card-verify:not(:disabled):focus-visible .card-verify-text{ text-decoration:underline; }
.card-verify:focus-visible{ outline:2px solid var(--gold); outline-offset:3px; border-radius:4px; }
.card-consent input{
  flex:none; width:13px; height:13px; margin:0; cursor:pointer;
  accent-color:var(--gold);
}
/* Встряска при ошибке — у полосы для подписи, а не у всей стороны: transform на самой
   грани отменил бы её поворот на 180°, и оборот показал бы себя изнанкой. */
.sign-panel.shake{ animation:shake .45s; }
/* Цвет ошибки берём не из токена --danger: тот подобран под светлую подложку кабинета и
   на баклажановой карте почти не отличается от фона. */
.card-back-error{
  margin:0; font-size:10.5px; line-height:1.2; color:#FFB3A7;
}
.card-back-error:empty{ display:none; }
.card-back-actions{ display:flex; align-items:center; gap:8px; }
/* «Сохранить» — главное действие, поэтому занимает всю оставшуюся ширину. */
.card-back-actions .btn-primary{
  flex:1 1 auto; margin-top:0; border-radius:11px; padding:7px 12px; font-size:13.5px;
}
.btn-card-ghost{
  flex:none; font-family:inherit; cursor:pointer;
  border:1px solid rgba(255,246,238,.26); border-radius:11px; padding:7px 13px;
  font-size:12.5px; background:transparent; color:rgba(255,246,238,.72);
  transition:border-color .15s ease, color .15s ease;
}
.btn-card-ghost:hover{ border-color:rgba(255,246,238,.5); color:#FFF6EE; }

/* Строка-подсказка под картой — третья из трёх (см. index.html). Набрана мельче и глуше
   строки про сгорание баллов: та говорит о деньгах, эта — об устройстве экрана. */
.card-hint{
  margin:11px auto 0; max-width:392px; text-align:center;
  font-size:12px; line-height:1.4; color:var(--ink-soft); opacity:.8;
}

/* Иврит: значок «перевернуть» уходит в левый угол — правый низ на иврите занят баллами
   (см. блок [lang="he"] .card-mid выше). Полоса для подписи зеркалится вместе со всей
   картой, а поля имени приходится разворачивать явно: text-align в input не наследуется
   (то же самое и в анкете — см. [lang="he"] .form input[name="firstName"]). */
[lang="he"] .points-card > .card-turn{ right:auto; left:clamp(11px, 3.4vw, 15px); }
[lang="he"] .card-back{ text-align:right; }
[lang="he"] .sign-panel input{ direction:rtl; text-align:right; }
/* Адрес почты остаётся слева направо на любом языке — он и написан латиницей. Зеркалим
   только выключку: поле обязано стоять по правому краю карты вместе со всем остальным,
   а буквы внутри него идти как положено адресу. */
[lang="he"] .card-line input{ direction:ltr; text-align:right; }
/* Галочка справа от текста — по той же причине, что и в анкете (см. [lang="he"]
   .form .checkbox-row): квадратик слева от правостороннего текста читается как
   относящийся к соседней строке. */
[lang="he"] .card-consent{ flex-direction:row-reverse; }

@media (prefers-reduced-motion: reduce){
  /* Человек просил систему не анимировать — карта меняет сторону мгновенно. Сам поворот
     при этом не отменяется: без него обе грани оказались бы видны разом. */
  .card-3d{ transition:none; }
  .card-flip.peek .card-3d{ animation:none; }
  /* Значок не поворачивается, не вспыхивает и не зовёт кольцом — вместо движения ему
     остаётся свет: он просто ярче прочего в углу карты, и постоянно, а не тактом. Молча
     спрятать его было бы хуже всего — нажать больше не на что. */
  .card-turn{
    animation:none;
    background:rgba(255,246,238,.16); border-color:rgba(233,205,157,.62);
  }
  .card-turn svg{ animation:none; }
  .card-turn.invite{ background:rgba(255,246,238,.24); border-color:rgba(233,205,157,.85); }
  .card-turn.invite::after{ animation:none; opacity:0; }
  .sign-panel.shake{ animation:none; }
}

/* Ореол вокруг цифр. Он на обёртке, а не на самом числе, потому что у числа нет цвета:
   текст прозрачный, сквозь него видно текстуру. drop-shadow работает по готовой картинке,
   то есть по контуру уже залитых кремом цифр. Дышит медленно — это блеск на глянце,
   а не мигание. */
.points-shine{
  display:flex; justify-content:flex-start;
  animation:pointsGlow 6s ease-in-out infinite;
}
@keyframes pointsGlow{
  0%, 100%{ filter:drop-shadow(0 0 10px rgba(255,205,150,.22)); }
  50%{ filter:drop-shadow(0 0 22px rgba(255,216,170,.5)); }
}

/* Сами баллы. Заливка цифр — не цвет, а картинка, обрезанная по контуру букв
   (background-clip:text): тёплый крем с золотой крошкой, который медленно течёт внутри
   числа. Слои сверху вниз: четыре поля мелких искр разной плотности, косой блик и сам
   крем. Смещение каждого поля искр за цикл равно размеру его плитки — поэтому петля
   сходится сама с собой и «шва» на повторе не видно.

   Начертание — антиква карты (--font-card) среднего веса. Тонкие засечки и есть разница
   между «суммой на счёте» и «номиналом сертификата»; кегль поэтому взят крупнее прежнего:
   у Cormorant цифры мельче в пределах кегля, чем у гротеска.

   ЗАМЕНА НА ВИДЕО. Если будет готовая текстура (ролик из Gemini/Veo, переведённый в
   анимированный webp — промпт и команда лежат в media/points-texture-prompt.md в корне
   проекта), включать её здесь и больше нигде:
     .points-value{
       background-image:url("media/points-texture.webp");
       background-size:cover; background-position:center; animation:none;
     }
   Обрезка по буквам, жирность, свечение и «докрутка» счётчика останутся как есть. */
.points-value{
  font-family:var(--font-card); font-weight:500;
  font-size:clamp(44px, 14vw, 60px); line-height:1.06; letter-spacing:0;
  font-variant-numeric:tabular-nums lining-nums; display:inline-block;
  background-image:
    radial-gradient(circle at 14% 32%, rgba(255,255,255,.95) 0 1.1px, transparent 2.2px),
    radial-gradient(circle at 63% 17%, rgba(255,238,200,.92) 0 1.3px, transparent 2.4px),
    radial-gradient(circle at 36% 74%, rgba(255,255,255,.85) 0 1px, transparent 2px),
    radial-gradient(circle at 86% 61%, rgba(255,230,180,.9) 0 1.2px, transparent 2.3px),
    linear-gradient(104deg, transparent 34%, rgba(255,255,255,.92) 47%, transparent 62%),
    linear-gradient(152deg, #FFFCF6 0%, #FFEBD2 26%, #F5CC98 50%, #FFF6E7 72%, #EEC085 100%);
  background-size:
    132px 118px, 174px 146px, 116px 104px, 158px 132px, 280% 100%, 100% 100%;
  background-repeat:repeat, repeat, repeat, repeat, no-repeat, no-repeat;
  -webkit-background-clip:text; background-clip:text;
  color:transparent; -webkit-text-fill-color:transparent;
  animation:pointsCream 11s linear infinite;
}
@keyframes pointsCream{
  from{ background-position:0 0, 0 0, 0 0, 0 0, 130% 0, 0 0; }
  to{ background-position:-132px 118px, 174px -146px, 116px 104px, -158px -132px, -45% 0, 0 0; }
}
/* Страховка на случай браузера, который не умеет обрезать фон по буквам: без неё цифры
   остались бы прозрачными, то есть баланса на карте не было бы вовсе. */
@supports not ((-webkit-background-clip:text) or (background-clip:text)){
  .points-value{ color:#FFF3E2; -webkit-text-fill-color:#FFF3E2; background-image:none; }
}
/* Прибавка баллов: число подпрыгивает, но крем при этом продолжает течь — обе анимации
   перечислены вместе, потому что вторая объявленная animation отменила бы первую. */
.points-value.pulse{ animation:pointsPulse .6s ease, pointsCream 11s linear infinite; }
@keyframes pointsPulse{ 0%{ transform:scale(1); } 35%{ transform:scale(1.1); } 100%{ transform:scale(1); } }
@media (prefers-reduced-motion: reduce){
  .points-shine{ animation:none; filter:drop-shadow(0 0 12px rgba(255,205,150,.28)); }
  .points-value{ animation:none; }
  .points-value.pulse{ animation:none; }
  .card-sheen{ animation:none; opacity:.5; }
  /* Рамке возвращаем ровную заливку без бегущего пятна: остановленное на месте
     пятно света — это уже не украшение, а засветка в одном углу. */
  .card-foil{ animation:none; background-image:linear-gradient(rgba(233,205,157,.26) 0 0); background-size:100% 100%; }
}

/* ---- Подарок вместо нуля: карта нового клиента до первого визита ----
   Число здесь не залито кремом, как настоящий баланс, а обведено контуром. Это не
   украшение, а единственное, что отличает «уже ваше» от «ещё не ваше» на расстоянии
   вытянутой руки: залитая цифра — деньги на карте, полая — обещание. Когда бонус
   разблокируют первым визитом, цифра станет обычной .points-value, залитой, и прилетит
   с монетками — переход из контура в заливку и есть «подарок получен». */
.points-gift{
  display:flex; align-items:center; justify-content:flex-start; gap:clamp(10px, 3.2vw, 16px);
  animation:pointsGlow 6s ease-in-out infinite;
}
/* Бант — линией, без подложки и без плашки. Плашка со скруглением делала из подарка
   иконку в интерфейсе; здесь он должен выглядеть нарисованным той же золотой линией,
   что и набор косметолога в углу карты. */
.points-gift-icon{
  flex:none; display:grid; place-items:center; color:#EFCE9B;
  width:clamp(29px, 9.2vw, 37px); height:clamp(29px, 9.2vw, 37px);
  /* Медленное «дыхание» — то же, что у карточки-обещания под картой: знак подарка должен
     попадаться на глаза при взгляде на баланс, но не мигать над цифрой. */
  animation:pendingBreath 3.4s ease-in-out 1.2s infinite;
}
.points-gift-icon svg{ width:100%; height:100%; }
.points-gift-value{
  font-family:var(--font-card); font-weight:500;
  font-size:clamp(42px, 13.4vw, 57px); line-height:1.06; letter-spacing:0;
  font-variant-numeric:tabular-nums lining-nums;
  color:transparent; -webkit-text-fill-color:transparent;
  -webkit-text-stroke:1.2px rgba(255,231,196,.92);
}
/* Браузер без обводки текста залил бы цифру ничем — то есть баланса на карте не было бы
   вовсе. Тогда лучше приглушённая заливка: она хуже отличается от настоящего баланса,
   но подпись над числом и строка под ним про первый визит остаются на месте. */
@supports not ((-webkit-text-stroke:1px #000)){
  .points-gift-value{ color:rgba(255,231,196,.6); -webkit-text-fill-color:rgba(255,231,196,.6); }
}
/* Подпись подарка над числом — золотом, а не приглушённым белым, как «ВАШИ БАЛЛЫ»:
   у обычного баланса подпись служебная, здесь она сама по себе новость. */
#points-label-gift{ color:#EFCE9B; }
@media (prefers-reduced-motion: reduce){
  .points-gift{ animation:none; filter:drop-shadow(0 0 12px rgba(255,205,150,.28)); }
  .points-gift-icon{ animation:none; }
}
/* Иврит. Карта — единственное место в приложении, которое зеркалится целиком, и не из
   любви к симметрии: весь её текст прижат к одному краю, а другой край занят узором. Не
   переверни мы её, ивритское имя упёрлось бы в рисунок, а полполя слева осталось пустым.
   Ни Cormorant, ни Pacifico ивритских букв не содержат — имя и подписи в них распались бы
   на запасной системный шрифт, поэтому на иврите вся карта набирается тем же Rubik. */
[lang="he"] .card-flip{ --font-card:var(--font-ui); --font-name:var(--font-ui); }
[lang="he"] .card-who, [lang="he"] .card-mid{ text-align:right; }
[lang="he"] .card-top{ flex-direction:row-reverse; }
/* Правый столбец переехал налево вместе со строкой — подпись и знак равняются по левому
   краю, иначе они повисли бы в воздухе посреди карты. */
[lang="he"] .card-kind-wrap{ align-items:flex-start; }
[lang="he"] .card-kind{ text-align:left; }
[lang="he"] .points-shine, [lang="he"] .points-gift{ justify-content:flex-end; }
[lang="he"] .points-gift{ flex-direction:row-reverse; }
[lang="he"] .card-decor{ right:auto; left:clamp(30px, 9.5vw, 40px); transform:scaleX(-1); }

/* Карточки «вас ждут N баллов» (.pending-card со знаком подарка и золотой рамкой) здесь
   больше нет вместе с самой карточкой: приветственный подарок лежит на балансе с
   регистрации, и объяснять крупный ноль отдельным блоком больше нечего. Условие про первый
   визит осталось строкой .points-sub под числом — той самой, вместо которой когда-то и
   завели карточку. Разница в том, что теперь эта строка уточняет настоящее число, а не
   заменяет собой отсутствующее.

   Медленное «дыхание» от неё пережило саму карточку: им дышат знаки подарка в карте
   (.points-gift-icon) и на экране начисления (.reward-gift-icon), и там оно решает ту же
   задачу — попасться на глаза при взгляде на баланс, но не мигать над цифрой. Имя
   оставлено прежним: оно есть в двух правилах выше и ниже. */
@keyframes pendingBreath{
  0%, 72%, 100%{ transform:scale(1); }
  80%{ transform:scale(1.09); }
  88%{ transform:scale(.99); }
}

/* ---- «Позовите подругу» ----
   Единственный блок в кабинете, который о чём-то ПРОСИТ. Сама карточка от этого не
   меняется: она остаётся сливовой (--plum) — тем же цветом, что кнопки и ссылки, то есть
   действия, — и не спорит с золотой картой выше.

   А вот значок стал золотым подарком, и это не украшение. Приглашение подруги — не
   поручение клиентке, а её собственная выгода: баллы получает она. Значок «человек +»
   говорил ровно обратное — «добавьте кого-нибудь», работа, — и нажимать его было незачем.
   Подарок в золоте карты говорит «здесь вам причитается», и потому же он единственный на
   экране светится: свечение зовёт нажать, и звать имеет право только то, что даёт.
   Гаснет оно, как только код создан (класс is-gift снимает app.js), — но не гаснет сам
   значок: он становится сливовой кнопкой «поделиться» и зовёт дальше, см.
   .referral-action.is-share ниже.

   Собран как .history — светлая карточка на светлом фоне держится краем, а не заливкой:
   волосяная линия, ближняя тень в пиксель и широкая дальняя. Так он встаёт в один ряд с
   историей ниже и не спорит с карточкой подарка выше. */
.referral-card{
  margin:0 0 18px; padding:13px 15px; border-radius:18px;
  display:flex; align-items:center; gap:13px;
  background:linear-gradient(180deg, var(--surface) 0%, color-mix(in srgb, var(--plum) 5%, var(--surface)) 100%);
  border:1px solid color-mix(in srgb, var(--plum) 26%, var(--border));
  box-shadow:0 1px 2px -1px var(--shadow-color), 0 18px 36px -26px var(--shadow-deep);
}
/* Кнопка — значок и подпись под ним. Габарит значка держим пальцевый (44px): всё, что
   меньше, на телефоне промахивается. Сбрасываем здесь же родное оформление <button>:
   рамку, фон и шрифт, — вид даёт .referral-icon внутри.

   Ширину задаём жёстко, а не по содержимому: подписи в трёх состояниях разной длины
   («Создать ссылку» → «Создаём…» → «Отправить»), и без этого текст справа прыгал бы
   вбок на каждом нажатии. 68px — это плашка 44px и запас, в который «Создать ссылку»
   ложится двумя строками на всех трёх языках. */
.referral-action{
  flex:none; width:68px; padding:0; border:0; background:none; font:inherit; color:inherit;
  display:flex; flex-direction:column; align-items:center; gap:5px;
  cursor:pointer; -webkit-tap-highlight-color:transparent;
}
/* Обёртка значка и точка отсчёта для ореола (::before ниже): он выходит за габарит
   плашки, а на самой плашке жить не может — там overflow:hidden ради блика. На кнопке
   ему тоже не место: она теперь с подписью, и пятно растянулось бы на неё. */
.referral-plate{ position:relative; display:block; }
.referral-icon{
  flex:none; width:44px; height:44px; border-radius:14px;
  display:grid; place-items:center; color:var(--plum-deep);
  background:color-mix(in srgb, var(--plum) 10%, var(--surface));
  box-shadow:inset 0 0 0 1px color-mix(in srgb, var(--plum) 28%, transparent);
  transition:transform .16s ease, background .16s ease;
  /* position + overflow: блик (::after) ходит внутри плашки и обязан обрезаться по её
     скруглению, иначе полоса света поедет по карточке. */
  position:relative; overflow:hidden;
}
.referral-action:active .referral-icon{
  transform:scale(.94);
  background:color-mix(in srgb, var(--plum) 18%, var(--surface));
}
/* Пока идёт запрос к серверу, кнопка не нажимается — иначе второе нажатие ушло бы вторым
   запросом. Раньше она на это время ещё и гасла до 55%: погашенная кнопка читается как
   «недоступно», а не как «работаем», и человек не понимал, началось ли хоть что-нибудь.
   Теперь вместо этого крутится кольцо и меняется подпись (класс is-busy ниже), а яркость
   остаётся прежней. */
.referral-action[disabled]{ cursor:default; }
.referral-action[disabled] .referral-icon{ transform:none; }
.referral-icon svg{ width:23px; height:23px; }
/* Подарок нарисован в клетке 32×32 (тот же знак, что в карте и на экране начисления),
   и в те же 23 пикселя он вписывается мельче, чем значок «поделиться» в клетке 24×24.
   Даём ему пару пикселей: это главное, что на карточке видно, и меньше соседа он быть
   не должен. */
.referral-icon svg.referral-ico-invite{ width:26px; height:26px; }

/* ---- Значок-подарок: пока код не создан ---- */
.referral-action.is-gift .referral-icon{
  color:var(--gold-ink);
  background:linear-gradient(158deg,
    color-mix(in srgb, var(--gold) 30%, var(--surface)) 0%,
    color-mix(in srgb, var(--gold) 12%, var(--surface)) 100%);
  box-shadow:inset 0 0 0 1px color-mix(in srgb, var(--gold) 58%, transparent),
             0 4px 14px -8px color-mix(in srgb, var(--gold) 75%, transparent);
  /* То же медленное «дыхание», что у знака подарка в карте: заметно боковым зрением,
     но не мигает. Пауза в 1.2s разводит его с ореолом, чтобы они не били в такт. */
  animation:pendingBreath 3.4s ease-in-out 1.2s infinite;
}
/* Нажатие важнее украшения: под пальцем значок обязан провалиться, а «дыхание» на этот
   миг выключается — иначе кадр анимации перебил бы наш transform (он тоже transform). */
.referral-action.is-gift:active .referral-icon{
  animation:none; transform:scale(.94);
  background:linear-gradient(158deg,
    color-mix(in srgb, var(--gold) 40%, var(--surface)) 0%,
    color-mix(in srgb, var(--gold) 20%, var(--surface)) 100%);
}
/* Ореол. Мягкое золотое пятно вокруг плашки, которое медленно разгорается и гаснет.
   Именно оно и есть «сияние»: подсветить сам значок ярче нельзя — плашка на светлом фоне
   просто станет жёлтой.

   Под плашку оно уходит без z-index, порядком отрисовки: ::before — первый ребёнок кнопки,
   а .referral-icon идёт после и тоже позиционирован, значит рисуется поверх. Отрицательный
   z-index здесь как раз НЕ годится: он увёл бы пятно за фон самой карточки (.referral-card
   своего слоя не создаёт), и свечения не было бы видно вовсе. */
.referral-action.is-gift .referral-plate::before{
  content:""; position:absolute; inset:-11px; border-radius:24px;
  pointer-events:none;
  background:radial-gradient(circle at 50% 50%,
    color-mix(in srgb, var(--gold) 62%, transparent) 0%,
    color-mix(in srgb, var(--gold) 22%, transparent) 45%,
    transparent 72%);
  animation:referralHalo 3.4s ease-in-out infinite;
}
/* Блик. Узкая полоса света раз в цикл проходит по плашке наискось — то, из-за чего
   золото читается металлом, а не жёлтой краской. Проходит быстро и большую часть
   времени стоит за краем: постоянно бегающий блик — это уже вывеска. */
.referral-action.is-gift .referral-icon::after{
  content:""; position:absolute; top:-40%; left:-70%; width:34%; height:180%;
  transform:rotate(18deg); pointer-events:none;
  background:linear-gradient(90deg, transparent 0%,
    color-mix(in srgb, #fff 82%, transparent) 50%, transparent 100%);
  animation:referralSheen 3.4s ease-in-out infinite;
}
@keyframes referralHalo{
  0%, 100%{ opacity:.16; transform:scale(.9); }
  46%{ opacity:.5; transform:scale(1.06); }
}
@keyframes referralSheen{
  0%, 34%{ left:-70%; opacity:0; }
  42%{ opacity:.9; }
  62%, 100%{ left:130%; opacity:0; }
}
/* ---- Значок «поделиться»: код уже создан ----
   Раньше здесь не было ничего: значок гас вместе с золотом — считалось, что звать нажать
   имеет смысл ровно до нажатия. Это счёт на один шаг короче, чем нужно. Созданный код сам
   по себе подруге не достался: пока ссылка не ушла в переписку, приглашения не существует,
   а через сутки не станет и кода. Главное нажатие в этом блоке — не первое, а второе, и
   серой плашкой оно быть не может.

   Золото не повторяем: оно означает «вам причитается» и принадлежит подарку. Здесь слива —
   тот же цвет, которым в приложении залиты кнопки-действия (.btn-primary), то есть
   «нажмите, чтобы отправить». Значок на ней белый: плашка стала фоном, а не подложкой. */
.referral-action.is-share .referral-icon{
  color:#fff;
  background:linear-gradient(135deg, var(--plum) 0%, var(--plum-deep) 100%);
  /* Пара теней та же, что у .btn-primary: наружная сливовая даёт вес, внутренняя светлая
     полоска по верхней кромке — блик выпуклой поверхности. Без неё заливка градиентом
     читается плоской наклейкой. */
  box-shadow:0 8px 18px -10px color-mix(in srgb, var(--plum-deep) 78%, transparent),
             inset 0 1px 0 rgba(255,255,255,.20);
  animation:pendingBreath 3.4s ease-in-out 1.2s infinite;
}
/* В тёмной теме слива отражается в светлую орхидею, и белый значок на ней теряет край:
   у заливки в самом светлом конце контраст к белому 2.5:1 — кнопке с надписью такого
   хватает (буквы толще волосяных линий значка), а значку нет. Берём тот же градиент, но
   от тёмного конца и ниже: 3.8:1 вверху, 6.6:1 внизу, и плашка всё ещё заметно светлее
   карточки под ней. */
@media (prefers-color-scheme: dark){
  .referral-action.is-share .referral-icon{
    background:linear-gradient(135deg, var(--plum-deep) 0%,
      color-mix(in srgb, var(--plum-deep) 62%, #2A1436) 100%);
  }
}
/* Заливку под пальцем повторяем целиком, а не только затемняем: общее правило
   .referral-action:active .referral-icon возвращает светлую плашку, и без этой строки
   значок на миг нажатия белел бы. */
.referral-action.is-share:active .referral-icon{
  animation:none; transform:scale(.94);
  background:linear-gradient(135deg,
    color-mix(in srgb, var(--plum-deep) 40%, var(--plum)) 0%,
    var(--plum-deep) 100%);
}
/* Ореол — тот же приём, что у подарка: подсветить саму плашку ярче нельзя, она уже залита.
   Сливовый и заметно тише золотого (44% против 62%): это второе нажатие подряд, человек уже
   смотрит в этот угол карточки, и звать его сюда так же громко — кричать в ухо. */
.referral-action.is-share .referral-plate::before{
  content:""; position:absolute; inset:-11px; border-radius:24px;
  pointer-events:none;
  background:radial-gradient(circle at 50% 50%,
    color-mix(in srgb, var(--plum) 44%, transparent) 0%,
    color-mix(in srgb, var(--plum) 16%, transparent) 45%,
    transparent 72%);
  animation:referralHalo 3.4s ease-in-out infinite;
}
/* Блик по заливке — тот же, что у золота, но вдвое слабее: на тёмной сливе белая полоса
   видна куда лучше, чем на светлой плашке подарка. */
.referral-action.is-share .referral-icon::after{
  content:""; position:absolute; top:-40%; left:-70%; width:34%; height:180%;
  transform:rotate(18deg); pointer-events:none;
  background:linear-gradient(90deg, transparent 0%,
    color-mix(in srgb, #fff 55%, transparent) 50%, transparent 100%);
  animation:referralSheen 3.4s ease-in-out infinite;
}
/* Просили не анимировать — не анимируем. Золото при этом остаётся: свечение здесь несёт
   смысл («вам причитается»), и отбирать его вместе с движением не за что. Вместо
   пульсации — ровный ореол, который просто есть. */
@media (prefers-reduced-motion: reduce){
  .referral-action.is-gift .referral-icon,
  .referral-action.is-share .referral-icon{ animation:none; }
  .referral-action.is-gift .referral-plate::before{ animation:none; opacity:.34; transform:scale(1); }
  .referral-action.is-share .referral-plate::before{ animation:none; opacity:.30; transform:scale(1); }
  .referral-action.is-gift .referral-icon::after,
  .referral-action.is-share .referral-icon::after{ display:none; }
}
/* Две картинки в одном значке, видна всегда одна (переключает app.js). Обе в одной
   клетке грида — иначе смена картинки дёргала бы соседний текст. */
.referral-icon svg.hidden{ display:none; }

/* ---- Подпись под значком ----
   Одна строчка слов, которая называет действие: «Создать ссылку» → «Создаём…» →
   «Отправить». Без неё блок держался на картинке, а картинка не умеет сказать ни что
   создаётся ССЫЛКА, ни что прямо сейчас идёт запрос.

   Цвет берём у состояния: золото подарка, слива отправки — те же, что у плашки над
   подписью, чтобы значок и слово читались одним предметом, а не значком и примечанием. */
.referral-action-label{
  font-family:var(--font-ui); font-size:10.5px; font-weight:600; line-height:1.2;
  letter-spacing:.01em; text-align:center; color:var(--ink-soft);
  /* Подпись меняется вместе с состоянием и на телефоне живёт под пальцем: перенос по
     словам обязателен, иначе «Создать ссылку» вылезет за габарит кнопки. */
  overflow-wrap:break-word; hyphens:none;
}
.referral-action.is-gift .referral-action-label{ color:var(--gold-ink); }
.referral-action.is-share .referral-action-label{ color:var(--plum-deep); }
.referral-action.is-busy .referral-action-label{ color:var(--plum-deep); }

/* ---- Ожидание ответа сервера ----
   Кольцо в плашке вместо значка. Рисуем рамкой с одной прозрачной четвертью — это
   единственный способ получить дугу без второй картинки и без SVG. Показываем только
   с классом is-busy: в остальное время его в разметке будто и нет. */
.referral-spinner{
  display:none; position:absolute; width:21px; height:21px; border-radius:50%;
  border:2px solid color-mix(in srgb, currentColor 26%, transparent);
  border-top-color:currentColor;
  animation:referralSpin .8s linear infinite;
}
@keyframes referralSpin{ to{ transform:rotate(360deg); } }
/* Просили не анимировать — кольцо всё равно крутится, только вдвое медленнее: это не
   украшение, а единственное, что отвечает на нажатие, пока идёт запрос. Стоит здесь, а не
   в общем блоке про движение выше: там правило пришлось бы раньше сокращённого
   animation в .referral-spinner и было бы им перебито. */
@media (prefers-reduced-motion: reduce){
  .referral-spinner{ animation-duration:1.6s; }
}
.referral-action.is-busy .referral-spinner{ display:block; }
/* Пока крутится кольцо, картинки в плашке нет: два знака сразу — это уже не ответ, а
   мельтешение. Заодно снимаем и всё украшение — «дыхание», ореол и блик: они говорят
   «нажмите», а нажатие уже состоялось. */
.referral-action.is-busy .referral-icon svg{ display:none; }
.referral-action.is-busy .referral-icon{ animation:none; }
.referral-action.is-busy .referral-icon::after{ display:none; }
.referral-action.is-busy .referral-plate::before{ animation:none; opacity:.22; transform:scale(1); }
.referral-text{ flex:1 1 auto; min-width:0; }
.referral-title{ font-family:var(--font-ui); font-size:15px; font-weight:600; color:var(--ink); margin:0; }
.referral-sub{ margin:2px 0 0; font-size:12.5px; line-height:1.45; color:var(--ink-soft); }

/* Код прямо в строке текста — не заголовок и не поле, а выделенное слово: рамка и цвет
   сливы ровно настолько, чтобы глаз нашёл его в предложении и понял, что переписывают
   именно это. Моноширинный шрифт по той же причине, что и раньше: код диктуют вслух и
   переписывают руками, и «l» не должна быть похожа на «1». */
.referral-code{
  display:inline-block; padding:1px 7px; border-radius:8px;
  font-family:ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace;
  font-size:13px; font-weight:600; letter-spacing:.14em; white-space:nowrap;
  color:var(--plum-deep); background:color-mix(in srgb, var(--plum) 9%, var(--surface));
  border:1px solid color-mix(in srgb, var(--plum) 30%, transparent);
  /* Код — единственное на экране, что человек может захотеть выделить пальцем.
     Остальному приложению выделение запрещено глобально (см. no-zoom.js / body). */
  user-select:text; -webkit-user-select:text;
}
/* Код только что создан. Полсекунды подсветки на месте, где секунду назад были точки:
   это единственное место в блоке, где появляется то, ради чего нажимали, и переход
   «точки → код» без вспышки человек просто не замечает.

   Только фон и рамка, без движения: строка текста, которая дёргается, читается хуже, а
   код в ней ещё и переписывают руками. */
.referral-code.is-fresh{ animation:referralCodeIn .85s ease-out 1; }
@keyframes referralCodeIn{
  0%{ background:color-mix(in srgb, var(--gold) 55%, var(--surface));
      border-color:color-mix(in srgb, var(--gold) 80%, transparent); }
  100%{ background:color-mix(in srgb, var(--plum) 9%, var(--surface));
        border-color:color-mix(in srgb, var(--plum) 30%, transparent); }
}
@media (prefers-reduced-motion: reduce){
  .referral-code.is-fresh{ animation:none; }
}

/* Кода ещё нет — на его месте точки. Те же габариты, чтобы строка не прыгала в момент,
   когда точки сменятся кодом, но без рамки: пустое место не притворяется значением. */
.referral-code.is-empty{
  color:color-mix(in srgb, var(--ink-soft) 80%, transparent);
  background:none; border-color:transparent; letter-spacing:.18em;
}
.referral-note{
  margin:4px 0 0; font-size:12px; line-height:1.4; color:var(--ink-soft);
}
/* «Создаём…» и «Ссылка готова» — сливой и полужирным, тем же цветом, что действия в
   приложении. Серым эта строка терялась ровно там, где она нужнее всего: под пальцем в
   секунду ожидания. Красный отказ ниже устроен так же и по той же причине. */
.referral-note.is-live{ color:var(--plum-deep); font-weight:600; }
/* Отказ — красным, тем же гранатом, что и ошибки в формах (.form-error выше). Строка эта
   появляется на карте клиента, где всё остальное спокойное и ничего не случилось, поэтому
   одного текста мало: цвет говорит «это не получилось» раньше, чем текст прочитан. Полужирный
   к цвету добавлен намеренно — гранат на светлом фоне в 12 пикселях сам по себе тихий, а в
   тёмной теме он и вовсе разбавлен до розового кварца. */
.referral-note.is-error{ color:var(--danger); font-weight:600; }
.referral-note:empty{ display:none; }

/* ---- Кнопка установки приложения ----
   Стоит в первой строке подвала, рядом с переключателем языка. Выглядит как обычная
   кнопка-таблетка, а не как призыв: человек найдёт её, когда захочет, а объяснять,
   зачем это нужно, будет окно с инструкцией — его открывает install.js. Обёртку
   #install-card он же показывает и прячет.

   Кегль и поля чуть меньше, чем у кнопок на экране: в подвале она стоит в одну линию
   с таблеткой языка и должна быть ей ровней, а не выше её. */
.install-pill{
  display:inline-flex; align-items:center; gap:6px;
  padding:6px 12px; border-radius:999px; cursor:pointer;
  font:inherit; font-size:12px; font-weight:600; line-height:1;
  color:var(--plum-deep); border:1px solid color-mix(in srgb, var(--plum) 36%, transparent);
  background:color-mix(in srgb, var(--plum) 7%, var(--surface));
  box-shadow:0 8px 18px -14px var(--shadow-color);
  transition:border-color .15s ease, transform .1s ease;
}
.install-pill svg{ width:15px; height:15px; flex:none; }
.install-pill:hover{ border-color:var(--plum); }
.install-pill:active{ transform:scale(.97); }
/* Пока телефон ставит приложение, кнопка перестаёт быть приглашением и становится
   состоянием: вместо значка телефона — кружок ожидания, вместо «Установить» —
   «Устанавливается…». Это единственный след установки на экране, когда окно ожидания
   свёрнуто; нажатие возвращает его обратно (install.js). */
.install-pill.is-busy svg, .install-pill.is-done svg{ display:none; }
.install-pill.is-busy::before{
  content:""; width:13px; height:13px; flex:none; border-radius:50%;
  border:2px solid color-mix(in srgb, var(--plum) 28%, transparent); border-top-color:var(--plum);
  animation:installSpin .9s linear infinite;
}
.install-pill.is-done::before{ content:"✓"; font-size:13px; line-height:1; }
.install-pill.is-done{
  border-color:color-mix(in srgb, var(--plum) 55%, transparent);
  background:color-mix(in srgb, var(--plum) 12%, var(--surface));
}
@media (prefers-reduced-motion: reduce){
  .install-pill.is-busy::before{ animation:installPulse 1.4s ease-in-out infinite; }
}
/* Тёмная тема: тёмная слива --plum-deep на тёмной таблетке давала 3.84:1 при 12px.
   Светлая орхидея --plum на той же подложке — около 6:1. */
@media (prefers-color-scheme: dark){
  .install-pill{ color:var(--plum); }
}

/* Белая карточка на белом фоне держится не заливкой, а краем: волосяная линия по контуру,
   ближняя тень в один пиксель и широкая дальняя. Плюс еле заметный градиент сверху вниз —
   свет падает сверху, и низ карточки чуть темнее. Одной мягкой тени для этого мало: на
   перламутре она размывалась, и карточка выглядела пятном, а не предметом. */
.history{
  background:linear-gradient(180deg, var(--surface) 0%, color-mix(in srgb, var(--bg) 55%, var(--surface)) 100%);
  border:1px solid var(--border);
  border-radius:20px; padding:18px 20px;
  box-shadow:0 1px 2px -1px var(--shadow-color), 0 18px 36px -26px var(--shadow-deep);
}
.history h2{ font-family:var(--font-ui); font-size:17px; margin:0 0 10px; }
/* Правило под заголовком истории: баллы за визит тратятся со следующего раза. Набрано
   мельче и мягче списка — это сноска к нему, а не запись в нём. Пустой (истории ещё нет)
   строка исчезает вместе с отступом. */
.history-note{ font-size:12.5px; line-height:1.45; color:var(--ink-soft); margin:-2px 0 12px; }
.history-note:empty{ display:none; }
.history-list{ list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:10px; }
.history-list li{
  display:flex; justify-content:space-between; font-size:14px; padding-bottom:10px;
  border-bottom:1px solid var(--border); opacity:0; animation:fadeInUp .35s ease-out both;
}
.history-list li:nth-child(1){ animation-delay:.05s; }
.history-list li:nth-child(2){ animation-delay:.10s; }
.history-list li:nth-child(3){ animation-delay:.15s; }
.history-list li:nth-child(4){ animation-delay:.20s; }
.history-list li:nth-child(5){ animation-delay:.25s; }
.history-list li:nth-child(n+6){ animation-delay:.30s; }
@media (prefers-reduced-motion: reduce){ .history-list li{ animation:none; opacity:1; } }
.history-list li:last-child{ border-bottom:none; padding-bottom:0; }
.history-empty{ color:var(--ink-soft); justify-content:center !important; border-bottom:none !important; }
.h-date{ color:var(--ink-soft); font-size:12.5px; }
.h-points{ font-family:var(--font-num); font-weight:800; font-variant-numeric:tabular-nums; }
.h-points.positive{ color:var(--success); }
.h-points.negative{ color:var(--danger); }

/* Иврит: строка истории читается справа — подпись с датой встаёт к правому краю, баллы
   уходят к левому. Зеркалим порядок flex-элементов (row-reverse), а НЕ направление текста
   (direction:rtl): в правосторонней строке "+" уезжает вправо от числа и "+10" читается
   как "10+", а число остаётся числом на любом языке и пишется слева направо. Само
   выравнивание текста внутри подписи уже правое (от .dash-content), направление каждой
   строки определяет unicode-bidi:plaintext для .view div — оба правила ниже в файле. */
[lang="he"] .history-list li{ flex-direction:row-reverse; }

/* Окно с ручной инструкцией — короткое, поэтому, в отличие от политики, это не экран
   на всю высоту, а карточка по центру: три шага незачем разворачивать во весь телефон. */
.install-modal{
  position:fixed; inset:0; margin:auto; padding:0; border:none;
  /* height:fit-content, а не auto: у окна заданы разом top:0 и bottom:0 (inset:0 нужен,
     чтобы margin:auto поставил его по центру), и при height:auto браузер растягивает
     его на всю доступную высоту — три коротких шага занимали бы весь экран. */
  width:min(420px, calc(100% - 32px)); max-width:none;
  height:-webkit-fit-content; height:fit-content; max-height:min(80vh, 620px);
  border-radius:24px; overflow:hidden;
  background:var(--surface); color:var(--ink);
  box-shadow:0 30px 60px -20px var(--shadow-color), 0 2px 10px -2px var(--shadow-color);
  flex-direction:column;
}
.install-modal:not([open]){ display:none; }
.install-modal[open]{ display:flex; animation:legalIn .28s cubic-bezier(.2,.9,.25,1) both; }
.install-modal::backdrop{ background:rgba(24,17,36,0.55); backdrop-filter:blur(3px); -webkit-backdrop-filter:blur(3px); }
@media (prefers-reduced-motion: reduce){ .install-modal[open]{ animation:none; } }
.install-modal-head{
  flex:none; display:flex; align-items:flex-start; gap:12px;
  padding:18px 20px 12px; border-bottom:1px solid var(--border);
}
.install-modal-head h2{
  flex:1; min-width:0; margin:0;
  font-family:var(--font-ui); font-size:18px; font-weight:600; line-height:1.25;
}
.install-modal-body{
  flex:1 1 auto; min-height:0; overflow-y:auto; overscroll-behavior:contain;
  -webkit-overflow-scrolling:touch; padding:16px 20px 18px; font-size:14px; line-height:1.55;
}
.install-steps{ margin:0; padding-inline-start:22px; display:flex; flex-direction:column; gap:9px; }
.install-steps li{ padding-inline-start:2px; }
.install-steps li::marker{ color:var(--plum); font-weight:700; }
.install-modal-note{
  margin:16px 0 0; padding-top:12px; border-top:1px solid var(--border);
  font-size:12.5px; line-height:1.5; color:var(--ink-soft);
}
/* «У меня оно уже установлено» — последняя строка окна, для iPhone и всех браузеров,
   которые сами ответить на этот вопрос не умеют (см. install.js). Ссылка, а не кнопка:
   действие редкое и не должно спорить с «Понятно» внизу окна. */
.install-done{ display:block; margin:18px auto 0; font-size:13px; color:var(--ink-soft); }
/* Крестик закрытия — свой, а не позаимствованный у окна политики: install.js лежит в обоих
   приложениях одним файлом, а в панели администратора окна политики нет вовсе. */
.install-close{
  flex:none; width:34px; height:34px; border:none; border-radius:50%; cursor:pointer;
  background:var(--bg-2); color:var(--ink); font-size:22px; line-height:1;
  display:flex; align-items:center; justify-content:center;
  transition:filter .15s ease, transform .15s ease;
}
.install-close:hover{ filter:brightness(.95); }
.install-close:active{ transform:scale(.92); }

/* Ожидание установки. Chrome отвечает «принято» сразу, а приложение появляется на
   телефоне заметно позже — он заказывает у Google сборку пакета. Это окно занимает
   паузу: кружок, пока идёт, и галочка, когда готово (install.js). */
.install-progress{
  display:flex; flex-direction:column; align-items:center; gap:14px;
  padding:14px 0 6px; text-align:center;
}
.install-spinner{
  width:44px; height:44px; flex:none; border-radius:50%;
  border:3px solid var(--border); border-top-color:var(--plum);
  animation:installSpin .9s linear infinite;
}
@keyframes installSpin{ to{ transform:rotate(360deg); } }
/* Движение — единственное, что говорит «идёт»; тем, кто его отключил, вместо вращения
   остаётся мигание, иначе кружок выглядел бы застрявшим. */
@media (prefers-reduced-motion: reduce){
  .install-spinner{ animation:installPulse 1.4s ease-in-out infinite; }
  @keyframes installPulse{ 0%,100%{ opacity:1; } 50%{ opacity:.45; } }
}
.install-check{
  width:44px; height:44px; flex:none; border-radius:50%;
  display:flex; align-items:center; justify-content:center;
  background:var(--plum); color:#fff;
  animation:installCheckIn .3s cubic-bezier(.2,.9,.25,1) both;
}
.install-check svg{ width:24px; height:24px; }
@keyframes installCheckIn{ from{ transform:scale(.6); opacity:0; } to{ transform:scale(1); opacity:1; } }
@media (prefers-reduced-motion: reduce){ .install-check{ animation:none; } }
.install-progress-text{ margin:0; max-width:32ch; font-size:14px; line-height:1.55; color:var(--ink-soft); }
/* Полоса и счётчик секунд под текстом. Долю установки браузер не сообщает никак, поэтому
   полоса идёт по времени и до конца сама не доходит (install.js): её дело — показать, что
   ожидание живое. Точную цифру говорит счётчик под ней, и он не врёт. */
.install-bar{
  width:min(240px, 100%); height:6px; flex:none;
  border-radius:999px; overflow:hidden; background:var(--border);
}
.install-bar > i{
  display:block; width:0; height:100%; border-radius:999px;
  background:var(--plum); transition:width .9s linear;
}
/* Последний шаг до ста процентов — быстрый: он отмечает готовность, а не продолжает ход. */
.install-progress.is-done .install-bar > i{ transition:width .35s ease-out; }
.install-progress-meta{
  margin:0; font-size:12.5px; line-height:1.4; color:var(--ink-soft);
  font-variant-numeric:tabular-nums;
}

/* Кнопок в подвале окна бывает две: «Открыть в Chrome» (только там, где браузер сам
   устанавливать не умеет) и «Понятно». Обе делят ширину поровну. */
.install-modal-foot{
  flex:none; display:flex; gap:10px; padding:12px 20px calc(env(safe-area-inset-bottom) + 14px);
  border-top:1px solid var(--border);
}
.install-modal-foot .btn-primary,
.install-modal-foot .btn-ghost{ flex:1; margin-top:0; }
.install-modal-foot .btn-ghost{ padding:13px; font-size:15px; border-radius:14px; }

/* ---- Окно подтверждения (сейчас — только выход) ----
   Маленькое окно по центру: один вопрос и две кнопки, разворачивать его во весь экран
   незачем. height:fit-content по той же причине, что и у окна установки: при inset:0
   и height:auto браузер растянул бы окно на всю доступную высоту. */
.confirm-modal{
  position:fixed; inset:0; margin:auto; padding:0; border:none;
  width:min(360px, calc(100% - 40px)); max-width:none;
  height:-webkit-fit-content; height:fit-content;
  border-radius:22px; overflow:hidden;
  background:var(--surface); color:var(--ink);
  box-shadow:0 30px 60px -20px var(--shadow-color), 0 2px 10px -2px var(--shadow-color);
  flex-direction:column;
}
.confirm-modal:not([open]){ display:none; }
.confirm-modal[open]{ display:flex; animation:confirmIn .2s cubic-bezier(.2,.9,.25,1) both; }
.confirm-modal::backdrop{ background:rgba(24,17,36,0.55); backdrop-filter:blur(3px); -webkit-backdrop-filter:blur(3px); }
@keyframes confirmIn{ from{ opacity:0; transform:scale(.94); } to{ opacity:1; transform:scale(1); } }
@media (prefers-reduced-motion: reduce){ .confirm-modal[open]{ animation:none; } }
.confirm-body{ padding:22px 22px 6px; text-align:center; }
.confirm-body h2{
  margin:0; font-family:var(--font-ui); font-size:18px; font-weight:600; line-height:1.3;
}
.confirm-text{ margin:8px 0 0; font-size:13.5px; line-height:1.5; color:var(--ink-soft); }
.confirm-lines{
  list-style:none; margin:12px 0 0; padding:10px 12px; text-align:start;
  border-radius:12px; background:color-mix(in srgb, var(--ink-soft) 8%, transparent);
  font-size:13.5px; line-height:1.6; color:var(--ink);
}
.confirm-lines li{ unicode-bidi:plaintext; }
.confirm-lines li + li{ margin-top:4px; }
/* Кнопки в столбец, а не в строку: «Отмена» и «Выйти» рядом на узком экране жмутся
   слишком тесно, а цена промаха — как раз тот выход, от которого мы и защищаемся. */
.confirm-foot{
  display:flex; flex-direction:column-reverse; gap:8px;
  padding:16px 22px calc(env(safe-area-inset-bottom) + 18px);
}
.confirm-foot .btn-primary{ width:100%; margin-top:0; }
.confirm-foot .btn-ghost{ width:100%; padding:11px; font-size:14px; border-radius:12px; }
[lang="he"] .confirm-modal{ direction:rtl; }
[lang="he"] .confirm-text{ unicode-bidi:plaintext; }

/* ---- Экран «вышло обновление» (update.js) ----
   Не карточка посреди затемнённой страницы, как вопрос про выход, а экран во всю высоту,
   как политика: обновление — это не «подтвердите действие», а отдельный разговор, и
   человек должен понять, что приложение сейчас перезапустится, а не просто закроется
   очередное окошко. Разметки в index.html у него нет вовсе — весь экран собирает
   update.js, поэтому и стили здесь описывают то, чего в html не найти.
   Настоящий <dialog>.showModal(): браузер сам кладёт его поверх всего, сам держит фокус
   внутри и сам закрывает по Esc и по системному «назад» — а закрытие update.js считает
   ответом «Позже». */
.update-screen{
  position:fixed; inset:0; width:100%; max-width:none; height:100%; max-height:none;
  margin:0; padding:0; border:none; overflow:hidden;
  background:var(--bg); color:var(--ink);
  flex-direction:column;
}
.update-screen:not([open]){ display:none; }
.update-screen[open]{ display:flex; animation:legalIn .28s cubic-bezier(.2,.9,.25,1) both; }
.update-screen::backdrop{ background:rgba(24,17,36,0.55); backdrop-filter:blur(3px); -webkit-backdrop-filter:blur(3px); }
@media (prefers-reduced-motion: reduce){ .update-screen[open]{ animation:none; } }

/* Значок и текст стоят по центру свободной высоты, кнопки — внизу, под большим пальцем. */
.update-body{
  flex:1 1 auto; min-height:0; overflow-y:auto;
  display:flex; flex-direction:column; align-items:center; justify-content:center;
  gap:14px; padding:calc(env(safe-area-inset-top) + 28px) 26px 20px; text-align:center;
}
.update-body h2{
  margin:0; font-family:var(--font-ui); font-size:21px; font-weight:600; line-height:1.25;
}
.update-text{
  margin:0; max-width:32ch; font-size:14.5px; line-height:1.6; color:var(--ink-soft);
  unicode-bidi:plaintext;
}
/* Круг со стрелкой вниз. Стрелка едет вниз и возвращается — то же движение, что у
   скачивания; тем, кто движение отключил, остаётся неподвижный значок. */
.update-mark{
  width:72px; height:72px; flex:none; border-radius:50%;
  display:flex; align-items:center; justify-content:center;
  background:color-mix(in srgb, var(--plum) 12%, transparent); color:var(--plum);
  box-shadow:0 0 0 10px color-mix(in srgb, var(--plum) 5%, transparent);
}
.update-mark svg{ width:34px; height:34px; animation:updateArrow 2.2s ease-in-out infinite; }
@keyframes updateArrow{
  0%, 62%, 100%{ transform:translateY(0); }
  30%{ transform:translateY(3px); }
}
@media (prefers-reduced-motion: reduce){ .update-mark svg{ animation:none; } }

/* Кнопки в столбец и развёрнуто: «Обновить сейчас» сверху, «Позже» под ней. Тот же
   порядок, что в окне подтверждения, — главное действие ближе к тексту, а не к краю. */
.update-foot{
  flex:none; display:flex; flex-direction:column-reverse; gap:8px;
  width:min(420px, 100%); margin:0 auto;
  padding:14px 26px calc(env(safe-area-inset-bottom) + 22px);
}
.update-foot .btn-primary{ width:100%; margin-top:0; }
.update-foot .btn-ghost{ width:100%; padding:12px; font-size:14px; border-radius:12px; }
.update-foot .btn-ghost:disabled{ opacity:.5; cursor:default; }
[lang="he"] .update-screen{ direction:rtl; }

/* ---- Напоминание после «Позже» ----
   Узкая плашка у нижнего края: обновление никуда не делось, но и разговор о нём человек
   уже закрыл — значит напоминание обязано быть тише всего остального на экране и не
   закрывать собой ничего важного. Нажатие возвращает экран обновления.
   z-index:60 — выше кабинета и его фона, но ниже поздравления с баллами (70…80): посреди
   летящих монеток напоминанию не место. */
.update-badge{
  position:fixed; z-index:60;
  left:50%; transform:translateX(-50%);
  bottom:calc(env(safe-area-inset-bottom) + 14px);
  display:flex; align-items:center; gap:8px;
  max-width:calc(100% - 32px);
  padding:9px 15px; border:1px solid var(--border); border-radius:999px; cursor:pointer;
  background:var(--surface); color:var(--ink);
  font-family:var(--font-ui); font-size:13px; line-height:1.2;
  box-shadow:0 10px 24px -12px var(--shadow-deep), 0 1px 3px -1px var(--shadow-color);
  animation:updateBadgeIn .3s cubic-bezier(.2,.9,.25,1) both;
}
.update-badge[hidden]{ display:none; }
.update-badge:active{ transform:translateX(-50%) scale(.96); }
@keyframes updateBadgeIn{
  from{ opacity:0; transform:translateX(-50%) translateY(10px); }
  to{ opacity:1; transform:translateX(-50%) translateY(0); }
}
@media (prefers-reduced-motion: reduce){ .update-badge{ animation:none; } }
/* Точка — единственное, что зовёт: цветная и еле заметно дышит. Подпись рядом объясняет,
   про что она, но сама остаётся обычным текстом приложения. */
.update-badge-dot{
  width:8px; height:8px; flex:none; border-radius:50%; background:var(--plum);
  box-shadow:0 0 0 4px color-mix(in srgb, var(--plum) 18%, transparent);
  animation:updateDot 2s ease-in-out infinite;
}
@keyframes updateDot{ 0%,100%{ opacity:1; } 50%{ opacity:.45; } }
@media (prefers-reduced-motion: reduce){ .update-badge-dot{ animation:none; } }
.update-badge-label{ min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }

/* ---- Окно подтверждения почты (verify-email.js) ----
   Живёт на рамке окна подтверждения (.confirm-modal): это одно и то же окно приложения, и
   разводить их по разным теням и скруглениям было бы разговором двух разных программ.
   Своё здесь — только ячейки кода и то, что вокруг них. */
.vmail-address{
  display:block; margin-top:4px; font-weight:600; color:var(--ink);
  /* Адрес может быть длинным и без пробелов — переносим по буквам, иначе он раздвинет
     окно на узком экране. */
  word-break:break-all;
}
.vmail-row{ display:flex; gap:8px; justify-content:center; margin-top:18px; }
/* Ячейки те же, что на экране входа по SMS (.otp-digit), но правила приходится повторить:
   те написаны через `.form input`, а окно — не форма, и наследовать оттуда нечего.
   Человеку это и не нужно знать: жест один и тот же, значит и вид один и тот же. */
.vmail-digit{
  -webkit-appearance:none; appearance:none;
  width:40px; height:50px; padding:0; text-align:center; font-size:21px;
  border-radius:12px; border:1px solid var(--border); background:var(--bg); color:var(--ink);
  font-family:var(--font-num); font-weight:800; font-variant-numeric:tabular-nums;
  transition:border-color .15s ease, box-shadow .15s ease, transform .1s ease;
}
.vmail-digit:focus{
  outline:none; border-color:var(--plum); transform:translateY(-2px);
  box-shadow:0 0 0 4px color-mix(in srgb, var(--plum) 18%, transparent);
}
.vmail-digit:not(:placeholder-shown){
  border-color:var(--gold);
  box-shadow:0 0 0 3px color-mix(in srgb, var(--gold) 15%, transparent);
}
.vmail-digit:disabled{ opacity:.55; }
/* Код уходит на проверку — ячейки гаснут, но не пропадают: окно не должно менять высоту
   в тот момент, когда человек смотрит на только что введённые цифры. */
.vmail-modal.is-busy .vmail-row{ opacity:.6; }
/* Ячейки всегда слева направо, даже в интерфейсе на иврите: код — число, и первая цифра
   письма обязана стоять в первой ячейке. */
.vmail-row{ direction:ltr; }
.vmail-row.shake{ animation:shake .45s; }
@media (prefers-reduced-motion: reduce){ .vmail-row.shake{ animation:none; } }
.vmail-error{
  margin:12px 0 0; min-height:1.2em;
  font-size:12.5px; line-height:1.4; color:var(--danger);
}
.vmail-error:not(.is-on){ visibility:hidden; }
/* Подсказка про кнопку в письме. Тише всего в окне — тише даже ошибки: она не о том, что
   надо сделать, а о том, чего можно НЕ делать. Тому, кто уже переписывает цифры, она не
   нужна; тому, кто вернулся из почты с пустыми руками, — объясняет второй путь. */
.vmail-hint{
  margin:10px 0 0; font-size:12px; line-height:1.45; color:var(--ink-soft); opacity:.85;
}
/* Пока на полосе горит ошибка, подсказки нет: два мелких текста подряд под ячейками
   читаются как один абзац, и красная строка в нём теряется. */
.vmail-error.is-on ~ .vmail-hint{ display:none; }
/* Кнопки в столбец, как и в окне подтверждения. «Отправить ещё раз» — главная из двух и
   стоит первой; «Позже» тише и мельче: уйти отсюда можно всегда, а вернуться — только
   через оборот карты, и напоминать об этом кнопкой в полный голос незачем. */
.vmail-foot{ flex-direction:column; padding-top:14px; }
.vmail-foot .btn-ghost{ width:100%; padding:11px; font-size:14px; border-radius:12px; }
.vmail-foot .btn-ghost:disabled{ opacity:.5; cursor:default; }
.vmail-later{ font-size:13px; opacity:.75; }
/* Успех: ячейки и кнопки уходят, остаётся заголовок с подписью — окно закрывается само
   через секунду с небольшим (verify-email.js). */
.vmail-modal.is-done .vmail-row,
.vmail-modal.is-done .vmail-error,
.vmail-modal.is-done .vmail-hint,
.vmail-modal.is-done .vmail-foot{ display:none; }
.vmail-modal.is-done .confirm-body{ padding-bottom:26px; }

/* Иврит: инструкция — связный текст с нумерованным списком, и цифры шагов должны
   стоять справа. Зеркалим её так же, как окно политики. */
[lang="he"] .install-modal{ direction:rtl; text-align:right; }
[lang="he"] .install-steps li,
[lang="he"] .install-modal-note{ unicode-bidi:plaintext; }

/* ---- Подвал: строка действий (язык, установка) + служебная строка (reCAPTCHA, политика) ---- */
/* Подвал — настоящий блок в общем потоке, а не полоса, зафиксированная поверх экрана.
   Пока он был position:fixed, он лежал НАД содержимым: под ним пропадали кнопки и
   строки, а прокрутка проносила текст прямо сквозь флаги, и никакой запас нижнего
   отступа этого до конца не убирал — отступ подгоняется под один размер экрана, а
   налезание вылезало на другом. В потоке подвал занимает собственную высоту: экран
   выше него просто становится ниже, и перекрывать друг друга им уже нечем.

   Все вертикальные промежутки подвала задаёт ОДНО значение --footer-gap: столько же
   сверху над переключателем, столько же между ним и строкой про reCAPTCHA, столько же
   снизу под ней. Раньше каждый кусок отмерял отступ сам (padding у переключателя,
   margin-top у абзаца, пустой <br> в самом низу), значения не совпадали — и
   переключатель стоял заметно ближе к содержимому экрана, чем к строке под собой.

   position:relative + z-index:1 — не про наложение, а про фон: и .ambient-bg, и
   .dash-bg лежат на z-index:0, а элемент без позиционирования рисуется ниже любого
   позиционированного даже с нулевым уровнем — без этих двух строк подвал оказался
   бы ЗА фоном и пропал. */
.app-footer{
  /* Промежутки подвала едут вместе с высотой экрана. Пока они были жёсткими, подвал на
     низком телефоне (360x640 и меньше) занимал 165px — четверть экрана, — и форме входа
     оставалось меньше, чем ей нужно: кнопка «Получить код по SMS» уезжала за нижний край
     области прокрутки, ровно под флаги. Со стороны это читалось как «подвал наехал на
     поле ввода номера».
     svh, а не vh: vh на телефоне считается по экрану БЕЗ адресной строки и завышает
     высоту на те самые 60-100px, из-за которых всё и не помещалось. Строка без svh стоит
     первой — она запасная, для браузеров, которые единицу не знают. */
  --footer-gap:clamp(5px, 1vh, 9px);
  --footer-gap:clamp(5px, 1svh, 9px);
  flex:none; position:relative; z-index:1;
  display:flex; flex-direction:column; align-items:center; gap:var(--footer-gap);
  width:100%; min-width:var(--app-min-w); margin:0 auto;
  padding:var(--footer-gap) 12px calc(env(safe-area-inset-bottom) + var(--footer-gap));
  /* Своей заливки у подвала НЕТ и быть не должно — он насквозь прозрачный и показывает
     тот же фон, что и содержимое над ним. Это единственный способ гарантировать, что
     цвета совпадают: любая собственная заливка (даже ровно var(--bg)) рано или поздно
     разойдётся с фоном экрана — на входе под подвалом проходит перламутровое пятно
     (.blob), в кабинете — общий градиент (.dash-bg), и заливка накрыла бы их светлым
     прямоугольником вместо того, чтобы слиться. Если подвал снова выглядит темнее
     содержимого — чинить надо ФОН ЭКРАНА (см. .dash-bg), а не подкрашивать подвал. */
}
/* В кабинете reCAPTCHA не работает — обязательного упоминания про неё там нет, и от
   служебной строки остаётся одна ссылка на политику (точка-разделитель уходит вместе
   с упоминанием: разделять ей больше нечего).
   Читаем состояние прямо из разметки: #view-dashboard — сосед подвала и стоит перед
   ним, поэтому отдельный класс на <body> из app.js для этого не нужен. */
#view-dashboard:not(.hidden) ~ .app-footer :is(.recaptcha-note, .footer-sep){ display:none; }
/* Первая строка подвала: язык и кнопка установки рядом, по центру. Своих
   вертикальных отступов она не отмеряет — промежутки в подвале задаёт одно значение
   --footer-gap. Перенос оставлен на случай самых узких экранов с длинным переводом. */
.lang-switch{
  display:flex; flex-wrap:wrap; align-items:center; justify-content:center;
  gap:6px 10px;
}
/* Обёртка нужна ради position:relative: всплывающий список позиционируется от самой
   кнопки, а не от всей полосы подвала — иначе он растянулся бы по её ширине. */
.lang-picker{ position:relative; }
/* Постоянно видна только эта кнопка: флаг текущего языка + код. Флаг в неё копирует
   i18n.js из активного пункта списка, поэтому разметка флага живёт в одном месте. */
.lang-current{
  display:flex; align-items:center; gap:7px;
  background:none; border:1.5px solid transparent; border-radius:999px;
  padding:5px 10px; cursor:pointer; color:var(--ink-soft);
  font-family:inherit; font-size:12px; font-weight:600; letter-spacing:.06em; line-height:1;
  opacity:.7; transition:opacity .15s ease, border-color .15s ease, background-color .15s ease;
}
.lang-current:hover{ opacity:1; }
.lang-current[aria-expanded="true"]{ opacity:1; border-color:var(--border); background:var(--surface); }
.lang-current-flag{ display:block; }
/* Галочка смотрит вверх — список открывается вверх, — и переворачивается, когда он открыт. */
.lang-caret{ width:11px; height:11px; transition:transform .18s ease; }
.lang-current[aria-expanded="true"] .lang-caret{ transform:rotate(180deg); }
.lang-menu{
  position:absolute; bottom:calc(100% + 8px); left:50%; transform:translateX(-50%);
  display:flex; flex-direction:column; gap:2px;
  min-width:170px; padding:6px;
  background:var(--surface); border:1px solid var(--border); border-radius:14px;
  box-shadow:0 20px 44px -20px var(--shadow-color), 0 2px 8px -4px var(--shadow-color);
  animation:langMenuIn .16s ease-out both;
}
/* display:flex выше сильнее встроенного [hidden]{display:none}, поэтому скрытие
   приходится задавать явно — без этой строки список висел бы открытым всегда. */
.lang-menu[hidden]{ display:none; }
@keyframes langMenuIn{
  from{ opacity:0; transform:translate(-50%, 6px); }
  to{ opacity:1; transform:translate(-50%, 0); }
}
@media (prefers-reduced-motion: reduce){ .lang-menu{ animation:none; } }
.lang-flag{
  display:flex; align-items:center; gap:10px; width:100%;
  background:none; border:0; border-radius:10px; padding:8px 10px;
  cursor:pointer; color:var(--ink); text-align:left;
  font-family:inherit; font-size:14px; line-height:1;
  transition:background-color .15s ease;
}
.lang-flag:hover{ background:var(--bg-2); }
.lang-flag.active{ background:var(--bg-2); font-weight:600; }
/* Флаги — инлайновые SVG, а не эмодзи: эмодзи-флаги не на всех телефонах есть в системном
   шрифте (особенно Android) и рисуются текстом ("RU"/"GB"/"IL") вместо картинки флага. */
.lang-flag-img{ display:block; flex:none; }
.lang-flag-img svg, .lang-current-flag svg{
  display:block; width:22px; height:16px; border-radius:3px; box-shadow:0 0 0 1px rgba(0,0,0,0.12);
}

/* Иврит: меняем только смысл (перевод) и выравнивание текста на правое — порядок
   элементов и раскладка карточек не зеркалятся. Более специфичные правила выше
   (например .auth-card .brand, .welcome, .points-card с их text-align:center)
   продолжают действовать поверх этого — каскад решает конфликт сам. */
[lang="he"] .auth-card, [lang="he"] .dash-content{ text-align:right; }

/* Порядок слов в смешанных строках ("קבלת קוד ב-SMS", "סכום הרכישה (₪)"). Без явного
   направления браузер считает такую строку левосторонней и ставит латиницу/цифры справа —
   а справа в иврите читают ПЕРВЫМ, и "SMS" выглядит переехавшим в начало фразы.
   unicode-bidi:plaintext определяет направление каждой строки по её же первой букве: иврит
   становится правосторонним и SMS уходит в конец, а номера телефонов, "+50" и "(₪)"
   внутри него остаются левосторонними. Берём именно unicode-bidi, а не dir="rtl"/direction:
   он меняет только порядок текста внутри строки и не зеркалит flex-раскладку (см. выше).
   Перечислены только блочные контейнеры: на строчном (span, a) plaintext изолирует
   кусок от соседей, и родитель снова стал бы левосторонним. */
[lang="he"] .view p,
[lang="he"] .view div,
[lang="he"] .view h2,
[lang="he"] .view li,
[lang="he"] .view label > span,
[lang="he"] .view button{ unicode-bidi:plaintext; }

/* ---- Большой экран (планшет/компьютер) ---- */
/* Здесь и только здесь приложение снова становится карточкой по центру страницы.
   Условие по ВЫСОТЕ так же важно, как по ширине: телефон, повёрнутый набок, легко
   бывает шире 768px, но остаётся телефоном — низкий экран отсекает этот случай,
   и в альбомной ориентации приложение по-прежнему занимает весь экран. */
@media (min-width:768px) and (min-height:640px){
  body{ align-items:center; justify-content:center; }
  /* overflow-y возвращаем: на телефоне экран входа прокручивается вместе со страницей
     (overflow:visible, см. #view-auth выше), а здесь приложение снова карточка по центру
     страницы — содержимое выше окна должно ездить ВНУТРИ неё, иначе центрирование по
     body отрезает верх карточки без возможности до него доскроллить. */
  #view-auth{ flex:0 1 auto; min-height:0; overflow-y:auto; padding:24px; }
  /* Карточка здесь по содержимому, свободного места внутри неё нет: делить нечего, а
     запас высоты у форм оставил бы под короткой формой пустую полосу. */
  .auth-forms{ min-height:0; }
  .auth-card{
    flex:none; max-width:420px; margin:0 auto;
    border:1px solid rgba(255,255,255,0.5); border-radius:28px;
    padding:30px 26px;
    box-shadow:0 24px 48px -20px var(--shadow-color), 0 2px 8px -2px var(--shadow-color);
    animation:cardIn .5s cubic-bezier(.2,.9,.25,1.1) both;
  }
  @media (prefers-color-scheme: dark){ .auth-card{ border-color:rgba(255,255,255,0.08); } }
  /* Приглашение на большом экране — такая же карточка по центру страницы, что и вход.
     Стекло переезжает с экрана на саму карточку: вокруг снова есть «страница», и
     подложка на всю её ширину означала бы матовый лист от края до края монитора. */
  #view-invite{ padding:24px; background:none; backdrop-filter:none; -webkit-backdrop-filter:none; }
  .invite-card{
    background:var(--surface-glass);
    backdrop-filter:blur(22px) saturate(160%); -webkit-backdrop-filter:blur(22px) saturate(160%);
    border:1px solid rgba(255,255,255,0.5); border-radius:28px;
    padding:30px 26px;
    box-shadow:0 24px 48px -20px var(--shadow-color), 0 2px 8px -2px var(--shadow-color);
  }
  @media (prefers-color-scheme: dark){ .invite-card{ border-color:rgba(255,255,255,0.08); } }
  /* Боковые поля под картой на большом экране — свои, шире общих (см. .dash-content).
     Верх и низ не трогаем: там высота маски и запас под последнюю строку. */
  .dash-below{ padding-left:20px; padding-right:20px; }
}
@media (prefers-reduced-motion: reduce){ .auth-card{ animation:none; } }

/* ---- Значок reCAPTCHA: убран с экрана, вместо него — строка о защите ----
   Google вставляет свой значок ("protected by reCAPTCHA") как отдельный блок 256×60
   у нижнего края и переставляет его своими inline-стилями. На узком экране телефона
   он налезал на кнопки и сдвигал приложение вбок. Прятать значок Google разрешает
   при условии, что упоминание reCAPTCHA остаётся видимым в интерфейсе — это строка
   .recaptcha-note под формами (ссылок на правила Google в ней больше нет, почему —
   в комментарии к ней в index.html). Само окно проверки ("выберите светофоры") это
   не трогает: у него другой
   контейнер, и оно по-прежнему показывается и вписывается в экран (см. app.js).

   visibility, а не display:none: скрытый через display элемент Google иногда пытается
   показать заново и повторяет попытки; невидимый его устраивает. Контейнер при этом
   схлопываем в нулевую высоту, чтобы он не занимал места в колонке карточки. */
#recaptcha-container{ width:0; height:0; overflow:hidden; }
.grecaptcha-badge{
  visibility:hidden !important; opacity:0 !important; pointer-events:none !important;
}
/* Отступы — забота подвала (.app-footer с его gap), поэтому здесь margin:0. Строка
   служебная, поэтому мелкая и в цвет приглушённого текста: её задача — быть на виду,
   а не спорить с формой входа. */
.footer-line{
  display:flex; flex-wrap:wrap; align-items:baseline; justify-content:center;
  gap:0 6px; margin:0; text-align:center;
  font-size:11px; line-height:1.35; color:var(--ink-soft);
}
.footer-line .link-btn{ font-size:inherit; }
/* Точка-разделитель между двумя половинами строки. В кабинете уезжает вместе со
   строкой про reCAPTCHA — одна ссылка разделять ничего не может. */
.footer-sep{ opacity:.45; }
/* Иврит: две половины строки меняются местами. Здесь это обязательно — в отличие от
   карточек, которые мы намеренно не зеркалим: строка читается справа налево, и первой
   под правым краем должна оказаться та половина, что и в остальных языках стоит первой.
   Само упоминание reCAPTCHA набрано смешанно (иврит + латиница), поэтому направление
   внутри него определяем по тексту, а не по контейнеру. */
[lang="he"] .footer-line{ direction:rtl; }
[lang="he"] .recaptcha-note{ unicode-bidi:plaintext; }
/* ---- Кнопка, которая выглядит ссылкой ---- */
/* Именно <button>, а не <a>: политика открывается окном внутри приложения, никуда не
   ведёт и не должна выглядеть как что-то, что можно открыть в новой вкладке или
   скопировать адрес. Обнуляем всё, что браузер добавляет кнопке по умолчанию. */
.link-btn{
  display:inline; padding:0; border:none; background:none; font:inherit; color:var(--plum);
  text-decoration:underline; text-underline-offset:2px; cursor:pointer;
}
.link-btn:hover{ filter:brightness(1.1); }

/* ---- Окно политики конфиденциальности ---- */
/* Настоящий <dialog>.showModal(), а не самодельный слой: браузер сам кладёт окно поверх
   всего (top layer — выше и фона с пятнами, и подвала с переключателем языка),
   сам не пускает фокус за его пределы и сам закрывает окно по Esc.

   На телефоне окно занимает весь экран: это документ на несколько экранов текста, и
   читать его в узкой коробочке посреди затемнённой страницы неудобно. Карточка по
   центру появляется только на большом экране — тем же правилом и по той же причине,
   что и сама .auth-card. */
.legal-modal{
  position:fixed; inset:0; width:100%; max-width:none; height:100%; max-height:none;
  margin:0; padding:0; border:none; overflow:hidden;
  background:var(--surface); color:var(--ink);
  flex-direction:column;
}
.legal-modal:not([open]){ display:none; }
.legal-modal[open]{ display:flex; animation:legalIn .28s cubic-bezier(.2,.9,.25,1) both; }
.legal-modal::backdrop{ background:rgba(24,17,36,0.55); backdrop-filter:blur(3px); -webkit-backdrop-filter:blur(3px); }
@keyframes legalIn{ from{ opacity:0; transform:translateY(16px); } to{ opacity:1; transform:translateY(0); } }
@media (prefers-reduced-motion: reduce){ .legal-modal[open]{ animation:none; } }

.legal-head{
  flex:none; display:flex; align-items:flex-start; gap:12px;
  padding:calc(env(safe-area-inset-top) + 18px) 20px 14px;
  border-bottom:1px solid var(--border); background:var(--surface);
}
.legal-head h2{
  margin:0; font-family:var(--font-ui); font-size:20px; font-weight:600; line-height:1.25;
}
.legal-subtitle{ margin:4px 0 0; font-size:12px; line-height:1.4; color:var(--ink-soft); }
.legal-head > div{ flex:1; min-width:0; }
.legal-close{
  flex:none; width:34px; height:34px; border:none; border-radius:50%; cursor:pointer;
  background:var(--bg-2); color:var(--ink); font-size:22px; line-height:1;
  display:flex; align-items:center; justify-content:center;
  transition:filter .15s ease, transform .15s ease;
}
.legal-close:hover{ filter:brightness(.95); }
.legal-close:active{ transform:scale(.92); }

/* Прокручивается только текст, шапка и кнопка остаются на месте. overscroll-behavior
   contain — чтобы докрутив политику до конца, не начать протягивать экран за ней. */
.legal-body{
  flex:1 1 auto; min-height:0; overflow-y:auto; overscroll-behavior:contain;
  -webkit-overflow-scrolling:touch;
  padding:16px 20px 20px; font-size:14px; line-height:1.6;
}
.legal-body h3{
  margin:22px 0 8px; font-size:14.5px; font-weight:700; color:var(--plum-deep);
}
.legal-body h3:first-child{ margin-top:0; }
.legal-body p{ margin:0 0 10px; }
.legal-body ul{ margin:0 0 10px; padding-inline-start:20px; }
.legal-body li{ margin:0 0 5px; }

/* Реквизиты салона — две колонки «поле → значение», которые на узком экране
   складываются в строки, чтобы длинный email не ломал раскладку. */
.legal-dl{ margin:0 0 10px; display:grid; grid-template-columns:auto 1fr; gap:5px 12px; }
.legal-dl dt{ color:var(--ink-soft); font-size:13px; }
.legal-dl dd{ margin:0; font-weight:500; overflow-wrap:anywhere; }

/* Незаполненный реквизит (см. ORG в legal.js) — не пустое место, а заметный пропуск:
   пока он виден, политика не готова к публикации. */
.legal-blank{
  color:var(--danger); background:color-mix(in srgb, var(--danger) 12%, transparent);
  border-radius:4px; padding:0 4px; letter-spacing:.05em;
}
.legal-version{ margin-top:20px; padding-top:14px; border-top:1px solid var(--border);
  font-size:12px; color:var(--ink-soft); }

.legal-foot{
  flex:none; display:flex; justify-content:flex-end;
  padding:12px 20px calc(env(safe-area-inset-bottom) + 14px);
  border-top:1px solid var(--border); background:var(--surface);
}
.legal-foot .btn-primary{ flex:1; margin-top:0; }

/* Иврит: здесь, в отличие от остального приложения, раскладку зеркалим по-настоящему
   (direction:rtl) — это документ, и маркеры списков, отступы и крестик закрытия должны
   стоять справа, иначе текст читается «наизнанку». */
[lang="he"] .legal-modal{ direction:rtl; text-align:right; }
[lang="he"] .legal-body p,
[lang="he"] .legal-body li,
[lang="he"] .legal-dl dd{ unicode-bidi:plaintext; }

@media (min-width:768px) and (min-height:640px){
  .legal-modal{
    position:fixed; inset:0; margin:auto;
    width:min(600px, calc(100% - 48px)); height:auto; max-height:min(82vh, 760px);
    border-radius:24px;
    box-shadow:0 30px 60px -20px var(--shadow-color), 0 2px 10px -2px var(--shadow-color);
  }
  .legal-head{ padding:20px 24px 14px; }
  .legal-body{ padding:18px 24px 22px; }
  .legal-foot{ padding:14px 24px 18px; }
  .legal-foot .btn-primary{ flex:none; min-width:150px; }
}

/* ---- Минимальный размер приложения ----
   min-height:0 у .view (см. выше) снимает встроенную защиту flex-элемента от сжатия —
   без неё экран послушно ужимается под любую высоту, и на низком экране (телефон в
   альбомной ориентации, открытая клавиатура, маленькое окно на компьютере) форма
   оказывается вплотную к подвалу с языками, а её содержимое — прямо на флагах.
   Ниже порога перестаём сжимать: экран берёт свою естественную высоту (flex:none),
   внутренняя прокрутка выключается, и прокручивается вся страница целиком — один
   привычный скролл вместо двух вложенных. Подвал при этом остаётся последним блоком
   потока и просто уезжает под содержимое.
   Порог по высоте написан числом, а не переменной: медиа-запрос не умеет читать
   пользовательские свойства. 600px — высота, ниже которой форма входа со всеми полями
   и подвалом уже не помещается целиком. */
/* Ни экран входа, ни кабинет сюда больше не входят: оба прокручиваются вместе со
   страницей на ЛЮБОЙ высоте (см. #view-auth и #view-dashboard выше), и порог им не
   нужен. Правило осталось ради остальных экранов (заставка, приглашение): у них
   внутренняя прокрутка на месте, но на низком экране один общий скролл удобнее двух
   вложенных.
   flex:1 0 auto, а не flex:none: grow:1 нужен, чтобы при коротком содержимом экран
   всё-таки растянулся на свободное место и подвал остался внизу экрана, а не
   подтянулся вплотную к последней строке. */
@media (max-height:599px), (max-width:319px){
  .view{
    flex:1 0 auto; min-height:auto; overflow-y:visible; overflow-x:visible;
  }
}


/* ---- Маленькие праздники: приветствие после регистрации и «прилёт» баллов ----
   Разметку этих элементов создаёт celebrate.js; здесь только их вид. Всё движение,
   у которого случайные координаты (конфетти, капли эликсира, «+N»), задаётся оттуда же
   ключевыми кадрами — в CSS остаются размер, цвет и слой. */

/* Общий слой поверх приложения. pointer-events:none — обязательное: слой закрывает весь
   экран, и без него конфетти перехватывало бы касания по кнопкам под собой.
   overflow:hidden — чтобы разлетевшиеся бумажки не растягивали страницу вбок.
   z-index выше окна поздравления (.welcome-overlay, 70): главный залп конфетти вылетает
   именно из этого окна, и на слое ниже он оказывался ЗА окном — за непрозрачной карточкой
   и за размытой подложкой, то есть его не было видно вовсе. */
.celebrate-layer{
  position:fixed; inset:0; z-index:80; pointer-events:none; overflow:hidden;
}
.confetti-piece{
  position:absolute; width:8px; height:8px; border-radius:2px;
  will-change:transform, opacity;
}
/* ---- Золотой эликсир ----
   Начисление показывается каплями золотой сыворотки, а не монетками: пипетка, капля,
   кольцо по «поверхности» числа и золотая пыльца. Почему именно так — у dropInto в
   celebrate.js; движение целиком задаётся оттуда, здесь только вид. */

/* Капля: тот же тёплый золотой градиент, что у рамки клубной карты. Форма — слегка
   вытянутая вниз слеза; на лету celebrate.js её ещё растягивает и сплющивает. */
.serum-drop{
  position:absolute; width:11px; height:14px;
  border-radius:50% 50% 50% 50% / 38% 38% 62% 62%;
  background:radial-gradient(circle at 34% 28%, #FFF6E0 0%, #EED9A8 40%, #C9A566 100%);
  box-shadow:0 2px 8px -2px rgba(201,165,102,.7), inset 0 -2px 3px rgba(115,85,30,.25);
  will-change:transform, opacity;
}

/* Кольцо от упавшей капли. Плоский эллипс, а не круг: рябь по жидкости, не взрыв. */
.serum-ripple{
  position:absolute; width:64px; height:22px; border-radius:50%;
  border:1.5px solid rgba(238,215,160,.85);
  box-shadow:0 0 18px rgba(201,165,102,.4), inset 0 0 12px rgba(243,217,166,.35);
  will-change:transform, opacity;
}

/* Искра золотой пыльцы — светящаяся точка без твёрдого края: хлопья золота в
   сыворотке, а не блёстки из хлопушки. */
.shimmer-fleck{
  position:absolute; width:4px; height:4px; border-radius:50%;
  background:radial-gradient(circle, #FFF6E0 0%, #E4C88F 60%, rgba(201,165,102,0) 100%);
  box-shadow:0 0 6px rgba(243,217,166,.9);
  will-change:transform, opacity;
}

/* Пипетка над числом. Якорь — носик (низ элемента): transform в celebrate.js ставит
   её точкой рождения капель ровно в left/top, а наклон вращается вокруг того же носика,
   чтобы капли не разъезжались с ним. Тень мягкая и глубокая — предмет висит НАД картой. */
.serum-pipette{
  position:absolute; width:30px; height:97px;
  transform-origin:50% 96%;
  filter:drop-shadow(0 8px 16px rgba(18,10,30,.45));
  will-change:transform, opacity;
}
.serum-pipette svg{ display:block; width:100%; height:100%; }
/* «+50» над числом. Крупно и цветом успеха: цифра баланса отвечает «сколько всего»,
   а этот значок — «сколько только что пришло», и без него прибавку приходится вычитать
   в уме. Он же — единственное, что остаётся при отключённых анимациях. */
.points-pop{
  position:absolute; transform:translate(-50%,-50%);
  font-family:var(--font-ui); font-size:30px; font-weight:700; white-space:nowrap;
  color:var(--success); text-shadow:0 2px 10px var(--shadow-color);
  will-change:transform, opacity;
}
/* Карточка баллов в момент начисления: по золотой рамке проходит волна, и сама карточка
   чуть «вдыхает». Подсветка живёт в ::after, а не в фоне карточки, — фон там уже занят. */
.points-card.celebrating{ animation:cardBreath 1.2s cubic-bezier(.2,.9,.25,1); }
.points-card.celebrating::after{
  content:""; position:absolute; inset:0; border-radius:inherit; pointer-events:none;
  background:radial-gradient(circle at 50% 45%, color-mix(in srgb, var(--gold) 34%, transparent), transparent 62%);
  animation:cardGlow 1.4s ease-out forwards;
}
@keyframes cardBreath{
  0%{ transform:scale(1); }
  28%{ transform:scale(1.028); }
  60%{ transform:scale(.996); }
  100%{ transform:scale(1); }
}
@keyframes cardGlow{ from{ opacity:0; } 35%{ opacity:1; } to{ opacity:0; } }
@media (prefers-reduced-motion: reduce){
  .points-card.celebrating{ animation:none; }
  .points-card.celebrating::after{ animation:none; content:none; }
}

/* Строка для экранного диктора: анимацию он не покажет, а «начислено 50 баллов» сказать
   должен. Убираем с глаз, но не через display:none и не через visibility — оба прячут
   текст и от диктора тоже. */
.celebrate-sr{
  position:absolute; width:1px; height:1px; margin:-1px; padding:0;
  overflow:hidden; clip:rect(0 0 0 0); clip-path:inset(50%); white-space:nowrap; border:0;
}

/* ---- Экран начисления баллов ----
   Отдельный экран на всё окно, а не всплывающая подпись в кабинете: см. подробное
   объяснение у pointsArrived в celebrate.js. Здесь только вид.
   z-index 75 — между кабинетом и летящим слоем (.celebrate-layer, 80): пипетка, капли
   эликсира и золотая пыльца в конце докрутки живут над этой картой и обязаны быть ВЫШЕ неё. Ниже окна регистрационного
   поздравления (.welcome-overlay, 70) он быть тоже не может: у нового клиента оба события
   идут подряд, и начисление приветственного бонуса не должно уехать под поздравление.
   pointer-events по умолчанию — экран намеренно перехватывает касания: под ним кабинет,
   и промахнуться в него мимо карты значит уйти с события, которое ещё идёт. */
.reward-overlay{
  position:fixed; inset:0; z-index:75;
  display:flex; align-items:center; justify-content:center;
  padding:24px calc(20px + env(safe-area-inset-right)) 24px calc(20px + env(safe-area-inset-left));
  background:color-mix(in srgb, var(--bg) 76%, transparent);
  backdrop-filter:blur(12px) saturate(130%); -webkit-backdrop-filter:blur(12px) saturate(130%);
  animation:rewardFade .38s ease-out both;
}
.reward-overlay.reward-out{ animation:rewardFade .5s ease-in reverse both; }
@keyframes rewardFade{ from{ opacity:0; } to{ opacity:1; } }

/* Та же карта клуба, что и в кабинете, — тот же тёмный винный фон и та же золотая нить по
   краю. Это важнее, чем кажется: баллы должны прилететь на карту, которую человек знает,
   а не на «экран уведомления». Отличие одно — здесь она посреди пустого экрана и крупнее.
   Выезжает с лёгким перелётом (та же кривая, что у поздравления после регистрации). */
.reward-card{
  position:relative; width:min(340px, 100%); padding:30px 26px 28px;
  border-radius:32px; text-align:center; overflow:hidden; color:#FFF6EE;
  /* Шрифт цифр — тот же, что в карте кабинета. Переменная объявлена на .points-card, а эта
     карта живёт вне кабинета и оттуда её не наследует: без этой строки число набиралось
     запасным Rubik, и «та же карта» переставала быть той же ровно в том месте, ради
     которого экран и существует. Значение продублировано с .points-card намеренно —
     выносить его в :root ради двух мест значило бы раздать шрифт карты всему приложению. */
  --font-card:"Cormorant Garamond", Georgia, "Times New Roman", serif;
  background:
    radial-gradient(120% 80% at 78% -12%, rgba(198,168,228,.30), transparent 62%),
    radial-gradient(100% 80% at 0% 110%, rgba(201,165,102,.34), transparent 62%),
    linear-gradient(158deg, #4A2C66 0%, #1C1028 52%, #37205A 100%);
  box-shadow:0 40px 90px -30px rgba(18,10,30,.95), 0 2px 14px -6px rgba(18,10,30,.6);
  animation:rewardIn .62s cubic-bezier(.2,1.2,.4,1) both;
}
.reward-card::before{
  content:""; position:absolute; inset:0; border-radius:inherit; padding:1px;
  background:linear-gradient(135deg, var(--gold), transparent 42%, #C6A8E4 100%);
  -webkit-mask:linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
  -webkit-mask-composite:xor; mask-composite:exclude; opacity:.7; pointer-events:none;
}
.reward-card > *{ position:relative; z-index:1; }
@keyframes rewardIn{
  from{ opacity:0; transform:translateY(26px) scale(.88); }
  to{ opacity:1; transform:translateY(0) scale(1); }
}
/* Момент, когда счётчик остановился: карта один раз «вдыхает» и по ней проходит золотая
   волна — то же движение, что было у карты в кабинете при начислении. */
.reward-card.reward-done{ animation:cardBreath 1.1s cubic-bezier(.2,.9,.25,1); }
.reward-card.reward-done::after{
  content:""; position:absolute; inset:0; border-radius:inherit; pointer-events:none;
  background:radial-gradient(circle at 50% 48%, rgba(201,165,102,.42), transparent 62%);
  animation:cardGlow 1.4s ease-out forwards;
}

/* «+50» — сколько именно пришло. Стоит НАД числом, а не под ним: сначала «вам начислили
   пятьдесят», потом «теперь у вас столько-то». Обратный порядок читается как ценник. */
.reward-badge{
  display:inline-block; margin:0 0 12px; padding:6px 16px; border-radius:999px;
  font-family:var(--font-ui); font-weight:700; font-size:20px; line-height:1.1;
  color:#2B1442; background:linear-gradient(135deg, #F3D9A6, #C9A566 70%, #E4C88F);
  box-shadow:0 8px 22px -10px rgba(201,165,102,.9);
  animation:rewardBadge .5s cubic-bezier(.2,1.3,.4,1) .18s both;
}
@keyframes rewardBadge{
  from{ opacity:0; transform:translateY(-10px) scale(.7); }
  to{ opacity:1; transform:translateY(0) scale(1); }
}
.reward-title{
  font-size:11.5px; letter-spacing:.14em; text-transform:uppercase;
  color:rgba(255,246,238,.62);
}
/* Свечение вокруг цифр — как в кабинете: у самого числа цвета нет (сквозь буквы видна
   текстура крема), поэтому ореол рисуется по готовой картинке, на обёртке. */
.reward-shine{
  display:flex; justify-content:center; margin:6px 0 2px;
  filter:drop-shadow(0 0 18px rgba(255,205,150,.34));
}
/* Крупнее, чем в кабинете: здесь на экране больше нет ничего, кроме этого числа. */
.reward-value{ font-size:clamp(64px, 22vw, 92px); }
.reward-sub{ margin-top:10px; font-size:13px; line-height:1.45; color:rgba(255,246,238,.6); }

/* Экран приветственного подарка — сразу после регистрации. Знак подарка стоит слева от
   цифры, а не над ней: строка «бант + число» и есть то, что человек через секунду узнает
   на своей карте. Раскладку этой строки задаёт .reward-gift, и она одна на оба случая.
   А вот сама цифра у них разная, и путать их нельзя (см. .points-gift-value): у подарка,
   который уже на балансе, она залита кремом, как настоящий баланс, — потому что это он и
   есть; у подарка старого образца, который до первого визита на балансе не лежит, — полая
   (.reward-gift-value ниже), потому что «ещё не ваше». */
.reward-gift{ align-items:center; gap:clamp(12px, 4vw, 18px); }
.reward-gift-icon{
  flex:none; display:grid; place-items:center; color:#EFCE9B;
  width:clamp(38px, 12vw, 52px); height:clamp(38px, 12vw, 52px);
  animation:pendingBreath 3.4s ease-in-out 1.2s infinite;
}
.reward-gift-icon svg{ width:100%; height:100%; }
.reward-gift-value{
  font-family:var(--font-card); font-weight:500; line-height:1.06; letter-spacing:0;
  font-variant-numeric:tabular-nums lining-nums; display:inline-block;
  color:transparent; -webkit-text-fill-color:transparent;
  -webkit-text-stroke:1.6px rgba(255,231,196,.94);
}
/* Браузер без обводки текста залил бы цифру ничем — то есть подарка на экране не было бы
   вовсе. Тогда приглушённая заливка: она хуже отличается от настоящего баланса, но знак
   подарка рядом и строка под числом про первый визит остаются на месте. */
@supports not ((-webkit-text-stroke:1px #000)){
  .reward-gift-value{ color:rgba(255,231,196,.72); -webkit-text-fill-color:rgba(255,231,196,.72); }
}
@media (prefers-reduced-motion: reduce){
  .reward-gift-icon{ animation:none; }
}

/* Человек просил систему не анимировать — сцены не будет вовсе: celebrate.js возвращает
   null, и начисление показывается по-старому, подписью «+N» в самом кабинете. Правила
   ниже нужны только на случай, если экран всё же оказался в разметке. */
@media (prefers-reduced-motion: reduce){
  .reward-overlay, .reward-overlay.reward-out, .reward-card, .reward-badge{ animation:none; }
  .reward-card.reward-done{ animation:none; }
  .reward-card.reward-done::after{ animation:none; content:none; }
}
/* На иврите карта разворачивается вправо — как и остальные окна приложения. Cormorant
   иврита не знает, поэтому цифры на ней, как и в кабинете, набираются тем же Rubik. */
[lang="he"] .reward-card{ direction:rtl; --font-card:var(--font-ui); }

/* ---- Поздравление после регистрации ----
   Не <dialog>, как окно подтверждения: там нужен был перехват фокуса и ответ «да/нет»,
   а здесь — картинка поверх уже открытого кабинета, которая сама уходит. Кабинет за ней
   виден размытым намеренно: человек видит, куда попал, ещё до того, как закроет окно. */
.welcome-overlay{
  position:fixed; inset:0; z-index:70;
  display:flex; align-items:center; justify-content:center; padding:24px;
  background:color-mix(in srgb, var(--bg) 70%, transparent);
  backdrop-filter:blur(6px); -webkit-backdrop-filter:blur(6px);
  animation:welcomeFade .28s ease-out both;
}
.welcome-overlay.welcome-out{ animation:welcomeFade .26s ease-in reverse both; }
@keyframes welcomeFade{ from{ opacity:0; } to{ opacity:1; } }
.welcome-card{
  position:relative; width:min(340px, 100%); padding:30px 26px 26px;
  border-radius:26px; text-align:center;
  background:var(--surface);
  box-shadow:0 30px 70px -24px var(--shadow-color), 0 2px 12px -4px var(--shadow-color);
  animation:welcomeIn .5s cubic-bezier(.2,1.25,.4,1) both;
}
/* Та же золото-розовая рамка, что у карточки баллов: поздравление и карта — одно и то же
   место, просто в разные моменты. */
.welcome-card::before{
  content:""; position:absolute; inset:0; border-radius:inherit; padding:1px;
  background:linear-gradient(135deg, var(--gold), transparent 45%, var(--plum) 100%);
  -webkit-mask:linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
  -webkit-mask-composite:xor; mask-composite:exclude; opacity:.7; pointer-events:none;
}
@keyframes welcomeIn{
  from{ opacity:0; transform:translateY(22px) scale(.9); }
  to{ opacity:1; transform:translateY(0) scale(1); }
}
/* Косметолог над поздравлением (ставит его celebrate.js) — тот же ролик и та же высота
   кадра, что на приглашении и на входе (--ck-h), поэтому фигура здесь ровно того же
   роста. Пропорции 640x488: кадр обрезан по самому широкому положению рук. Именно под
   этот кадр и под ширину окна поздравления подобран потолок --ck-h (см. :root) — так он
   вписывается в карточку целиком, без ужимания по ширине.
   На невысоком экране --ck-h сам становится меньше (он считается от высоты экрана), так
   что карточка со всем содержимым — ролик, галочка, заголовок, строка про баллы, кнопка —
   продолжает помещаться. */
.welcome-ck{ width:283px; height:216px; max-width:100%; margin:0 auto 10px; }
@supports (aspect-ratio: 640 / 488){
  .welcome-ck{
    height:var(--ck-h);
    width:auto; aspect-ratio:640 / 488; max-width:100%;
  }
}
/* Совсем низкий экран (телефон в горизонтальном положении): ролик убираем целиком —
   поздравление важнее. display:none здесь не только про внешний вид: по нему chromakey.js
   понимает, что виджета на экране нет, и не качает полмегабайта впустую. */
@media (max-height: 560px){ .welcome-ck{ display:none; } }
.welcome-check{ color:var(--success); margin:0 auto 14px; width:70px; height:70px; }
.welcome-check svg{ width:100%; height:100%; display:block; }
/* Кружок и галочка «рисуются»: длину линии превращаем в пунктир длиной во всю линию и
   сдвигаем его так, что сначала не видно ничего, — потом сдвиг уходит в ноль.
   Значения dasharray взяты с запасом (длина окружности r=23 — около 145). */
.welcome-check-ring{
  stroke-dasharray:150; stroke-dashoffset:150;
  animation:drawStroke .55s ease-out .1s forwards;
}
.welcome-check-mark{
  stroke-dasharray:44; stroke-dashoffset:44;
  animation:drawStroke .4s cubic-bezier(.2,.9,.25,1) .5s forwards;
}
@keyframes drawStroke{ to{ stroke-dashoffset:0; } }
.welcome-title{
  margin:0; font-family:var(--font-ui); font-size:23px; font-weight:700; line-height:1.25;
  color:var(--ink);
}
.welcome-text{ margin:10px 0 0; font-size:14.5px; line-height:1.5; color:var(--ink-soft); }
/* Строка про приветственные баллы — на золотой подложке: это единственное место в окне,
   где сказано, что человек уже что-то получил. */
.welcome-bonus{
  margin:16px 0 0; padding:11px 14px; border-radius:14px;
  background:var(--gold-soft); color:var(--ink);
  font-size:13.5px; line-height:1.45;
}
.welcome-btn{ margin-top:18px; width:100%; }
/* Предложение поставить приложение на телефон — внутри окна поздравления, между
   подарком и кнопкой «Открыть мою карту» (собирает его celebrate.js). Намеренно НЕ
   выглядит второй главной кнопкой: обведённая карточка в тон окна, с кнопкой поменьше
   внутри. Иначе в окне оказались бы два одинаковых призыва, и человек, которому нужно
   было просто открыть свою карту, выбирал бы между ними. */
.welcome-install{
  margin:16px 0 0; padding:13px 14px; width:100%;
  border-radius:16px; text-align:start;
  border:1px solid color-mix(in srgb, var(--plum) 22%, transparent);
  background:color-mix(in srgb, var(--plum) 5%, var(--surface));
}
.welcome-install-title{
  display:block; font-family:var(--font-ui); font-size:14.5px; color:var(--ink);
}
.welcome-install-sub{
  display:block; margin-top:3px; font-size:12.5px; line-height:1.45; color:var(--ink-soft);
}
.welcome-install-btn{
  display:inline-flex; align-items:center; justify-content:center; gap:7px;
  margin-top:10px; padding:9px 16px; border-radius:999px; cursor:pointer;
  font:inherit; font-size:13.5px; font-weight:600; line-height:1;
  color:var(--plum-deep);
  border:1px solid color-mix(in srgb, var(--plum) 40%, transparent);
  background:var(--surface);
  transition:border-color .15s ease, transform .1s ease;
}
.welcome-install-btn svg{ width:16px; height:16px; flex:none; }
.welcome-install-btn:hover{ border-color:var(--plum); }
.welcome-install-btn:active{ transform:scale(.97); }
/* Тёмная тема: тот же корень, что у .install-pill — тёмная слива на тёмной кнопке
   не читается, берём светлую орхидею. */
@media (prefers-color-scheme: dark){
  .welcome-install-btn{ color:var(--plum); }
}
/* На низком экране ролик с косметологом уже убран (@media max-height:560px выше), но
   окно всё равно может не поместиться — тогда предложение остаётся, а объяснение под
   заголовком уходит: сама кнопка понятна и без него. */
@media (max-height: 620px){
  .welcome-install{ padding:10px 12px; margin-top:12px; }
  .welcome-install-sub{ display:none; }
}

@media (prefers-reduced-motion: reduce){
  .welcome-overlay, .welcome-overlay.welcome-out, .welcome-card{ animation:none; }
  .welcome-check-ring, .welcome-check-mark{ animation:none; stroke-dashoffset:0; }
}
/* На иврите окно целиком разворачивается вправо — как и остальные окна приложения. */
[lang="he"] .welcome-card{ direction:rtl; }

/* ---- АВАРИЙНЫЙ РУБИЛЬНИК: сервис закрыт владелицей из панели ----
   Лежит поверх всего приложения, включая заставку (у неё z-index:5): какой бы экран ни
   был открыт, при поднятом флаге человек видит только это. Показывает и прячет экран
   подписка на settings/service в app.js; настоящий запрет — на сервере (см. index.html). */
.service-closed{
  position:fixed; inset:0; z-index:9;
  display:flex; align-items:center; justify-content:center;
  background:var(--bg); color:var(--ink);
  text-align:center; padding:32px 24px;
  padding-top:calc(32px + env(safe-area-inset-top));
  padding-bottom:calc(32px + env(safe-area-inset-bottom));
}
.closed-card{ display:flex; flex-direction:column; align-items:center; gap:14px; max-width:30em; }
.closed-title{ margin:8px 0 0; font-size:21px; }
.closed-text{ margin:0; font-size:14.5px; line-height:1.55; color:var(--ink-soft); }
/* Политика конфиденциальности — единственное действие на закрытом экране: адрес для
   вопросов о своих данных должен оставаться под рукой и здесь (см. index.html). */
.closed-policy{ margin-top:10px; font-size:13px; }
/* На иврите — как и остальные экраны приложения. */
[lang="he"] .service-closed{ direction:rtl; }
