/* ═══════════════════════════════════════════════════════════════════════════
   Voron VPN Mini App — Neo-Minimalist Fintech UI
   Apple HIG · Soft Glassmorphism · Liquid Glass · лёгкий Claymorphism
   ═══════════════════════════════════════════════════════════════════════════ */

/* ── Плавная смена темы ────────────────────────────────────────────────────
   Растворяем весь экран целиком через View Transitions: браузер сам снимает кадр
   «до», применяет тему и кроссфейдит его в кадр «после». Меняется ВСЁ разом —
   фон, карточки, тени, стекло, текст, — поэтому рассинхрона в принципе быть не
   может.

   До этого была попытка интерполировать цветовые токены через @property. Она и
   сделала хуже: плавно ехали только ЦВЕТА, а тени (--shadow-*) и стекло
   (--glass-*, --drop-*) — составные значения, интерполировать их нельзя, и они
   щёлкали мгновенно. Половина интерфейса плыла, половина прыгала.

   Где View Transitions не поддержан (WebKit до 18) — просто мгновенное
   переключение: некрасиво, но синхронно. Хром Telegram живёт вне страницы и в
   кроссфейд не попадает — его доводит JS теми же миллисекундами (см. applyTheme). */
::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: .3s;
  animation-timing-function: cubic-bezier(.4, 0, .2, 1);
}

/* ── Токены ── */
:root {
  --r-xl: 34px;
  --r-lg: 28px;
  --r-md: 22px;
  --r-sm: 16px;

  /* ═══ СЕРЕБРО — МАТЕРИАЛ, А НЕ КРАСКА ══════════════════════════════════
     Логотип Voron — серебряный ворон в щите на #01050c. Отсюда вся палитра:
     фоном служит цвет логотипа, а акцентом — металл. Синего (#0990DE) в
     приложении больше нет: он был цветом движка, доставшегося от Scale.

     Ступени --ag-* ОБЩИЕ ДЛЯ ОБЕИХ ТЕМ. Это металл, а не «цвет темы»: у
     серебра нет светлой и тёмной версии, есть только разное освещение. Всё,
     что меняется между темами, — фон под ним и НАКЛОН ПЛИТЫ (--metal). */
  --ag-0: #FFFFFF;                /* блик */
  --ag-1: #E9EEF6;
  --ag-2: #C9D2E0;                /* основное серебро: иконки, цифры, кромки */
  --ag-3: #A3AEC0;
  --ag-4: #7C8799;
  --ag-5: #5A6476;
  --ag-6: #3D4657;
  --ag-7: #262E3D;                /* самая глубокая тень металла */

  /* ── ПЛИТА (--metal) — главная кнопка и всё «первичное» ──────────────────
     Восемь стоп, а не две: у настоящего брашированного металла свет идёт
     полосами — блик, спад, тень, снова блик. Двухстоповый градиент читается
     как пластик, а не как металл, — ровно этим и отличалась старая синяя
     кнопка. Угол 148° повторяет наклон блика на самом логотипе.

     В СВЕТЛОЙ теме плита ТЁМНАЯ (полированная сталь), в тёмной — СВЕТЛАЯ
     (хром). Кнопка обязана быть контрастнее фона, а не «того же цвета,
     только металлического»: на белом хром сливается с бумагой. Материал один,
     освещение разное — как и должно быть у металла. */
  --metal: linear-gradient(148deg,
      #7A8698 0%, #4A5566 12%, #262E3C 28%, #6E7A8C 40%,
      #39434F 54%, #1B222D 70%, #5D6979 86%, #2B333F 100%);
  --metal-fg: #F4F7FC;            /* текст и иконки НА плите */
  /* Цифры и заголовки, залитые металлом через background-clip: text.
     Вертикальный, а не диагональный: строка текста узкая, диагональ на ней
     читается как грязь. */
  --metal-text: linear-gradient(180deg, #59647A 0%, #2B3444 45%, #10161F 100%);
  /* Световая кромка: 1px-полоса поперёк верха плиты — «свет лёг на грань».
     Прозрачная по краям, иначе видно, где она кончается. */
  --metal-edge: linear-gradient(90deg,
      transparent, rgba(255,255,255,.55) 20%, rgba(255,255,255,.9) 50%,
      rgba(255,255,255,.5) 80%, transparent);

  /* Совместимость: 34 правила (включая админку и веб-версию) написаны на
     --acc-1 / --grad-acc / --grad-acc-soft. Не переписываем их поимённо —
     переопределяем сам акцент, и синий уходит из приложения разом. */
  /* 🚨 СТУПЕНИ --ag-* ОБЩИЕ, НО «СЕРЕБРО НА ЭКРАНЕ» — НЕТ, И ЭТО НЕ ОПЕЧАТКА.
     Заливка металлом (--metal) одна на обе темы: это материал. А вот серебро в
     роли ЧЕРНИЛ — цвет иконки, обводки, кромки — обязано переворачиваться.
     Проверено на светлой теме: --ag-2 (#C9D2E0) на белой карточке не виден
     вовсе, иконки исчезли, чип «Активна» превратился в пустую капсулу.
     Отсюда пара семантических токенов: --ag-ink (чернила) и --ag-rim (грань).
     Правило простое: ЗАЛИВКА берёт --metal и --ag-*, ТЕКСТ и ГРАНЬ — эти два. */
  --ag-ink: #3D4657;                    /* тёмная грань металла на светлом фоне */
  --ag-rim: rgba(8, 16, 34, .20);

  --acc-1: var(--ag-5);
  --acc-2: var(--ag-6);
  --grad-acc: var(--metal);
  --grad-acc-soft: var(--surface-2);   /* плашки под иконками — нейтральный графит */

  --gold-grad: linear-gradient(135deg, #FFDF97 0%, #F7BE45 100%);
  --silver-grad: linear-gradient(135deg, #F0F3F9 0%, #C4CEDD 100%);
  --bronze-grad: linear-gradient(135deg, #F2C9A0 0%, #D99A5B 100%);

  /* Единственные три цвета, пережившие переход в монохром: они несут смысл,
     а не бренд. Подтянуты в холодную сторону, чтобы не спорить с серебром. */
  --ok: #2FBF8B;
  --warn: #E3A340;
  --bad: #E4566E;

  /* ── СВЕТ ВОРОНА ────────────────────────────────────────────────────────
     Единственный цвет, вернувшийся в монохром, и он не краска, а СВЕЧЕНИЕ.
     Снят пипеткой с самого маскота (`images/support-menu.jpg`, светящиеся
     линии брони и радужка): медиана #45B9D5, яркое ядро #5DE9EE — отсюда
     середина. Тем же цветом светится он сам на картинке, поэтому ореол под
     ним и его собственное свечение — один и тот же свет, а не два похожих.

     🚨 ЭТИМ НЕЛЬЗЯ ЗАЛИВАТЬ КНОПКИ И ПЛАШКИ. Серебро остаётся материалом
     действия (решение владельца 23.08, `claude/MINIAPP.md` §4д). Цвет живёт
     только там, где на экране физически есть маскот: это ореол ПОД вороном и
     больше ничего. Токен один, потому что применение одно — второй (сплошной
     #4FD3E4) завёлся бы «на будущее» и остался бы неиспользованным.

     Альфа зашита в значение, а не собирается через color-mix() из чистого
     цвета: вебвью Telegram на старых Android его не знает, и свет просто не
     нарисовался бы. */
  --voron-bloom: rgba(79, 211, 228, .30);

  --sheet-top: 10px;   /* просвет над раскрытой на весь экран шторкой */
  --backdrop: rgba(12, 15, 30, .4);                 /* затемнение под шторкой */
  --sheet-shadow: 0 -12px 44px rgba(20, 28, 66, .20); /* тень над шторкой */
  /* Прогресс раскрытия шторки (0..1). Здесь — значение по умолчанию, чтобы
     calc(... * var(--x)) в компонентах работал и вне шторок; внутри .sheet его
     покадрово переписывает жест. */
  --x: 0;

  /* ── СВЕТЛАЯ ТЕМА: «платина» ────────────────────────────────────────────
     Фон СЕРЫЙ, а не белый, и это не вкусовщина: серебряная кромка и серебряный
     текст на чистом белом не видны вовсе — не хватает контраста, чтобы металл
     прочитался как металл. Холодный светло-серый даёт карточкам (белым) чем
     подняться, а серебру — на чём проявиться. */
  --bg: #EDF0F6;
  --surface: #FFFFFF;
  --surface-2: #E7EBF3;
  --surface-3: #DBE1EC;          /* нажатие, вложенный уровень */
  /* Плейсхолдеры заметно темнее surface-2: на белом фоне светлой темы скелетоны
     на нём почти не читались. Блик — отдельной переменной, он разной силы по темам. */
  --sk: #DEE3EC;
  --sk-shine: rgba(255, 255, 255, .62);
  --text: #080C14;
  --text-2: #5C667A;
  --text-3: #8B95A8;
  --line: rgba(8, 16, 34, .09);
  /* Гравировка: разделы подписаны не «жирным потемнее», а мелкой разрядкой в
     верхнем регистре — как маркировка, выбитая на металле. Одна пара токенов
     на обе темы, чтобы надпись везде читалась одинаково тихо. */
  --engrave: var(--text-3);

  --shadow-soft: 0 10px 30px rgba(12, 22, 48, .08), 0 2px 6px rgba(12, 22, 48, .05);
  --shadow-float: 0 20px 48px rgba(12, 22, 48, .15), 0 4px 12px rgba(12, 22, 48, .07);
  /* Фаска карточки: тонкая светлая грань сверху. В тёмной теме она — ЕДИНСТВЕННОЕ,
     чем карточка отличается от фона (тени на почти-чёрном не видно вовсе), в
     светлой просто добавляет объёма. Поэтому это отдельный токен, а не часть тени. */
  --bevel: inset 0 1px 0 rgba(255, 255, 255, .9);

  /* ═══ LIQUID GLASS (таббар) ═══
     Прозрачный центр + лёгкий blur с высокой насыщенностью (контент читается
     сквозь стекло, а не тонет в «морозе»), объёмная фаска и спекулярное
     кольцо по кромке. Только широко поддерживаемый CSS — одинаково в
     WebKit/Chromium; для WebView без backdrop-filter есть --glass-fallback. */
  --glass-surface: rgba(255, 255, 255, 0.22);
  /* Центр стекла: почти прозрачный, лёгкое преломляющее «дрожание» */
  --glass-filter: blur(2.5px) saturate(1.8) brightness(1.04);
  /* Кромка-линза (фолбэк WebKit, где SVG-преломление невозможно):
     кольцо с сильным blur + подсветкой — толстый край стекла, собирающий свет */
  /* ТОЛЬКО blur, без saturate/brightness: кромка обязана быть ТОГО ЖЕ ЦВЕТА, что
     и центр — иначе кольцо видно как светлую полосу-контур. Цвет/насыщенность
     уже дал сам элемент (--glass-filter), а кромка лишь СИЛЬНЕЕ преломляет
     (больше blur). Так виден варп, а не граница. */
  /* ⚠️ ЭТИ ДВЕ РУЧКИ И ЕСТЬ «варп кромки» НА iPHONE.
     SVG-преломление (liquid-glass.js) рендерит ТОЛЬКО настоящий Chromium, а
     Telegram на iOS — это WKWebView. Там всю дисторсию даёт ::before: маска
     градиентом + этот blur. Значит менять надо здесь; правка bezel/thickness в
     liquid-glass.js на айфоне не изменит НИЧЕГО (она для Android/Desktop).
       --glass-rim-w      — ШИРИНА полосы преломления, тот самый «диапазон»;
       --glass-rim-filter — СИЛА размытия внутри неё.
     iOS 26 гнёт фон заметно шире, поэтому полоса поднята 14 → 22px, а blur
     13 → 18px. Обе цифры подобраны на глаз и правятся независимо: «слишком
     широко» и «слишком сильно» — разные жалобы. */
  --glass-rim-filter: blur(18px);
  --glass-rim-w: 22px;
  /* ⚠️ ЭТО ЗНАЧЕНИЕ ДЛЯ МЕЛКИХ ПАНЕЛЕЙ И ВЫШЕ ПОДНИМАТЬ НЕЛЬЗЯ.
     Кромка съедает элемент С ОБЕИХ сторон, поэтому плоская середина = высота −
     2×rim. У админского переключателя высота 46px: при 22px середины остаётся
     2px, то есть он уже почти весь под вуалью. На 26px её не остаётся вовсе, и
     стекло «мутнеет» — ровно то, из-за чего откатывали прошлую попытку.
     Таббар выше (72px) и запас у него есть, поэтому ему ручка своя, ниже. */
  --glass-outer:
      0 2px 6px rgba(24, 34, 84, .07),
      0 14px 34px rgba(24, 34, 84, .13);
  --glass-bezel:  /* фаска: светлый свод сверху, глубина снизу, свечение внутрь */
      inset 0 1.5px 3px -1px rgba(255, 255, 255, .65),
      inset 0 -2px 4px -2px rgba(24, 34, 84, .18),
      inset 0 0 14px rgba(255, 255, 255, .12);
  --glass-rim: rgba(255, 255, 255, .85);   /* спекулярная дуга сверху */
  --glass-rim-2: rgba(255, 255, 255, .40); /* эхо-дуга снизу */
  --glass-fallback: rgba(255, 255, 255, .88);
  /* Капля-линза: полупрозрачная стеклянная плашка. «Стеклянность» держится на
     СОБСТВЕННОЙ заливке, кромке и тени (см. .drop-glass), а не на backdrop-filter
     — вложенный backdrop-filter капли в WebKit не работает. Грани тонкие. */
  --drop-shadow:
      inset 0 1px 1px rgba(255, 255, 255, .75),
      inset 0 -1px 1.5px rgba(255, 255, 255, .35),
      inset 0 0 0 1px rgba(255, 255, 255, .22),
      0 2px 5px rgba(24, 34, 84, .07),
      0 8px 20px rgba(24, 34, 84, .09);

  --tabbar-h: 72px;
  --safe-b: env(safe-area-inset-bottom, 0px);
  --safe-t: env(safe-area-inset-top, 0px);

  /* ── Единая пружина шторок ────────────────────────────────────────────────
     Одна кривая на ВСЁ, что связано со шторками: выезд самой шторки, масштаб
     главной под ней, затемнение, доводка после перетаскивания и разжимание
     админ-панели. Раньше это был cubic-bezier(.22,.9,.28,1) — обычный ease-out;
     теперь настоящая пружина Apple `Spring(duration: .55, bounce: 0)`, ТА ЖЕ
     математика, что в js/motion.js (чтобы движение, которое ведёт JS, и
     движение, которое ведёт CSS, были неотличимы).

     Почему 0.47s, если «длительность» пружины 0.40s: у Apple duration — это
     ВОСПРИНИМАЕМОЕ время, а не время до полной остановки. Хвост экспоненты
     срезан на 99.5% пути (остаток — меньше 2px на 400px-шторке), и получившееся
     реальное время хода — 0.47s. К 0.30s пройдено уже 96% пути, поэтому
     ощущается заметно быстрее, чем говорит число.

     ФОРМА КРИВОЙ ОТ ДЛИТЕЛЬНОСТИ НЕ ЗАВИСИТ (при bounce 0 это чистое
     масштабирование по времени: x зависит только от ω·t), поэтому точки linear()
     ниже переписывать НЕ НУЖНО — скорость правится ОДНИМ значением здесь.
     Связь: реальное время = воспринимаемое × 1.183.
     История: .42s (старый ease-out) → .652s (медленно) → .54s (всё ещё) → .47s.
     Шаг меньше ~10% на глаз не читается — мельчить смысла нет. */
  --dur-sheet: .47s;
  /* Навигация админки и складывание дока. Кривая та же `--ease-spring`: при
     bounce 0 форма пружины НЕ ЗАВИСИТ от длительности (чистое масштабирование по
     времени), поэтому одни и те же точки linear() годятся для любого хода.
     --dur-page ДЕРЖАТЬ РАВНЫМ ADM_ANIM_MS в app.js. */
  --dur-page: .40s;
  --dur-dock: .46s;
  /* Switch. Two animations, deliberately NOT the same one.

     The KNOB is positional — it has mass, so it gets the spring
     (`--ease-spring`, bounce 0) exactly like navigation does.

     The TRACK COLOUR is not positional. A colour crossfade has no momentum,
     and driving it with a position spring is a category error: `--ease-spring`
     is roughly half-done by 35% of its duration and then crawls, so the fill
     lurched ahead of the knob and then hung there. It now gets its own, plainer
     ease-out and finishes slightly BEFORE the knob lands, which reads as the
     knob pushing the fill rather than the two racing.

     No bounce on the knob: the track has no `overflow: hidden` (clipping would
     eat the knob's shadow), so any overshoot would visibly poke the pill out
     past the end of the track. */
  --dur-switch: .3s;
  --dur-switch-fill: .2s;
  --ease-switch-fill: cubic-bezier(.4, 0, .2, 1);
  /* While a finger is dragging the knob, the fill must keep up with it —
     anything slower lags visibly behind the pill. `linear` is a valid
     CAMediaTimingFunction, so this stays compositor-accelerated. */
  --dur-switch-drag: .06s;
  /* Off-track fill. Light theme keeps a white knob, so the groove has to be
     visible on its own rather than relying on the knob's shadow. */
  --sw-off: rgba(12, 22, 48, .22);
  /* ⚠️ ЗДЕСЬ ДОЛЖЕН БЫТЬ cubic-bezier, А НЕ linear(). ЭТО И ЕСТЬ ПРИЧИНА 60 Гц.
     На ProMotion в 120 Гц идут только УСКОРЕННЫЕ (композиторные) анимации;
     остальная страница обновляется в 60. Ускорить анимацию Core Animation может
     лишь тогда, когда её кривую удаётся выразить через `CAMediaTimingFunction`,
     а это линейная, ease-* и КУБИЧЕСКАЯ БЕЗЬЕ — и только.
     `linear()` с девятнадцатью точками так выразить нельзя, поэтому WebKit
     считал такую анимацию на основном потоке, то есть в 60 Гц. Прокрутка при
     этом шла в 120 (её ведёт нативный скроллер), отчего разница и бросалась в
     глаза.

     Кривая ниже — приближение той же критически задемпфированной пружины,
     подобранное перебором по сетке контрольных точек: **макс. отклонение 2.5 %**
     (для сравнения: `.33,1,.35,1` даёт 13 %, прежняя `.22,.9,.28,1` — 23 %).
     Проверено, что x(t) строго монотонна (мин. наклон 0.43) — иначе кривая
     сложилась бы сама в себя и анимация бы дёргалась; и что обе y-точки ≤ 1,
     то есть перелёта за конечное положение нет (иначе въезжающий экран показал
     бы полоску того, что под ним).

     НЕ МЕНЯТЬ на linear() ради точности формы: цена — вдвое меньшая частота. */
  --ease-spring: cubic-bezier(.35, .5, .1, .94);

  color-scheme: light;
}

/* Точная пружина — ТОЛЬКО для сравнения на устройстве (панель диагностики
   переключает `data-easing` на <html>). Форма честнее, но анимация перестаёт
   быть ускоренной и падает в 60 Гц — см. предупреждение у `--ease-spring`.
   В бою не использовать; оставлено, чтобы разницу можно было увидеть, а не
   обсуждать на словах. */
:root[data-easing="precise"] {
  --ease-spring: linear(0, 0.0662, 0.2032, 0.3543, 0.4955, 0.6157, 0.7129,
    0.789, 0.847, 0.8902, 0.9224, 0.946, 0.963, 0.9753, 0.9841, 0.9903,
    0.9947, 0.9978, 1);
}

/* ── ТЁМНАЯ ТЕМА: «обсидиан» ──────────────────────────────────────────────
   Фон взят от цвета, на котором живёт логотип (#01050c), и поднят на пару
   пунктов светлоты по просьбе владельца. Это не чёрный, а очень тёмный
   сине-зелёный (H≈218°), и вся лестница поверхностей идёт в ТОМ ЖЕ тоне,
   теряя насыщенность по мере подъёма. Если
   держать насыщенность — интерфейс уходит в синеву, а нужен графит, на
   котором серебро выглядит серебром.

   Прошлая тема была нейтрально-серой (#191919/#232323) — на восемь ступеней
   светлее и без единой ноты бренда. Меняем только токены, компоненты
   перекрашиваются сами. ─────────────────────────────────────────────────── */
:root[data-theme="dark"] {
  /* ⚠️ ФОН ЧУТЬ СВЕТЛЕЕ ПОДЛОЖКИ ЛОГОТИПА, И ЭТО РЕШЕНИЕ ВЛАДЕЛЬЦА (23.08).
     Исходно здесь стоял #01050c — цвет из логотипа дословно, L≈2.5%. На нём
     приложение читалось почти как OLED-чёрное. Вся лестница поднята примерно на
     2.4 пункта светлоты с СОХРАНЕНИЕМ шагов между ступенями: поднять один --bg
     было нельзя, он подошёл бы вплотную к --surface (3% разницы) и карточки
     перестали бы отделяться от фона вовсе. Тон прежний, H≈218°. */
  --bg: #050A14;                 /* подложка логотипа + 2.4 пункта светлоты */
  --surface: #0B1220;            /* карточка поднимается на 3.5%, не больше */
  --surface-2: #131B2A;          /* вложенная плашка, инпут, плитка */
  --surface-3: #1B2436;          /* нажатие */
  /* Затемнение и тень шторки — НЕЙТРАЛЬНО-ЧЁРНЫЕ: синеватые из светлой темы
     подсинивали фон главной под шторкой */
  --backdrop: rgba(0, 0, 0, .62);
  --sheet-shadow: 0 -12px 44px rgba(0, 0, 0, .6);
  --sk: #131B2A;                        /* плейсхолдеры чуть светлее поверхности */
  --sk-shine: rgba(200, 220, 255, .06);
  --text: #E7ECF4;                      /* НЕ чистый белый: на почти-чёрном он слепит */
  --text-2: #8B96A9;
  --text-3: #586274;
  --line: rgba(190, 208, 236, .09);
  --engrave: var(--text-3);

  /* 🚨 НА ПОЧТИ-ЧЁРНОМ ФОНЕ ТЕНЬ НЕ РАБОТАЕТ. Карточка #0B1220 на фоне #050A14
     отличается на 3% светлоты, и «мягкая тень снизу» на таком фоне физически
     нечем нарисоваться — под чёрным нет ничего темнее. Границу держит ФАСКА:
     светлая волосяная линия по верхней грани, как свет, легший на кромку.
     Именно она, а не тень, отделяет карточку от фона в этой теме. */
  --shadow-soft: 0 8px 26px rgba(0, 0, 0, .55);
  --shadow-float: 0 22px 52px rgba(0, 0, 0, .72);
  --bevel: inset 0 1px 0 rgba(226, 236, 250, .07);

  /* Плита в тёмной теме — ХРОМ: светлая, с тёмным текстом. Обратная светлой
     теме по светлоте и та же по материалу (см. --metal в :root). */
  --metal: linear-gradient(148deg,
      #FDFEFF 0%, #DDE5F0 11%, #A6B1C4 25%, #F1F5FB 38%,
      #C1CBDA 51%, #8A95A8 67%, #E2E9F3 83%, #A8B3C5 100%);
  --metal-fg: #050A12;
  --metal-text: linear-gradient(180deg, #FFFFFF 0%, #D5DDEA 45%, #9BA6B9 100%);

  --grad-acc-soft: var(--surface-2);

  /* Стекло в тёмной теме — БЕЗ белой графики: объём дают тёмная глубина,
     преломление фона (backdrop-filter) и едва заметная нейтральная кромка. */
  /* ⚠️ ОПТИКА СТЕКЛА НЕ ТРОНУТА (решение владельца: ликвид гласс оставляем как
     есть) — blur, saturate, фаска, кромка, тени ниже те же, что были. Сдвинут
     только ТОН заливки и фолбэка: они были привязаны к старому фону #191919, и
     на #01050c нейтральное rgba(26,26,26) читалось как светло-серая полоса
     поперёк экрана. Светлота и альфа сохранены один в один, изменён лишь оттенок. */
  /* Тон подтянут к обсидиану по просьбе владельца: было `rgba(11,18,32,.45)`
     (ровно `--surface`), и на фоне #050A14 полоса читалась светловатой —
     её ещё поднимает `brightness(1.1)` в фильтре. Ушли ближе к `--bg` и чуть
     добавили синевы.
     ⚠️ ОПТИКА НЕ ТРОНУТА (решение владельца, `MINIAPP.md` §4д): blur, saturate,
     brightness, фаска, кромка и тени — те же. Изменён только ЦВЕТ заливки. */
  --glass-surface: rgba(7, 13, 27, 0.52);
  --glass-filter: blur(3px) saturate(1.45) brightness(1.1);
  --sw-off: #232C3C;                /* dark: the knob is grey here, contrast is already 5.2:1 */
  --glass-rim-filter: blur(18px);   /* только blur — кромка того же цвета, что центр (см. светлую тему) */
  --glass-outer:
      0 2px 6px rgba(0, 0, 0, .3),
      0 14px 34px rgba(0, 0, 0, .38);
  --glass-bezel:
      inset 0 4px 8px -4px rgba(0, 0, 0, .45),
      inset 0 -4px 8px -4px rgba(0, 0, 0, .3);
  --glass-rim: rgba(255, 255, 255, .09);   /* только тонкая кромка, не блик */
  --glass-rim-2: rgba(255, 255, 255, .04);
  --glass-fallback: rgba(9, 15, 30, .95);

  /* Капля в тёмной теме: без светлого контура (по просьбе) — только мягкая
     тень для объёма. Видимость даёт полупрозрачная заливка + зажжённая вкладка
     под каплей (вариант A). Раньше тут были светлые inset-блик и 1px-кромка. */
  --drop-shadow:
      0 2px 6px rgba(0, 0, 0, .22),
      0 6px 16px rgba(0, 0, 0, .2);

  --ag-ink: #C9D2E0;                    /* на обсидиане серебро светлое */
  --ag-rim: rgba(190, 208, 236, .20);

  color-scheme: dark;
}

/* ── База ── */
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }

html, body { height: 100%; }
/* Фон и на КОРНЕ (не только на body): корень красится первым, поэтому тема
   применяется мгновенно и до отрисовки body — без белой вспышки на запуске. */
html { background: var(--bg); }

body {
  font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  background: var(--bg);
  color: var(--text);
  /* NEVER taller than the visible viewport — see the note above #app. */
  min-height: 100vh;                                              /* old engines */
  min-height: min(var(--tg-viewport-stable-height, 100vh), 100dvh);
  overflow-x: hidden;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  user-select: none;
  /* 🚨 БЕЗ transition на background/color — он тут был и противоречил тому, что
     написано в шапке файла. Смена темы идёт через View Transitions: браузер
     кроссфейдит ВЕСЬ кадр разом. Там, где их нет (WebKit до 18), шапка прямо
     называет запасной путь «мгновенным переключением: некрасиво, но синхронно».
     А `transition: background .35s` превращал его ровно в то, от чего уходили:
     фон полз треть секунды, пока карточки, тени и стекло щёлкали в первом кадре.
     На пути с View Transitions он тоже мешал — тянулся ещё 50 мс после того, как
     кроссфейд (300 мс) уже закончился.
     @supports сюда добавлять НЕЛЬЗЯ: JS проверяет наличие
     document.startViewTransition, а @supports проверял бы view-transition-name —
     две разные проверки, которые могут разойтись. */
}

/* ── No scrollbar track. Anywhere. ────────────────────────────────────────────
   This is a phone UI: phones scroll, they do not draw a bar down the side. The
   web build already did this for its frame; the rule now applies everywhere,
   including inside Telegram, because a visible track there was both ugly and
   the source of two real layout bugs.

   It REPLACES `scrollbar-gutter: stable`, which used to sit on body. That was a
   workaround for a width jump: opening a sheet sets `overflow: hidden`, the
   scrollbar vanished, the page got wider and the background twitched (the
   profile name gained a letter before its «…», buttons slid). Reserving a
   permanent dead strip fixed the twitch by paying for it on every screen.

   With a zero-width track there is nothing to reserve and nothing to lose: the
   page cannot change width when a scrollbar appears or disappears, because one
   never occupies space in the first place. That also removes the shift measured
   during loading, where the viewport went 500px -> 485px the moment the content
   got tall enough to scroll.

   Scrolling itself is untouched — only the track is hidden. `scrollbar-width` is
   the standard property (and is NOT inherited, hence the universal selector);
   the ::-webkit rule covers Safari and Chrome before 121. */
* { scrollbar-width: none; }
::-webkit-scrollbar { width: 0; height: 0; }

.svg-defs { position: absolute; width: 0; height: 0; overflow: hidden; }

.icon {
  width: 24px; height: 24px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
  flex: none;
}

/* touch-action: manipulation — убирает ожидание двойного тапа: браузер
   отдаёт click мгновенно, нажатия ощущаются моментальными */
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; touch-action: manipulation; }
input { font: inherit; color: inherit; }

/* ═══ LIQUID GLASS ═══
   Слои (все — детерминированный CSS, одинаковый в WebKit и Chromium):
   1) сам элемент: почти прозрачная подложка + backdrop-filter (лёгкий blur,
      высокая насыщенность — контент «живёт» под стеклом) + наружная тень;
   2) ::before — фаска: светлый свод сверху, глубина снизу (объём линзы);
   3) ::after — кольцо кромки: симметричные спекулярные дуги сверху/снизу.
   overflow:hidden клипует blur по радиусу (лечит квадратный blur на части
   Android WebView — главный источник «на устройствах по-разному»). */

/* Таббар — самая крупная стеклянная панель (72px), и именно её варп владелец
   оценивает. Своя, более широкая кромка: 30px оставляет 12px плоской середины
   (72 − 2×30), стекло не мутнеет. Выше 32px середина схлопывается.
   Мелкие панели (`.lbseg` 54px, `.adm-lbseg` 46px) остаются на глобальных 22px —
   им шире физически некуда. */
.tabbar {
  --glass-rim-w: 30px;
  --glass-rim-filter: blur(24px);
}

.liquid-glass {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  border-radius: var(--r-xl);
  background: var(--glass-surface);
  -webkit-backdrop-filter: var(--glass-filter);
  backdrop-filter: var(--glass-filter);
  box-shadow: var(--glass-outer);
}
/* Кромка-линза: преломление НАРАСТАЕТ к краю и ПЛАВНО СХОДИТ НА НЕТ к центру.
   Почему именно градиентная маска: раньше кольцо вырезалось жёсткой маской
   (content-box XOR) — на стыке размытого кольца и резкого центра получалась
   РЕЗКАЯ ЛИНИЯ, и её было видно как контур («края видны»), даже когда цвет
   кольца совпал с центром. Градиент убирает стык: blur втекает от кромки внутрь
   без единой границы. Две линейки (по X и по Y) объединяются (mask-composite:
   add) — получается мягкая рамка по всему периметру.
   БЕЗ box-shadow: var(--glass-bezel) — это нарисованная фаска, т.е. буквально
   видимый контур; оставляем чистое преломление. */
.liquid-glass::before {
  content: '';
  position: absolute; inset: 0;
  border-radius: inherit;
  pointer-events: none;
  z-index: 2;
  -webkit-backdrop-filter: var(--glass-rim-filter);
  backdrop-filter: var(--glass-rim-filter);
  -webkit-mask:
    linear-gradient(to right, #000, transparent var(--glass-rim-w), transparent calc(100% - var(--glass-rim-w)), #000),
    linear-gradient(to bottom, #000, transparent var(--glass-rim-w), transparent calc(100% - var(--glass-rim-w)), #000);
  -webkit-mask-composite: source-over;
  mask:
    linear-gradient(to right, #000, transparent var(--glass-rim-w), transparent calc(100% - var(--glass-rim-w)), #000),
    linear-gradient(to bottom, #000, transparent var(--glass-rim-w), transparent calc(100% - var(--glass-rim-w)), #000);
  mask-composite: add;
}
/* Кольцо кромки: conic-градиент маскируется в рамку толщиной ~1.2px.
   Ярче в зените, мягкое эхо в надире, прозрачно по бокам — симметрично. */
.liquid-glass::after {
  content: '';
  position: absolute; inset: 0;
  border-radius: inherit;
  pointer-events: none;
  z-index: 2;
  padding: 1.2px;
  background: conic-gradient(
      var(--glass-rim) 0%,
      transparent 16%,
      transparent 34%,
      var(--glass-rim-2) 50%,
      transparent 66%,
      transparent 84%,
      var(--glass-rim) 100%);
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
          mask-composite: exclude;
}
/* WebView без backdrop-filter: плотная подложка вместо прозрачной,
   чтобы стекло выглядело максимально похоже, а не исчезало */
@supports not ((backdrop-filter: blur(2px)) or (-webkit-backdrop-filter: blur(2px))) {
  .liquid-glass { background: var(--glass-fallback); }
}
/* Кромка-линза (::before, преломляющий blur по краю) остаётся ВСЕГДА — это
   надёжное преломление кромки на ЛЮБОМ движке. Раньше её гасили на .lg-live,
   надеясь на SVG-фильтр в backdrop-filter, но он рендерится только в НАСТОЯЩЕМ
   Chromium — во встроенных вебвью Telegram (Qt/WebView) SVG в backdrop-filter
   часто не работает, и тогда у панели НЕ БЫЛО искажения кромки вообще («нет
   дисторшна на границах»). Теперь ::before всегда рисует край, а SVG (где реально
   работает) добавляется поверх бонусом. Гасим только тонкое conic-кольцо (::after)
   — на части устройств оно «лезло» жёсткой линией. */
.liquid-glass.lg-live::after,
.liquid-glass.lg-static::after { display: none; }
/* ── Каркас ── */
/* ⚠️ `--tg-viewport-stable-height` is NOT a safe min-height on its own.

   It is Telegram's number, and on Telegram Desktop it can come back LARGER than
   the webview actually shows — notably after the window is resized, where it is
   not lowered back. Used bare, it made both #app and body taller than the window
   by that difference, and the page got a stretch of scroll holding NOTHING: pure
   min-height, no content. On a Mac that read as «the scrollbar moves, the page
   stands still» (measured 2026-07-29).

   `min(…, 100dvh)` keeps Telegram's value while it is sane and clamps it to the
   real visible height when it is not. Clamping DOWN is safe: the background is
   painted on `html` too, so a short #app still cannot show a white edge.
   The plain `100vh` line above it is the fallback for engines without dvh/min(). */
#app {
  position: relative;
  z-index: 1;
  max-width: 480px;
  margin: 0 auto;
  min-height: 100vh;                                              /* old engines */
  min-height: min(var(--tg-viewport-stable-height, 100vh), 100dvh);
  display: flex;
  flex-direction: column;
}

/* ── Шапка главной: профиль слева, кнопка темы справа (не закреплены) ── */
.home-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.theme-btn {
  width: 56px; height: 56px; flex: none;
  border-radius: 50%;
  display: grid; place-items: center;
  color: var(--ag-ink);
  background: var(--surface);
  box-shadow: var(--shadow-soft), var(--bevel);
  transition: transform .16s ease, color .2s ease;
}
.theme-btn:active { transform: scale(.9); }
.theme-btn .icon { width: 22px; height: 22px; stroke-width: 1.9; }
/* Мягкая смена иконки при переключении.
   🚨 ТОЛЬКО по классу, который вешает applyTheme(animate = true). На базовом
   правиле анимация проигрывалась заново КАЖДЫЙ раз, когда renderHome()
   пересобирал кнопку, — то есть на любом возврате в приложение, любом обновлении
   состояния и любой смене языка, при неизменной теме. */
.theme-btn .icon.swap-in { animation: theme-swap .3s cubic-bezier(.34,1.56,.64,1); }
@keyframes theme-swap {
  from { transform: rotate(-40deg) scale(.6); opacity: 0; }
  to   { transform: none; opacity: 1; }
}
/* Тот же селектор, что выше: `.theme-btn .icon` (0,2,0) проиграл бы
   `.theme-btn .icon.swap-in` (0,3,0), и просьба уменьшить движение молча
   перестала бы действовать. */
@media (prefers-reduced-motion: reduce) { .theme-btn .icon.swap-in { animation: none; } }

/* ── Профильная «таблетка» (главная, слева сверху, скроллится с контентом) ── */
.profile-chip {
  display: inline-flex; align-items: center; gap: 12px;
  min-width: 0;
  padding: 7px 15px 7px 7px;
  border-radius: 100px;
  background: var(--surface);
  box-shadow: var(--shadow-soft), var(--bevel);
  transition: transform .16s ease;
  max-width: calc(100% - 134px);   /* две круглые утилиты справа: 56+10+56 + зазор 12 */
  flex: 0 1 auto;
}
.profile-chip:active { transform: scale(.97); }
.profile-ava {
  width: 42px; height: 42px; flex: none;
  border-radius: 50%;
  overflow: hidden;
  display: grid; place-items: center;
  font-weight: 800; font-size: 17px; color: var(--metal-fg);
  background: var(--metal);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.5);
}
.profile-ava img { width: 100%; height: 100%; object-fit: cover; display: block; grid-area: 1 / 1; opacity: 0; transition: opacity .45s ease; }
.profile-ava img.on { opacity: 1; }

/* ── Утилиты шапки: «О нас» и тема — два круга справа ──────────────────────
   Раньше «О нас» была ШИРОКОЙ пилюлей с текстом и занимала треть шапки,
   оставаясь при этом наименее важной кнопкой на экране. Теперь шапка читается
   как «ты + два инструмента»: имя слева забирает всё освободившееся место.
   🚨 Размеры зеркалит скелетон (index.html + renderHomeSkeleton) — менять втроём. */
.head-tools { display: flex; align-items: center; gap: 10px; flex: none; }
.head-btn {
  width: 56px; height: 56px; flex: none;
  border-radius: 50%;
  display: grid; place-items: center;
  color: var(--ag-ink);
  background: var(--surface);
  box-shadow: var(--shadow-soft), var(--bevel);
  transition: transform .16s ease, color .2s ease;
}
.head-btn:active { transform: scale(.9); }
.head-btn .icon { width: 22px; height: 22px; stroke-width: 1.9; }

/* ── Гравировка ───────────────────────────────────────────────────────────
   Подпись раздела: мелкая разрядка в верхнем регистре, как маркировка на
   металле. Заменила «жирный заголовок потемнее» — тот спорил с содержимым
   карточки за первое место, а подпись обязана быть тише того, что подписывает. */
.engrave {
  display: flex; align-items: center; gap: 7px;
  font-size: 10.5px; font-weight: 800;
  letter-spacing: .14em; text-transform: uppercase;
  color: var(--engrave);
}
/* Иконок в гравировке НЕТ намеренно: на 10.5px строке иконка выходит 14px и
   любой штриховой глиф превращается в кляксу (проверено на i-key — читался как
   «σ»). Подпись держится одной типографикой, ей этого достаточно. */
.card > .engrave:first-child { margin-bottom: 12px; }

/* ── Полоса действий: три кнопки, ВЫФРЕЗЕРОВАННЫЕ ИЗ ОДНОЙ ПЛАСТИНЫ ────────
   Были три отдельные плитки с зазорами — три предмета там, где смысл один
   («что сделать с ключом»). Теперь одна плашка, разделённая волосяными
   линиями: тот же приём, что у полосок на металле. */

/* ── Карточка-список: несколько строк на одной подложке ───────────────────
   «Прокси» и «Поддержка» были двумя отдельными карточками — два острова с
   зазором, хотя это один список второстепенных разделов. */

/* ── Скелетоны загрузки главной + плавное появление контента ─────────────── */
.sk { display: block; background: var(--sk); border-radius: 16px; position: relative; overflow: hidden; }
.sk::after {
  content: ''; position: absolute; inset: 0; transform: translateX(-100%);
  background: linear-gradient(90deg, transparent, var(--sk-shine), transparent);
  animation: sk-shimmer 1.25s infinite;
}
@keyframes sk-shimmer { to { transform: translateX(100%); } }
/* Размеры плейсхолдеров = реальным блокам главной; отступы даёт gap:14px экрана,
   поэтому margin у скелетонов НЕ ставим (иначе двойной интервал и «съезд»). */
/* ⚠️ Kept BYTE-FOR-BYTE in step with the copy in index.html's inline <style> —
   that one paints the first frame before this file has arrived, this one takes
   over afterwards, and any difference between them IS a visible jump.
   They mirror .profile-chip / .head-btn / .theme-btn including flex behaviour;
   see the note there for the measurements.

   🚨 ШАПКА ПЕРЕСОБРАНА: раньше в ряду было ТРИ элемента — чип имени, широкая
   резиновая пилюля «О нас» и круглая кнопка темы, и весь фокус скелетона был в
   том, чтобы правильно растянуть среднюю (замеренное расхождение — 726px). Её
   больше нет: «О нас» стала круглой кнопкой, обе утилиты лежат в .head-tools и
   имеют собственный фиксированный размер. Резиновых элементов в шапке не
   осталось, поэтому и особый случай ниже снят вместе с ними. */
.sk-chip  { width: 176px; max-width: calc(100% - 134px); flex: 0 1 auto;
            height: 56px; border-radius: 28px; }
.sk-tbtn  { width: 56px; height: 56px; flex: none; border-radius: 50%; }
/* Формы первого кадра = блокам новой главной: карта доступа, плита кнопки и
   две плиты монолита. Прежние .sk-hero/.sk-card/.sk-row описывали карточную
   раскладку, которой больше нет. */
.sk-acard { height: 206px; border-radius: 24px; }
.sk-plate { height: 53px;  border-radius: 18px; }
.sk-slab  { height: 240px; border-radius: var(--r-lg); }
.sk-slab2 { height: 128px; border-radius: var(--r-lg); }
/* Слой морфинга: шиммеры лежат поверх готовой главной и перетекают в её реальные
   блоки (_morphHome в js/app.js), после чего гаснут — и из-под них проступает
   контент. Слой ПРОЗРАЧНЫЙ: контент проявляется прямо под ним, поэтому мигания
   пустотой нет. Координаты, размеры, скругления и переходы шиммеров ставит JS. */
.reveal-ov {
  position: absolute; inset: 0; z-index: 4;
  pointer-events: none;
}
.reveal-ov .sk { position: absolute; }
@media (prefers-reduced-motion: reduce) {
  .sk::after { animation: none; }
  .reveal-ov .sk { transition: opacity .2s ease !important; }
  .screen { transition: none !important; }
}
.profile-name {
  font-size: 17px; font-weight: 700; letter-spacing: -.3px;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.profile-chip .chev { color: var(--text-3); width: 19px; height: 19px; flex: none; }


/* ── Экраны ── */
#view {
  flex: 1; padding: calc(14px + var(--safe-t)) 16px calc(var(--tabbar-h) + var(--safe-b) + 42px);
  /* Глубина под шторкой, как в iOS: страница чуть отъезжает назад. transform на
     #view безопасен — фиксированные таббар и шторки живут вне него. */
  transform-origin: 50% 0;
  transition: transform var(--dur-sheet) var(--ease-spring);
  /* Все экраны лежат в ОДНОЙ grid-ячейке (.screen → grid-area:1/1). Поэтому при
     смене вкладки/страницы уходящий может гаснуть НАД входящим без absolute и без
     «мигания». align-items:start — чтобы экраны не растягивались до высоты соседа.
     grid-template-columns: minmax(0,1fr) — ОБЯЗАТЕЛЬНО: без него неявная колонка
     auto-размера раздувается до max-content самого широкого блока (длинное имя,
     ссылка) и растягивает страницу по горизонтали. minmax(0,…) держит колонку по
     ширине #view, а контент переносится/скроллится внутри блока, как и раньше. */
  display: grid; grid-template-columns: minmax(0, 1fr); align-items: start;
}
body.sheet-open #view { transform: scale(.955) translateY(4px); }
/* Кривая cubic-bezier(.16,1,.3,1) — фирменная «эппловская» плавность (экспо-
   затухание): контент быстро появляется и мягко успокаивается на месте. */
.screen { position: relative; display: flex; flex-direction: column; gap: 14px; grid-area: 1 / 1; min-width: 0; animation: screen-in .42s cubic-bezier(.16, 1, .3, 1); }
.screen[hidden] { display: none; }
/* Появление, а не «вылет»: мягкое проявление с почти незаметным приближением */
@keyframes screen-in {
  from { opacity: 0; transform: scale(.992); }
  to   { opacity: 1; transform: none; }
}
/* Уходящий экран при смене страницы плавно гаснет НАД входящим (см. #view grid),
   а не исчезает мгновенно — зеркало screen-in. forwards держит конечный кадр,
   пока JS не спрячет экран. */
.screen.screen-out { pointer-events: none; animation: screen-out .34s cubic-bezier(.16, 1, .3, 1) forwards; }
@keyframes screen-out {
  from { opacity: 1; transform: none; }
  to   { opacity: 0; transform: scale(.992); }
}
@media (prefers-reduced-motion: reduce) { .screen, .screen.screen-out { animation: none; } }

.section-title {
  font-size: 14px; font-weight: 700;
  color: var(--text-2);
  letter-spacing: .2px;
  margin: 8px 4px 0;
  display: flex; align-items: center; gap: 7px;
}
.section-title .icon { width: 17px; height: 17px; stroke-width: 2; }

/* ═══════════════════════════════════════════════════════════════════════════
   МОНОЛИТ — архитектура пользовательских экранов
   ═══════════════════════════════════════════════════════════════════════════
   🚨 ЭТО ЗАМЕНА КАРТОЧНОЙ ВЁРСТКИ, А НЕ ДОБАВКА К НЕЙ.

   Было: экран — стопка плавающих карточек (.card) с зазором 14px, у каждой своя
   тень и своё скругление. Пять предметов там, где смысл один. Это универсальный
   шаблон, по которому мини-аппа читалась как «ещё одно приложение из шаблона», —
   собственно, ровно это владелец и сказал, увидев первый заход редизайна:
   палитра сменилась, дизайн — нет.

   Стало: экран — ОДНА фрезерованная поверхность. Разделяют не зазоры и тени, а
   ШВЫ (волосяная линия) и ГРАВИРОВКА. Отсюда три правила:

     1. Ряды идут ВПЛОТНУЮ друг к другу внутри плиты (.slab), шов рисует
        `inset 0 1px 0` — не border, чтобы не сдвигать раскладку.
     2. У иконок в рядах НЕТ ПЛАШЕК. Скруглённый квадрат под каждым глифом —
        главная примета карточного шаблона; на монолите глиф лежит прямо на
        поверхности.
     3. Предметов на экране ровно два: КАРТА ДОСТУПА и ПЛИТА кнопки. Всё
        остальное — поверхность, и объёма не имеет.

   .card НЕ УДАЛЁН: на нём держатся ~25 шторок и вся админка. Он просто больше
   не используется на главной, рефералке и заданиях. */

.slab {
  background: var(--surface);
  border-radius: var(--r-lg);
  overflow: hidden;
  box-shadow: var(--shadow-soft), var(--bevel);
}

/* Ряд монолита. Заменяет .support-card / .route-row / .nav-row / .action-tile —
   всё это были разные внешности одного и того же «строка со значением и
   стрелкой». */
.line {
  display: flex; align-items: center; gap: 13px;
  width: 100%; text-align: start;
  padding: 15px 17px;
  min-height: 56px;
  transition: background .14s ease;
}
.line + .line { box-shadow: inset 0 1px 0 var(--line); }
.line:active { background: var(--surface-2); }
/* Глиф без плашки — см. правило 2 выше. */
.line > .icon:first-child { width: 21px; height: 21px; color: var(--ag-ink); flex: none; }
.line-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.line-label { font-size: 15px; font-weight: 650; letter-spacing: -.15px; }
.line-sub { font-size: 12.5px; color: var(--text-3); font-weight: 500; }
.line-val {
  font-size: 14px; font-weight: 700; color: var(--text-2);
  font-variant-numeric: tabular-nums; white-space: nowrap;
}
.line .chev { color: var(--text-3); width: 18px; height: 18px; flex: none; }

/* Подпись группы — гравировка НАД плитой, на самом фоне. Так группы читаются
   разделами одной панели, а не отдельными карточками с заголовками внутри. */
.slab-label {
  font-size: 10.5px; font-weight: 800;
  letter-spacing: .14em; text-transform: uppercase;
  color: var(--engrave);
  margin: 6px 0 -4px 18px;
}

/* ── ФИГУРА: голая типографика на грунте ──────────────────────────────────
   Второй способ открыть экран, кроме предмета. На главной субъект — ВЕЩЬ (ключ),
   и она оформлена картой. На рефералке субъект — ЧИСЛО (сколько людей ты
   привёл), и оборачивать число в карточку незачем: оно само по себе крупнее
   всего на экране. Ни подложки, ни рамки, ни тени — цифра выгравирована прямо
   на грунте. */
.figure { padding: 10px 18px 4px; position: relative; }

/* Маскота на «фигуре» больше нет: задания и промокоды стали герой-картами —
   ворону нужна ТЁМНАЯ поверхность, а голый грунт в светлой теме белый.
   Ограничение ширины текста снято вместе с ним: теснить фигуру больше некому. */
.figure-label {
  font-size: 10.5px; font-weight: 800;
  letter-spacing: .14em; text-transform: uppercase;
  color: var(--engrave);
}
.figure-num {
  margin-top: 10px;
  font-size: 62px; font-weight: 800; letter-spacing: -3px; line-height: .92;
  font-variant-numeric: tabular-nums;
  background: var(--metal-text);
  -webkit-background-clip: text; background-clip: text;
  -webkit-text-fill-color: transparent; color: transparent;
}
/* «1 / 4»: знаменатель мельче и обычным текстом — числитель это ответ,
   знаменатель лишь мера. Заливку металлом с родителя гасим явно, иначе он
   пропадёт вместе с ней. */
.figure-of {
  font-size: 30px; letter-spacing: -1px; font-weight: 700;
  -webkit-text-fill-color: var(--text-3); color: var(--text-3);
  margin-inline-start: 2px;
}
.figure-cap {
  margin-top: 6px;
  font-size: 17px; font-weight: 700; letter-spacing: -.3px; color: var(--text);
}
.figure-note { margin-top: 10px; font-size: 13px; font-weight: 500; line-height: 1.5; color: var(--text-3); max-width: 34ch; }
.figure-rank {
  display: inline-flex; align-items: center; gap: 7px;
  margin-top: 14px;
  font-size: 13px; font-weight: 700; color: var(--text-2);
}
.figure-rank .icon { width: 16px; height: 16px; color: var(--ag-ink); }

/* ── ЖЕТОН: младший брат карты доступа ────────────────────────────────────
   Та же металлическая кромка и та же выемка под моно-текст, но без наклона и
   блика: предмет на экране «живой» ровно один, иначе эффект обесценивается. */
.token {
  position: relative;
  margin-top: 16px;
  padding: 14px 16px 16px;
  border-radius: 20px;
  background: linear-gradient(157deg, #141D2E 0%, #0C1420 46%, #060C16 100%);
  box-shadow:
      0 12px 28px rgba(0, 0, 0, .45),
      inset 0 0 0 1px rgba(190, 208, 236, .10),
      inset 0 1px 0 rgba(226, 236, 250, .09);
}
:root[data-theme="light"] .token,
:root:not([data-theme="dark"]) .token {
  background: linear-gradient(157deg, #2C3546 0%, #1A2130 48%, #0E1420 100%);
  box-shadow:
      0 12px 26px rgba(12, 22, 48, .24),
      inset 0 0 0 1px rgba(255, 255, 255, .10),
      inset 0 1px 0 rgba(255, 255, 255, .16);
}
.token-label { font-size: 10.5px; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; color: var(--ag-4); }
.token-well {
  /* width: 100% обязателен — это <button>, а кнопка не тянется по родителю и
     берёт ширину по содержимому. С длинной ссылкой выемка вылезала за жетон,
     а иконку копирования выдавливало за экран. */
  display: flex; align-items: center; gap: 10px; width: 100%;
  margin-top: 10px; padding: 11px 12px;
  border-radius: 13px;
  background: rgba(0, 0, 0, .32);
  box-shadow: inset 0 1px 2px rgba(0, 0, 0, .5), inset 0 0 0 1px rgba(190, 208, 236, .07);
  cursor: pointer;
}
.token-well code {
  flex: 1; min-width: 0;
  font-family: ui-monospace, "SF Mono", SFMono-Regular, Menlo, monospace;
  font-size: 12px; color: var(--ag-3);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.token-well .icon { width: 17px; height: 17px; color: var(--ag-3); flex: none; }

/* ── ТРАКТ ЗАДАНИЙ ────────────────────────────────────────────────────────
   Было: четыре одинаковые карточки в столбик — четыре независимых предмета,
   хотя это ОДИН путь к награде. Стало: фрезерованный канал с узлами. Линия
   между узлами и есть «путь», пройденные узлы залиты металлом.

   Узел перекрывает линию собственным фоном (--bg) — так канал выглядит
   прерванным узлом, а не проходящим под ним насквозь. */
.track { position: relative; padding-inline-start: 52px; margin-top: 6px; }
/* Канал рисуется ПООТРЕЗКАМ, по одному на узел, а не одной линией на весь
   тракт. Сплошная линия не знает, где центр первого и последнего кружка:
   высота узла зависит от длины заголовка, и хвосты торчали сверху и снизу.
   Отрезок же привязан к своему узлу — от нижней кромки его кружка до кружка
   следующего, — поэтому попадает точно при любой высоте. */
.node::before {
  content: '';
  position: absolute; inset-inline-start: -33px;
  top: 44px; bottom: -6px; width: 2px; border-radius: 2px;
  background: var(--line);
}
.node:last-child::before { content: none; }
.node {
  position: relative;
  display: block; width: 100%; text-align: start;
  padding: 10px 0 22px;
}
.node-dot {
  position: absolute; inset-inline-start: -52px; top: 6px;
  width: 38px; height: 38px; border-radius: 50%;
  display: grid; place-items: center;
  font-size: 15px; font-weight: 800; font-variant-numeric: tabular-nums;
  color: var(--ag-ink);
  background: var(--bg);
  box-shadow: inset 0 0 0 1.5px var(--ag-rim);
}
.node.done .node-dot { color: #fff; background: var(--ok); box-shadow: none; }
.node.done .node-dot .icon { width: 19px; height: 19px; stroke-width: 2.6; }
.node-title { font-size: 15px; font-weight: 700; letter-spacing: -.15px; line-height: 1.3; }
.node-reward { display: block; margin-top: 5px; font-size: 12.5px; font-weight: 600; color: var(--text-3); }
.node-foot { display: flex; align-items: center; gap: 12px; margin-top: 11px; }
.node-bar { flex: 1; height: 5px; border-radius: 100px; background: var(--surface-2); overflow: hidden; box-shadow: inset 0 0 0 1px var(--line); }
.node-bar i {
  display: block; height: 100%; width: 100%;
  background: var(--metal);
  transform-origin: left; transform: scaleX(0);
  transition: transform .8s cubic-bezier(.22, .9, .3, 1);
}
[dir="rtl"] .node-bar i { transform-origin: right; }
.node.done .node-bar i { background: var(--ok); }
.node-count { font-size: 12.5px; font-weight: 700; color: var(--text-2); white-space: nowrap; font-variant-numeric: tabular-nums; }
.node .chev { position: absolute; inset-inline-end: 0; top: 12px; width: 18px; height: 18px; color: var(--text-3); }
.node.open { cursor: pointer; padding-inline-end: 28px; }
.node.open:active .node-title { opacity: .6; }
@media (prefers-reduced-motion: reduce) { .node-bar i { transition: none; } }

/* ═══ КАРТА ДОСТУПА ════════════════════════════════════════════════════════
   Единственный настоящий ПРЕДМЕТ в приложении. Смысл: у продукта есть ровно
   один физический носитель — ключ VLESS, который человек переносит в Happ. В
   прежнем дизайне он лежал серой строчкой `<code>` в третьей карточке сверху,
   то есть выглядел наименее важной вещью на экране.

   Карта собирает срок, статус и ключ в один объект и снимает вопрос «что тут
   герой»: спорить сроку и ключу больше не о чем, они на одной пластине.

   Наклон и блик ведёт палец (см. wireAccessCard в app.js): --sx двигает
   спекулярную полосу, --tx/--ty кладут карту в перспективе. Это и есть тот
   самый «вау», и он повторяется КАЖДЫЙ раз при открытии приложения, а не один
   раз на онбординге. */
.acard {
  position: relative;
  display: flex; flex-direction: column;
  /* Высоту задаёт СОДЕРЖИМОЕ — как в карточке подписки ScaleVPN, откуда взята
     схема «кнопка внутри карты». Запас сверх содержимого держать нельзя: он
     превращается в пустую полосу между текстом и кнопкой, а ворон при этом
     ужимается в остаток. Ворону место даёт не высота карты, а ПРАВАЯ ПОЛОВИНА
     во всю высоту (см. `.ac-raven`). */
  min-height: 232px;
  padding: 18px 20px 16px;
  border-radius: 24px;
  /* 🚨 СНОВА `hidden`, и это откат моей же правки. Ненадолго стояло `visible`,
     чтобы ворон мог высунуть за кромку РУКУ. Но арт `companion` владелец
     оставил прежний, а в нём рука прижата к груди — за край торчало ПЛЕЧО, и
     на живом телефоне это читалось как брак вёрстки, а не как приём.
     🚨 `hidden` — и ЭТО ТЕПЕРЬ ГЛАВНОЕ, ЧТО ДЕРЖИТ КОМПОЗИЦИЮ. Тело ворона
     обязано обрываться о кромку КАРТЫ. Пока тут стояло `visible`, он выходил
     за карту и упирался в край ЭКРАНА (у страницы всего 16px полей), то есть
     срезался о телефон — на разной ширине по-разному. Наружу теперь выходит
     не тело, а отдельный слой `.ac-raven-out`. */
  overflow: hidden;
  cursor: pointer;
  background: linear-gradient(157deg, #141D2E 0%, #0C1420 46%, #060C16 100%);
  box-shadow:
      0 20px 44px rgba(0, 0, 0, .55),
      inset 0 0 0 1px rgba(190, 208, 236, .11),
      inset 0 1px 0 rgba(226, 236, 250, .10);
}
/* Наклон переехал на `.acard-wrap` (см. там): карта и рука должны
   поворачиваться как одно целое. */

/* Спекулярная полоса. Ездит за пальцем по --sx (0..1). Три стопа вплотную —
   узкий яркий гребень с мягкими склонами: так выглядит свет на брашированном
   металле, одностоповый градиент читается как пластик. */
.ac-shine::before {
  content: '';
  position: absolute; inset: -20% -60%;
  background: linear-gradient(112deg,
      transparent 38%,
      rgba(226, 236, 250, .07) 46%,
      rgba(255, 255, 255, .16) 50%,
      rgba(226, 236, 250, .06) 55%,
      transparent 64%);
  /* 26%, а не 60%. Слой шире карты в 2.2 раза (inset -60% по бокам), поэтому
     сдвиг считается от ЕГО ширины: на 60% гребень к краю диапазона уезжал с
     карты совсем и блик просто пропадал. 26% дают ход примерно в половину
     ширины карты — блик всё время на металле. */
  transform: translateX(calc((var(--sx, .5) - .5) * 26%));
  pointer-events: none;
  transition: transform .55s var(--ease-spring);
}
.acard-wrap.lit .ac-shine::before { transition: none; }

/* Ворон, вытравленный в металле. Уходит за угол — видна часть марки, не картинка. */
.ac-shine::after {
  content: '';
  position: absolute;
  right: -17%; bottom: -34%;
  width: 58%; aspect-ratio: 1;
  background: url('/icon.png') no-repeat center / contain;
  /* .055, не .075: на .075 крыло ворона пересекало строку с датой и читалось
     как вторая графика поверх текста, а не как травление в металле. */
  opacity: .055;
  pointer-events: none;
}

.ac-shine {
  position: absolute; inset: 0;
  border-radius: inherit;
  overflow: hidden;          /* весь смысл элемента: режет декор, но не ворона */
  pointer-events: none;
  z-index: 0;
}

.ac-top { position: relative; z-index: 1; display: flex; align-items: center; justify-content: space-between; }
.ac-brand {
  display: flex; align-items: center; gap: 8px;
  font-size: 11px; font-weight: 800; letter-spacing: .22em; text-transform: uppercase;
  color: var(--ag-4);
}
.ac-brand img { width: 17px; height: 17px; display: block; }
.ac-state {
  display: inline-flex; align-items: center; gap: 6px;
  font-size: 11.5px; font-weight: 700; letter-spacing: .02em;
  color: var(--text-2);
}
.ac-state i { width: 7px; height: 7px; border-radius: 50%; background: var(--ok); flex: none; }
/* Чип статуса стоит в правом верхнем углу — ровно там, где капюшон ворона.
   Он и так лежит ПОВЕРХ (z-index 1), но белый текст на синем капюшоне
   читается плохо. Тень по буквам возвращает контраст, не заводя под чип
   плашку (плашек в этом дизайне нет принципиально). На пустой карте фон под
   чипом — сам ворон, поэтому тень нужна именно здесь. */
.ac-state { text-shadow: 0 1px 7px rgba(2, 6, 14, .9); }

/* 🚨 НА ПУСТОЙ И ИСТЕКШЕЙ КАРТЕ ЧИПА СТАТУСА НЕТ — он там ДУБЛИРУЕТ ЗАГОЛОВОК.
   «Не активна» рядом с «Подключите VPN» и «Истекла» рядом с «Подписка истекла»
   говорят одно и то же дважды, а занимают правый верхний угол — единственное
   место, куда помещается голова ворона. Убран не ради ворона: на активной карте
   чип остаётся, потому что там он несёт своё («Активна» против «24 дня»). */
.acard.is-idle .ac-state { display: none; }
.ac-state.off i { background: var(--text-3); }
.ac-state.warn i { background: var(--warn); }

.ac-days {
  position: relative; z-index: 1;
  /* 🚨 Было `margin-top: auto` — срок прибивался к НИЗУ карты, потому что под
     ним стояла только строка ключа. С переездом кнопки внутрь (23.08) карта
     выросла до 310px, и этот же `auto` оставлял вверху пустую полосу в треть
     карты. Теперь низ держат `.ac-key` и `.ac-actions`, а срок идёт сразу под
     марку — как и текст на пустой карте. */
  margin-top: 0; padding-top: 10px;
  font-size: 52px; font-weight: 800; letter-spacing: -2.4px; line-height: .96;
  font-variant-numeric: tabular-nums;
}
.ac-days b {
  font-weight: 800;
  background: var(--metal-text);
  -webkit-background-clip: text; background-clip: text;
  -webkit-text-fill-color: transparent; color: transparent;
}
.ac-days small {
  font-size: 15px; font-weight: 600; letter-spacing: 0; color: var(--text-2);
  margin-inline-start: 8px; -webkit-text-fill-color: currentColor;
}
/* Текстовый вариант — «Подписка истекла», «Подключите VPN». */
.ac-days.ac-text { font-size: 25px; letter-spacing: -.6px; line-height: 1.15; color: var(--text); }

.ac-until { position: relative; z-index: 1; margin-top: 7px; font-size: 12.5px; color: var(--text-3); font-weight: 500; }

/* Действие внутри карты. Кнопки — НАД вороном (`z-index: 1`), иначе он ложится
   на них картинкой и они перестают читаться. */
.ac-actions {
  position: relative; z-index: 1;
  margin-top: 12px;
  display: flex; flex-direction: column; gap: 8px;
}

/* Ключ — выемка в пластине: утоплен, а не наложен. Отсюда inset-тень сверху и
   светлая грань снизу: свет падает в углубление. */
.ac-key {
  position: relative; z-index: 1;
  margin-top: 14px;
  display: flex; align-items: center; gap: 10px;
  padding: 10px 12px;
  border-radius: 13px;
  cursor: pointer;
  background: rgba(0, 0, 0, .32);
  box-shadow:
      inset 0 1px 2px rgba(0, 0, 0, .5),
      inset 0 0 0 1px rgba(190, 208, 236, .07),
      0 1px 0 rgba(226, 236, 250, .05);
}
.ac-key code {
  flex: 1; min-width: 0;
  font-family: ui-monospace, "SF Mono", SFMono-Regular, Menlo, monospace;
  font-size: 12px; letter-spacing: .01em;
  color: var(--ag-3);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.ac-key .icon { width: 17px; height: 17px; color: var(--ag-3); flex: none; }

/* Светлая тема: карта — полированная сталь, не обсидиан. */
:root[data-theme="light"] .acard,
:root:not([data-theme="dark"]) .acard {
  background: linear-gradient(157deg, #2C3546 0%, #1A2130 48%, #0E1420 100%);
  box-shadow:
      0 18px 38px rgba(12, 22, 48, .28),
      inset 0 0 0 1px rgba(255, 255, 255, .10),
      inset 0 1px 0 rgba(255, 255, 255, .18);
}
:root[data-theme="light"] .ac-until,
:root:not([data-theme="dark"]) .ac-until { color: var(--ag-4); }
:root[data-theme="light"] .ac-state,
:root:not([data-theme="dark"]) .ac-state { color: var(--ag-2); }
:root[data-theme="light"] .ac-days small,
:root:not([data-theme="dark"]) .ac-days small { color: var(--ag-3); }
/* 🚨 background-IMAGE, а не background. Сокращённое свойство `background`
   сбрасывает ВСЕ фоновые подсвойства, включая background-clip, — а цифра залита
   металлом именно через `background-clip: text`. Со сброшенным клипом
   `-webkit-text-fill-color: transparent` остаётся в силе, и на месте «24» в
   светлой теме рисовался белый прямоугольник, а самой цифры не было.
   Переопределять здесь приходится потому, что карта в светлой теме ТЁМНАЯ
   (полированная сталь): общий --metal-text там тёмный и на ней пропал бы. */
:root[data-theme="light"] .ac-days b,
:root:not([data-theme="dark"]) .ac-days b {
  background-image: linear-gradient(180deg, #FFFFFF 0%, #D5DDEA 45%, #9BA6B9 100%);
  -webkit-background-clip: text; background-clip: text;
}
:root[data-theme="light"] .ac-days.ac-text,
:root:not([data-theme="dark"]) .ac-days.ac-text { color: #F2F5FA; }

/* Наклон — украшение, а не смысл: при reduce карта просто стоит ровно. */
@media (prefers-reduced-motion: reduce) {
  .acard-wrap { transform: none !important; }
  .ac-shine::before { transform: none !important; }
}

/* ═══ МАСКОТ ═══════════════════════════════════════════════════════════════
   Ворон живёт НА КАРТЕ ДОСТУПА, а не отдельной картинкой рядом с ней. Это не
   вкусовщина: «предметов на экране два» (карта и плита) — правило, на котором
   держится монолит, и третий предмет его ломает. Обитатель карты предметом не
   становится.

   Карта ТЁМНАЯ В ОБЕИХ ТЕМАХ (в светлой — «полированная сталь», правило выше),
   поэтому отдельная подложка ворону не нужна ни здесь, ни в светлой теме: он
   уже стоит на почти чёрном — том самом фоне, на котором нарисован.

   🚨 КАРТИНОК В РЕПОЗИТОРИИ МОЖЕТ НЕ БЫТЬ. Они генерируются отдельно
   (`claude/ops/mascot-prompts.md`) и приезжают позже кода. Поэтому всё
   включается классом `.has-raven`, который ставит JS ТОЛЬКО после успешной
   загрузки файла. Без картинок ни один селектор ниже не срабатывает, экран
   остаётся ровно таким, каким его сдал редизайн 23.08, и травление щита на
   карте никуда не девается. Проверять — `document.documentElement.className`. */
/* Обёртка нужна, чтобы слой-вынос имел ту же систему координат, что и карта.
   🚨 Радиус на обёртке БЕЗ ФОНА обязателен: `_saveHomeLayout` снимает
   `border-radius` с прямых детей экрана для скелетона первого кадра, а
   ребёнком стала обёртка — без радиуса на месте карты рисовался бы
   прямоугольник. */
.acard-wrap {
  position: relative; border-radius: 24px;
  /* Наклон под пальцем — здесь, а не на карте: вместе с картой поворачивается
     и полоска руки, которая лежит соседом. Разбор — в `wireAccessCard`. */
  transform: perspective(900px) rotateX(var(--tx, 0deg)) rotateY(var(--ty, 0deg));
  transition: transform .55s var(--ease-spring);
  will-change: transform;
  /* 🚨 `pan-y`, а не `auto`. Карта забирает pointer-события себе (наклон под
     пальцем), но браузеру об этом не сообщает, и ГОРИЗОНТАЛЬНУЮ составляющую
     жеста он отдаёт себе — в вебвью Telegram это оборачивается свайпами
     влево-вправо, стоит только тронуть карту. Проверено: горизонтального
     переполнения на странице нет (`scrollWidth` = `clientWidth` даже при
     наклоне), то есть дело именно в жесте, а не в вылезающей картинке.
     `pan-y` оставляет вертикальную прокрутку браузеру, горизонталь — нам.
     Тот же приём уже стоит на `.spark-wrap` и переключателе периода. */
  touch-action: pan-y;
}
.acard-wrap.lit { transition: none; }

/* Ширина полосы, которая вылезает за кромку карты. В ПИКСЕЛЯХ: у страницы по
   бокам 16px, и всё, что шире, срезается краем экрана вместо карты. */
:root { --raven-out: 15px; }

/* 🚨 ПОЛОСУ РЕЖЕТ КОНТЕЙНЕР, А НЕ `clip-path`. Раньше стоял
   `clip-path: inset(0 0 0 calc(100% - var(--raven-out)))` — в хроме работало,
   а на живом айфоне НЕТ: WebKit спотыкается о `calc()` с переменной внутри
   `inset()`, клип не применяется, и весь ворон целиком (а он `z-index: 2`,
   поверх карты) наезжает на кнопку. Владелец это и увидел: «на телефоне он
   поверх кнопки, а при нажатии на секунду уходит назад» — нажатие вызывало
   перерисовку, и слой на миг вставал правильно.
   Узкий контейнер с `overflow: hidden` делает то же самое, но так, что
   ошибиться нечему: даже если внутренний слой отрисуется целиком, наружу
   выйдет только полоса шириной `--raven-out`. */
.ac-edge {
  position: absolute; z-index: 2;
  top: 0; bottom: 0;
  right: calc(-1 * var(--raven-out));
  width: var(--raven-out);
  overflow: hidden;
  pointer-events: none;
}
/* Геометрия — ОДИН В ОДИН как у тела внутри карты: контейнер растянут на всю
   высоту обёртки, поэтому проценты считаются от той же базы, и шов совпадает. */
.ac-raven-out {
  position: absolute;
  right: 0; bottom: 0;
  height: 86%; width: auto;
  aspect-ratio: 700 / 897;
  background: no-repeat right bottom / contain;
  background-image: url('/img/companion.webp');
}
.acard-wrap.is-idle .ac-raven-out {
  height: 62%; aspect-ratio: 800 / 1017; bottom: 38%;
  background-image: url('/img/welcome.webp');
}
.acard-wrap.is-idle.is-expired .ac-raven-out {
  height: 66%; aspect-ratio: 800 / 861; bottom: 30%;
  background-image: url('/img/expired.webp');
}

.ac-raven {
  position: absolute;
  /* z-index 0 и ПОСЛЕ `.ac-shine` в разметке: ворон ложится поверх декора
     (щит, блик), но ПОД текстом и кнопками — у тех z-index 1. Поэтому он не
     перекрывает ни чип статуса, ни иконку копирования. */
  z-index: 0;

  /* 🚨 РАЗМЕР СЧИТАЕТСЯ ОТ ШИРИНЫ + `aspect-ratio`, И `top` ЗДЕСЬ НЕТ.
     Так было раньше — `top` и `bottom` вместе задавали высоту коробки, а
     `contain` вписывал в неё картинку. Высота карты зависит от СОДЕРЖИМОГО
     (сколько строк занял текст), то есть на другой ширине экрана менялось
     соотношение коробки — и `contain` переключался с «упёрся в ширину» на
     «упёрся в высоту». Ворон менял размер и глубину среза от телефона к
     телефону: на превью 375px одно, на живом аппарате другое. Владелец это и
     увидел.

     Теперь высота выводится из ширины через `aspect-ratio`, равный пропорциям
     самого файла. Коробка совпадает с картинкой, `contain` заполняет её
     ровно, и всё — размер, положение, доля срезанного плеча — становится
     чистой функцией ширины карты. Одинаково на любом экране.

     ⚠️ `aspect-ratio` обязан совпадать с реальным webp. Пересняли арт —
     сверь размеры (`python -c "from PIL import Image; print(Image.open('miniapp/img/companion.webp').size)"`). */
  aspect-ratio: 700 / 897;               /* == companion.webp, СВЕРЯТЬ с выводом mascot_prep */
  /* 🚨 ВЕДУЩИЙ РАЗМЕР — ВЫСОТА, а ширина выводится из неё.
     Сперва было наоборот (ширина в % от карты), и это ломалось: высота карты
     зависит от содержимого — на широком экране «Действует до 2 августа 2026»
     помещается в строку, на узком переносится, и карта то ниже, то выше. При
     ведущей ширине ворон оставался прежнего размера, а карта под ним менялась
     — и на низкой карте ему СРЕЗАЛО ГОЛОВУ верхней кромкой.
     От высоты он всегда занимает одну и ту же долю карты и никогда не упирается
     макушкой в верх. Ширина при этом гуляет на пару пикселей — не видно. */
  height: 86%; width: auto;
  right: calc(-1 * var(--raven-out)); bottom: 0;

  background: no-repeat right bottom / contain;
  background-image: url('/img/companion.webp');
  opacity: 1;
  pointer-events: none;
}

/* Щит и ворон делят один угол карты — вместе это две графики друг на друге.
   Щит гасим только когда ворон реально загрузился (см. .has-raven выше);
   заодно уходит повтор — марка уже стоит в `.ac-brand` слева сверху. */
:root.has-raven .ac-shine::after { opacity: 0; }

/* ── Карта без подписки и с истекшей ────────────────────────────────────────
   Здесь на карте только две короткие строки, и место есть. Ворон выходит из
   травления в полную силу: именно на этих двух экранах он и нужен — человеку
   нечего делать, и это единственный момент, когда бренду есть что сказать
   кроме цифры. */
/* Текст не залезает на ворона — ЛЕВАЯ ПОЛОВИНА КАРТЫ ЕГО, правая вороньего.
   Правило общее для всех состояний: на активной карте «Действует до 2 августа
   2026» — самая длинная строка, без ограничения она уходит ворону на грудь.
   Строка ключа НЕ ограничена: она непрозрачная и лежит поверх, ворон уходит
   под неё. */
.acard .ac-days, .acard .ac-until { max-width: 52%; }

.acard.is-idle .ac-raven {
  /* Пустая и истекшая карта: ворон не травление, а полноценная иллюстрация —
     на этих двух экранах человеку нечего делать, и это единственный момент,
     когда бренду есть что сказать кроме цифры.

     Арт здесь — БЮСТ, и от этого зависят все числа ниже: глобус он держит у
     груди, ВНУТРИ своего силуэта, поэтому влево ничего не торчит и заголовок
     свободен. Пояса и ног нет — низ обрывается по груди, и кнопки его не
     режут. (Прошлая версия была полуфигурой с глобусом на вытянутой руке:
     глобус лез в заголовок, а торс срезали кнопки — переснято.)

     `bottom: 30%` оставляет полосу под кнопки, `right: -8%` уводит его правее
     текста и слегка подрезает плечо о кромку карты. */
  opacity: 1;
  /* 🚨 РАЗМЕР ЗАДАЁТ ШИРИНА, А НЕ ВЫСОТА, и это главное правило этого блока.
     Требование владельца: за кнопки заходить МОЖНО (они непрозрачные, ворон
     просто уходит под них), а на ТЕКСТ наезжать нельзя. Текст занимает левые
     52% карты (см. `.acard .ac-days, .ac-until`), значит единственная жёсткая
     граница — левый край ворона: он обязан остаться правее 52%.
     Отсюда: 48% ширины + вынос на 2% за правую кромку дают левый край около
     185px при границе текста 178px. Высоту не ограничиваем вовсе (`bottom: 0`),
     она перестаёт быть узким местом — ворон вырастает и спокойно уходит низом
     под кнопки.
     ⚠️ `background-position: right TOP` — иначе при свободной высоте картинка
     тонет к низу коробки и голова уезжает под кнопки вместе с телом. */
  /* Ведущий размер — ВЫСОТА, как и на активной карте (разбор — в `.ac-raven`).
     `bottom` держит линию среза торса под кнопками, `height` — чтобы глобус
     остался ВЫШЕ кнопок. Между этими двумя и зажат весь диапазон. */
  aspect-ratio: 800 / 1017;              /* == welcome.webp, СВЕРЯТЬ с выводом mascot_prep */
  height: 62%; width: auto;
  right: calc(-1 * var(--raven-out)); bottom: 38%;
  /* 🚨 ЯКОРЬ ПО НИЗУ, и `bottom: 28%` подобран не на глаз.
     У бюста низ — это ЛИНИЯ СРЕЗА торса, и её нельзя оставлять на виду: она
     висит в воздухе поперёк карты. Прятать её нужно за кнопкой.
     Замеры: у `expired` плотное тело кончается на 87% высоты арта (ниже —
     только слабое свечение брони, которое `trim` честно оставляет), у
     `welcome` — на 95%. Карты разной высоты: 232px с одной кнопкой и 268px с
     двумя, кнопки в обеих начинаются на 152px. При 28% низ картинки садится
     на 167px и 193px соответственно — обе линии среза уходят под кнопки.
     ⚠️ Якорь по ВЕРХУ (`right top`) тут не годится: при разной высоте карт
     линия среза оказывается на разной глубине, и на короткой — вылезает. */
  background-position: right bottom;
  filter: drop-shadow(0 0 22px var(--voron-bloom));
  background-image: url('/img/welcome.webp');
}

/* Подписка кончилась — тот же бюст, та же посадка, но глобус ПОГАС. Именно
   разные картинки, а не одна на оба состояния: «истекла» и «ещё не покупал» —
   разные вещи, и погасший шар говорит это без единого слова.

   ⚠️ Своя посадка по низу, и это не придирка: на истекшей карте кнопка ОДНА,
   карта на 36px короче, а глобус в этом арте нарисован выше. С общими 36%
   линия среза торса вылезала бы из-под кнопки. Числа проверены глазами на
   обоих состояниях. */
.acard.is-idle.is-expired .ac-raven {
  background-image: url('/img/expired.webp');
  aspect-ratio: 800 / 861;               /* == expired.webp, СВЕРЯТЬ с выводом mascot_prep */
  /* Своя пара чисел: кнопка ОДНА, карта на 36px короче — при общих значениях
     линия среза торса вылезала бы из-под кнопки. */
  height: 66%; bottom: 30%;
}

/* ── Экран успешной оплаты ──────────────────────────────────────────────────
   Единственное место, где ворон — сам сюжет, а не обитатель. Круглая иконка с
   галочкой заменяется им целиком (разметка в showPaymentSuccess). */
.success-raven {
  width: 190px; height: 190px; margin: 0 auto 4px;
  background: no-repeat center bottom / contain;
  filter: drop-shadow(0 0 26px var(--voron-bloom));
}
.success-raven.is-paid  { background-image: url('/img/success.webp'); }
.success-raven.is-trial { background-image: url('/img/trial.webp'); }

/* Экран «не удалось загрузить». Ворон вместо эмодзи 🚧: это единственный экран,
   который человек видит, когда всё сломалось, — и единственный, где маскот
   работает не украшением, а тем, что снимает раздражение. */
/* Пустые списки: ворон разводит руками вместо штрихового глифа. Только на
   ПОЛЬЗОВАТЕЛЬСКИХ экранах — в админке пустых состояний десятки, и там глиф
   уместнее: это рабочий инструмент, а не витрина. */
/* Маскот в шапке шторки (выбор срока, способ оплаты). Компактный: шторки —
   плотные списки, и большая картинка утащила бы сам выбор ниже сгиба. */
.sheet-raven {
  display: block;
  /* 🚨 ВЕРХНИЙ ОТСТУП — НЕ ВОЗДУХ, А МЕСТО ПОД СВЕЧЕНИЕ. `.sheet-body` —
     скролл-контейнер (`overflow-y: auto`), а значит он РЕЖЕТ всё, что выходит
     за его рамку, включая ореол `drop-shadow`. При прежних −2px ворон стоял
     вплотную к верхней кромке, и свечение обрубалось ровной линией под
     заголовком шторки. 24px — чуть больше радиуса ореола (22px).
     ⚠️ Нижний отступ ОТРИЦАТЕЛЬНЫЙ: ворон уходит под первую строку списка и
     как будто поднимается из неё. Строки идут в разметке ПОСЛЕ него и
     непрозрачны, поэтому просто перекрывают ему низ. */
  margin: 24px auto -14px;
  height: 176px; width: auto;
  background: no-repeat center bottom / contain;
  filter: drop-shadow(0 0 22px var(--voron-bloom));
}
.sheet-raven.is-period { aspect-ratio: 800 / 790; background-image: url('/img/period.webp'); }
.sheet-raven.is-pay    { aspect-ratio: 800 / 770; background-image: url('/img/pay.webp'); }

.empty-raven {
  width: 150px; height: 150px; margin: 0 auto 2px;
  background: url('/img/empty.webp') no-repeat center bottom / contain;
  filter: drop-shadow(0 0 20px var(--voron-bloom));
}

.boot-raven {
  width: 190px; height: 190px; margin: 0 auto 2px;
  background: url('/img/error.webp') no-repeat center bottom / contain;
  filter: drop-shadow(0 0 26px var(--voron-bloom));
}

/* Крупная кнопка-плита под картой. */
.btn-lg { padding: 17px 18px; font-size: 15.5px; border-radius: 18px; }

/* ── Карточки ── */
.card {
  background: var(--surface);
  border-radius: var(--r-lg);
  padding: 18px;
  /* Фаска обязательна: на фоне #050A14 карточка #0B1220 отличается на 3.5%
     светлоты, и тени под ней рисоваться нечем. Границу держит светлая грань. */
  box-shadow: var(--shadow-soft), var(--bevel);
  position: relative;
  overflow: hidden;
}

/* Hero-карта тарифа (Apple Wallet-стиль) */
/* ── ГЕРОЙ ────────────────────────────────────────────────────────────────
   🚨 ГЕРОЙ БОЛЬШЕ НЕ ЗАЛИВАЕТСЯ АКЦЕНТОМ, И ЭТО ГЛАВНОЕ РЕШЕНИЕ РЕДИЗАЙНА.
   Раньше это была сплошная синяя плита во весь экран. Стоило подставить на её
   место серебро — получилась зеркальная простыня с белым текстом поверх,
   нечитаемая (проверено, скриншот в разборе). Дело не в оттенке: у металла
   светлота гуляет ВНУТРИ заливки, поэтому текста, который был бы контрастен
   на всей её площади, не существует в принципе.

   Поэтому роли развели: серебро = ДЕЙСТВИЕ, обсидиан = ПОВЕРХНОСТЬ. Карточка
   тёмная, металлом залиты только кнопка и цифры. Побочно решилось и то, чего
   не просили: экран перестал светить в лицо большим ярким пятном ночью. */
.hero-card {
  border-radius: var(--r-xl);
  padding: 20px;
  color: var(--text);
  background:
      radial-gradient(130% 100% at 82% -14%, rgba(150, 172, 208, .17), transparent 60%),
      var(--surface);
  box-shadow: var(--shadow-soft), var(--bevel);
  position: relative;
  overflow: hidden;
}
/* Свет, легший на верхнюю грань. Единственная яркая линия на карточке —
   ею, а не тенью, обсидиан отделяется от почти чёрного фона. */
.hero-card::before {
  content: '';
  position: absolute;
  left: 10%; right: 10%; top: 0; height: 1px;
  background: var(--metal-edge);
  opacity: .5;
}
/* Ворон водяным знаком. Настоящий логотип (`/icon.png`, его же отдаёт бот и
   показывает экран входа), а не силуэт-заменитель: марка у проекта одна. */
.hero-card.hero-brand::after {
  content: '';
  position: absolute;
  /* Знак уходит ЗА угол: видна четверть марки, а не иллюстрация. Иначе крыло
     ворона пересекало кнопку продления ровно посередине. */
  width: 230px; height: 230px;
  right: -76px; bottom: -86px;
  background: url('/icon.png') no-repeat center / contain;
  opacity: .08;
  pointer-events: none;
}
:root[data-theme="light"] .hero-card.hero-brand::after,
:root:not([data-theme="dark"]) .hero-card.hero-brand::after { opacity: .055; filter: brightness(.25); }
.hero-top { display: flex; align-items: center; justify-content: space-between; position: relative; z-index: 1; }
/* Название раздела — не «жирным потемнее», а гравировкой: мелкая разрядка в
   верхнем регистре, как маркировка, выбитая на металле. Одна и та же подпись
   стоит в шапке каждой карточки, и она же держит всю типографику приложения. */
.hero-plan {
  display: flex; align-items: center; gap: 7px;
  font-size: 10.5px; font-weight: 800;
  letter-spacing: .14em; text-transform: uppercase;
  color: var(--engrave);
}
/* Цифры залиты металлом. Это единственное место, кроме кнопки, где серебро
   работает как заливка, — и ровно поэтому взгляд идёт сначала на срок, потом
   на действие. */
.hero-sub { position: relative; z-index: 1; margin-top: 8px; font-size: 13px; color: var(--text-2); font-weight: 500; }

/* Текстовый герой — там, где вместо числа стоит фраза («Подписка истекла»,
   «Подключите VPN», «Выполняйте задания»). Раньше каждое такое место
   переопределяло крупную цифру инлайном (`style="font-size:24px;…"`) — четыре
   копии одних и тех же трёх свойств, разъехавшиеся между собой на 1px и .1em. */
/* Марка Voron в подписи раздела. Настоящий логотип (`/icon.png`), а не
   штриховая иконка из спрайта: щит с галочкой в `#i-logo` приехал вместе с
   движком из Scale и к этому проекту отношения не имеет.
   В светлой теме серебро на белом почти не видно — притемняем фильтром, форма
   при этом остаётся той же. */
.brand-mark { width: 20px; height: 20px; flex: none; display: block; }
:root[data-theme="light"] .brand-mark,
:root:not([data-theme="dark"]) .brand-mark { filter: brightness(.5) contrast(1.15); }


/* ⚠️ ШКАЛЫ СРОКА ЗДЕСЬ НЕТ, И ЭТО ОСОЗНАННО. Она была нарисована и снята:
   чтобы честно показать «сколько подписки прожито», нужна ДЛИНА периода, а
   `/api/app/state` отдаёт только `days_left` и `ends_at` (src/miniapp.py, ~454).
   Считать её от «примерно тридцати дней» на экране, где рядом стоит кнопка
   оплаты, нельзя: у годового тарифа полоса врала бы в разы. Нужна шкала —
   сначала `plan.total_days` с сервера. */

.hero-foot {
  position: relative; z-index: 1;
  margin-top: 18px; padding-top: 14px;
  border-top: 1px solid var(--line);
  display: flex; gap: 16px;
  font-size: 12.5px; font-weight: 600; color: var(--text-2);
}
.hero-foot span { display: flex; align-items: center; gap: 6px; }
.hero-foot .icon { width: 15px; height: 15px; stroke-width: 2.1; }
/* Силуэт-иконка раздела (рефералка, задания, кошелёк…). На обсидиане белый
   с прошлой темы был почти не виден — берём серебро приглушённое. */
.hero-shield {
  position: absolute; right: -12px; bottom: -34px; z-index: 0;
  width: 128px; height: 128px;
  color: var(--ag-ink);
  /* .10, а не .16: на обсидиане серебряный контур в 120px пересекал заголовок
     героя и читался как вторая графика поверх текста, а не как фон. */
  opacity: .10;
}
.hero-shield .icon { width: 100%; height: 100%; stroke-width: 1.1; }

/* ── ГЕРОЙ-КАРТА рефералки и заданий ───────────────────────────────────────
   Возврат к схеме ScaleVPN по просьбе владельца: редизайн 23.08 заменил их
   голой типографикой (`.figure`), но с маскотом карта работает лучше — ворону
   нужна поверхность, на которой он стоит, а не пустой грунт. */
.hero-days {
  position: relative; z-index: 1;
  margin-top: 18px;
  font-size: 46px; font-weight: 800; letter-spacing: -2px; line-height: 1.05;
  font-variant-numeric: tabular-nums;
  background: var(--metal-text);
  -webkit-background-clip: text; background-clip: text;
  -webkit-text-fill-color: transparent; color: transparent;
}
/* Хвост («друзей приглашено», «/ 4») — обычным текстом. Заливку металлом с
   родителя гасим ЯВНО, иначе хвост исчезает вместе с ней: та же мина, что
   уже ловилась на `.figure-of`. */
.hero-days small {
  font-size: 16px; font-weight: 600; letter-spacing: 0;
  -webkit-text-fill-color: var(--text-3); color: var(--text-3);
  margin-inline-start: 7px;
}

/* Маскот в герое. Геометрия — как на карте доступа: ведущий размер ВЫСОТА,
   ширина из `aspect-ratio`, поэтому доля карты одинакова на любом экране.
   Наружу тут ничего не выходит: `.hero-card` режет своим `overflow: hidden`,
   и второй слой не нужен — герой не про «вылезающую руку». */
.hero-raven {
  position: absolute; z-index: 0;
  right: -3%; bottom: 0;
  height: 98%; width: auto;
  background: no-repeat right bottom / contain;
  pointer-events: none;
}
/* 🚨 РЕФЕРАЛКА — ЕДИНСТВЕННЫЙ ГЕРОЙ С ПОЛОСОЙ ВО ВСЮ ШИРИНУ, и дело в арте.
   `duo` — два ворона рядом (900x551), он широкий. В углу, как остальных, его
   ставить бессмысленно: чтобы влезть сбоку от текста, он ужимается настолько,
   что двух персонажей уже не различить. Поэтому здесь он не обитатель угла, а
   БАННЕР: статичный блок во всю ширину карты (с выходом в её паддинги), а
   текст компактно под ним. */
/* Порядок на карте рефералки: ПОДПИСЬ РАЗДЕЛА -> ВОРОНЫ -> ЧИСЛО.
   То есть вороны сидят НАД строкой «7 друзей приглашено», а «РЕФЕРАЛКА» стоит
   над ними. ⚠️ Поверх воронов подпись не кладём — пробовал, она тонет в
   капюшоне даже с тенью.
   Полоса во всю ширину карты, но с БОКОВЫМИ ПОЛЯМИ: вороны не должны
   упираться в кромку. */
.hero-ref .hero-banner {
  position: relative;
  /* Нижнее поле ОТРИЦАТЕЛЬНОЕ: подтягивает «7 друзей приглашено» ближе к
     воронам. У арта снизу свой воздух (кресла), и без этого разрыв читался
     как случайный. */
  margin: 8px -20px -12px;
  padding: 0 22px;
}
.hero-ref .hero-raven.is-ref {
  position: static;
  /* `<i>` — строчный элемент, и у статичного строчного НЕ работают ни `width`,
     ни `aspect-ratio`: баннер просто не рисовался. */
  display: block;
  aspect-ratio: 900 / 551;
  width: 100%; height: auto; margin: 0;
  background-image: url('/img/duo.webp');
  background-position: center bottom;
}
/* Текст под баннером идёт во всю ширину — справа его больше никто не теснит. */
.hero-card.hero-ref :is(.hero-days, .hero-sub, .hero-foot) { max-width: none; }
.hero-raven.is-tasks { aspect-ratio: 800 / 849; background-image: url('/img/tasks.webp'); }
.hero-raven.is-promo { aspect-ratio: 800 / 876; background-image: url('/img/promo.webp'); }
/* «О нас»: ворон не при исполнении — сидит с кофе.
   🚨 Ведущий размер — ШИРИНА, хотя арт портретный. Этот герой ВЫСОКИЙ (в нём
   заголовок, подзаголовок и три факта), и от высоты ворон вырастал во весь
   блок и накрывал текст целиком. Правило простое: от ВЫСОТЫ считаем там, где
   высота блока задана содержимым в одну-две строки (карта доступа, задания);
   от ШИРИНЫ — там, где блок может вырасти (герой «О нас», альбомный duo). */
.hero-raven.is-about {
  aspect-ratio: 700 / 920;
  height: auto; width: 40%;
  /* ⚠️ Привязан к ВЕРХУ, а не к низу. С `bottom: 0` он садился на разделитель
     перед строкой фактов и наезжал на неё — выглядело как съехавшая картинка.
     Сверху же его держит заголовок, который всегда одной высоты. */
  top: 4%; bottom: auto;
  background-image: url('/img/about.webp');
}
.about-hero .about-claim, .about-hero .hero-sub { max-width: 62%; }
/* Текст героя — в левой половине, правая вороньего. */
/* `.hero-days` НЕ ограничиваем: маскот сидит в правом нижнем углу, верх строки
   свободен, а с ограничением хвост («друзей приглашено») переносился под число
   и висел отдельной строкой через пустоту. */
.hero-card .hero-sub, .hero-card .hero-foot { max-width: 58%; }
/* На промокодах хвост «≈ 7,90 USDT» идёт в ОДНУ строку с суммой и без
   ограничения уезжает ворону на грудь. */
.hero-promo .hero-days { max-width: 64%; }
/* Чип награды не должен наезжать на маскота. */
.hero-card .hero-top { position: relative; z-index: 1; }

/* ── 🚨 СВЕТЛАЯ ТЕМА: ГЕРОЙ-КАРТА ТОЖЕ ТЁМНАЯ ─────────────────────────────
   Решение владельца: «где ворон — там тёмная карточка», как на главной.
   И это не вкусовщина. Маскот нарисован на почти чёрном и живёт за счёт
   бирюзового свечения; на белой карточке он превращался в тёмную наклейку, а
   свечению было не на чем светиться. Карта доступа этот вопрос решила ещё в
   редизайне (в светлой теме она «полированная сталь»), герои просто повторяют
   то же решение — теми же числами. */
:root[data-theme="light"] .hero-card,
:root:not([data-theme="dark"]) .hero-card {
  background: linear-gradient(157deg, #2C3546 0%, #1A2130 48%, #0E1420 100%);
  box-shadow:
      0 18px 38px rgba(12, 22, 48, .28),
      inset 0 0 0 1px rgba(255, 255, 255, .10),
      inset 0 1px 0 rgba(255, 255, 255, .18);
  color: #E7ECF4;
}
/* 🚨 Крупное число залито металлом через `background-clip: text`, поэтому
   переопределять его можно ТОЛЬКО `background-image`: сокращённое `background`
   сбрасывает клип, `-webkit-text-fill-color: transparent` остаётся, и вместо
   числа рисуется прямоугольник. Та же мина, что уже ловилась на `.ac-days`. */
:root[data-theme="light"] .hero-card .hero-days,
:root:not([data-theme="dark"]) .hero-card .hero-days {
  background-image: linear-gradient(180deg, #FFFFFF 0%, #D5DDEA 45%, #9BA6B9 100%);
  -webkit-background-clip: text; background-clip: text;
}
:root[data-theme="light"] .hero-card :is(.hero-sub, .about-claim),
:root:not([data-theme="dark"]) .hero-card :is(.hero-sub, .about-claim) { color: #E7ECF4; }
:root[data-theme="light"] .hero-card :is(.hero-plan, .hero-foot, .hero-days small),
:root:not([data-theme="dark"]) .hero-card :is(.hero-plan, .hero-foot, .hero-days small) {
  color: var(--ag-3);
  -webkit-text-fill-color: var(--ag-3);
}
:root[data-theme="light"] .hero-card .hero-foot,
:root:not([data-theme="dark"]) .hero-card .hero-foot { border-top-color: rgba(255, 255, 255, .16); }

/* Чипы */
.chip {
  display: inline-flex; align-items: center; gap: 5px;
  padding: 5px 11px;
  border-radius: 100px;
  font-size: 12px; font-weight: 700;
  letter-spacing: .2px;
}
.chip .icon { width: 13px; height: 13px; stroke-width: 2.4; }
/* Без backdrop-filter: у элемента с backdrop-filter внутри карты со
   скруглением + overflow:hidden блюр не клипуется по радиусу и вылезает
   квадратом за угол (в тёмной теме особенно заметно справа сверху).
   Матовость даём полупрозрачной заливкой и внутренним бликом. */
/* Чип на карточке героя. Был белым полупрозрачным поверх синевы — на
   обсидиане от него оставалось мутное пятно. Теперь серебряная обводка. */
.chip-glass {
  color: var(--text-2);
  background: transparent;
  box-shadow: inset 0 0 0 1px var(--ag-rim);
}
.chip-ok   { color: var(--ok);   background: color-mix(in srgb, var(--ok) 13%, transparent); }
.chip-warn { color: var(--warn); background: color-mix(in srgb, var(--warn) 14%, transparent); }
.chip-bad  { color: var(--bad);  background: color-mix(in srgb, var(--bad) 12%, transparent); }
.chip-acc  { color: var(--text-2); background: transparent; box-shadow: inset 0 0 0 1px var(--ag-rim); }
/* «Популярно» — контрастный бейдж (тёмный фон + светлый текст), чтобы не
   сливался с карточкой тарифа. Тема-зависимый: в тёмной теме инвертируется. */
/* «Популярно» — единственный чип с металлической заливкой: он и должен
   выдёргивать взгляд на нужный тариф. */
.chip-popular { color: var(--metal-fg); background: var(--metal); box-shadow: inset 0 1px 0 rgba(255,255,255,.5); }

/* Карточка тарифа «Трафик» */
.plan-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.plan-name { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 15.5px; }
/* Плашка под иконкой. Раньше — бледно-синяя заливка (--grad-acc-soft), теперь
   графит с волосяной гранью: та же логика, что у карточек, только мельче.
   Сама иконка серебряная, не белая: белый на обсидиане звенит слишком громко
   для второстепенного элемента. */
.plan-ico {
  width: 40px; height: 40px;
  border-radius: 14px;
  display: grid; place-items: center;
  color: var(--ag-ink);
  background: var(--surface-2);
  box-shadow: inset 0 0 0 1px var(--line);
}
.plan-ico .icon { width: 21px; height: 21px; }

/* Кнопки */
.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 8px;
  padding: 14px 18px;
  border-radius: var(--r-sm);
  font-weight: 700; font-size: 14.5px;
  /* `.btn` was only ever put on <button>, which has no underline. On an <a> it
     inherits one and the button reads as a link with a line through the middle
     of it. Declared here, for the whole app, so both tags look identical —
     changes nothing for a <button>. */
  text-decoration: none;
  transition: transform .16s ease, box-shadow .2s ease, opacity .2s ease;
}
.btn:active { transform: scale(.965); }
.btn .icon { width: 18px; height: 18px; stroke-width: 2.1; }
/* ── ПЛИТА: главная кнопка Voron ──────────────────────────────────────────
   Единственный элемент интерфейса, залитый металлом целиком. Отсюда правило,
   на котором держится вся навигация по экрану: серебряная плита на экране
   ОДНА — это то самое действие, ради которого экран открыт. Всё остальное
   действие — обводка или строка.

   `--metal-fg` меняется вместе с плитой: в тёмной теме плита светлая и текст
   на ней тёмный, в светлой наоборот. Захардкодить #fff, как было у синей
   кнопки, нельзя — в тёмной теме он ляжет белым по хрому. */
.btn-primary {
  color: var(--metal-fg);
  background: var(--metal);
  box-shadow:
      inset 0 1px 0 rgba(255,255,255,.55),
      inset 0 -1px 0 rgba(0,0,0,.18),
      0 4px 14px rgba(0,0,0,.28);
  position: relative;
  overflow: hidden;
}
/* Блик, который проходит по плите при нажатии — «свет скользнул по металлу».
   Без него плита выглядит наклейкой: у металла отклик на движение обязан быть. */
.btn-primary::before {
  content: ''; position: absolute; inset: 0;
  background: linear-gradient(105deg, transparent 30%, rgba(255,255,255,.5) 48%, transparent 66%);
  transform: translateX(-120%);
  pointer-events: none;
}
.btn-primary:active::before { transition: transform .5s ease-out; transform: translateX(120%); }
@media (prefers-reduced-motion: reduce) { .btn-primary::before { display: none; } }
/* Стеклянная кнопка внутри hero-карты (на градиенте) */
/* 🚨 ЛЮБАЯ кнопка внутри героя обязана быть спозиционирована. Водяной знак
   (.hero-shield) — абсолютный элемент с z-index:0, то есть уже в контексте
   наложения; статичная кнопка рисуется РАНЬШЕ него и уезжает под силуэт.
   Ловится не глазами, а первым же не-.btn-hero в герое: так и случилось, когда
   «Транзакции» и «Промокод по-умолчанию» на странице промокодов перестали быть
   плитами и стали обводками. */
.hero-card .btn { position: relative; z-index: 1; }

/* Кнопка внутри героя = та же плита. Раньше это было матовое стекло поверх
   синевы; на тёмной карточке стекло не за что цеплять — под ним тот же
   обсидиан, и кнопка исчезала. */
.btn-hero {
  position: relative; z-index: 1;
  margin-top: 18px;
  color: var(--metal-fg);
  background: var(--metal);
  box-shadow:
      inset 0 1px 0 rgba(255,255,255,.55),
      inset 0 -1px 0 rgba(0,0,0,.18),
      0 4px 14px rgba(0,0,0,.3);
  overflow: hidden;
}
.btn-hero::before {
  content: ''; position: absolute; inset: 0;
  background: linear-gradient(105deg, transparent 30%, rgba(255,255,255,.5) 48%, transparent 66%);
  transform: translateX(-120%); pointer-events: none;
}
.btn-hero:active::before { transition: transform .5s ease-out; transform: translateX(120%); }
@media (prefers-reduced-motion: reduce) { .btn-hero::before { display: none; } }
/* Второе действие — не заливка, а ГРАНЬ: серебряная волосяная обводка на
   прозрачном. Две плиты рядом дрались бы за внимание. */
.btn-soft {
  color: var(--text);
  background: transparent;
  box-shadow: inset 0 0 0 1px var(--ag-rim);
}
.btn-ghost { color: var(--text-2); background: var(--surface-2); }
/* Обводка поярче — там, где второе действие всё же нужно заметить
   (напр. «Предложить идею»). */
.btn-outline {
  color: var(--text);
  background: transparent;
  box-shadow: inset 0 0 0 1.5px var(--ag-ink);
}
.btn-danger { color: var(--bad); background: color-mix(in srgb, var(--bad) 10%, transparent); }
.btn-block { width: 100%; }
.btn-sm { padding: 10px 14px; font-size: 13.5px; border-radius: 13px; }

/* Экран «не удалось загрузить» — восстановимая ошибка вместо белого экрана, когда
   ядро (getCore) не пришло. Показывается из boot() → _showBootError. */
.boot-error {
  display: flex; flex-direction: column; align-items: center; text-align: center;
  gap: 8px; padding: 64px 24px 0;
}
.boot-error-emoji { font-size: 40px; line-height: 1; margin-bottom: 6px; }
.boot-error-title { font-weight: 700; font-size: 17px; color: var(--text); }
.boot-error-sub   { font-size: 14px; color: var(--text-2); max-width: 260px; }
.boot-error .btn  { margin-top: 18px; min-width: 160px; }

/* Ховер для мыши (ПК/десктоп Telegram) — ОДИНАКОВЫЙ ВЕЗДЕ по построению:
   поверх элемента лежит вуаль ::after, на ховере она плавно набирает одну и ту
   же прозрачность с одной и той же скоростью. Цвет вуали — currentColor, т.е.
   ЦВЕТ ТЕКСТА САМОГО элемента: текст по определению контрастен своему фону,
   поэтому вуаль одинаково заметна на любом фоне (белая на синих кнопках,
   тёмная на серых плашках, светлая в тёмной теме). Прежняя вуаль var(--text)
   была тёмной и на тёмно-синих кнопках почти исчезала — «в каждом меню ховер
   разной силы». border-radius: inherit повторяет скругление хозяина. */
@media (hover: hover) and (pointer: fine) {
  /* ⚠️ ЭТО ЕДИНСТВЕННОЕ НАВЕДЕНИЕ ВО ВСЁМ ПРИЛОЖЕНИИ, и списки ниже — ручные.
     Новый интерактивный компонент, не вписанный сюда, НА ДЕСКТОПЕ МЁРТВ: он
     нажимается, но под курсором никак не отзывается. Именно так вся админка
     осталась без наведения — `:active` у неё был, а в этих списках её не было
     ни одним классом (исправлено 29.07.2026).
     СПИСКОВ ТРИ, и они обязаны совпадать: `:is(...)` даёт position:relative,
     `:where(...)::after` — саму вуаль, `:where(...):hover::after` — её показ.
     Добавляете компонент — впишите его во ВСЕ ТРИ.

     Панели «жидкого стекла» (вкладки таббара, кнопки периода) вуали НЕ получают:
     у них своя интерактивность (капля/портал), подсветка поверх стекла — чужеродна.
     Тумблер `.admin-sw` тоже не получает — у него свой отклик через `.pressing`.
     Строки-меню (оферта/политика, роутинг) вуаль СНОВА получают — скруглённой
     таблеткой (правило в конце блока), без острых углов и без клипа. */
  :is(.btn, .icon-btn, .sheet-close, .row.selectable, .invite-link,
      .profile-chip, .head-btn, .theme-btn,
      .key-link, .set-row,
      .admin-tile, .hub-tile, .admin-row, .admin-h-info, .adm-select,
      .adm-date, .admin-search-go, .adm-chip, .cfg-tap, .opt,
      .period-pill, .line, .mono-copy, .toast-undo, .login-link,
      .dev-step-btn) {
    position: relative;
  }
  /* Списки — через :where() (нулевая специфичность), чтобы точечные правила ниже
     по файлу (глушилка вуали у .key-link .icon-btn, скруглённые таблетки строк)
     всегда выигрывали. */
  :where(.btn, .icon-btn, .sheet-close, .row.selectable, .invite-link,
      .profile-chip, .head-btn, .theme-btn,
      .key-link, .set-row,
      .admin-tile, .hub-tile, .admin-row, .admin-h-info, .adm-select,
      .adm-date, .admin-search-go, .adm-chip, .cfg-tap, .opt,
      .period-pill, .line, .mono-copy, .toast-undo, .login-link,
      .dev-step-btn)::after {
    content: '';
    position: absolute; inset: 0;
    border-radius: inherit;
    background: currentColor;
    opacity: 0;
    transition: opacity .18s ease;
    pointer-events: none;
  }
  :where(.btn, .icon-btn, .sheet-close, .row.selectable, .invite-link,
      .profile-chip, .head-btn, .theme-btn,
      .key-link, .set-row,
      .admin-tile, .hub-tile, .admin-row, .admin-h-info, .adm-select,
      .adm-date, .admin-search-go, .adm-chip, .cfg-tap, .opt,
      .period-pill, .line, .mono-copy, .toast-undo, .login-link,
      .dev-step-btn):hover::after {
    opacity: .08;
  }
  /* Плоские строки-меню (оферта/политика в профиле, пункты роутинга, строка
     «Тип роутинга» на главной): вуаль — таблетка ВО ВСЮ ШИРИНУ строки (как в
     iOS-списках), с вертикальным отступом, чтобы не липнуть к разделителям.
     Горизонтальный inset НУЛЕВОЙ (строка и так внутри отступов карточки, до её
     overflow-клипа скруглённого угла вуаль не достаёт — потому раньше и «клипало»
     при отрицательном выносе). У .route-row верх опущен ниже её разделителя. */
  /* Таблетка обнимает КОНТЕНТ строки с равными полями сверху и снизу. Ключевое:
     у строк РАЗНЫЕ вертикальные отступы, поэтому единый inset давал кривой размер.
     .set-row: padding 11px сверху И снизу → симметрично, inset 4px.
     .route-row: padding-top 14px, а СНИЗУ ОТСТУПА НЕТ (иконка 38px упирается в
     нижний край) — прежний inset снизу 4px ОБРЕЗАЛ низ контента, отсюда «не по
     размеру». Теперь верх 9px (5px над иконкой) и низ −5px (5px под иконкой, уходит
     в нижний паддинг карточки, до её скруглённого угла не достаёт). */
  /* Ширину таблетки увеличили: она выходит на 6px за строку с каждой стороны
     (строка стоит в 14px от края карточки → таблетка в 8px). БОЛЬШЕ НЕЛЬЗЯ: у
     .card радиус 28px и overflow:hidden, и у ПЕРВОГО ряда угол карточки начинает
     срезать таблетку в острую кромку (это и был баг «острые углы»). Предел по
     геометрии — около −7px; берём −6px с запасом. */
  .set-row::after { inset: 4px -6px; border-radius: 14px; }

  /* 🚨 ВЫБОР ИЗ СПИСКА С ПОДВИЖНОЙ ПИЛЮЛЕЙ — ВУАЛИ НЕ ПОЛУЧАЕТ, И ЭТО НАМЕРЕННО.
     Речь про `.route-opt`: пункты «Типа роутинга» и выбора языка — это один и
     тот же компонент.

     Отметку выбора там рисует ПОДВИЖНАЯ пилюля `.route-pill`: она во всю строку
     и со скруглением `--r-sm` (16px). Вуаль же ложилась своим прямоугольником —
     `inset: 4px 0` и радиус 14px, — то есть другого размера и другой формы. При
     наведении появлялся контур, который не совпадал с тем, что через мгновение
     в эту же строку прилетит, и спорил с ним.

     Вместо подсветки — лёгкое увеличение. Оно ничего не обводит, поэтому форму
     пилюли не с чем сравнивать, и складывается с нажатием: наведение
     приподнимает (1.02), нажатие вдавливает (.985 из `.row.selectable:active`,
     у которого специфичность выше, так что нажатие выигрывает).

     ⚠️ Это ИСКЛЮЧЕНИЕ из правила «новый тапабельный компонент получает вуаль»,
     а не забытая строка. `CLAUDE/tests/hover-veil.js` знает про него поимённо. */
  .route-opt::after { content: none; }
  .route-opt:hover { transform: scale(1.02); }
  /* Строки «Настроек» админки — ТОТ ЖЕ случай, что оферта/политика в профиле:
     они лежат в ОДНОЙ карточке с разделителями (`.cfg-group` — это и есть
     `.card`), у самой строки радиуса нет, поэтому вуаль по умолчанию
     (`border-radius: inherit` → 0) читалась прямоугольником, упирающимся в
     разделители. Лечится так же — таблеткой с вертикальным отступом.
     Числа не на глаз: у `.cfg-row` padding 8px сверху и снизу, поэтому inset 4px
     оставляет до разделителя те же 4px, что и у `.set-row`. По горизонтали
     строка стоит в 16px от края карточки, так что −8px даёт 8px просвета —
     ровно столько, на скольких `.set-row` проверен. Ближе нельзя: у `.card`
     радиус 28px и overflow:hidden, и у ПЕРВОГО ряда угол начинает срезать
     таблетку в острую кромку (проверено: на y=10px край карточки на x=6.6px). */
  .cfg-tap::after { inset: 4px -8px; border-radius: 14px; }
  /* Ряд с ключом на главной ховерится ЦЕЛИКОМ (как ряд с ID в профиле) — вуаль
     вложенной кнопки-копии глушим, иначе при наведении она темнела бы дважды. */
  .key-link .icon-btn::after { display: none; }
}

.icon-btn {
  width: 42px; height: 42px; flex: none;
  border-radius: 14px;
  display: grid; place-items: center;
  color: var(--ag-ink);
  background: var(--surface-3);
  box-shadow: inset 0 0 0 1px var(--line);
  transition: transform .16s ease;
}
.icon-btn:active { transform: scale(.92); }
.icon-btn .icon { width: 19px; height: 19px; stroke-width: 2; }

/* Ключ доступа */
.key-link {
  display: flex; align-items: center; gap: 10px;
  margin-top: 14px;
  padding: 12px 12px 12px 15px;
  border-radius: var(--r-sm);
  background: var(--surface-2);
  box-shadow: inset 0 0 0 1px var(--line);
  cursor: pointer;   /* копирует ключ тапом по ВСЕЙ строке (как ряд с ID в профиле) */
}
.key-link code {
  flex: 1; min-width: 0;
  font-family: ui-monospace, "SF Mono", SFMono-Regular, Menlo, monospace;
  font-size: 12.5px;
  color: var(--text-2);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}


/* Строка выбора типа роутинга внутри карточки ключа */

/* Прокси для Telegram */
.proxy-intro { font-size: 13px; line-height: 1.5; color: var(--text-2); font-weight: 500; margin-bottom: 14px; }
.proxy-row { gap: 11px; }
.proxy-flag { font-size: 25px; flex: none; line-height: 1; width: 30px; text-align: center; }
.proxy-row .row-main b { font-size: 14.5px; }
.proxy-go { flex: none; padding: 9px 16px; }

/* Опции роутинга в шторке (с описанием) */
/* 🚨 `text-align: start` тут не декорация. `.row.selectable` — это <button>, а у
   кнопки браузер по умолчанию центрирует текст; `.row` своего выравнивания не
   задаёт (в отличие от .opt/.nav-row/.support-card). У коротких строк это не
   видно, а описание режима роутинга — четыре строки, и они стояли по центру
   под заголовком, выровненным влево. Досталось от Scale, чинится здесь. */
.route-opt { align-items: flex-start; text-align: start; }
.route-opt .row-main b { font-size: 15px; }
.route-opt .row-main span { display: block; margin-top: 3px; font-size: 12.5px; line-height: 1.45; color: var(--text-2); font-weight: 500; white-space: normal; }
.route-opt .radio-dot { margin-top: 3px; }

/* Карточка поддержки (главная) — обычная белая, иконка с мягкой заливкой */

/* ── «Чего-то не хватает?» ────────────────────────────────────────────────
   Плита монолита: текст сверху, действие отдельным рядом под швом. Прошли две
   крайности — карточка с контрастной кнопкой (кнопка выходила ЯРЧЕ, чем
   «Продлить подписку», и предложение написать идею перебивало покупку) и голый
   текст на грунте (нет ни границ, ни зоны нажатия — тапать по абзацу). Плита
   даёт блоку границы, а ряду — честную зону нажатия, и при этом не заводит на
   экране третий предмет: заливки и тени у неё те же, что у любой другой плиты. */
.wish { overflow: hidden; }
.wish-body { padding: 16px 17px 14px; }
.wish-title { display: block; font-size: 17px; font-weight: 800; letter-spacing: -.35px; color: var(--text); }
.wish-sub {
  display: block; margin-top: 6px;
  font-size: 13px; font-weight: 500; line-height: 1.5; color: var(--text-3);
}
/* Шов между текстом и действием — тот же, что между рядами внутри плиты. */
.wish .line { box-shadow: inset 0 1px 0 var(--line); }

/* ── Рефералка ────────────────────────────────────────────────────────────
   Композиция переставлена: ГЕРОЙ теперь говорит только факт (сколько друзей и
   какое место в топе), а объяснение «как это работает» уехало под кнопку.
   Раньше в герое стояли четыре строки мелкого текста — они забивали само
   число, ради которого экран и открывают, и отодвигали ссылку за первый экран.
   Кнопка «Поделиться» стала металлической плитой: на этой вкладке она и есть
   главное действие (правило «одна плита на экран»). */
/* ── Рефералка: лаконичная карточка приглашения ── */
.invite { display: flex; flex-direction: column; gap: 12px; }
.invite-title { font-size: 13px; font-weight: 650; color: var(--text-2); letter-spacing: -.1px; }
.invite-link {
  display: flex; align-items: center; gap: 10px;
  width: 100%; text-align: start;
  padding: 13px 13px 13px 16px;
  border-radius: var(--r-sm);
  background: var(--surface-2);
  box-shadow: inset 0 0 0 1px var(--line);
  transition: transform .16s ease;
}
.invite-link:active { transform: scale(.985); }
/* Блок «Ваш ID» — без отклика на нажатие: при копировании он сжимался и тут же
   разжимался обратно, из-за чего вся карточка дёргалась. Отклик даём тактильный
   (hap) и тостом, а не движением. Так же гасим transition, чтобы на WebView с
   «липким» :active не осталось подвисшего кадра анимации. */
.invite-link.uid-link { transition: none; }
.invite-link.uid-link:active { transform: none; }
.invite-link code {
  flex: 1; min-width: 0;
  font-family: ui-monospace, "SF Mono", SFMono-Regular, Menlo, monospace;
  font-size: 12.5px; color: var(--text-2);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.invite-copy { flex: none; color: var(--ag-ink); display: grid; place-items: center; }
/* .invite — flex с gap:12px, поэтому собственный отступ гравировки здесь лишний
   (иначе между подписью и ссылкой 24px вместо 12). */
.invite > .engrave:first-child { margin-bottom: 0; }
.invite-copy .icon { width: 19px; height: 19px; stroke-width: 2; }

/* ── Минималистичная навигационная строка (Управление промокодами) ── */

/* ── Подстраница (Промокоды) — поверх вкладки, таббар остаётся ── */
.empty-card {
  display: flex; flex-direction: column; align-items: center; text-align: center;
  gap: 5px; padding: 30px 22px;
}
.empty-ico {
  width: 34px; height: 34px; margin-bottom: 10px;
  display: grid; place-items: center;
  color: var(--ag-ink);
}
.empty-ico .icon { width: 25px; height: 25px; }
.empty-card b { font-size: 15px; letter-spacing: -.15px; }
.empty-card span { font-size: 13px; color: var(--text-2); font-weight: 500; line-height: 1.5; }

/* Действия в hero-блоке промокодов */
.hero-actions .btn { margin-top: 0; width: 100%; white-space: nowrap; }

/* Карточка статистики промокодов: заголовок + пилюля периода + метрики */
.stats-card { padding: 16px 18px 18px; }
.stats-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.stats-title { font-size: 15px; font-weight: 750; letter-spacing: -.2px; }
.period-pill {
  display: inline-flex; align-items: center; gap: 5px;
  padding: 7px 10px 7px 13px;
  border-radius: 100px;
  font-size: 13px; font-weight: 650; color: var(--text);
  background: var(--surface-2);
  transition: transform .15s ease;
}
.period-pill:active { transform: scale(.95); }
.period-pill .chev { width: 15px; height: 15px; color: var(--text-3); transform: rotate(90deg); stroke-width: 2.4; }

.stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); margin-top: 16px; }
.metric { display: flex; flex-direction: column; gap: 4px; align-items: center; text-align: center; position: relative; }
.metric + .metric::before {
  content: ''; position: absolute; left: 0; top: 50%; transform: translateY(-50%);
  height: 30px; width: 1px; background: var(--line);
}
.metric b { font-size: 19px; font-weight: 800; letter-spacing: -.6px; white-space: nowrap; }
.metric span { font-size: 11.5px; color: var(--text-3); font-weight: 600; }

/* Метка «по-умолчанию» у промокода */
.tag-default {
  display: inline-block; vertical-align: middle;
  margin-inline-start: 6px; padding: 2px 7px;
  border-radius: 7px;
  font-size: 10px; font-weight: 700; letter-spacing: .2px;
  color: var(--text);
  background: color-mix(in srgb, var(--acc-1) 11%, transparent);
}

/* Премиальный список выбора (период статистики) */
.opt-list { display: flex; flex-direction: column; gap: 8px; }
.opt {
  display: flex; align-items: center; justify-content: space-between; gap: 12px;
  width: 100%; text-align: start;
  padding: 15px 16px;
  border-radius: var(--r-sm);
  font-size: 15px; font-weight: 600; letter-spacing: -.1px;
  color: var(--text-2);
  background: var(--surface-2);
  box-shadow: inset 0 0 0 1px var(--line);
  transition: background .15s ease, transform .15s ease, color .15s ease;
}
.opt:active { transform: scale(.985); }
.opt .opt-check { width: 20px; height: 20px; flex: none; color: var(--text); opacity: 0; stroke-width: 2.4; }
.opt.sel { color: var(--text); background: color-mix(in srgb, var(--acc-1) 9%, transparent); font-weight: 700; }
.opt.sel .opt-check { opacity: 1; }
.opt-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.opt-main b { font-size: 15px; font-weight: 700; letter-spacing: -.1px; }
.opt-main span { font-size: 12px; color: var(--text-3); font-weight: 600; }

/* Метка «по-умолчанию» у промокода */
.tag-default {
  display: inline-block; vertical-align: middle;
  margin-inline-start: 6px; padding: 2px 7px;
  border-radius: 7px;
  font-size: 10px; font-weight: 700; letter-spacing: .2px;
  color: var(--text);
  background: color-mix(in srgb, var(--acc-1) 11%, transparent);
}

/* Списки-строки */
.row {
  display: flex; align-items: center; gap: 12px;
  padding: 13px 4px;
}
.row + .row { border-top: 1px solid var(--line); }
/* Плашки под глифом НЕТ — правило монолита: глиф лежит на поверхности.
   Правится ЗДЕСЬ, в самом компоненте, а не сводным блоком ниже по файлу:
   такой блок уже был и проигрывал компонентам при равной специфичности —
   иконки в шторке «О нас» так и остались с плашками. */
.row-ico {
  width: 26px; height: 26px; flex: none;
  display: grid; place-items: center;
  color: var(--ag-ink);
}
.row-ico .icon { width: 20px; height: 20px; }
.row-main { flex: 1; min-width: 0; }
.row-main b { display: block; font-size: 14px; letter-spacing: -.1px; }
.row-main span { font-size: 12px; color: var(--text-3); font-weight: 500; }
.row-side { font-size: 13px; font-weight: 700; color: var(--text-2); display: flex; align-items: center; gap: 6px; }
.row-side .icon { width: 16px; height: 16px; color: var(--text-3); }
/* ── Счётчик устройств (шаг покупки и докупка) ────────────────────────────
   Живёт в правой части обычной .row, на месте .row-side. Ничего своего:
   заливка и цвет — у .btn-ghost, скругление круглое, отклик на нажатие такой
   же, как у .btn (та же длительность и та же кривая `ease`). */
.dev-step { display: flex; align-items: center; gap: 10px; }
.dev-step-n { font-size: 15px; font-weight: 700; min-width: 20px; text-align: center; }
.dev-step-btn {
  width: 28px; height: 28px; flex: none;
  border: 0; border-radius: 50%;
  font-size: 17px; font-weight: 600; line-height: 1;
  color: var(--ag-ink); background: var(--surface-2);
  box-shadow: inset 0 0 0 1px var(--line);
  /* Без перехода на базовом правиле нажатие щёлкает и читается как «ничего не
     произошло» — то же требование, что и у .btn выше. */
  transition: transform .16s ease, opacity .2s ease;
}
.dev-step-btn:active { transform: scale(.88); }
.dev-step-btn:disabled { opacity: .35; }

.row.selectable { border-radius: var(--r-sm); padding: 13px 10px; transition: background .15s ease, transform .15s ease; }
/* Нажатие — ТОЛЬКО сжатие, никакой заливки. Это была ПОСЛЕДНЯЯ фоновая подсветка
   по нажатию во всём файле: на WebView с липким :active серый филл замерзал на
   тапнутой строке и выглядел «застрявшим бликом», который не переезжает. */
.row.selectable:active { transform: scale(.985); }
/* Фолбэк первой строкой: WebView без color-mix отбрасывал вторую и выбранная
   строка оставалась ВООБЩЕ без подсветки — казалось, что «подсветка не переехала». */
.row.selected { background: rgba(9, 144, 222, .09); }
.row.selected { background: color-mix(in srgb, var(--acc-1) 7%, transparent); }

/* ── Роутинг: подсветка выбора = ОДНА нейтральная пилюля, которая ПЕРЕЕЗЖАЕТ ──
   Контейнер НАМЕРЕННО плоский, без .card: у карточки в тёмной теме в shadow-soft
   зашит inset-ободок (1px белого по периметру) — та самая «внешняя обводка»,
   которая выглядела застрявшей рамкой выбора вокруг обеих опций и переживала
   любые перерисовки строк. Теперь и фон, и обводку несёт ТОЛЬКО подвижная
   пилюля; строки не красятся никогда, разделителей нет. */
.route-list { position: relative; padding: 2px 0; }
.route-list .row { position: relative; z-index: 1; }
.route-list .row + .row { border-top: 0; }
.route-list .row.selected,
.route-list .row.selectable:active { background: transparent; }
.route-list .radio-dot { width: 24px; height: 24px; }
.route-pill {
  position: absolute; left: 0; top: 0;
  z-index: 0; opacity: 0;
  border-radius: var(--r-sm);
  background: var(--surface-2);                 /* нейтральный, как в списках iOS */
  box-shadow: inset 0 0 0 1px var(--line);      /* обводка — НА подвижном элементе */
  pointer-events: none;
  will-change: transform;
  /* 🚨 ОДНА длительность и ОДНА кривая на всё, что двигается. Было
     `transform .34s`, а `height/width/left` — `.25s ease`: пилюля меняет высоту
     вместе с переездом (у пунктов роутинга описания разной длины), и её края
     доезжали с разницей почти в 90 мс — верх уже встал, низ ещё едет.
     Кривая — общая `--ease-spring`: шапка этого файла прямо называет прежнюю
     `.22,.9,.28,1` устаревшей (отклонение 23 % против 2.5 % у пружины). */
  transition: transform .34s var(--ease-spring),
              height .34s var(--ease-spring),
              width .34s var(--ease-spring),
              left .34s var(--ease-spring),
              opacity .2s ease;
}
/* Выбранная строка НЕ рисует плашку под глифом: плашек под глифами в
   приложении больше нет. Раньше здесь была сплошная заливка акцентом (--acc-1
   был синим), потом акцент стал серебром — и на выбранном способе оплаты
   вылезал светлый квадрат, единственный на весь экран. Выбор показывают
   подложка строки и залитое радио, этого достаточно. */
.row.selected .row-ico { color: var(--ag-ink); }

.radio-dot {
  width: 22px; height: 22px; flex: none;
  border-radius: 50%;
  border: 2px solid var(--ag-rim);
  display: grid; place-items: center;
  transition: border-color .18s ease, background-color .18s ease;
}
.row.selected .radio-dot { border-color: var(--text); background: var(--acc-1); }
.radio-dot::after { content: ''; width: 8px; height: 8px; border-radius: 50%; background: #fff; opacity: 0; transform: scale(.4); transition: opacity .18s ease, transform .18s ease; }
.row.selected .radio-dot::after { opacity: 1; transform: none; }

/* ── Лидерборд ── */
/* Аватар участника (инициал или фото) */
.ava {
  position: relative;
  width: 40px; height: 40px; flex: none;
  border-radius: 50%;
  display: grid; place-items: center;
  color: #fff; font-size: 15px; font-weight: 700; letter-spacing: -.2px;
  background-size: cover; background-position: center;
  box-shadow: inset 0 0 0 1px rgba(0,0,0,.05);
  overflow: hidden;
}
/* Фото перекрывает букву-заглушку целиком; при onerror img удаляется — буква видна */
.ava img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; }
/* Avatar of a hidden entrant («Аноним»): a white ghost on a dark grey circle.
   The colours are HARD-CODED and identical in both themes on purpose — this is
   the badge of anonymity, not part of the screen's palette (neighbouring avatars
   carry their own AVA_COLORS background and ignore the theme too). */
.ava-anon { background: #303034; color: #fff; }
.ava-anon .icon { width: 23px; height: 23px; stroke-width: 1.7; }
/* Место в топе — маленький бейдж на углу аватара (аватар занял «место») */
.lb-ava { position: relative; flex: none; line-height: 0; }
.lb-badge {
  position: absolute; right: -4px; bottom: -4px;
  min-width: 20px; height: 20px; padding: 0 5px;
  border-radius: 10px;
  display: grid; place-items: center;
  font-size: 11px; font-weight: 800; font-variant-numeric: tabular-nums;
  color: var(--text-2);
  background: var(--surface);
  border: 2px solid var(--surface);
  box-shadow: 0 2px 6px rgba(20,28,66,.16);
}
.lb-badge.me-badge { color: #fff; background: var(--acc-1); border-color: var(--text); }
.lb-row .row-main b { font-size: 14.5px; }
/* Скролл-производительность на слабых Android: строка — изолированная область
   отрисовки, браузер не перерасчитывает/не перерисовывает соседей при скролле */
.lb-row { contain: layout paint; }

/* Переключатель периода (неделя / месяц / год / всё время) */
/* ── Переключатель периода = МЛАДШИЙ БРАТ ТАББАРА ─────────────────────────
   Тот же рецепт один в один: .liquid-glass с настоящим SVG-преломлением через
   LiquidGlass.attach (Chromium) или чистым блюром (.lg-static, WebKit), та же
   капля .drop-glass со спекулярным сводом, та же сетка равных колонок. Никаких
   собственных заливок и отключённых слоёв — прошлые самодельные варианты и
   выглядели «не как таббар». */
/* Обёртка переключателя: как .tabbar-wrap — держит бегунок СНАРУЖИ панели
   (у .lbseg.liquid-glass overflow:hidden резал лифт бегунка за границы) */
.lbseg-wrap { position: relative; margin-top: 6px; }
.lbseg {
  position: relative;
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 1fr;
  align-items: stretch;
  height: 54px;
  padding: 7px;
  border-radius: 100px;
  user-select: none; -webkit-user-select: none; -webkit-touch-callout: none;
  cursor: grab;
}
/* Свечение изнутри — БЕЗ ИСКЛЮЧЕНИЙ. У контрола над плоским фоном гасим все три
   источника света базового стекла:
   1) прибавку яркости в backdrop-фильтре контейнера (brightness до 1.1 —
      равномерно «подсвечивала» весь контрол изнутри);
   2) нарисованные фаску и кольцо (::before с brightness 1.12–1.22 и белыми
      inset-тенями) — на ПК они ЖИЛИ до первого resize: attach() пропускает
      инициализацию, пока вкладка спрятана (размер 0), и lg-класс не ставился;
   3) спекуляр SVG-фильтра выключается в JS (spec: 0). */
.lbseg.liquid-glass {
  -webkit-backdrop-filter: blur(2.5px) saturate(1.4);
  backdrop-filter: blur(2.5px) saturate(1.4);
}
.lbseg.liquid-glass::before,
.lbseg.liquid-glass::after { display: none; }
.lbseg-drop {
  position: absolute; left: 0; top: 0;
  z-index: 4; pointer-events: none;   /* НАД кнопками (как капля таббара): стекло лежит поверх зажжённого пункта */
  will-change: transform;
  transition: transform .55s cubic-bezier(.3, 1.35, .35, 1), width .3s ease, height .3s ease;
}
/* Бегунок теперь СОСЕД панели (внутри .lbseg-wrap) — селекторы через «+» */
.lbseg.drag + .lbseg-drop { transition: none; }
.lbseg.drag + .lbseg-drop .drop-glass {
  /* Захват: ядро пружинисто набухает ПО ОБЕИМ осям и держит размер весь драг —
     как у капли таббара. Раньше стоял scale(1.12, 1) (только ширина), из-за чего
     бегунок при удержании вообще не подрастал по высоте. Рост идёт симметрично от
     центра и целиком помещается в 7px паддинга панели, а сама капля — сосед панели
     (.lbseg-wrap), её ничто не клипует. */
  transform: scale(1.12);
  box-shadow: var(--drop-shadow), 0 8px 20px rgba(20, 28, 66, .18);
  animation: drop-grab-seg .3s;
  transition: box-shadow .36s ease;
}
.lbseg-drop.drop-release .drop-glass { animation: drop-release-seg .44s; }
/* Мгновенный отклик на нажатие и продолжение с его формы — как у капли таббара
   (см. .tab-drop.press / drop-grab-tab), но под свой scale 1.12. */
.lbseg-drop.press .drop-glass {
  transform: scale(1.05, .988);
  transition: transform .13s cubic-bezier(.3, 0, .3, 1);
}
@keyframes drop-grab-seg {
  0%   { transform: scale(1.05, .988);  animation-timing-function: cubic-bezier(.2, 1.4, .36, 1); }
  100% { transform: scale(1.12); }
}
@keyframes drop-release-seg {
  /* Старт = форма и хвост ядра в момент отпускания — см. drop-release-tab */
  0%   { transform: translateX(var(--rel-t, 0%)) scale(var(--rel-x, 1.12), var(--rel-y, 1.12));
                                        animation-timing-function: cubic-bezier(.22, .72, .3, 1); }
  62%  { transform: translateX(0%) scale(.99, 1.008);
                                        animation-timing-function: cubic-bezier(.33, 0, .3, 1); }
  100% { transform: translateX(0%) scale(1); }
}
/* Пока плашку тянут — прижатый пункт не сжимается своим :active, иначе кнопка,
   с которой начали жест, оставалась «втянутой» весь драг («размер сам меняется») */
.lbseg.drag button:active { transform: none; }
.lbseg button {
  position: relative; z-index: 3;
  border-radius: 100px;
  font-size: 12.5px; font-weight: 650; letter-spacing: -.1px;
  color: var(--text-2);
  white-space: nowrap;
  transition: color .22s ease, transform .15s ease;
}
.lbseg button:active { transform: scale(.94); }
/* Вариант A (как у таббара): копии-линзы под бегунком больше нет. «Зажигается»
   сам выбранный пункт — .on в покое, .lit при перетаскивании (ведёт JS по
   ближайшему). Стекло без blur — подпись под ним читается, второго рисунка нет,
   значит нет и двоения. */
.lbseg button.on,
.lbseg button.lit { color: var(--text); }
/* При драге прежний .on, если он уже НЕ под каплей, гасим — иначе горели бы два */
.lbseg.drag button.on:not(.lit) { color: var(--text-2); }

/* Подиум топ-3 */
.lb-podium {
  display: grid;
  grid-template-columns: 1fr 1.14fr 1fr;
  gap: 10px;
  align-items: end;
}
.pod {
  position: relative;
  text-align: center;
  background: var(--surface);
  border-radius: 22px;
  padding: 22px 6px 14px;
  box-shadow: var(--shadow-soft);
}
.pod-ava-wrap { position: relative; width: 52px; margin: 0 auto 8px; line-height: 0; }
.pod-ava { width: 52px; height: 52px; font-size: 19px; margin: 0 auto; }
.pod-medal {
  position: absolute;
  right: -5px; bottom: -5px;
  width: 24px; height: 24px;
  border-radius: 50%;
  display: grid; place-items: center;
  font-size: 12px; font-weight: 800;
  border: 2.5px solid var(--surface);
}
.pod.p1 { padding-top: 28px; padding-bottom: 18px; background: linear-gradient(180deg, rgba(255, 199, 84, .2), transparent 62%), var(--surface); }
.pod.p1 .pod-ava-wrap { width: 62px; margin-bottom: 10px; }
.pod.p1 .pod-ava { width: 62px; height: 62px; font-size: 23px; box-shadow: inset 0 0 0 1px rgba(0,0,0,.05), 0 0 0 3px rgba(244,178,33,.55); }
.pod.p1 .pod-medal { width: 27px; height: 27px; font-size: 13px; color: #7A5200; background: var(--gold-grad); box-shadow: 0 4px 10px rgba(244, 178, 33, .45), inset 0 1px 2px rgba(255,255,255,.65); }
.pod.p2 { background: linear-gradient(180deg, rgba(148, 163, 190, .14), transparent 60%), var(--surface); }
.pod.p2 .pod-ava { box-shadow: inset 0 0 0 1px rgba(0,0,0,.05), 0 0 0 3px rgba(148,163,190,.5); }
.pod.p2 .pod-medal { color: #4E586B; background: var(--silver-grad); box-shadow: 0 3px 8px rgba(120, 136, 165, .35), inset 0 1px 2px rgba(255,255,255,.7); }
.pod.p3 { background: linear-gradient(180deg, rgba(212, 138, 66, .13), transparent 60%), var(--surface); }
.pod.p3 .pod-ava { box-shadow: inset 0 0 0 1px rgba(0,0,0,.05), 0 0 0 3px rgba(212,138,66,.5); }
.pod.p3 .pod-medal { color: #7B4315; background: var(--bronze-grad); box-shadow: 0 3px 8px rgba(196, 128, 62, .35), inset 0 1px 2px rgba(255,255,255,.55); }
.pod-name {
  font-size: 13px; font-weight: 750; letter-spacing: -.1px;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  padding: 0 4px;
}
.pod.p1 .pod-name { font-size: 14px; }
.pod-count {
  display: inline-flex; align-items: center; gap: 4px;
  margin-top: 4px;
  font-size: 12px; font-weight: 700;
  color: var(--text-2);
}
.pod-count .icon { width: 13px; height: 13px; stroke-width: 2.2; }
.pod-prize { margin-top: 8px; }
.chip-gold {
  color: #7A5200;
  background: var(--gold-grad);
  box-shadow: 0 4px 10px rgba(244, 178, 33, .3), inset 0 1px 1px rgba(255,255,255,.55);
}

/* Задания */
/* Невыполненное задание — вся карточка и есть кнопка: стрелка справа по центру
   (как у строк-настроек), под неё освобождено место справа */
/* Вся карточка — кнопка (role="button"), а отклика на нажатие у неё не было
   вовсе: единственный крупный тапабельный элемент приложения, который под
   пальцем не проминался. */
/* Номер задания — гравированная цифра в кольце, а не плашка с заливкой.
   Плашка выглядела кнопкой, хотя не нажимается: нажимается вся карточка. */
/* «Выполнено» остаётся ЗЕЛЁНЫМ, и это единственное исключение из монохрома на
   этом экране: --ok несёт смысл, а не бренд. Металл сюда не ставим — плита в
   приложении значит «нажми», а выполненное задание уже не нажимается. */
/* scaleX, а не width: width — раскладка и не ускоряется, а главное — правило
   на width не срабатывало НИКОГДА, потому что узел приезжал с готовым
   инлайновым значением. border-radius снят: полоску и так режет .task-bar
   (overflow: hidden), а под scaleX скругление растянулось бы в эллипс. */
.task-fill { height: 100%; width: 100%; background: var(--metal);
             transform-origin: left; transform: scaleX(0);
             transition: transform .8s cubic-bezier(.22, .9, .3, 1); }
[dir="rtl"] .task-fill { transform-origin: right; }
@media (prefers-reduced-motion: reduce) { .task-fill { transition: none; } }
.task-fill.done { background: var(--ok); }

/* ── «Плати по миру» — партнёрская страница (монохромный бренд партнёра) ── */
.pm-hero {
  position: relative; overflow: hidden;
  border-radius: var(--r-lg);
  padding: 22px 20px 24px;
  color: #fff;
  background: linear-gradient(160deg, #262A33 0%, #12141A 60%, #0A0C11 100%);
  box-shadow: var(--shadow-soft);
}
.pm-badge {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 6px 11px; border-radius: 100px;
  font-size: 11.5px; font-weight: 700; letter-spacing: .2px;
  background: rgba(255, 255, 255, .12);
  -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
}
.pm-badge .icon { width: 14px; height: 14px; stroke-width: 2; }
.pm-h { margin-top: 14px; font-size: 27px; font-weight: 800; letter-spacing: -.6px; }
.pm-sub { margin-top: 8px; max-width: 32ch; font-size: 13.5px; line-height: 1.5; font-weight: 500; opacity: .82; }

/* Декоративная карта */
.pm-card {
  position: relative; overflow: hidden;
  margin-top: 20px; height: 156px;
  border-radius: 18px;
  background: linear-gradient(135deg, #3C424E 0%, #191C23 100%);
  box-shadow: 0 18px 40px rgba(0, 0, 0, .45), inset 0 1px 1px rgba(255, 255, 255, .12);
}
.pm-card::after {
  content: ''; position: absolute; inset: 0;
  background: radial-gradient(120% 90% at 100% 0, rgba(255, 255, 255, .16), transparent 55%);
}
.pm-card > span { position: absolute; z-index: 1; }
.pm-card-brand { top: 16px; left: 18px; font-size: 12px; font-weight: 800; letter-spacing: 1.6px; }
.pm-card-chip {
  top: 50px; left: 18px; width: 38px; height: 28px; border-radius: 7px;
  background: linear-gradient(135deg, #EAD08A, #B8923F);
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .25);
}
.pm-card-num { bottom: 40px; left: 18px; font-size: 16.5px; font-weight: 600; letter-spacing: 2px; font-variant-numeric: tabular-nums; }
.pm-card-sys { bottom: 16px; right: 18px; font-size: 15px; font-weight: 800; font-style: italic; letter-spacing: .5px; }

.pm-cta {
  margin-top: 20px; width: 100%;
  color: #14161C; background: #fff;
  box-shadow: 0 8px 22px rgba(0, 0, 0, .28);
}
.pm-cta:active { transform: scale(.97); }

.pm-stats { display: grid; grid-template-columns: repeat(3, 1fr); padding: 16px 8px; }
.pm-stats > div { position: relative; text-align: center; }
.pm-stats > div + div::before {
  content: ''; position: absolute; left: 0; top: 50%; transform: translateY(-50%);
  width: 1px; height: 26px; background: var(--line);
}
.pm-stats b { display: block; font-size: 20px; font-weight: 800; letter-spacing: -.5px; }
.pm-stats span { font-size: 11.5px; color: var(--text-3); font-weight: 600; }

.pm-note { font-size: 11.5px; color: var(--text-3); line-height: 1.5; font-weight: 500; text-align: center; padding: 0 10px; }

/* ── Таббар (liquid glass) ── */
/* Позиционирование живёт на ОБЁРТКЕ (overflow: visible!): сама панель —
   .liquid-glass с overflow:hidden (клип для Android-блюра), и пока капля была
   её ребёнком, «поднятие» физически не могло выйти за границы панели. Капля
   теперь сосед панели внутри обёртки — геометрия та же (обёртка == панель),
   а клип панели её больше не касается. */
.tabbar-wrap {
  position: fixed;
  left: 50%;
  transform: translateX(-50%);
  bottom: calc(var(--safe-b) + 12px);
  z-index: 60;
  width: min(calc(100% - 28px), 420px);
  height: var(--tabbar-h);
  /* Число вкладок: панель раскладывает их равными колонками (grid-auto-columns).
     Дефолтную ширину капли считаем от этого же числа, а не от жёсткой «3» —
     JS (boot) выставляет актуальное значение по видимым вкладкам. */
  --tabs: 3;
}

/* ── The tab bar springs in once the app is live ─────────────────────────────
   While the app loads there is no tab bar at all: `html:not(.app-ready)` hides
   it from the inline <style> in index.html — i.e. before THIS file has even
   arrived. app.js adds `app-ready` the moment the first screen is on screen,
   and the bar jumps up from below.

   🚨 EVERY KEYFRAME MUST CARRY translateX(-50%). The bar is centred by that
   transform (see .tabbar-wrap above), so a frame with only translateY would
   drop the centring and throw it against the left edge mid-flight.

   fill-mode is omitted DELIBERATELY: when the animation ends the element falls
   back to its base rule, where the transform is exactly translateX(-50%). A
   permanent extra transform on a fixed container breaks native scrolling of
   nested scrollers on iOS, so no frame may be held — and since the last
   keyframe equals the base, the hand-off is invisible.

   The curve is `--ease-spring`, the same one navigation and the dock collapse
   use: a spring without overshoot. ONLY transform is animated, so the frames go
   to the compositor and run at the display's full rate (CLAUDE.md, 120 Hz). */
@keyframes tabbar-in {
  from { transform: translate(-50%, calc(var(--tabbar-h) + var(--safe-b) + 24px)); }
  to   { transform: translateX(-50%); }
}
html.app-ready .tabbar-wrap {
  animation: tabbar-in .52s var(--ease-spring);
}
/* Honour the system setting: with motion off the bar simply appears. */
@media (prefers-reduced-motion: reduce) {
  html.app-ready .tabbar-wrap { animation: none; }
}
.tabbar {
  position: relative;
  width: 100%;
  height: 100%;
  border-radius: 36px;
  display: grid;
  /* Колонки создаются по числу вкладок (одна равная колонка на вкладку), а не
     фиксировано под 4 — так 3 вкладки растягиваются на всю панель без пустого
     места, а при возврате «Плати по миру» станет снова 4. .tab-drop вне потока
     (position:absolute), поэтому в раскладке колонок не участвует. */
  grid-auto-flow: column;
  grid-auto-columns: 1fr;
  align-items: stretch;
  padding: 8px;
}
.tab {
  position: relative;
  z-index: 3;
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px;
  border-radius: 26px;
  color: var(--text-2);
  font-weight: 650;
  transition: color .22s ease, transform .15s ease;
}
.tab span { font-size: 10px; letter-spacing: 0; white-space: nowrap; }
.tab:active { transform: scale(.94); }
/* Пока плашку тянут — прижатая вкладка не сжимается своим :active, иначе
   вкладка, с которой начали жест, оставалась «втянутой» весь драг */
.tabbar.dragging .tab:active { transform: none; }
/* Переход был на `transform`, которого этой иконке никто никогда не задаёт, —
   мёртвое правило. А менялась у неё ТОЛЩИНА обводки (1.8 -> 2.1 у активной),
   и менялась скачком, пока цвет подписи на том же элементе плавно ехал .22s.
   Цвет иконки едет сам (currentColor), рвалась только толщина. */
.tab .icon { width: 22px; height: 22px; transition: stroke-width .22s ease; }
/* Вариант A: копии-линзы под каплей больше НЕТ (она двоила/кривила текст на
   стыке). «Зажигается» сама настоящая вкладка под каплей: в покое — .active, в
   перетаскивании — .lit (её ведёт JS по ближайшей). Стекло капли просто едет
   поверх зажжённой вкладки и слегка её преломляет — второго рисунка текста нет,
   значит нет и двоения. Иконки без transform: смена состояния — только цвет и
   толщина обводки, без «подпрыгивания». */
.tab.active,
.tab.lit { color: var(--text); }
.tab.active .icon,
.tab.lit .icon { stroke-width: 2.1; }
/* Во время драга семантически активную вкладку, если она сейчас НЕ под каплей,
   гасим до неактивной — иначе горели бы две (прежняя активная + та, что под
   каплей). Ровно одна .lit ведётся из JS. */
.tabbar.dragging .tab.active:not(.lit) { color: var(--text-2); }
.tabbar.dragging .tab.active:not(.lit) .icon { stroke-width: 1.8; }

/* Капля-индикатор активной вкладки: стеклянная плашка, которая ЕДЕТ (translateX)
   к активной вкладке. Вариант A: копии-линзы внутри больше нет — «зажжённость»
   даёт сама вкладка под каплей (.active/.lit), поэтому нет второго рисунка текста
   и двоения на стыке. Позицию и размер задаёт moveDrop() в app.js. */
.tab-drop {
  position: absolute;
  top: 8px; left: 0;
  height: calc(100% - 16px);
  width: calc((100% - 16px) / var(--tabs, 3));
  /* НАД вкладками (у .tab z-index:3): стекло лежит поверх зажжённой вкладки и
     слегка её преломляет. Под вкладками капля читалась бы как «сломанная» —
     серый текст кнопки рисовался бы поверх стекла. */
  z-index: 4;
  pointer-events: none;
  /* Скрыта, пока JS (moveDrop) не поставит её под активную вкладку. Иначе за
     время асинхронного старта (await темы/состояния) браузер успевает
     отрисовать каплю на CSS-дефолте (left:0) — эффект «появилась слева». */
  opacity: 0;
  will-change: transform, opacity;
  transition:
    transform .55s cubic-bezier(.3, 1.35, .35, 1),
    width .3s ease,
    height .3s ease,
    opacity .25s ease;
}
.tab-drop.placed { opacity: 1; }
/* Ядро ОБЕИХ капель (таббар и период) — полупрозрачная стеклянная плашка:
   градиентная вуаль + фирменные блики + мягкая тень. КЛЮЧЕВОЙ ИНВАРИАНТ: капля
   обязана читаться БЕЗ backdrop-filter. В WebKit вложенный backdrop-filter
   (капля стоит НАД стеклом панели, у которого свой backdrop-filter) не
   применяется вовсе — вся «стеклянность»/яркость от него там пропадает, и в
   тёмной теме индикатор становился почти невидимым. Поэтому объём держим на
   заливке, кромке и тени, а не на фильтре. blur НЕ ставим — иначе подпись под
   стеклом расплывается (а у периода ещё и двоится с копией в линзе). У ТАББАРА
   копии нет (вариант A): под каплей зажигается сама вкладка. У ПЕРИОДА копия-
   линза пока остаётся. SVG-«варп по кромке» (LiquidGlass.attach на .drop-glass)
   — лишь бонус в Chromium, где он поддерживается. */
.drop-glass {
  position: absolute;
  inset: 0;
  display: block;
  border-radius: 100px;
  background: linear-gradient(180deg, rgba(255, 255, 255, .30), rgba(255, 255, 255, .06) 52%, rgba(255, 255, 255, .15));
  box-shadow: var(--drop-shadow);
  -webkit-backdrop-filter: saturate(1.8) brightness(1.06);
  backdrop-filter: saturate(1.8) brightness(1.06);
  will-change: transform;
  /* Возврат на ОТПУСКАНИИ и посадка после желе — заметная, но честная пружина.
     Крайности обе плохи: 1.6 проскакивало сильно НИЖЕ единицы (капля сжималась в
     «щепотку» — нереалистично), 1.25 почти не читалось («анимации нет»). 1.42 —
     ощутимый отскок без пережима. */
  transition: transform .46s cubic-bezier(.28, 1.42, .38, 1);
}
/* У ПОДВИЖНОЙ капли кромки-линзы НЕТ (по просьбе): варп по границам остаётся
   только у ФОНОВОЙ панели (.liquid-glass::before). Капля — чистая стеклянная
   плашка: заливка + блик + тень, без искажения контура. */
/* Верхний спекулярный свод — объём линзы (в тёмной теме гасим) */
.drop-glass::after {
  content: ''; position: absolute;
  left: 9%; right: 9%; top: 2px; height: 46%;
  border-radius: 100px;
  pointer-events: none;
  z-index: 2;
  background: linear-gradient(180deg, rgba(255, 255, 255, .9), rgba(255, 255, 255, 0) 92%);
  opacity: .5;
}
:root[data-theme="dark"] .drop-glass {
  /* Чуть прозрачнее (.14 → .10) и без светлого контура — капля мягче. Выбор
     всё равно виден по зажжённой вкладке под ней (вариант A). */
  background: rgba(255, 255, 255, .10);
}
/* В тёмной теме гасим только спекулярный блик (::after). Кромка-линза (::before)
   ОСТАЁТСЯ — это и есть варп по границам, он нужен в обеих темах. */
:root[data-theme="dark"] .drop-glass::after { opacity: 0; }
/* Захват: зажали каплю → ядро подрастает и становится «интерактивным», каплю
   можно тянуть между вкладками. Внешний слой в это время без перехода — едет
   точно за пальцем; ядро масштабируется отдельно (пружинистая посадка).
   Лифт заметно тянется по ВЕРТИКАЛИ и выходит за границы панели (панель ничего
   не клипует) — как у Apple; полёт по клику при этом жёсткий, без сплющивания. */
/* Фаза полёта: ядро вытягивается по ходу движения и сплющивается, как жидкость;
   позиция при этом не искажается — тянется только ядро, внешний слой едет ровно.
   Обратно ядро пружинит базовым переходом .drop-glass (overshoot). Это и есть
   «упругая» капля; вернули после того, как копия-линза ушла (вариант A) — теперь
   деформироваться нечему рассинхронизироваться с текстом. */
.tab-drop.drop-stretch .drop-glass,
.lbseg-drop.drop-stretch .drop-glass {
  transform: scale(1.22, .84);
  transition: transform .2s cubic-bezier(.4, 0, .6, 1);
}
.tab-drop.drag { transition: none; cursor: grabbing; }
.tab-drop.drag .drop-glass {
  /* Захват: ядро набухает и держит этот размер весь драг; скоростную деформацию
     ведёт _jelly (JS-пружина) ПОСЛЕ того, как отыграет анимация захвата —
     инлайновый transform с transition:none, включённый раньше, рвал бы её на
     полувзлёте. Конечный кадр анимации РАВЕН transform ниже, поэтому анимация
     идёт без fill-mode (иначе она перебивала бы инлайновое желе), и по её
     окончании ядро остаётся ровно там же — стыка не видно. */
  transform: scale(1.17);
  box-shadow: var(--drop-shadow), 0 12px 30px rgba(20, 28, 66, .22);
  animation: drop-grab-tab .3s;
  transition: box-shadow .36s ease;
}
.tab-drop.drop-release .drop-glass { animation: drop-release-tab .44s; }
/* НАЖАТИЕ: отклик СРАЗУ по pointerdown, ещё до того как жест признан удержанием.
   Перетаскивание включается только через 240мс (holdTimer), и раньше весь набух
   ждал этого момента — палец уже держит, а капля ещё мертва, и пружина
   приезжала заметно позже нажатия. Теперь капля отвечает мгновенно: чуть
   собирается (сжатие по высоте ЕЛЕ заметное, 1.5%) и слегка растёт. */
.tab-drop.press .drop-glass {
  transform: scale(1.06, .985);
  transition: transform .13s cubic-bezier(.3, 0, .3, 1);
}
/* Захват (240мс): продолжаем РОВНО с формы нажатия — 0%-кадр равен .press, иначе
   капля скакнула бы обратно к 1 и набухла заново. Один пружинистый рост, без
   отдельной фазы «вдоха»: вдох уже отыгран мгновенным .press. */
@keyframes drop-grab-tab {
  0%   { transform: scale(1.06, .985);  animation-timing-function: cubic-bezier(.2, 1.4, .36, 1); }
  100% { transform: scale(1.17); }
}
/* Отпускание: спокойное оседание с ОДНИМ мягким недолётом, а не серия качаний.
   Четыре кадра «туда-сюда» за .54s читались как дрожь, да и подскок вверх на
   30% выглядел странно для только что положенной капли. Теперь капля ровно
   опадает, чуть проседает по ширине с отдачей вверх — и садится на место. */
/* Список transform-функций во ВСЕХ кадрах одинаков (translateX + scale): при
   разной длине списков CSS уходит в матричную интерполяцию, и покачивание
   плывёт. Хвост (--rel-t) с формой вместе гасится к нулю. */
@keyframes drop-release-tab {
  /* Старт = форма ядра в момент отпускания (её ставит _dropRelease); без броска
     это ровно scale захвата, после броска — растянутая желейная форма с хвостом. */
  0%   { transform: translateX(var(--rel-t, 0%)) scale(var(--rel-x, 1.17), var(--rel-y, 1.17));
                                        animation-timing-function: cubic-bezier(.22, .72, .3, 1); }
  62%  { transform: translateX(0%) scale(.985, 1.012);
                                        animation-timing-function: cubic-bezier(.33, 0, .3, 1); }
  100% { transform: translateX(0%) scale(1); }
}
/* На ПК показываем, что каплю можно зажать и потянуть */
.tabbar { cursor: grab; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; }
@media (prefers-reduced-motion: reduce) {
  .tab-drop, .drop-glass, .tab-drop.drag .drop-glass,
  .tab-drop.drop-stretch .drop-glass, .lbseg-drop.drop-stretch .drop-glass,
  .lbseg-drop, .lbseg.drag + .lbseg-drop .drop-glass,
  .tab-drop.drop-release .drop-glass, .lbseg-drop.drop-release .drop-glass,
  .tab-drop.press .drop-glass, .lbseg-drop.press .drop-glass {
    transition: none; transform: none; animation: none;
  }
}

/* ── Шторки (bottom sheets) ── */
.sheet-backdrop {
  position: fixed; inset: 0; z-index: 80;
  /* Цвет затемнения — по теме: старый rgba(12,15,30,…) — тёмно-СИНИЙ, и в
     тёмной теме он подсинивал весь фон главной под шторкой. */
  background: var(--backdrop);
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
  opacity: 0;
  will-change: opacity; /* слой готов заранее — фейд без рывка первого кадра */
  /* На той же пружине, что и сама шторка: затемнение, отстающее по кривой от
     того, что оно затемняет, читается как рассинхрон. */
  transition: opacity var(--dur-sheet) var(--ease-spring);
}
.sheet-backdrop.show { opacity: 1; }
/* Пока открыта шторка — фон под ней не скроллится. */
body.sheet-open { overflow: hidden; }
/* ⚠️ Тот же замок нужен и админке. `.adm-stack` — position:fixed поверх главной,
   но САМА ГЛАВНАЯ под ней остаётся прокручиваемой: вход в панель снимает
   sheet-open (см. openAdmin → closeSheet), и документ снова свободен.

   На macOS это видно и ощущается как баг: колесо/тачпад над панелью, если
   `.adm-scroll` прокрутку не забрал (контент влез целиком или уже упёрся в
   конец), уходит документу. Ползунок окна едет, главная листается ЗА непрозрачной
   панелью, а панель — fixed, поэтому стоит намертво. Читается как «страница не
   двигается, а внизу что-то невидимое».
   `overscroll-behavior-y: contain` у `.adm-scroll` тут не спасает: он про цепочку
   ОТ скроллера, а когда содержимое влезает, скроллера для колеса просто нет.
   На iOS не заметно — оверлейные полосы и тач-путь другие.

   Ширину это не дёргает: полосы прокрутки скрыты глобально (см. правило
   `::-webkit-scrollbar` рядом с `.svg-defs`), поэтому её появление и пропадание
   не меняет ширину страницы. Раньше ту же задачу решал `scrollbar-gutter:
   stable` у body — он убран вместе с самой полосой. */
body.adm-open { overflow: hidden; }

/* Шторка «как в iOS»: во всю ширину, вплотную к низу и бокам — по краям просвета
   нет, скруглены только верхние углы, зазор виден лишь сверху. Высотой управляет
   JS: две ступени — обычная (по контенту) и раскрытая на весь экран. --x это
   прогресс раскрытия 0..1: за ним тянутся отступы и кегль внутри шторки, поэтому
   «разворачивание» привязано прямо к пальцу (см. _makeSheetDraggable). */
.sheet {
  --x: 0;
  position: fixed;
  left: 0; right: 0; bottom: 0;
  z-index: 90;
  width: 100%;
  /* 🚨 THE STUCK-SHEET BUG LIVED ON THIS LINE. It used to be Telegram's number,
     bare: `calc(var(--tg-viewport-stable-height, 100vh) - …)`.
     Telegram briefly under-reports that variable while the Mini App is minimised
     and restored — `_sheetGeom` already documents the symptom, «крошечная
     ступень, виден только заголовок внизу». When it under-reports and does NOT
     correct itself, this max-height pins the sheet to a sliver at the bottom of
     the screen, and NOTHING IN JS CAN UNDO IT: max-height beats the inline
     `height` that remeasureTopSheet writes. REPRODUCED: with the variable at
     80px the sheet renders 70px tall and setting height:600px changes nothing.
     Only a correct value from Telegram brings it back — i.e. a full restart of
     the Mini App, which is exactly what users had to do.
     The header, and with it the × button, ends up inside the bottom system
     gesture strip, which is why the thing could not even be dismissed.

     clamp() makes the value SAFE IN BOTH DIRECTIONS:
       · floor 50dvh — Telegram's number is nonsense, ignore it; the sheet stays
         big enough to read and, crucially, to close;
       · ceiling 100dvh — the OTHER known lie, on Telegram Desktop, where the
         variable comes back LARGER than the webview (see the note on #app);
       · in between, Telegram's value wins, so a genuinely smaller viewport
         (keyboard up on Android) still shrinks the sheet as it should.
     dvh is the real visible height, so both guards scale with the device.
     The plain-vh line above is the fallback for engines without dvh/clamp —
     same pattern as #app. It ignores Telegram's number entirely, which is worse
     for the keyboard but can never produce the sliver. */
  max-height: calc(100vh - var(--sheet-top) - var(--safe-t));
  max-height: calc(
    clamp(50dvh, min(var(--tg-viewport-stable-height, 100dvh), 100dvh), 100dvh)
    - var(--sheet-top) - var(--safe-t));
  display: flex; flex-direction: column;
  background: var(--surface);
  border-radius: 32px 32px 0 0;
  /* Клип по скруглённому прямоугольнику: на Qt/Chromium Telegram Desktop без
     этого прокручиваемые дети .sheet-body вырисовываются за границей шторки
     («выплывают снизу экрана»). Собственная тень шторки при overflow сохраняется. */
  overflow: hidden;
  box-shadow: var(--sheet-shadow), var(--bevel);
  transform: translateY(100%);
  will-change: transform, height; /* высота и выезд — на компоузере */
  transition: transform var(--dur-sheet) var(--ease-spring),
              height var(--dur-sheet) var(--ease-spring);
}
.sheet.open { transform: translateY(0); }
/* Верхняя «ручка»: полоска тонкая, но зона захвата — во всю ширину и 26px по
   высоте, чтобы за неё легко было ухватиться пальцем. Вверх — раскрыть шторку на
   весь экран, вниз — свернуть и закрыть. touch-action:none — это жест, а не скролл.
   Шапка, ручка и тело растут вместе с раскрытием: их размеры ведёт --x, поэтому
   контент разворачивается ровно по ходу пальца, а не рывком после отпускания. */
.sheet-grab {
  /* Ручка растворяется при раскрытии — её зона СЖИМАЕТСЯ (26 → 12px), а не
     растёт: освободившееся место забирают заголовок и крестик, поднимаясь
     вверх. Всё на --x, т.е. едет прямо за пальцем во время раскрытия. */
  align-self: stretch; height: calc(26px - 14px * var(--x)); flex: none;
  display: flex; align-items: center; justify-content: center;
  touch-action: none; cursor: grab;
}
.sheet-grab::before {
  content: '';
  width: calc(40px + 8px * var(--x)); height: 4.5px;
  border-radius: 100px; background: var(--line);
  /* как в iOS: на весь экран ручка растворяется (тянуть можно по-прежнему) */
  opacity: calc(1 - var(--x));
}
.sheet-head {
  display: flex; align-items: center; justify-content: space-between; gap: 10px;
  /* Верхний отступ при раскрытии не растёт, а слегка поджимается: шапка
     занимает место исчезнувшей ручки — контенту достаётся больше экрана */
  padding: calc(10px - 4px * var(--x)) calc(18px + 6px * var(--x)) calc(4px + 8px * var(--x));
  flex: none; touch-action: none;
}
.sheet-head h3 { font-size: calc(19px + 12px * var(--x)); font-weight: 800; letter-spacing: -.4px;
                 transition: opacity .15s ease; }   /* тот же такт, что у тела (replaceSheet) */
/* Кнопка закрытия — серый круг как в iOS; крестик компактный и плотный.
   При раскрытии слегка подрастает вместе с заголовком. */
.sheet-close {
  color: var(--ag-ink); background: var(--surface-2);
  width: calc(32px + 4px * var(--x)); height: calc(32px + 4px * var(--x));
  border-radius: 50%;
  display: grid; place-items: center; flex: none;
  transition: transform .15s ease, opacity .15s ease;
}
.sheet-close:active { transform: scale(.9); opacity: .7; }
.sheet-close .icon { width: 16px; height: 16px; stroke-width: 2.6; }
.sheet-close .icon { width: 15px; height: 15px; stroke-width: 2.3; display: block; }
.sheet-body {
  padding: calc(12px + 8px * var(--x)) calc(18px + 6px * var(--x)) calc(18px + var(--safe-b));
  overflow-y: auto; overscroll-behavior: contain;
  flex: 1 1 auto; min-height: 0;   /* тело скроллится внутри шторки фиксированной высоты */
  font-size: calc(15px + 1px * var(--x));
  transition: opacity .15s ease;   /* кроссфейд контента при замене шага (replaceSheet) */
}
/* Подвал с кнопкой-продолжением — СВОЯ кнопка вместо нативного tg.MainButton
   (тот рисуется хромом Telegram: прямоугольный, на чужой полосе-подложке под
   нашей шторкой). Здесь — наши цвета, наши скругления, свой safe-area. */
.sheet-foot {
  flex: none;
  padding: 10px 18px calc(14px + var(--safe-b));
  background: var(--surface);
  border-top: 1px solid var(--line);
  transition: opacity .15s ease;   /* тот же такт, что у тела (replaceSheet) */
}
.sheet-foot .btn { border-radius: 18px; padding: 15px 18px; font-size: 15px; }
/* Когда подвал есть, нижний отступ тела не нужен (safe-area уже в подвале) */
.sheet:has(.sheet-foot) .sheet-body { padding-bottom: 10px; }
/* Гейт «подписка на канал»: две кнопки столбиком. Главная (на канал) — крупная,
   с нейтральным «плавающим» свечением (палитра CTA нейтральна в обеих темах),
   чтобы бросалась в глаза без листания; вторая — проверка членства. */
.sheet-foot.gate-foot { display: flex; flex-direction: column; gap: 9px; }
.gate-foot .gate-cta {
  font-size: 15.5px; font-weight: 800; letter-spacing: -.2px;
  box-shadow: 0 12px 28px rgba(0,0,0,.22);
}
.gate-foot .gate-cta:active { box-shadow: 0 6px 16px rgba(0,0,0,.18); }
.sheet-note { font-size: 12.5px; color: var(--text-3); line-height: 1.5; font-weight: 500; }

/* ── Продление подписки: список тарифов как отдельные карточки-опции ── */
.plan-list { display: flex; flex-direction: column; gap: 9px; }
.plan-list .row.selectable {
  padding: 14px 15px;
  border-radius: var(--r-md);
  background: var(--surface-2);
  border: 1.5px solid transparent;
  transition: border-color .18s ease, background .18s ease, transform .15s ease;
}
.plan-list .row.selectable:active { transform: scale(.985); }
.plan-list .row.selected {
  background: color-mix(in srgb, var(--acc-1) 8%, var(--surface));
  border-color: var(--text);
}

/* ── Скидка win-back: премиальный ценник «было → стало» ───────────────────
   Акцент ЗОЛОТОЙ, а не синий: синие фоны в этом дизайне намеренно погашены
   (см. «ПОЛНАЯ НЕЙТРАЛИЗАЦИЯ СИНИХ ФОНОВ» ниже), синим остаются только hero-
   карты. Золото — язык призовых бейджей (.chip-gold): премиально и уместно
   для ограниченного предложения.

   Строка со скидкой подсвечена тёплым ореолом из правого верхнего угла (приём
   .pay-card: position+overflow клипуют его по радиусу). Ореол не спорит с
   состоянием .selected — та несёт свою рамку. */
.plan-list .row.offer { position: relative; overflow: hidden; }
.plan-list .row.offer::before {
  content: ''; position: absolute; inset: 0; pointer-events: none;
  background: radial-gradient(130% 105% at 100% 0%,
              rgba(244, 178, 33, .14) 0%, transparent 62%);
}
.plan-list .row.offer > * { position: relative; z-index: 1; }

/* Бейдж «−34%» — золотой градиент с мягким свечением.
   ВАЖНО: селектор включает .row-main. Бейдж — это <span> внутри .row-main, а
   правило `.row-main span` (0,1,1) специфичнее одиночного `.chip-off` (0,1,0)
   и перекрашивало текст в серый --text-3 весом 500 — из-за этого бейдж был
   блёклым и почти не читался. Через .row-main .chip-off (0,2,0) мы выигрываем. */
.row-main .chip-off {
  color: #7A5200;
  font-weight: 800;
  letter-spacing: .3px;
  background: var(--gold-grad);
  box-shadow: 0 4px 10px rgba(244, 178, 33, .30), inset 0 1px 1px rgba(255, 255, 255, .55);
}

/* Ценник: старая цена сверху (зачёркнута, приглушена), новая — крупная снизу.
   Колонка с выравниванием вправо, поэтому переопределяем flex из .row-side. */
.row-side.price-off {
  display: flex; flex-direction: column; align-items: flex-end; gap: 1px;
  line-height: 1.15;
}
.row-side.price-off .was {
  font-size: 12px; font-weight: 600; color: var(--text-3);
  text-decoration: line-through;
  text-decoration-color: color-mix(in srgb, var(--text-3) 65%, transparent);
}
.row-side.price-off .now {
  font-size: 18px; font-weight: 800; letter-spacing: -.35px; color: var(--text);
}

/* ── Оплата: премиальная карточка суммы + пункты доверия ── */
/* Карточка суммы — ровно тот же приём, что у героя подписки: обсидиан под
   низом, металл ТОЛЬКО на цифре. Заливать металлом весь блок нельзя по той же
   причине — сумма к оплате обязана читаться на всей площади карточки. */
.pay-card {
  position: relative; overflow: hidden;
  border-radius: var(--r-lg);
  padding: 22px 20px 20px;
  color: var(--text); text-align: center;
  background:
      radial-gradient(120% 100% at 50% -20%, rgba(150, 172, 208, .16), transparent 62%),
      var(--surface);
  box-shadow: var(--shadow-soft), var(--bevel);
}
.pay-card::before {
  content: ''; position: absolute;
  left: 16%; right: 16%; top: 0; height: 1px;
  background: var(--metal-edge);
  opacity: .5;
}
.pay-card-label {
  position: relative; z-index: 1;
  font-size: 10.5px; font-weight: 800; letter-spacing: .14em; text-transform: uppercase;
  color: var(--engrave);
}
.pay-card-sum {
  position: relative; z-index: 1;
  font-size: 44px; font-weight: 800; letter-spacing: -1.6px; line-height: 1.04; margin-top: 8px;
  font-variant-numeric: tabular-nums;
  background: var(--metal-text);
  -webkit-background-clip: text; background-clip: text;
  -webkit-text-fill-color: transparent; color: transparent;
}
.pay-card-plan { position: relative; z-index: 1; font-size: 13.5px; font-weight: 500; color: var(--text-2); margin-top: 8px; }
.pay-card-glow { position: absolute; right: -14px; bottom: -24px; z-index: 0; opacity: .1; color: var(--ag-ink); }
.pay-card-glow .icon { width: 120px; height: 120px; stroke-width: 1.1; }

.trust-list { display: flex; flex-direction: column; margin-top: 16px; }
.trust { display: flex; align-items: center; gap: 12px; padding: 11px 2px; }
.trust + .trust { border-top: 1px solid var(--line); }
.t-ico {
  width: 26px; height: 26px; flex: none;
  display: grid; place-items: center;
  color: var(--ag-ink);
}
.t-ico .icon { width: 19px; height: 19px; }
.trust > div { min-width: 0; }
.trust b { display: block; font-size: 14px; letter-spacing: -.1px; }
.trust > div > span { display: block; margin-top: 1px; font-size: 12px; color: var(--text-3); font-weight: 500; }

/* Выбор тарифа в шторке */
.plan-pick {
  display: flex; align-items: center; gap: 13px;
  width: 100%;
  padding: 16px;
  border-radius: var(--r-md);
  background: var(--surface-2);
  text-align: start;
  margin-bottom: 11px;
  transition: transform .16s ease, box-shadow .2s ease;
}
.plan-pick:active { transform: scale(.97); }
.plan-pick .plan-ico { width: 46px; height: 46px; border-radius: 16px; }
.plan-pick b { font-size: 15px; display: block; letter-spacing: -.15px; }
.plan-pick span { font-size: 12px; color: var(--text-2); font-weight: 500; line-height: 1.4; display: block; margin-top: 2px; }
.plan-pick .icon.chev { color: var(--text-3); width: 18px; height: 18px; }

/* Инпуты */
.field { margin: 12px 0; }
.field label { display: block; font-size: 12.5px; font-weight: 700; color: var(--text-2); margin: 0 4px 7px; }
.field input {
  width: 100%;
  padding: 14px 16px;
  border-radius: var(--r-sm);
  border: 1.5px solid transparent;
  background: var(--surface-2);
  font-size: 15px; font-weight: 600;
  outline: none;
  transition: border-color .2s ease, box-shadow .2s ease;
}
.field input:focus { border-color: var(--ag-ink); box-shadow: 0 0 0 4px color-mix(in srgb, var(--ag-ink) 18%, transparent); }
.field input::placeholder { color: var(--text-3); font-weight: 500; }

/* Итог оплаты */
.total-line {
  display: flex; justify-content: space-between; align-items: center;
  padding: 15px 16px;
  border-radius: var(--r-sm);
  background: var(--surface-2);
  box-shadow: inset 0 0 0 1px var(--line);
  font-weight: 700; font-size: 14px;
  margin-top: 10px;
}
.total-line b { font-size: 19px; font-weight: 800; letter-spacing: -.4px; color: var(--text); }

/* QR. Код генерируется в высоком разрешении (512px, см. openQRSheet), а на экран
   выводится размером по --x: при раскрытии шторки он вырастает почти во всю ширину,
   оставаясь резким (это уменьшение исходника, а не растягивание). Потолок в vw и
   max-width страхуют от вылезания за края в полноэкранном режиме. */
.qr-wrap {
  display: grid; place-items: center;
  margin: calc(12px + 4px * var(--x)) auto;
  /* при раскрытии рамку УЖИМАЕМ — освободившееся место отдаём самому коду */
  padding: calc(18px - 4px * var(--x));
  width: fit-content; max-width: 100%;
  border-radius: calc(var(--r-md) + 6px * var(--x));
  background: #fff;
  box-shadow: var(--shadow-soft);
}
.qr-wrap img, .qr-wrap canvas {
  border-radius: 8px; display: block;
  /* Растёт почти во всю ширину. Потолок в vw считает отступы тела шторки и рамки,
     поэтому в полноэкранном режиме код не вылезает за края. */
  width:  min(calc(210px + 260px * var(--x)), calc(100vw - 84px)) !important;
  height: min(calc(210px + 260px * var(--x)), calc(100vw - 84px)) !important;
}

/* Шаги инструкции */
.step-row { display: flex; gap: 13px; padding: 12px 0; }
.step-row + .step-row { border-top: 1px solid var(--line); }
.step-n {
  width: 30px; height: 30px; flex: none;
  border-radius: 50%;
  display: grid; place-items: center;
  font-size: 13.5px; font-weight: 800;
  color: var(--ag-ink);
  background: transparent;
  box-shadow: inset 0 0 0 1.5px var(--ag-rim);
}
.step-row p { font-size: 13.5px; line-height: 1.5; color: var(--text-2); font-weight: 500; padding-top: 4px; }
.step-row p b { color: var(--text); }

/* Список устройств: прокручивается, если их много (15+) */
.dev-list {
  max-height: 46vh;
  overflow-y: auto;
  overscroll-behavior: contain;
  margin: 0 -4px;
  padding: 0 4px;
}

/* Меню профиля: шапка + список настроек (стиль как в настройках Telegram) */
/* Шапка профиля в «Настройках». При раскрытии шторки аватар вырастает и уезжает в
   центр, а имя с ником спускаются под него: «строка» непрерывно превращается в
   «портрет». Ведёт всё --x, поэтому перестроение идёт ровно за пальцем. Дети
   позиционированы абсолютно намеренно — flex-direction переходом не анимируется,
   а через left/top/transform раскладку можно менять плавно, без скачка. */
.prof-head {
  position: relative;
  height: calc(54px + 189px * var(--x));       /* 54 → 243: аватар + имя под ним */
  margin: 2px 2px calc(16px + 10px * var(--x));
}
/* Свой аватар — металлический кружок, тот же, что в шапке главной
   (см. .profile-ava). Цвет из имени сюда не приходит: avatar() зовут с
   tint = false. */
.prof-ava {
  background: var(--metal);
  color: var(--metal-fg);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.5);
  font-weight: 800;
}
.prof-ava {
  position: absolute; top: 0;
  left: calc(50% * var(--x));                  /* слева → по центру */
  transform: translateX(calc(-50% * var(--x)));
  width:  calc(54px + 116px * var(--x));       /* 54 → 170: «действительно большой» */
  height: calc(54px + 116px * var(--x));
  font-size: calc(21px + 34px * var(--x));     /* инициал под фото */
}
.prof-id {
  position: absolute;
  width: max-content;
  max-width: calc(100% - 76px + 68px * var(--x));
  text-align: start;                            /* имя и @username от ОДНОГО левого края */
  left: calc(68px + (50% - 68px) * var(--x));  /* справа от аватара → по центру */
  top:  calc(27px + 161px * var(--x));         /* по центру строки → под аватаром */
  transform: translateX(calc(-50% * var(--x)))
             translateY(calc(-50% + 50% * var(--x)));
}
.prof-id b {
  display: block; font-size: calc(18px + 9px * var(--x)); font-weight: 750; letter-spacing: -.3px;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.prof-id span {
  display: block; margin-top: calc(1px + 5px * var(--x));
  font-size: calc(13px + 4px * var(--x)); color: var(--text-3); font-weight: 500;
  /* @username по левому краю, ровно под именем (1:1, только меньше кеглем) */
}

/* Статистика в «Профиле» — РОВНО карточки «О нас» (.feat), только компактнее и
   в две колонки: белая поверхность, мягкая тень, иконка на сером скруглённом
   квадрате слева, справа значение и подпись. Без бликов и «сфер». */
.stat-grid {
  display: grid; grid-template-columns: repeat(2, 1fr);
  gap: 10px;
  margin-bottom: 14px;
}
.stat-cell {
  display: flex; align-items: center; gap: 11px;
  padding: calc(12px + 2px * var(--x)) 13px;
  border-radius: var(--r-md);
  background: var(--surface);
  box-shadow: var(--shadow-soft), var(--bevel);
  min-width: 0;
}
.stat-ico {
  width: calc(38px + 2px * var(--x)); height: calc(38px + 2px * var(--x)); flex: none;
  border-radius: 12px;
  display: grid; place-items: center;
  color: var(--ag-ink);
  background: var(--surface-2);
  box-shadow: inset 0 0 0 1px var(--line);
}
.stat-ico .icon { width: calc(20px + 1px * var(--x)); height: calc(20px + 1px * var(--x)); }
.stat-body { min-width: 0; }
.stat-body b {
  display: block;
  font-size: calc(15.5px + 2px * var(--x)); font-weight: 800; letter-spacing: -.3px;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.stat-body span {
  display: block; margin-top: 1px;
  font-size: calc(11px + 1px * var(--x)); color: var(--text-2); font-weight: 500;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}

/* Оферта и политика — служебные строки, они НЕ должны раздуваться при раскрытии.
   Растут едва заметно, только чтобы не выглядеть мелкими рядом с крупной шапкой. */
.set-list { padding: calc(6px + 2px * var(--x)) calc(14px + 2px * var(--x)); }
.set-row {
  display: flex; align-items: center; gap: calc(13px + 1px * var(--x));
  width: 100%; text-align: start;
  padding: calc(11px + 2px * var(--x)) 2px;
  /* Тот же отклик, что у `.nav-row`. Раньше строка шторки НЕ поджималась вовсе,
     а просто гасла до 55 % — единственное место в приложении, где нажатие
     выглядело иначе, отчего шторка ощущалась чужой. */
  transition: transform .16s ease;
}
.set-row + .set-row { border-top: 1px solid var(--line); }
.set-row:active { transform: scale(.98); }
.set-ico {
  width: calc(26px + 2px * var(--x)); height: calc(26px + 2px * var(--x)); flex: none;
  display: grid; place-items: center;
  color: var(--ag-ink);
}
.set-ico .icon { width: calc(21px + 1px * var(--x)); height: calc(21px + 1px * var(--x)); stroke-width: 2; }
.set-label { flex: 1; min-width: 0; font-size: calc(15px + 1px * var(--x)); font-weight: 600; letter-spacing: -.1px; }
.set-row .chev { color: var(--text-3); width: 19px; height: 19px; flex: none; }

/* Успех */
.success-box { text-align: center; padding: 12px 0 6px; }
.success-ico {
  width: 74px; height: 74px;
  margin: 6px auto 16px;
  border-radius: 50%;
  display: grid; place-items: center;
  color: #fff;
  background: linear-gradient(135deg, #2EBD85, #1FA873);
  box-shadow: 0 14px 32px rgba(46,189,133,.38), inset 0 1px 2px rgba(255,255,255,.5);
  animation: pop-in .45s cubic-bezier(.34,1.56,.64,1);
}
.success-ico .icon { width: 34px; height: 34px; stroke-width: 2.4; }
@keyframes pop-in { from { transform: scale(.4); opacity: 0; } to { transform: none; opacity: 1; } }
.success-box h4 { font-size: 18px; font-weight: 800; letter-spacing: -.3px; margin-bottom: 7px; }
.success-box p { font-size: 13.5px; color: var(--text-2); line-height: 1.55; font-weight: 500; }

/* Тост */
/* Своё подтверждение вместо нативного диалога Telegram */
.confirm-actions { display: flex; gap: 10px; margin-top: 18px; }
.confirm-actions .btn { flex: 1; min-width: 0; }
.btn-danger { color: #fff; background: var(--bad); }

.toast {
  position: fixed;
  left: 50%; bottom: calc(var(--tabbar-h) + var(--safe-b) + 30px);
  transform: translate(-50%, 16px);
  z-index: 120;
  /* На десктопе плашка вылезала за край окна: ограничиваем ширину вьюпортом и
     разрешаем перенос — теперь она всегда целиком на экране. */
  max-width: min(calc(100vw - 32px), 420px);
  text-align: center;
  padding: 12px 20px;
  border-radius: 100px;
  /* Цвета ПОВЕРХНОСТИ, не инверсия. Раньше плашка была инвертированной
     (фон = --text): в тёмной теме это БЕЛАЯ плашка — читалась как «телеграмовский
     попап, который игнорирует тему». Теперь тёмная тема → тёмная плашка. */
  background: var(--surface);
  color: var(--text);
  border: 1px solid var(--line);
  font-size: 13.5px; font-weight: 700;
  box-shadow: var(--shadow-float);
  opacity: 0;
  pointer-events: none;
  will-change: transform, opacity;
  transition: opacity .28s ease, transform .28s cubic-bezier(.22,.9,.3,1);
  white-space: nowrap;
}
.toast.show { opacity: 1; transform: translate(-50%, 0); }

/* Кнопка-дублёр MainButton (только предпросмотр в браузере) */
/* Дублёр Telegram MainButton для браузера. MainButton живёт только в шторках,
   поэтому кнопка закреплена у нижнего края поверх шторки (z-index выше .sheet). */
.fallback-main {
  position: fixed;
  left: 50%; transform: translateX(-50%);
  bottom: calc(var(--safe-b) + 14px);
  z-index: 100;
  width: min(calc(100% - 32px), 432px);
  padding: 16px;
  border-radius: 19px;
  color: #fff;
  font-size: 15px; font-weight: 800; letter-spacing: -.1px;
  background: var(--grad-acc);
  box-shadow: var(--shadow-float), inset 0 1px 1.5px rgba(255,255,255,.5);
  transition: transform .16s ease;
}
.fallback-main:active { transform: translateX(-50%) scale(.97); }

/* Промо-баннер (создание промокода / пробный период) */
.banner {
  border-radius: var(--r-lg);
  padding: 17px;
  display: flex; align-items: center; gap: 13px;
  background: var(--grad-acc-soft);
  box-shadow: inset 0 1px 1px rgba(255,255,255,.4), var(--shadow-soft);
  overflow: hidden;
  position: relative;
  text-align: start;
  width: 100%;
}
.banner-ico {
  width: 28px; height: 28px; flex: none;
  display: grid; place-items: center;
  color: var(--ag-ink);
}
.banner-ico .icon { width: 22px; height: 22px; }
/* Статичный баннер (просто блок, не кнопка) */
.banner-static { cursor: default; }
.banner-text { flex: 1; min-width: 0; }
.banner-text b { font-size: 14px; display: block; letter-spacing: -.1px; }
.banner-text span { font-size: 12px; color: var(--text-2); font-weight: 500; display: block; margin-top: 2px; line-height: 1.4; }
.banner .icon.chev { color: var(--text-3); width: 18px; height: 18px; margin-inline-start: auto; }

/* ── Одно правило на все главные числа ────────────────────────────────────
   Срок подписки, сумма к оплате, итог в шторке, счётчики профиля — залиты
   металлом. Это ориентир на экране: где металл, там либо действие (плита
   кнопки), либо ЧИСЛО, ради которого экран открыт. Больше металл нигде не
   появляется, иначе он перестаёт что-либо значить.
   ⚠️ background-clip: text требует, чтобы у элемента был СВОЙ бокс, поэтому
   правило вешаем на конкретные элементы, а не на строку целиком. */
.pay-card-sum,
.total-line b,
.stat-box b {
  background: var(--metal-text);
  -webkit-background-clip: text; background-clip: text;
  -webkit-text-fill-color: transparent; color: transparent;
}
/* `em` внутри .stat-box b — вторая половина значения («из 5»), она обязана
   остаться обычным текстом, иначе исчезнет вместе с заливкой родителя. */
.stat-box b em { -webkit-text-fill-color: var(--text-2); color: var(--text-2); }

/* Хелперы */
.mt-8 { margin-top: 8px; } .mt-12 { margin-top: 12px; } .mt-16 { margin-top: 16px; }
.muted { color: var(--text-3); font-size: 12px; font-weight: 500; }
.hidden { display: none !important; }
.stat-strip { display: flex; gap: 10px; }
.stat-box {
  flex: 1;
  background: var(--surface);
  border-radius: var(--r-md);
  padding: 14px;
  box-shadow: var(--shadow-soft);
  text-align: center;
}
.stat-box b { font-size: 21px; font-weight: 800; letter-spacing: -.5px; display: block; }
.stat-box b em { font-style: normal; color: var(--text); }
.stat-box span { font-size: 11.5px; color: var(--text-3); font-weight: 600; }

/* ⚠️ ЗДЕСЬ БЫЛ БЛОК «Нейтральные фоны у иконок-плашек» — он ушёл вместе с
   плашками. Существовал он ради того, чтобы перекрасить синие подложки под
   глифами в серый; подложек под глифами больше нет ни на экранах, ни в
   шторках, красить нечего. */


/* ── Таблица лидеров: топ-3 построчно, со статусом ────────────────────────
   Медальный бейдж-место (золото/серебро/бронза) на аватаре + аккуратный
   приз «+1 месяц» под именем (мягкий золотистый лейбл, а не громкий пилл). */
.lb-badge.rank-1 { color: #7A5200; background: var(--gold-grad); }
.lb-badge.rank-2 { color: #4E586B; background: var(--silver-grad); }
.lb-badge.rank-3 { color: #7B4315; background: var(--bronze-grad); }
/* Приз «+1 месяц» у топ-3 — премиальный «золотой» бейдж: тёплый
   шампань-градиент, тонкая золотая кромка и внутренний блик (эффект
   металла), глубокий золотой текст. Не кричит, но читается как награда. */
.lb-prize {
  display: inline-flex; align-items: center; gap: 5px;
  margin-top: 5px; padding: 3px 10px 3px 8px;
  border-radius: 100px;
  font-size: 11px; font-weight: 700; letter-spacing: .1px;
  color: #7C5A16;
  background: linear-gradient(180deg, #FFF3D6 0%, #F6E1AE 100%);
  border: 1px solid rgba(199, 154, 58, .45);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 3px rgba(176,128,28,.14);
}
.lb-prize .icon { width: 12.5px; height: 12.5px; stroke-width: 2.1; color: #C79A3A; }
:root[data-theme="dark"] .lb-prize {
  color: #F2D486;
  background: linear-gradient(180deg, rgba(120,92,38,.55), rgba(84,64,26,.42));
  border-color: rgba(214,168,74,.4);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.12), 0 1px 3px rgba(0,0,0,.2);
}
:root[data-theme="dark"] .lb-prize .icon { color: #E9C877; }

/* ── Hero «подписка истекла» ──────────────────────────────────────────────
   Приглушённый графит с тёплым янтарным подтоном (спящий доступ), статусный
   чип «Истекла» и акцент на продлении. Премиально, без тревожного красного. */
/* «Истекла» — тот же обсидиан, что у активной карточки, плюс тёплый янтарный
   подсвет из угла. Раньше это была отдельная тёплая графитовая заливка; на фоне
   тёмном фоне она выглядела светлее активной карточки, и просроченная подписка
   бросалась в глаза сильнее работающей. */
.hero-expired {
  background:
      radial-gradient(130% 100% at 82% -14%, rgba(226, 168, 92, .13), transparent 60%),
      var(--surface);
}
.hero-expired .hero-shield { color: #E2A85C; opacity: .12; }
.chip-expired {
  color: #F4C983;
  background: rgba(244, 178, 33, .16);
  box-shadow: inset 0 0 0 1px rgba(244, 178, 33, .28);
}
.chip-expired .icon { color: #F4C983; }
/* Отделяем «моё место» от списка тонкой линией сверху */
.lb-row.lb-me { border-top: 1px solid var(--line); margin-top: 4px; padding-top: 12px; }

/* ═══ СЕРЕБРО ВМЕСТО СИНЕГО — ХВОСТЫ ═══════════════════════════════════════
   🚨 ЗДЕСЬ БЫЛА СЕКЦИЯ «ПОЛНАЯ НЕЙТРАЛИЗАЦИЯ СИНИХ ФОНОВ», И ЕЁ БОЛЬШЕ НЕТ.
   Она гасила синеву ПОВЕРХ компонентов: перекрашивала .btn-primary в
   `var(--text)`, подложки — в `--surface-2`, кружки успеха — в `!important`.
   Приём был правильный, пока синий сидел в самих правилах. Теперь его нет в
   принципе: акцент переопределён на уровне ТОКЕНОВ (--acc-1 / --grad-acc), и
   каждое из тех правил не убирало синий, а СТИРАЛО МЕТАЛЛ — главная кнопка
   приезжала плоской белой плашкой (`background: var(--text)`), поверх честного
   градиента.

   Осталось только то, что действительно должно отличаться от базового
   компонента, и ничего сверх. */

/* Дублёр Telegram MainButton — та же плита, что у .btn-primary. */
.fallback-main {
  background: var(--metal);
  color: var(--metal-fg);
  box-shadow: var(--shadow-float), inset 0 1px 0 rgba(255,255,255,.5);
}

/* Выбор — контрастом и гранью, без цвета: палитра монохромная, и «выбранное»
   больше нечем показать. */
.lb-badge.me-badge { background: var(--metal); color: var(--metal-fg); border-color: transparent; }
.row.selected .radio-dot { border-color: var(--ag-ink); background: var(--ag-ink); }
.row.selected .radio-dot::after { background: var(--surface); }
.row.selected { background: color-mix(in srgb, var(--text) 6%, transparent); }
.opt.sel { background: var(--surface); box-shadow: inset 0 0 0 1.5px var(--ag-ink); }
.plan-list .row.selected { background: var(--surface); border-color: var(--ag-ink); }

/* Крупный кружок успеха. Был зелёным градиентом, потом инвертированной
   заливкой через !important — теперь плита, как и любое «главное» в
   приложении. Инлайновые цвета из JS всё ещё перебиваем. */
.success-ico {
  background: var(--metal) !important;
  color: var(--metal-fg) !important;
  box-shadow: 0 14px 32px rgba(0,0,0,.35), inset 0 1px 0 rgba(255,255,255,.5) !important;
}

/* ═══ Шторка «О нас» — лендинг ═════════════════════════════════════════════
   Раздел собран из уже существующего языка приложения, а не из собственных
   форм. Два уровня вместо ровной стены одинаковых карточек:
   1) hero — ТА ЖЕ .hero-card, что и на главной (раньше был свой центрированный
      hero со своим скруглением и своим бликом — раздел выглядел чужим);
   2) .about-feats — ОДНА сгруппированная карта со строками-разделителями
      (раньше 12 отдельных плавающих карточек сливались в кашу).
   ═════════════════════════════════════════════════════════════════════════ */
.about { display: flex; flex-direction: column; gap: 14px; }

/* Hero — .hero-card + правки под лендинг. Своих фона/скругления/блика нет:
   всё наследуется от hero-семейства, поэтому карта совпадает с главной. */
.about-claim {
  position: relative; z-index: 1;
  margin-top: 18px;
  font-size: 27px; font-weight: 800; letter-spacing: -.8px; line-height: 1.16;
}
/* Факты в подвале на узких экранах переносим, а не сжимаем. */
/* Шапка «О нас» — БЕЗ карточки: это заголовок раздела, а не предмет.
   Карточка здесь была последним следом прежней вёрстки на пользовательских
   экранах и спорила с самим заявлением, ради которого экран открывают. */
/* `position: relative` — якорь для маскота. Без него абсолютный ворон уезжает
   к ближайшему позиционированному предку (шторке) и на экране его просто нет.
   Карточкой этот блок быть перестал ещё в редизайне 23.08, поэтому ворон тут
   стоит на голом грунте — как на заданиях и промокодах. */
.about-hero { padding: 4px 2px 0; position: relative; }
.about-hero .hero-foot { flex-wrap: wrap; row-gap: 9px; margin-top: 16px; padding-top: 14px; }
/* Чип «Безлимит трафика» уехал в подвал к остальным фактам: он такой же факт,
   а не статус, и наверху перетягивал внимание с заголовка. */

/* Заголовок секции — негромкий, как .invite-title: работать должны карточки,
   а не подписи над ними. */
.about-title {
  font-size: 13px; font-weight: 700; letter-spacing: -.1px;
  color: var(--text-2);
  margin: 10px 6px -2px;
}

/* ── Особенности: одна карта, строки через разделитель ───────────────────── */
.about-feats {
  background: var(--surface);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-soft);
  padding: 2px 17px;
  overflow: hidden;
}
.feat { display: flex; align-items: flex-start; gap: 14px; padding: 15px 0; }
.feat + .feat { border-top: 1px solid var(--line); }
.feat-ico {
  width: 26px; height: 26px; flex: none;
  display: grid; place-items: center;
  color: var(--ag-ink);
}
.feat-ico .icon { width: 20px; height: 20px; }
.feat-body { min-width: 0; }
.feat-body b { display: block; font-size: 14.5px; letter-spacing: -.15px; }
.feat-body span { display: block; margin-top: 4px; font-size: 12.5px; line-height: 1.55; color: var(--text-2); font-weight: 500; }

.about-cta { margin-top: 6px; }
.about-foot { text-align: center; font-size: 12px; color: var(--text-3); font-weight: 600; line-height: 1.5; margin: 6px 6px 0; }

/* ═══ Меню администратора (read-only) ════════════════════════════════════════
   Палитра — строго НЕЙТРАЛЬНАЯ (никакого цвета): те же поверхности, что и в
   остальном мини-аппе (--surface / --surface-2 / --text*). Иконки монохромные.
   --x нужен формулам .lbseg/.set-* (они рассчитаны на шторки, где --x = степень
   раскрытия); на экране его нет — фиксируем 0. */
#screen-admin { --x: 0; }

/* Нейтральный чип под иконку — общий для плиток, строк и заголовков.
   Фон — ТОТ ЖЕ --surface-2, что у всех иконочных плашек мини-аппа
   (.row-ico/.set-ico/.feat-ico/.nav-ico …). Свой полупрозрачный
   color-mix(--text N%) здесь давал ДРУГОЙ оттенок серого: он подмешивает тон
   текста (в светлой теме синевато-графитовый) и просвечивает подложку, поэтому
   админка не совпадала с пользовательскими экранами. */
#screen-admin .admin-tile-ico,
#screen-admin .admin-row-ico,
#screen-admin .admin-h-ico,
#screen-admin .node-sum-ico,
#screen-admin .node-metric-ico {
  color: var(--text);
  background: var(--surface-2);
  box-shadow: none;
}

/* Шапка экрана — жирный заголовок, БЕЗ кнопки «назад» (её даёт Telegram) */
.admin-head { display: flex; flex-direction: column; gap: 3px; padding: 2px; }
.admin-title { font-size: 24px; font-weight: 820; letter-spacing: -.5px; line-height: 1.1; }
.admin-sub { font-size: 13px; color: var(--text-2); font-weight: 500; }

/* ⚠️ DEAD SINCE THE HUB REDESIGN — kept on purpose, see the `.hub-*` block below.
   Nothing emits `.admin-tile` any more (`adminHub` builds `.hub-grid` now), but
   another session is translating comments across this whole file right now and
   deleting a block under it turns a text merge into a lost-code merge. Safe to
   remove once that lands. */
/* Плитки хаба */
.admin-tiles { display: flex; flex-direction: column; gap: 10px; }
.admin-tile {
  display: flex; align-items: center; gap: 13px; width: 100%; text-align: start;
  padding: 15px 16px; border-radius: var(--r-lg);
  background: var(--surface); box-shadow: var(--shadow-soft);
  /* Отклик на нажатие — РОВНО как у `.nav-row` в главном меню: тот же масштаб,
     та же длительность, тот же набор свойств. Здесь стояло `scale(.985)` плюс
     `opacity:.9` — и то, и другое чуть иначе, чем везде, отчего панель на ощупь
     отличалась от остального приложения. Гашение убрано намеренно: в главном
     меню строки не бледнеют при нажатии, они только поджимаются. */
  transition: transform .16s ease;
}
.admin-tile:active { transform: scale(.98); }
.admin-tile-ico {
  width: 44px; height: 44px; flex: none; border-radius: 13px;
  display: grid; place-items: center;
}
.admin-tile-ico .icon { width: 23px; height: 23px; stroke-width: 2; }
.admin-tile-txt { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.admin-tile-txt b { font-size: 15.5px; font-weight: 700; letter-spacing: -.15px; }
.admin-tile-txt small { font-size: 12px; color: var(--text-2); font-weight: 500; }
.admin-tile .chev { color: var(--text-3); width: 19px; height: 19px; flex: none; }

/* ═══ Витрина разделов админки (`.hub-*`) — главная страница панели ══════════
   Replaces the seven identical `.admin-tile` rows the hub used to be. A list is
   the right shape for homogeneous items; these seven are not homogeneous, and
   as identical rows they read as one grey wall with nothing to look at.

   ⚠️ NOTHING HERE RESTYLES A SHARED COMPONENT. `.hub-*` are this screen's own
   classes, assembled from tokens that already exist (--surface / --surface-2 /
   --text / --shadow-soft / --r-lg). The header (`.admin-head`/`.admin-title`)
   and the search (`.admin-search`) are used exactly as every other admin screen
   uses them — untouched. `.admin-tile*` above is left in the file even though
   the hub no longer emits it: another session is translating comments across
   this file right now, and deleting a block under it turns a text merge into a
   lost-code merge.

   ── The geometry, and why every number is derived ─────────────────────────
   ONE spacing value runs the whole page: **14px**. `.adm-content` already puts
   14px between blocks, so the grid gutter is 14 too — the gap between two tile
   rows is then exactly the gap between the search box and the grid, and the
   page has a single vertical rhythm instead of two competing ones.

   ONE radius: `--r-lg`, the same the search card gets from `.card`. Tile padding
   is 14, so the icon chip is 28 − 14 = **14px** — the concentric-radius rule, not
   a number picked by eye. Outer 28 → padding 14 → inner 14, each half the last.

   The optical axis: chip and name share the same 14px left inset, so every tile
   has one vertical edge and the whole grid lines up on four of them.

   ── Where the character comes from ────────────────────────────────────────
   The oversized ghosted glyph bleeding out of the bottom-right corner is not an
   import — it is `.hero-shield`, the app's own device, in neutral tones. Every
   hero card in this Mini App already carries the same glyph twice: small and
   crisp at the top, huge and faded at the bottom (i-shield/i-shield on the
   subscription card, i-users/i-users on the referral card, i-wallet/i-wallet on
   the balance card). The tiles are that card, scaled down and stripped of colour.
   It is also what stops the grid reading as six clones: each section's glyph has
   its own silhouette, so each tile has its own picture.

   ── No entrance animation here, deliberately ──────────────────────────────
   A staggered reveal was built and dropped. `openAdmin` paints the hub while the
   panel is still scaled and BLURRED (`body.sheet-open .adm-stack`), and a
   `filter` on an ancestor takes the whole subtree off the compositor — the
   stagger would run on the main thread, under a blur, fighting the panel's own
   entrance. The panel already has an entrance; the hub does not need a second. */

.hub-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 14px;
}
/* Odd number of sections (giveaways switched on) — the last tile takes the full
   width instead of leaving a hole. One rule, no per-count special cases, and it
   lands well: «Настройки» as a wide footer under the grid. It drops the three
   steps of air a square tile needs (14 + 40 + 14 + 20 + 14 = 102): at twice the
   width the same height reads as a mostly empty card. */
.hub-tile:last-child:nth-child(odd) { grid-column: 1 / -1; min-height: 102px; }

.hub-tile {
  position: relative; overflow: hidden;
  display: flex; flex-direction: column; align-items: flex-start;
  /* 130 = 14 padding + 40 chip + three 14px steps of air + a 20px text line +
     14 padding. The height is derived from the same 14 as everything else, not
     eyeballed. */
  width: 100%; min-height: 130px; padding: 14px;
  text-align: start;
  border-radius: var(--r-lg);
  background: var(--surface); box-shadow: var(--shadow-soft);
  /* Same press response as `.nav-row`, `.admin-row` and everything else in the
     app: a squeeze, no dimming, .16s ease. */
  transition: transform .16s ease;
}
.hub-tile:active { transform: scale(.98); }

.hub-ico {
  width: 40px; height: 40px; flex: none; border-radius: 14px;
  display: grid; place-items: center;
  color: var(--text); background: var(--surface-2);
}
.hub-ico .icon { width: 21px; height: 21px; stroke-width: 2; }

/* `margin-top: auto` pins the name to the bottom edge whatever the tile height,
   so a name that wraps to two lines eats the empty middle instead of making its
   row taller than the others. */
.hub-name {
  position: relative; z-index: 1;
  margin-top: auto; padding-top: 10px;
  font-size: 15.5px; font-weight: 700; letter-spacing: -.15px; line-height: 1.25;
}

/* The ghost echo. Clipped by the tile's own overflow, so it reads as a shape cut
   by the corner rather than a floating icon. No `filter` on it: on older
   Chromium/Qt (Telegram Desktop) a filtered child breaks the rounded clip and
   the glyph escapes past the corner — the same trap `.hero-card::before`
   documents. z-index 0 keeps it in the positioned layer under `.hub-name`.

   ⚠️ ANCHORED TOP-RIGHT, and that is not interchangeable with bottom-right.
   Bottom-right was built first and looked right on four glyphs out of six —
   then `i-server` (two wide rounded rectangles) and `i-cog` (two long slider
   rails) laid a pale horizontal line straight through «Нагрузка нод» and
   «Настройки». Moved to the top-right corner the name band is clean on every
   tile, more of each glyph stays inside the clip so it reads as a picture
   rather than as stray strokes, and the tile gains a diagonal: crisp small
   glyph top-left, huge ghost top-right, name bottom-left. */
.hub-echo {
  position: absolute; right: -14px; top: -16px; z-index: 0;
  width: 104px; height: 104px;
  color: var(--text); opacity: .075;
  pointer-events: none;
}
.hub-echo .icon { width: 100%; height: 100%; stroke-width: 1.1; }
:root[data-theme="dark"] .hub-echo { opacity: .1; }

/* ⚠️ `.hub-tile.is-lead` LIVED HERE and is gone — the inverted first cell
   («Пользователи» on `--text`/`--surface`, its chip and echo following suit).
   It existed to carry hierarchy through contrast; the owner asked for every
   section to look the same, so no tile is singled out any more. `adminHub` no
   longer emits the class either — if the emphasis ever comes back, both halves
   have to return together. */

.admin-body { display: flex; flex-direction: column; gap: 12px; }
/* Здесь ЖИЛ старый скелетон-спиннер: `.admin-skel { align-items: center;
   justify-content: center; padding: 30px 0 }` плюс крутящаяся иконка. Спиннер
   давно заменён силуэтом (см. блок ниже по файлу), а центрирование осталось — и
   поджимало КАЖДЫЙ силуэт в админке по ширине содержимого вместо полной строки.
   Отсюда и были «маленькие и нелогичные» заглушки. Правило удалено; крутилка
   `adm-spin` осталась, её используют «Проверить целостность» и «потянуть». */
@keyframes adm-spin { to { transform: rotate(360deg); } }
.admin-count { font-size: 12.5px; color: var(--text-2); font-weight: 600; margin: -2px 4px; }
.admin-count b { color: var(--text); }

/* Индикатор авто-обновления (нейтральный) */
.admin-live { display: inline-flex; align-items: center; gap: 8px; align-self: flex-start;
  font-size: 12.5px; font-weight: 600; color: var(--text-2); margin: -2px 4px; }
.admin-live-dot { width: 7px; height: 7px; border-radius: 50%;
  background: var(--text-3); animation: adm-pulse 1.8s ease-in-out infinite; }
@keyframes adm-pulse { 0%,100% { opacity: .35; } 50% { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .admin-live-dot { animation: none; } }

/* Строки-навигация (промокоды, кампании, розыгрыши) */
.admin-list { display: flex; flex-direction: column; gap: 8px; }
.admin-row {
  display: flex; align-items: center; gap: 13px; width: 100%; text-align: start;
  padding: 13px 15px; border-radius: var(--r-sm);
  background: var(--surface); box-shadow: var(--shadow-soft);
  transition: transform .16s ease;              /* см. .admin-tile — эталон .nav-row */
}
.admin-row:active { transform: scale(.98); }
.admin-row-ico {
  width: 40px; height: 40px; flex: none; border-radius: 12px;
  display: grid; place-items: center;
}
.admin-row-ico .icon { width: 21px; height: 21px; stroke-width: 2; }
.admin-row-txt { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.admin-row-txt b { font-size: 15px; font-weight: 700; letter-spacing: -.1px;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.admin-row-txt small { font-size: 12px; color: var(--text-2); font-weight: 500;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Поиск по дате: подпись «чем подошло». Тот же размер, что и остальной
   вторичный текст строки — своего типоразмера здесь не заводим. */
.adm-why { font-style: normal; color: var(--text-3); }

/* `.admin-search-hint` was a paragraph under the admin search listing the search
   operators, back when there was no other way to discover them. The type-ahead
   dropdown now names every filter as you type, so the paragraph was redundant —
   removed 2026-07-29 along with its markup. */
.adm-why::before { content: ' · '; }
.admin-row .chev { color: var(--text-3); width: 18px; height: 18px; flex: none; }
.admin-badge {
  flex: none; font-size: 11px; font-weight: 700; padding: 4px 9px; border-radius: 999px;
  letter-spacing: .01em; color: var(--text-2); background: var(--surface-2);
}
/* «Живой» бейдж — та же плашка --surface-2, отличается контрастным текстом и
   тонкой обводкой (--line), а не собственным полупрозрачным оттенком. */
.admin-badge.is-live {
  color: var(--text); background: var(--surface-2);
  box-shadow: inset 0 0 0 1px var(--line);
}

/* «Отчёт» бота: подзаголовок с иконкой + пары «label: value» */
.admin-kvs { display: flex; flex-direction: column; gap: 0; padding: 6px 18px; }
.admin-kv { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; padding: 9px 0; }
.admin-kv + .admin-kv { border-top: 1px solid var(--line); }
.admin-kv span { font-size: 13.5px; color: var(--text-2); font-weight: 500; min-width: 0; }
.admin-kv b { font-size: 14.5px; font-weight: 750; letter-spacing: -.1px; text-align: end;
  overflow-wrap: anywhere; }
.admin-h {
  display: flex; align-items: center; gap: 9px;
  font-size: 15px; font-weight: 800; letter-spacing: -.2px; color: var(--text);
  margin: 8px 4px 0;
}
.admin-h-ico { width: 28px; height: 28px; flex: none; border-radius: 9px; display: grid; place-items: center; }
.admin-h-ico .icon { width: 16px; height: 16px; stroke-width: 2.1; }
.admin-h-count { margin-inline-start: auto; font-size: 12px; font-weight: 700; color: var(--text-3);
  background: var(--surface-2); border-radius: 999px; padding: 2px 9px; }
/* Кнопка ⓘ у заголовка раздела: раскрывает пояснение под карточкой. Плашка та же
   --surface-2, что у всех иконок-подложек в приложении. */
.admin-h-info {
  margin-inline-start: auto; width: 26px; height: 26px; flex: none;
  display: grid; place-items: center; border-radius: 50%;
  color: var(--text-3); background: var(--surface-2);
  transition: transform .14s ease, color .14s ease;
}
.admin-h-count + .admin-h-info { margin-inline-start: 8px; }
.admin-h-info .icon { width: 16px; height: 16px; stroke-width: 2; }
.admin-h-info:active { transform: scale(.92); }
.admin-h-info[aria-expanded="true"] { color: var(--text); }
/* Кнопка настроек воронки. Носит .admin-h-info — значит та же плашка, тот же
   размер, тот же отклик и та же вуаль наведения; своего вида не заводит.
   Здесь только раскладка: прижимающий вправо `margin-inline-start: auto` переезжает на
   неё (она левее), а ⓘ получает обычный зазор — ровно как у .admin-h-count. */
.admin-h-set { margin-inline-start: auto; }
.admin-h-set + .admin-h-info { margin-inline-start: 8px; }
.admin-line { padding: 7px 18px; font-size: 13.5px; color: var(--text-2); font-weight: 500; line-height: 1.5; }
.admin-kvs .admin-line { padding: 7px 0; }

/* Список админов — карточки с реальной аватаркой (нейтральный фон под инициал) */
.admin-adm-card { display: flex; flex-direction: column; gap: 0; padding: 6px 16px; }
.admin-adm-row { display: flex; align-items: center; gap: 12px; padding: 9px 0; }
.admin-adm-row + .admin-adm-row { border-top: 1px solid var(--line); }
.admin-adm-ava {
  position: relative; width: 38px; height: 38px; flex: none; border-radius: 50%;
  display: grid; place-items: center; overflow: hidden;
  color: var(--text); font-size: 15px; font-weight: 700;
  background: var(--surface-2);
}
.admin-adm-ava img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
/* `flex: 1` like its siblings `.admin-row-txt` and `.cfg-txt`: it is what pushes
   a TRAILING element (the badge on the journal-entry card) to the right edge
   instead of leaving it wherever the text happens to end. The admins list has
   nothing after the text, so it never needed this and never got it — the column
   is left-aligned either way, so nothing there changes. */
.admin-adm-txt { flex: 1; display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.admin-adm-txt b { font-size: 14.5px; font-weight: 700; }
.admin-adm-txt small { font-size: 12px; color: var(--text-3); font-weight: 500;
  font-family: ui-monospace, "SF Mono", SFMono-Regular, Menlo, monospace; }

/* Выбор периода: поле-селект, список под ним и строка «дата — дата».
   Заливки, скругления и кегль — те же, что у полей формы ниже (.admin-date
   input), поэтому обе строки читаются как один блок, а не как два разных
   элемента. Строки списка — общие .opt, как во всех выборах приложения. */
.adm-period { display: flex; flex-direction: column; gap: 9px; }
.adm-select-wrap { position: relative; z-index: 5; }
.adm-select {
  display: flex; align-items: center; justify-content: space-between; gap: 10px;
  width: 100%; padding: 12px 14px; border-radius: 12px;
  background: var(--surface-2); color: var(--text);
  font-size: 14px; font-weight: 650; text-align: start;
  transition: transform .14s ease, box-shadow .14s ease;
}
.adm-select:active { transform: scale(.99); }
.adm-select-val { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Пара шевронов вверх/вниз — как у нативного селекта. Своей иконки не заводим:
   это тот же i-chevron, повёрнутый в две стороны. */
.adm-select-arr { display: flex; flex-direction: column; gap: 1px; flex: none; color: var(--text-3); }
.adm-select-arr .icon { width: 13px; height: 13px; stroke-width: 2.6; }
.adm-select-arr .icon:first-child { transform: rotate(-90deg); }
.adm-select-arr .icon:last-child { transform: rotate(90deg); }
.adm-select.is-open { box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--text) 26%, transparent); }

/* Список поверх содержимого, а не раздвигая его: иначе при открытии страница
   прыгает, а закрытие возвращает её обратно. */
.adm-drop {
  position: absolute; left: 0; right: 0; top: calc(100% + 6px);
  padding: 8px; border-radius: var(--r-sm);
  background: var(--surface); box-shadow: var(--shadow-float);
  gap: 4px;
}
/* .opt-list задаёт display:flex, а он сильнее UA-правила для [hidden] — без
   этой строки список нарисовался бы раскрытым. */
.adm-drop[hidden] { display: none; }
.adm-drop .opt { padding: 12px 14px; font-size: 14px; }
.adm-drop .opt .opt-check { width: 18px; height: 18px; }

/* Детализация графиков — второй такой же селект под периодом.
   Отрицательный отступ выравнивает расстояние: между строками внутри .adm-period
   9px, а между блоками страницы (.adm-content) 14px — без поправки детализация
   висела бы дальше от периода, чем даты от селекта. */
.adm-gran { z-index: 4; margin-top: -5px; }  /* z ниже периода: их списки не пересекаются */
/* Недоступный пункт списка: «По дням» на окне длиннее месяца. Гасим и делаем
   непрожимаемым — «нельзя» должно быть видно, а не выясняться нажатием. */
.opt.is-off { opacity: .38; pointer-events: none; }
.adm-dates { display: flex; align-items: center; gap: 10px; }
.adm-dates .adm-date { flex: 1 1 0; min-width: 0; }
.adm-dash { flex: none; font-style: normal; font-weight: 650; color: var(--text-3); }
/* z-index:0 → СВОЙ контекст наложения: капля сегмента-тумблера (.lbseg-drop,
   z-index:4) и её тёмная тень остаются ВНУТРИ обёртки и не перекрашивают
   соседние статичные кнопки (в тёмной теме тень капли — rgba(0,0,0,.2)).
   Клипа нет — «лифт» капли над сегментом сохраняется. */
.adm-seg-wrap { position: relative; z-index: 0; }
.adm-custom { position: relative; z-index: 1; }
.adm-lbseg { height: 46px; padding: 6px; border-radius: 100px; }
.adm-lbseg button { font-size: 12.5px; font-weight: 650; }
.adm-toggle button { font-size: 13.5px; }
.adm-seg-wrap.no-drop > .lbseg-drop { opacity: 0; }

/* Поля дат периода. Раньше это была отдельная форма «свой период» в теле экрана
   (.admin-range-form с кнопками «Отмена/Показать»); теперь даты видны всегда в
   самом баре, и от формы осталась только сама плашка поля.

   Дату РИСУЕМ САМИ (ДД.ММ.ГГГГ): нативный input[type=date] печатает её в формате
   системы — на английском телефоне это 07/01/2026, и ни атрибутом, ни стилем это
   не меняется. Поэтому input лежит поверх плашки полностью прозрачным: тап по
   полю открывает штатный календарь, а видно нашу подпись. */
/* Calendar icon pinned to the leading edge, date centred in the plate itself.
   The icon is taken out of the flow (absolute, same 12px as the plate padding)
   precisely so it does not shift the centre: an in-flow icon would push the date
   right by its width plus the gap, and the date would only be centred in the
   space left over. Vertical centring stays on align-items — icon and text sit on
   different baselines. The transparent input is absolute too, so it affects
   neither. */
.adm-date { position: relative; display: flex; align-items: center; justify-content: center;
  width: 100%; padding: 11px 12px;
  border-radius: 12px; background: var(--surface-2);
  color: var(--text); font-size: 14px; font-weight: 600;
  /* Плашка даты нажимается (под ней прозрачный нативный input), а отклика не
     имела — единственная кликабельная вещь в панели без него. Тот же .nav-row. */
  transition: transform .16s ease; }
/* Scales the whole plate, icon included — the plate is one button. The icon's own
   translateY is a separate transform on a separate element, so the two do not
   fight; and the containing block for the absolute icon is this element either
   way (it is already position:relative), so pressing does not move the icon. */
.adm-date:active { transform: scale(.98); }
.adm-date-ico { position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
  width: 16px; height: 16px; stroke-width: 2; color: var(--text-3); }
.adm-date input {
  position: absolute; inset: 0; width: 100%; height: 100%;
  opacity: 0; border: none; background: none; padding: 0;
  font-family: inherit; font-size: inherit;
  -webkit-appearance: none; appearance: none;
}
.adm-date.is-empty .adm-date-txt { color: var(--text-3); }

/* Нагрузка нод — сводка и карточки (нейтральные, без обрезки «…») */
.node-summary-head { display: flex; align-items: center; gap: 10px; margin-bottom: 15px;
  font-size: 16px; font-weight: 800; letter-spacing: -.2px; }
.node-sum-ico { width: 30px; height: 30px; flex: none; border-radius: 9px; display: grid; place-items: center; }
.node-sum-ico .icon { width: 18px; height: 18px; stroke-width: 2; }
/* Две колонки — метрике хватает ширины, значение не приходится резать */
.node-metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 14px 12px; }
.node-metric { display: flex; align-items: center; gap: 10px; min-width: 0; }
.node-metric-ico { width: 32px; height: 32px; flex: none; border-radius: 9px; display: grid; place-items: center; }
.node-metric-ico .icon { width: 17px; height: 17px; stroke-width: 2; }
.node-metric-txt { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.node-metric-txt b { font-size: 15px; font-weight: 750; letter-spacing: -.2px; }
.node-metric-txt small { font-size: 10.5px; font-weight: 600; color: var(--text-3);
  text-transform: uppercase; letter-spacing: .04em; }
.node-card .node-name { font-size: 15px; font-weight: 750; letter-spacing: -.15px; margin-bottom: 14px; }
.node-summary .node-name { opacity: 1; }
.node-err-txt { display: flex; align-items: center; gap: 7px; font-size: 13.5px; font-weight: 650; color: var(--text-2); }
.node-err-txt .icon { width: 16px; height: 16px; stroke-width: 2.4; }

/* ── Пользователи: поиск, фильтры, список, карточка ──────────────────────────
   Тот же нейтральный набор: поверхность-карточка, монохромные иконки, ссылки в
   карточке переносятся по любому символу (длинный UUID не расширяет экран). */
#screen-admin .admin-search-ico,
#screen-admin .admin-urow-ava,
#screen-admin .admin-gate-ico,
#screen-admin .admin-warn-ico,
#screen-admin .admin-fail-ico,
#screen-admin .admin-run-ico {
  color: var(--text);
  background: var(--surface-2);
}

.admin-search { display: flex; align-items: center; gap: 10px; padding: 8px 8px 8px 12px; }
.admin-search-ico { width: 32px; height: 32px; flex: none; border-radius: 10px; display: grid; place-items: center; }
.admin-search-ico .icon { width: 17px; height: 17px; stroke-width: 2; }
.admin-search input {
  flex: 1; min-width: 0; border: none; background: none; padding: 8px 0;
  font-family: inherit; font-size: 14.5px; font-weight: 600; color: var(--text);
}
.admin-search input::placeholder { color: var(--text-3); font-weight: 500; }
.admin-search input::-webkit-search-cancel-button { display: none; }
/* Никакой системной рамки при вводе — поле уже оформлено карточкой */
.admin-search input:focus, .admin-search input:focus-visible { outline: none; box-shadow: none; }
.admin-search-go {
  flex: none; padding: 10px 15px; border-radius: 12px;
  font-size: 13px; font-weight: 700; color: var(--surface); background: var(--text);
  transition: transform .14s ease, opacity .14s ease;
}
.admin-search-go:active { transform: scale(.96); opacity: .9; }
.admin-note { font-size: 12px; line-height: 1.45; color: var(--text-3); font-weight: 500; margin: -4px 6px 0; }

.admin-urow { gap: 12px; }
.admin-urow-ava {
  position: relative; overflow: hidden;
  width: 40px; height: 40px; flex: none; border-radius: 50%; display: grid; place-items: center;
  font-size: 15px; font-weight: 700;
}
/* Фото из Telegram ложится поверх инициала; не загрузилось — <img> удаляет себя */
.admin-urow-ava img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.admin-badge.is-ban { color: var(--surface); background: var(--text); }
.admin-more { width: 100%; margin: 2px 0 0; }

/* Пороги антифрода — компактный ряд чипов (три равные доли) */
.adm-chips { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.adm-chip {
  padding: 10px 6px; border-radius: 100px; text-align: center;
  font-size: 12px; font-weight: 650; color: var(--text-2); background: var(--surface-2);
  transition: transform .14s ease;
}
.adm-chip:active { transform: scale(.97); }
.adm-chip.on { color: var(--surface); background: var(--text); }

/* Search filters: a row of removable pills + the type-ahead list.
   Not one new colour, radius or type size — the pill IS `.adm-chip` and the
   suggestion row IS `.admin-row` (which brings its own surface and shadow, so
   the dropdown is NOT wrapped in a `.card`). A separate container is needed for
   LAYOUT: `.adm-chips` is a three-equal-columns grid (the antifraud thresholds),
   while filters vary in length and have to wrap. */
.adm-fchips { display: flex; flex-wrap: wrap; gap: 6px; margin: -4px 4px 10px; }
.adm-fchips:empty { display: none; }
.adm-fchips .adm-chip {
  display: inline-flex; align-items: center; gap: 5px; padding: 7px 10px;
}
.adm-fchips .adm-chip .icon { width: 12px; height: 12px; opacity: .75; }
.adm-drop { margin-bottom: 10px; }
.adm-drop[hidden] { display: none; }

/* Длинная ссылка подписки в карточке пользователя */
.admin-line { overflow-wrap: anywhere; }

/* ── Обязательная подписка: два гейта одной высоты ───────────────────────── */
.admin-gates { display: flex; flex-direction: column; gap: 0; padding: 6px 16px; }
.admin-gate { display: flex; align-items: center; gap: 12px; padding: 13px 0; }
.admin-gate + .admin-gate { border-top: 1px solid var(--line); }
.admin-gate-ico { width: 36px; height: 36px; flex: none; border-radius: 11px; display: grid; place-items: center; }
.admin-gate-ico .icon { width: 18px; height: 18px; stroke-width: 2.2; }
.admin-gate-txt { flex: 1; min-width: 0; font-size: 14.5px; font-weight: 700; letter-spacing: -.1px;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* ── Switch, matched to iOS 26 ───────────────────────────────────────────────
   NOT the pre-26 switch. Two independently confirmed facts drive every number
   here:
     1) iOS 26 stretched the control HORIZONTALLY ONLY, by roughly 20%;
     2) its HEIGHT did NOT change — still the same 31pt.
   Hence the track went 51×31 → 61×31. The knob stopped being a circle: it kept
   its height (27 = 31 − 2×2) but widened along with the track, 27 → 37, which
   leaves the travel exactly as it was: 61 − 2×2 − 37 = 20.

   ⚠️ The knob WIDTH (37) is DERIVED from those two facts, not measured — Apple
   publishes no control dimensions. Verify against a screenshot of a real switch.

   ⚠️ An earlier revision used 64×25 with a 35×17 knob. Those came from a
   third-party "flat pill" web recreation whose author had slimmed the HEIGHT to
   taste, and it read as both too small and too flat. Someone's iOS-style CSS is
   not a spec.

   All transitions run on `transform`/`background`/`opacity` with a cubic bezier:
   `linear()` is forbidden here, it drops the animation off the compositor and
   back to 60 Hz (see CLAUDE.md). */
.admin-sw {
  position: relative; flex: none;
  width: 61px; height: 31px; border-radius: 999px; padding: 0;
  /* Own token, not --surface-2: against a white knob that measured 1.10:1 and
     the knob all but vanished. Same ink as --line, just carried further. */
  background: var(--sw-off);
  transition: background var(--dur-switch-fill) var(--ease-switch-fill), opacity .15s ease;
  touch-action: pan-y;            /* vertical scrolling stays native, horizontal is ours */
  -webkit-tap-highlight-color: transparent;
}
/* On state uses the ink fill shared by every active state in the panel.
   Apple puts green here; the owner dropped it as an unnecessary colour. */
.admin-sw.on { background: var(--text); }

/* The track is 31px but HIG wants a 44px target, so an invisible layer grows
   the hit area without touching the look. No transform: it would turn the
   element into a containing block (see the scrolling rule in CLAUDE.md). */
.admin-sw::after { content: ''; position: absolute; left: 0; right: 0; top: -7px; bottom: -7px; }

/* The knob. `left` is never animated — transform only, or it drops off the
   compositor to 60 Hz. Inset is a uniform 2px: knob height is 31 − 2×2 = 27. */
.admin-sw > i {
  position: absolute; top: 2px; left: 2px;
  display: block; width: 37px; height: 27px; border-radius: 999px;
  background: var(--surface);
  box-shadow: 0 2px 5px rgba(24, 34, 84, .22), 0 0 1px rgba(24, 34, 84, .3);
  transition: transform var(--dur-switch) var(--ease-spring),
              width var(--dur-switch) var(--ease-spring),
              background var(--dur-switch-fill) var(--ease-switch-fill);
}
/* 61 − 2 − 2 − 37 = 20. Keep in step with ADM_SW_TRAVEL in app.js. */
.admin-sw.on > i { transform: translateX(20px); }

/* Pressed — the pill STRETCHES wider. Width, not scale: scaling would smear
   the rounded caps, whereas the pill itself should lengthen.
   In the on position it grows LEFTWARD, so the offset shrinks by the same
   amount (43 − 37 = 6) or the right cap would poke outside the track. */
.admin-sw.pressing > i { width: 43px; }
.admin-sw.on.pressing > i { transform: translateX(14px); }

/* While the finger drives the knob the settle is silenced — it must track the
   finger exactly. Width still eases, only the position is hand-driven.
   ⚠️ `background` MUST stay in this list. Naming only `width` replaces the whole
   transition, so the knob lost its fill transition mid-drag — and in dark theme
   the knob DOES change colour between states (--text-2 off, --surface on), so it
   snapped at the midpoint instead of blending. Light theme keeps one knob colour
   throughout, which is why the jump only ever showed up in dark. It gets the same
   fast linear fill as the track, so both track and knob follow the finger together. */
.admin-sw.dragging > i {
  transition: width var(--dur-switch) var(--ease-spring),
              background var(--dur-switch-drag) linear;
}
/* ...and so does the fill. With the normal .2s ease the track colour flipped a
   fifth of a second after the pill crossed the midpoint, which is what made the
   drag feel broken. */
.admin-sw.dragging { transition: background var(--dur-switch-drag) linear; }

/* ⚠️ NO dim on :disabled — that is why the colour used to "jump".
   `disabled` on this switch is NEVER a resting state: it is set for the length of
   the request and cleared in every branch of the response (admWireSwitch), and a
   read-only admin never gets a switch at all — cfgToggle renders an .admin-badge
   for them. So `.admin-sw:disabled { opacity: .55 }` could only ever fire for the
   100-300ms of the round-trip: the control faded to 55% and back WHILE its fill
   was mid-transition, which reads as the colour lurching rather than blending.
   The optimistic UI already shows the result, so there is nothing to signal.
   If a genuinely disabled-at-rest switch ever appears, give it its own class —
   do not put the look back on :disabled. */
/* In dark theme the off track and a white knob nearly match — use grey. */
:root[data-theme="dark"] .admin-sw:not(.on) > i { background: var(--text-2); }
@media (prefers-reduced-motion: reduce) {
  .admin-sw, .admin-sw > i { transition: none; }
}

/* ── Проверка целостности: предупреждение / ход / ошибка (одна вертикаль) ── */
.admin-warn, .admin-run, .admin-fail {
  display: flex; flex-direction: column; align-items: center; gap: 8px;
  padding: 22px 18px; text-align: center;
}
.admin-warn-ico, .admin-fail-ico, .admin-run-ico {
  width: 46px; height: 46px; border-radius: 14px; display: grid; place-items: center; margin-bottom: 2px;
}
.admin-warn-ico .icon, .admin-fail-ico .icon, .admin-run-ico .icon { width: 23px; height: 23px; stroke-width: 2; }
.admin-run-ico .icon { animation: adm-spin 1s linear infinite; }
@media (prefers-reduced-motion: reduce) { .admin-run-ico .icon { animation: none; } }
.admin-warn b, .admin-run b, .admin-fail b { font-size: 16px; font-weight: 800; letter-spacing: -.2px; }
/* :not([class]) — только текстовые span'ы, не иконочный чип над ними */
.admin-warn > span:not([class]), .admin-run > span:not([class]), .admin-fail > span:not([class]) {
  font-size: 13px; line-height: 1.5; color: var(--text-2); font-weight: 500; max-width: 30em;
}
.admin-run small { font-size: 11.5px; color: var(--text-3); font-weight: 600; }
.admin-run-btn { width: 100%; margin: 0; }

/* ═══ «Настройки» (the `system` screen) — ONE row shape for the whole page ════
   Nothing here is a restyle of a shared component: `.cfg-*` are this screen's
   own classes, built out of the tokens every other screen already uses
   (--surface / --surface-2 / --line, the .card radius, the .admin-gate icon
   chip). The screen used to mix three row geometries at once — grouped gates
   inside one card, five numeric rows as five separate floating cards, and a
   centred warning block — which is why it read as three pages stacked.

   ⚠️ `.admin-gate*`, `.admin-run` and `.admin-fail` are left in the file on
   purpose even though this screen no longer emits them: another session is
   rewriting comments across this file right now, and deleting blocks under it
   turns a text merge into a lost-code merge. They can go once that lands. */

.cfg-group { display: flex; flex-direction: column; gap: 0; padding: 6px 16px; }
.cfg-group > * + * { border-top: 1px solid var(--line); }

/* min-height instead of vertical padding: a one-line toggle row and a two-line
   navigation row then come out exactly the same height, which is the whole
   point of the redesign. */
.cfg-row {
  display: flex; align-items: center; gap: 12px;
  width: 100%; text-align: start;
  min-height: 62px; padding: 8px 0;
}
/* Press feedback is a squeeze and nothing else — a background tint sticks on
   WebView's :active and looks like a frozen highlight (see .row.selectable). */
.cfg-tap { transition: transform .16s ease; }
.cfg-tap:active { transform: scale(.985); }
/* The one row allowed to grow: a truncated Remnawave error is worse than an
   uneven list. */
.cfg-row.is-wrap { padding: 13px 0; }
.cfg-row.is-wrap .cfg-txt small { white-space: normal; }

.cfg-ico {
  width: 36px; height: 36px; flex: none; border-radius: 11px;
  display: grid; place-items: center;
  color: var(--text); background: var(--surface-2);
}
.cfg-ico .icon { width: 18px; height: 18px; stroke-width: 2.2; }

.cfg-txt { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.cfg-txt b { font-size: 14.5px; font-weight: 700; letter-spacing: -.1px;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cfg-txt small { font-size: 12px; color: var(--text-2); font-weight: 500;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

.cfg-val { flex: none; font-size: 13.5px; font-weight: 700; letter-spacing: -.1px; color: var(--text-2); }
.cfg-chev { flex: none; width: 18px; height: 18px; color: var(--text-3); margin-inline-start: -3px; }

/* Result counters, in the SAME card as the state row above them. */
.cfg-kv {
  display: flex; align-items: center; justify-content: space-between; gap: 12px;
  min-height: 44px; padding: 8px 0;
}
.cfg-kv span { font-size: 13.5px; color: var(--text-2); font-weight: 500; min-width: 0; }
.cfg-kv b { font-size: 14.5px; font-weight: 750; letter-spacing: -.1px; text-align: end; }
.cfg-kv b i { font-style: normal; font-weight: 600; color: var(--text-3); margin-inline-start: 6px; }

/* Each half of the page is its own column so the section header, the card and
   the button sit at the same rhythm as .admin-body. */
.cfg-sec { display: flex; flex-direction: column; gap: 12px; }

/* Trailing action inside a row (the integrity pass). Shape and colour come
   entirely from the app's own `.btn.btn-sm.btn-primary`; all this adds is the
   refusal to shrink.

   ⚠️ It lives INSIDE the card for a reason beyond tidiness. The full-width
   button that used to sit UNDER the card came out with a dark band along its
   top edge: `.card` is `position: relative`, so it paints in the positioned
   layer — above an in-flow sibling — and its drop shadow (--shadow-soft, offset
   10px DOWN, 34px blur) landed across the button. Anything placed under a
   `.card` here needs `position: relative` of its own. */
.cfg-go { flex: none; }

/* The integrity block has no section header of its own any more, so it lost the
   8px that `.admin-h`'s top margin puts above every other group. Without this
   it butts up against the card above it 8px tighter than any other boundary on
   the page. */
.cfg-sec.is-tail { margin-top: 8px; }
/* An empty flex item still eats the parent's gap — the same 14px of nothing the
   period slot on the promo screen used to leave behind. */
.cfg-sec:empty { display: none; }

/* ⚠️ A sticky liquid-glass bar («Проверка идёт», `.cfg-live*`) used to stand
   here, riding the top of the page while an integrity pass ran. Removed by the
   owner — this screen uses no glass at all now. Two things it knew, kept in case
   glass ever returns here: it must be a DIRECT child of #adminBody (a sticky
   element only travels inside its containing block), and its z-index must stay
   BELOW `.adm-refresh` (3) and `.adm-scrim` (5), because `.adm-scroll` opens no
   stacking context of its own and a higher value paints over the
   pull-to-refresh spinner and over the dimming of the outgoing page. */

/* Silhouette for the wait: same 62px rows in the same cards, so the real
   content lands exactly where the placeholder stood. */
.cfg-skel { gap: 12px; }
.cfg-sk-ico { width: 36px; height: 36px; border-radius: 11px; flex: none; }
.cfg-sk-txt { flex: 1; max-width: 168px; height: 11px; border-radius: 6px; }
.cfg-sk-side { width: 61px; height: 31px; border-radius: 999px; flex: none; }

/* ── Копируемое моно-значение (там, где в боте <code>) ───────────────────── */
.mono-copy {
  display: inline; text-align: inherit; padding: 0; border-radius: 6px;
  font-family: ui-monospace, "SF Mono", SFMono-Regular, Menlo, monospace;
  font-size: .94em; font-weight: 650; color: inherit; overflow-wrap: anywhere;
  text-decoration: underline; text-decoration-style: dotted;
  text-underline-offset: 3px; text-decoration-color: var(--text-3);
  transition: opacity .14s ease;
}
.mono-copy:active { opacity: .55; }

/* ── Список действий под данными экрана ─────────────────────────────────── */
.admin-acts { margin-top: 4px; }
/* Подзаголовок внутри карточки (название сервера над ссылкой прокси) */
.admin-sub-title { padding: 9px 0 3px; font-size: 14.5px; font-weight: 750; letter-spacing: -.1px; }
.admin-row.is-danger .admin-row-txt b { color: var(--bad); }
.admin-row.is-danger .admin-row-ico { color: var(--bad); background: color-mix(in srgb, var(--bad) 10%, transparent); }

/* ── Универсальная форма (замена FSM-промптов бота) ─────────────────────── */
.admin-form { display: flex; flex-direction: column; gap: 14px; }
.admin-field { display: flex; flex-direction: column; gap: 6px; }
.admin-field > span {
  font-size: 12px; font-weight: 650; color: var(--text-3);
  text-transform: uppercase; letter-spacing: .04em;
}
.admin-field input, .admin-field select {
  width: 100%; padding: 12px 14px; border-radius: 12px; border: none;
  background: var(--surface-2); color: var(--text);
  font-family: inherit; font-size: 15px; font-weight: 600;
  -webkit-appearance: none; appearance: none;
}
.admin-field select {
  background-image: linear-gradient(45deg, transparent 50%, var(--text-3) 50%),
                    linear-gradient(135deg, var(--text-3) 50%, transparent 50%);
  background-position: calc(100% - 19px) 21px, calc(100% - 14px) 21px;
  background-size: 5px 5px, 5px 5px; background-repeat: no-repeat;
  padding-inline-end: 38px;
}
.admin-field input::placeholder { color: var(--text-3); font-weight: 500; }
.admin-field > small { font-size: 11.5px; line-height: 1.4; color: var(--text-3); font-weight: 500; }
.admin-form-go { width: 100%; margin: 2px 0 0; }

/* ── Динамика: спарклайны, дельты, воронка ───────────────────────────────────
   Палитра — та же нейтральная, что и во всей админке: НИ ОДНОГО собственного
   цвета. Рост/падение показывает стрелка и знак, а не зелёный/красный — иначе
   в экран, из которого цвет убирали намеренно, он вернулся бы через графики.
   Линия и заливка наследуют --text через currentColor, поэтому обе темы
   работают без отдельных правил. */
.metric-card { display: flex; flex-direction: column; gap: 2px; padding: 15px 17px 12px; }
.metric-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.metric-title { font-size: 13px; font-weight: 650; color: var(--text-2); letter-spacing: -.05px; }
.metric-value { font-size: 26px; font-weight: 800; letter-spacing: -.7px; line-height: 1.15; }
.metric-card > small { font-size: 11.5px; font-weight: 600; color: var(--text-3); margin-top: 2px; }

/* Высота фиксированная, ширина резиновая: preserveAspectRatio="none" в разметке
   тянет viewBox по горизонтали, поэтому линия не зависит от числа корзин. */
.spark { display: block; width: 100%; height: 56px; margin: 8px 0 2px; color: var(--text); overflow: visible; }
.spark-line { fill: none; stroke: currentColor; stroke-width: 2;
  stroke-linejoin: round; stroke-linecap: round;
  /* Линия растягивается вместе с viewBox — без этого при широкой карточке
     горизонтальные участки становятся заметно тоньше вертикальных. */
  vector-effect: non-scaling-stroke; }
.spark-area { fill: currentColor; fill-opacity: .10; stroke: none; }

/* Ведение пальцем: курсор — HTML-оверлей, а не элемент внутри SVG. viewBox
   растягивается по ширине карточки (preserveAspectRatio="none"), и нарисованный
   внутри кружок расплющило бы тем же преобразованием, которое делает линию
   независимой от плотности экрана. `touch-action: pan-y` оставляет вертикальную
   прокрутку браузеру: поперёк ведём мы, вдоль — он. */
.spark-wrap { position: relative; touch-action: pan-y; }
.spark-cur {
  position: absolute; top: 8px; bottom: 2px; width: 1px; margin-inline-start: -.5px;
  background: var(--text-3); opacity: 0; pointer-events: none;
  transition: opacity .14s ease;
}
.spark-cur > b {
  position: absolute; left: 50%; width: 9px; height: 9px; border-radius: 50%;
  background: var(--text); transform: translate(-50%, -50%);
  box-shadow: 0 0 0 3px var(--surface);
}
.metric-card.is-scrub .spark-cur,
.node-hist.is-scrub .spark-cur { opacity: 1; }

/* Края окна под графиком. Промежуточных подписей нет намеренно: на 30-90
   корзинах они сливаются, а дату точки показывает сам жест. */
.spark-axis {
  display: flex; justify-content: space-between; gap: 10px; margin: 1px 0 3px;
  font-size: 11px; font-weight: 600; color: var(--text-3);
}

/* Дельта к прошлому периоду */
.delta {
  display: inline-flex; align-items: center; gap: 3px; flex: none;
  font-size: 11.5px; font-weight: 700; letter-spacing: .01em;
  padding: 3px 8px 3px 6px; border-radius: 999px;
  color: var(--text-2); background: var(--surface-2);
}
.delta .icon { width: 13px; height: 13px; stroke-width: 2.6; }
/* Up and down read at the same strength on purpose — the arrow already carries
   the direction, so a colour would only repeat it. `is-down` had been missing
   since this component was written, which left falling numbers quieter than
   rising ones; that asymmetry was an oversight, not a design choice. */
.delta.is-up,
.delta.is-down { color: var(--text); }
.delta.is-flat { padding: 3px 9px; }

/* ── Спидометр удержания ─────────────────────────────────────────────────────
   ЕДИНСТВЕННОЕ место в админке, где есть цвет: так решил владелец. Зелёный и
   красный — ОБЩИЕ токены --ok/--bad (те же, что у чипов и опасных кнопок), своей
   палитры блок не заводит. Числа под дугой — общий компонент .stats-grid/.metric,
   тот же, что в статистике промокодов. */
.gauge-card { display: flex; flex-direction: column; align-items: center; padding: 16px 18px 16px; }
.gauge-wrap { position: relative; width: 100%; max-width: 264px; }
.gauge { display: block; width: 100%; height: auto; }
/* Концы РАЗНЫЕ: на стыке цветов плоский срез (цвета сходятся одной линией), по
   краям полукруга — скругление. `stroke-linecap` красит оба конца пути сразу и
   так не умеет, поэтому дуги плоские, а кончики — отдельные кружки .gauge-cap
   радиусом в полтолщины обводки. Подложка остаётся страховкой на случай
   округлений. */
.gauge-track, .gauge-keep, .gauge-lost { fill: none; stroke-width: 15; }
.gauge-track { stroke: var(--surface-2); }
.gauge-keep { stroke: var(--ok); }
.gauge-lost { stroke: var(--bad); }
.gauge-cap { stroke: none; }
.gauge-cap.is-keep { fill: var(--ok); }
.gauge-cap.is-lost { fill: var(--bad); }
/* Проценты стоят В ЧАШЕ дуги: удержание слева, отток справа — «левая часть
   зелёная, правая красная» читается без легенды. Ряд отцентрован, а не прибит к
   краям: у краёв чаша сужается и текст налезал бы на саму дугу. */
.gauge-read {
  position: absolute; left: 0; right: 0; bottom: 2px;
  display: flex; justify-content: center; gap: 26px;
}
.gauge-side { display: flex; flex-direction: column; align-items: center; gap: 2px; line-height: 1; }
.gauge-side b { font-size: 21px; font-weight: 800; letter-spacing: -.6px; }
.gauge-side small { font-size: 10.5px; font-weight: 650; color: var(--text-3); }
.gauge-side.is-keep { color: var(--ok); }
.gauge-side.is-lost { color: var(--bad); }
.gauge-card .stats-grid { width: 100%; margin-top: 12px; }

/* Воронка: ширина полосы = сквозная доля от вершины. В строке два процента —
   <i> шаговый (конверсия из предыдущей ступени, тот же кегль, что у числа) и
   <em> сквозной (доля от вершины, мелкий). */
.funnel { display: flex; flex-direction: column; gap: 13px; padding: 15px 17px; }
.funnel-step { display: flex; flex-direction: column; gap: 6px; }
.funnel-top { display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
  font-size: 13px; font-weight: 600; color: var(--text-2); }
.funnel-top b { font-size: 14.5px; font-weight: 750; color: var(--text); letter-spacing: -.1px; }
.funnel-top b i { font-style: normal; font-weight: 700; color: var(--text-2); margin-inline-start: 9px; }
.funnel-top b em { font-style: normal; font-size: 11.5px; font-weight: 650;
  color: var(--text-3); margin-inline-start: 7px; }
.funnel-bar { height: 8px; border-radius: 100px; background: var(--surface-2); overflow: hidden; }
.funnel-bar > i { display: block; height: 100%; border-radius: 100px; background: var(--text-2); }

/* История нагрузки под метриками ноды */
.node-hist { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); }
/* The scrub cursor must cover EXACTLY the chart box: the dot's `top` is a
   percentage of the cursor's height, so any mismatch drifts the dot off the line.
   Here the wrap's box already IS the chart box — `.node-hist` is a plain block, so
   the spark's 6px top margin collapses out of `.spark-wrap` and pushes the wrap
   itself down. On a metric card the wrap is a flex item (a BFC root, no collapsing)
   and the margins stay inside, which is why that cursor is inset by 8/2 instead.
   Measured, not assumed: cursor 44px against a 44px chart in both blocks. */
.node-hist .spark-cur { top: 0; bottom: 0; }
.node-hist-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
  font-size: 11.5px; font-weight: 600; color: var(--text-3);
  text-transform: uppercase; letter-spacing: .04em; }
.node-hist-head b { font-size: 12px; font-weight: 700; color: var(--text-2); text-transform: none; letter-spacing: 0; }
.node-hist .spark { height: 44px; margin: 6px 0 0; }

/* ═══════════════════════════════════════════════════════════════════════════
   МЕНЮ АДМИНИСТРАТОРА — стек живых карточек
   ═══════════════════════════════════════════════════════════════════════════
   ⚠️ В ЭТОМ БЛОКЕ ТОЛЬКО ГЕОМЕТРИЯ И ДВИЖЕНИЕ. Ни одного правила, меняющего
   ВНЕШНИЙ ВИД компонентов. Карточки, кнопки, строки, поиск, поля, скругления,
   тени, размеры иконок, типографика, цвета — ОБЩИЕ с остальным мини-аппом.
   Админка обязана выглядеть как то же самое приложение, а не как отдельное.

   Однажды здесь лежал набор `#screen-admin .admin-row {…}` и т.п., который
   переоформил панель «под Settings.app»: плоские группы, свои серые, свои
   размеры иконок, свои кнопки. Панель перестала быть частью приложения.
   НЕ ДОБАВЛЯТЬ СЮДА ПРАВИЛА ВНЕШНЕГО ВИДА. Нужно поменять вид строки — правьте
   .admin-row там, где она объявлена, и осознанно для всего приложения.

   Почему движение ведёт JS, а не CSS-переход: переход не умеет стартовать СО
   СКОРОСТЬЮ ПАЛЬЦА, а без неё смахнутая и медленно уведённая страница летят
   одинаково. Пружины — в js/motion.js. */

/* ── Контейнер стека ──────────────────────────────────────────────────────── */
.adm-stack {
  position: fixed; inset: 0; z-index: 70;
  overflow: hidden;
  background: var(--bg);
  /* Ведёт себя РОВНО как #view: тот же origin, та же кривая, тот же масштаб и
     то же размытие под шторкой. Благодаря этому вход в панель выглядит как
     обычное закрытие шторки — панель стоит уменьшенной и размытой за уходящей
     вниз шторкой и «оживает» вместе с её уходом, а не «прыгает» на экран. */
  transform-origin: 50% 0;
  /* Размытие СВОИМ фильтром, а не от backdrop-filter шторочного фона.
     Фон (#backdrop) лежит выше по z-index и теоретически должен размывать панель
     сам, но панель — отдельный композиторский слой (её двигает JS покадрово), а
     сэмплирование таких слоёв в backdrop-filter ненадёжно от движка к движку.
     Свой filter даёт гарантированный результат.
     blur(0px), а не none: между `none` и длиной браузер не интерполирует, и
     размытие бы щёлкало вместо плавного ухода. */
  /* ⚠️ `none`, а НЕ `blur(0px)`. Нулевой блюр — это всё равно ФИЛЬТР: браузер
     заводит под него отдельную поверхность и держит её постоянно, а поддерево с
     фильтром теряет право ехать на компоузере. Из-за этого переходы карточек
     считались на основном потоке и панель ощущалась медленнее экрана даже после
     перевода анимаций на CSS.
     На интерполяцию это не влияет: по спеке фильтров `none` при переходе
     заменяется единичной функцией другого списка, то есть `none ↔ blur(6px)`
     анимируется ровно как `blur(0) ↔ blur(6px)`. */
  filter: none;
  opacity: 1;
  /* Прозрачность гасим БЫСТРЕЕ, чем идёт движение (0.6 от общей длительности):
     затянутый кроссфейд оставляет обе картинки полупрозрачными надолго и мутит
     кадр. К моменту, когда панель ещё только разжимается, она уже непрозрачна. */
  transition: transform var(--dur-sheet) var(--ease-spring),
              filter var(--dur-sheet) var(--ease-spring),
              opacity calc(var(--dur-sheet) * .6) var(--ease-spring);
}
/* Состояние «панель только что встала на место главной»: тот же масштаб, то же
   размытие (6px — как у .sheet-backdrop), и ПОЛНАЯ прозрачность. Снятие
   sheet-open вместе с уходом шторки проявляет её поверх главной — это и есть
   кроссфейд «главная → админка». */
body.sheet-open .adm-stack {
  transform: scale(.955) translateY(4px);
  filter: blur(6px);
  opacity: 0;
}
.adm-stack[hidden] { display: none; }

/* ── Центрированное окно админки (выбор ступеней воронки) ──────────────────
   🚨 НЕ шторка и шторкой быть не может: `body.sheet-open .adm-stack` гасит всю
   панель под любой шторкой, и за окном был бы виден главный экран вместо
   админки. По этой же причине не шторка и выпадашка периода.

   Своего внешнего вида окно почти не заводит: поверхность, радиус и тень взяты
   у шторки, строки внутри — обычные `.opt` с галочкой (как в выборе периода),
   кнопка — `.btn.btn-primary`, крестик — `.sheet-close`. Новое здесь только
   «коробка по центру».

   Живёт в <body>, а не в `.adm-stack`: у стека `overflow: hidden` и собственный
   transform во время переходов — fixed-ребёнок оказался бы заперт внутри.
   z-index 95: выше панели (70) и подложки шторки (80), ниже самой шторки (90) —
   шторок в админке всё равно нет, но порядок пусть остаётся честным. */
.adm-modal-back {
  position: fixed; inset: 0; z-index: 95;
  display: flex; align-items: center; justify-content: center;
  padding: calc(var(--safe-t) + 16px) 16px calc(var(--safe-b) + 16px);
  background: var(--backdrop);
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
  opacity: 0;
  transition: opacity .26s var(--ease-spring);
}
.adm-modal-back.show { opacity: 1; }
.adm-modal {
  width: 100%; max-width: 380px;
  max-height: 100%;
  display: flex; flex-direction: column;
  background: var(--surface);
  border-radius: var(--r-lg);
  overflow: hidden;
  box-shadow: 0 18px 50px rgba(20, 28, 66, .22);
  /* Только transform и opacity — обе на компоузере. Кривая — bezier, а не
     linear(): linear() с многими точками роняет анимацию в 60 Гц. */
  transform: scale(.94);
  opacity: 0;
  transition: transform .26s var(--ease-spring), opacity .26s var(--ease-spring);
}
.adm-modal-back.show .adm-modal { transform: scale(1); opacity: 1; }
/* ⚠️ Трансформ снимается, как только окно доехало (класс вешает JS по
   transitionend). Постоянный transform делает элемент трансформированным
   предком и ломает нативную прокрутку вложенных скроллеров на iOS — а тело
   окна прокручивается, когда ступеней много. */
.adm-modal.rest { transform: none; }
.adm-modal-head {
  display: flex; align-items: center; gap: 12px;
  padding: 18px 18px 12px;
}
.adm-modal-head h3 { font-size: 19px; font-weight: 800; letter-spacing: -.4px; }
.adm-modal-head .sheet-close { margin-inline-start: auto; }
.adm-modal-body { padding: 0 16px; overflow-y: auto; }
.adm-modal-foot { padding: 14px 16px calc(16px); }
.adm-modal-foot .btn { border-radius: 18px; padding: 15px 18px; font-size: 15px; }

:root[data-theme="dark"] .adm-modal { box-shadow: 0 18px 50px rgba(0, 0, 0, .5); }

/* ── Карточка = одна страница стека ───────────────────────────────────────── */
/* ⚠️ У КАРТОЧКИ В ПОКОЕ НЕ ДОЛЖНО БЫТЬ НИ `transform`, НИ `will-change`.
   Здесь стояли `transform: translate3d(0,0,0)` и `will-change: transform` — как
   «подсказка компоузеру». Обе делают карточку ТРАНСФОРМИРОВАННЫМ ПРЕДКОМ
   постоянно (по спеке `will-change: transform` тоже заводит containing block и
   stacking context, ровно как настоящий transform), а трансформированный предок
   на iOS ломает нативную прокрутку вложенного скроллера — это и была «после
   „назад“ перестаёт скроллиться». Снятия ИНЛАЙНОВОГО transform не хватало:
   правило из таблицы стилей продолжало действовать.
   Теперь и то, и другое навешивается только на время движения (admTransition)
   и снимается по приезде. НЕ ВОЗВРАЩАТЬ СЮДА. */
.adm-card {
  position: absolute; inset: 0;
  display: flex; flex-direction: column;
  background: var(--bg);
}
/* Нижние карточки не кликаются: во время жеста палец не должен попадать по
   строкам страницы, которая видна из-под текущей. */
.adm-card:not(.is-top) { pointer-events: none; }

/* Затемнение нижней страницы — как в UINavigationController: уезжающий экран
   не просто отъезжает, он уходит в тень. Лежит ПОВЕРХ содержимого. */
.adm-scrim {
  position: absolute; inset: 0; z-index: 5;
  background: #000; opacity: 0; pointer-events: none;
}

/* Полосы `.adm-navbar` со «схлопывающимся» заголовком здесь БОЛЬШЕ НЕТ (решение
   владельца): при прокрутке сверху ничего не выезжает. Заголовок на экране один
   — крупный, в начале страницы, и уходит вместе с содержимым. Разметку и
   слушатель прокрутки убрали в `admBuildCard`. */

/* Отступы РОВНО как у #view — панель не должна отличаться полями от остальных
   экранов (сверху там же, где на главной начинается ряд профиля). Снизу дока
   нет, он убран, поэтому только безопасная зона. */
.adm-scroll {
  flex: 1; min-height: 0;
  overflow-y: auto; overflow-x: hidden;
  /* `-webkit-overflow-scrolling: touch` УБРАН НАМЕРЕННО. С iOS 13 ускоренная
     прокрутка включена для всех overflow-элементов, свойство не нужно — а вот
     вреда от него хватает: именно оно фигурирует в баге «прокрутка отваливается,
     если у предка есть transform», а предок-карточка у нас трансформируется на
     каждом переходе. Не возвращать. */
  overscroll-behavior-y: contain;
  padding: calc(14px + var(--safe-t)) 16px calc(var(--safe-b) + 32px);
  display: flex; flex-direction: column;
  /* Та же колонка, что у #app — иначе на широком экране панель расползается на
     всю ширину, а главная под ней остаётся в 480px, и «замена фона» не сходится. */
  width: 100%; max-width: 480px; margin: 0 auto;
}
/* Крупный заголовок живёт в потоке контента и уезжает вместе с ним */
.adm-large { flex: none; padding-bottom: 14px; }
.adm-large:empty { display: none; }
/* gap как у .screen — интервалы между блоками те же, что на обычных экранах */
.adm-content { display: flex; flex-direction: column; gap: 14px; min-width: 0; }

/* ── Ожидание данных: силуэт вместо спиннера ─────────────────────────────────
   Крутящаяся иконка с «Загрузка…» ничего не сообщала и ломала впечатление от
   перехода. Здесь — силуэт БУДУЩИХ СТРОК, геометрия 1-в-1 как у .admin-row
   (те же отступы, размер иконки, скругление, тень), а мерцание — общий .sk
   мини-аппа. Никакого своего оформления. */
/* Скелетон ПОЯВЛЯЕТСЯ НЕ СРАЗУ. Он ждёт 220мс и только потом проступает: если
   данные пришли раньше (а с кэшем и предзагрузкой это обычный случай), его
   успевают заменить, пока он ещё полностью прозрачен — то есть человек не
   видит его вообще. Это дешевле и честнее, чем рисовать под каждый экран свой
   «похожий» силуэт: силуэт всё равно врёт, а отсутствие ожидания — нет. */
.admin-skel { display: flex; flex-direction: column; gap: 8px;
  opacity: 0; animation: adm-skel-in .18s ease .22s forwards; }
@keyframes adm-skel-in { to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .admin-skel { animation-duration: .01s; } }
.admin-skel-row {
  display: flex; align-items: center; gap: 13px;
  padding: 13px 15px; border-radius: var(--r-sm);
  background: var(--surface); box-shadow: var(--shadow-soft);
}
.admin-skel-ico { width: 40px; height: 40px; border-radius: 12px; flex: none; }
.admin-skel-lines { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 7px; }
.admin-skel-line { height: 11px; border-radius: 6px; }
.admin-skel-line.is-short { width: 45%; height: 9px; }

/* Скелетон «Статистики». Общий силуэт из строк списка врал: на этом экране
   приезжают не строки, а заголовок раздела, карточки с графиком и таблица
   «подпись — значение». Размеры взяты с РЕАЛЬНЫХ блоков (.admin-h, .metric-card,
   .admin-kvs), поэтому контент встаёт ровно на место силуэта, а не прыгает. */
.admin-skel.is-stats { gap: 12px; }        /* как .admin-body */
.sk-h { display: flex; align-items: center; gap: 9px; margin: 8px 4px 0; }
.sk-h-ico { width: 28px; height: 28px; border-radius: 9px; flex: none; }
.sk-h-txt { width: 104px; height: 13px; border-radius: 7px; }
.sk-metric { display: flex; flex-direction: column; padding: 15px 17px 12px; }
.sk-metric-title { width: 92px; height: 11px; border-radius: 6px; }
.sk-metric-value { width: 148px; height: 24px; border-radius: 8px; margin-top: 9px; }
.sk-metric-chart { height: 56px; border-radius: 12px; margin: 12px 0 8px; }
.sk-metric-cap { width: 156px; height: 9px; border-radius: 5px; }
.sk-kvs { display: flex; flex-direction: column; padding: 6px 18px; }
.sk-kv { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 0; }
.sk-kv + .sk-kv { border-top: 1px solid var(--line); }
.sk-kv > i:first-child { width: 42%; height: 11px; border-radius: 6px; }
.sk-kv > i:last-child { width: 62px; height: 11px; border-radius: 6px; }

/* ── Потянуть вниз, чтобы обновить ────────────────────────────────────────────
   Новый элемент, а не переоформление существующего: индикатора обновления в
   приложении не было. Оформлен теми же токенами (--text-3, .icon) и той же
   крутилкой adm-spin, что уже жила в стилях админки. --pull (0..1) ведёт JS. */
.adm-refresh {
  position: absolute; z-index: 3; left: 0; right: 0;
  /* 46px тут стояли, чтобы разойтись с полосой `.adm-navbar`. Полосу убрали
     (заголовок теперь только крупный, в потоке), и индикатор висел в пустоте
     посреди контента — подтягиваем его к верхней кромке. */
  top: calc(var(--safe-t) + 10px);
  display: grid; place-items: center;
  pointer-events: none;
  opacity: var(--pull, 0);
  transform: translateY(calc(-14px + 20px * var(--pull, 0)))
             scale(calc(.7 + .3 * var(--pull, 0)));
}
.adm-refresh .icon {
  width: 19px; height: 19px; color: var(--text-3); stroke-width: 2.2;
  /* До порога стрелка ПОВОРАЧИВАЕТСЯ за пальцем — так видно, сколько осталось
     тянуть; после отпускания включается обычное вращение. */
  transform: rotate(calc(var(--pull, 0) * 260deg));
}
.adm-refresh.is-spinning .icon { animation: adm-spin 1s linear infinite; transform: none; }
@media (prefers-reduced-motion: reduce) { .adm-refresh.is-spinning .icon { animation: none; } }

/* ── Плашка «Отменить» ───────────────────────────────────────────────────────
   Отдельный элемент, а не .toast с кнопкой внутри: обычный тост зовут из
   десятка мест и он ставит textContent — добавлять туда разметку значило бы
   переделывать общий компонент ради одного случая. Геометрия и цвета взяты у
   .toast один в один, чтобы это читалось как та же плашка. */
.toast-undo {
  position: fixed;
  left: 50%; bottom: calc(var(--tabbar-h) + var(--safe-b) + 30px);
  transform: translate(-50%, 16px);
  z-index: 121;
  max-width: min(calc(100vw - 32px), 420px);
  display: flex; align-items: center; gap: 14px;
  padding: 10px 10px 10px 20px;
  border-radius: 100px;
  background: var(--surface); color: var(--text);
  border: 1px solid var(--line);
  font-size: 13.5px; font-weight: 700;
  box-shadow: var(--shadow-float);
  opacity: 0;
  transition: opacity .28s ease, transform var(--dur-sheet) var(--ease-spring);
  white-space: nowrap;
}
.toast-undo.on { opacity: 1; transform: translate(-50%, 0); }
.toast-undo button {
  flex: none; padding: 8px 16px; border-radius: 100px;
  font-size: 13px; font-weight: 700;
  color: var(--surface); background: var(--text);
  transition: transform .14s ease, opacity .14s ease;
}
.toast-undo button:active { transform: scale(.95); opacity: .9; }
/* Полоска утекающего времени — видно, сколько осталось на «передумать». */
.toast-undo i {
  position: absolute; left: 0; bottom: 0; height: 2px;
  /* width остаётся 100%: элемент пустой и абсолютный, без ширины ему нечего
     масштабировать. Сжимает его теперь transform. */
  width: 100%;
  background: var(--text-3); border-radius: 0 0 100px 100px;
  transform-origin: left;
  animation: undo-drain 5s linear forwards;
}
/* 🚨 Была анимация `width` длиной ПЯТЬ СЕКУНД — самая долгая неускоряемая
   анимация во всём файле: пять секунд подряд браузер пересчитывал раскладку
   каждый кадр ради полоски в два пикселя. scaleX композитится и стоит ноль.
   RTL-переворот origin сознательно НЕ добавлен: полоска прибита физическим
   `left: 0`, а стек админки принудительно `direction: ltr`. */
@keyframes undo-drain { from { transform: scaleX(1); } to { transform: scaleX(0); } }
@media (prefers-reduced-motion: reduce) {
  .toast-undo i { animation: none; transform: scaleX(0); }
}

/* ── Долгое нажатие на строку: быстрые действия ──────────────────────────────
   Новый элемент (такого в приложении не было). Собран на существующих токенах:
   поверхность .card, тени, --line, .admin-row внутри. Строка под пальцем
   приподнимается — это единственный намёк, что «держат» именно её. */
.adm-peek-veil {
  position: fixed; inset: 0; z-index: 95;
  background: rgba(0, 0, 0, .18);
  -webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px);
  opacity: 0; transition: opacity .18s ease;
}
.adm-peek-veil.on { opacity: 1; }
.adm-peek {
  position: fixed; z-index: 96;
  width: min(240px, calc(100vw - 32px));
  background: var(--surface);
  border-radius: var(--r-md);
  box-shadow: var(--shadow-float);
  overflow: hidden;
  transform-origin: 50% 0;
  transform: scale(.86); opacity: 0;
  transition: transform var(--dur-sheet) var(--ease-spring), opacity .16s ease;
}
.adm-peek.on { transform: scale(1); opacity: 1; }
.adm-peek button {
  display: flex; align-items: center; gap: 11px; width: 100%; text-align: start;
  padding: 13px 15px; font-size: 15px; font-weight: 600; color: var(--text);
  transition: background-color .14s ease;
}
.adm-peek button + button { box-shadow: inset 0 1px 0 var(--line); }
.adm-peek button:active { background: var(--surface-2); }
.adm-peek button .icon { width: 19px; height: 19px; color: var(--text-2); flex: none; }
.adm-peek button.is-danger, .adm-peek button.is-danger .icon { color: var(--bad); }
.admin-row.is-peeked { transform: scale(1.02); z-index: 2; }

/* ── Медиа проявляется сверху вниз по очереди (см. Motion.orderedMedia) ────── */
img.media-pending { opacity: 0; }
img.media-in { animation: media-in .34s cubic-bezier(.2, .8, .3, 1) both; }
@keyframes media-in { from { opacity: 0; transform: scale(1.06); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) {
  img.media-pending { opacity: 1; }
  img.media-in { animation: none; }
}

/* ── Док (таббар) складывается вниз, как док iPadOS ──────────────────────────
   --dock-hide (0..1) ведёт JS: 1 — убран, 0 — на месте. Значение непрерывное:
   во время жеста «назад» с корня док раскрывается ПРОПОРЦИОНАЛЬНО пальцу. */
.tabbar-wrap {
  transform:
    translateX(-50%)
    translateY(calc(var(--dock-hide, 0) * 155%))
    scale(calc(1 - var(--dock-hide, 0) * .1));
  transform-origin: 50% 130%;
  opacity: max(0, calc(1 - var(--dock-hide, 0) * 1.35));
}
.tabbar-wrap.is-tucked { pointer-events: none; }

/* Затемнение главной, когда её открывает уезжающая вправо админка */
.adm-view-scrim {
  position: fixed; inset: 0; z-index: 55;
  background: #000; opacity: 0; pointer-events: none;
}
.adm-view-scrim[hidden] { display: none; }


/* ═══ WEB VERSION ═══════════════════════════════════════════════════════════
   The same bundle is served inside Telegram AND as an ordinary website, because
   Telegram is blocked in Russia. Two things live here: the login card, and the
   landscape rearrangement.

   ⚠️ NOTHING BELOW INTRODUCES A COLOUR, A RADIUS OR A TYPE SIZE. Every value is
   an existing token or an existing literal from this file, and every component
   on the login card is one that already existed (.card, .field, .btn). `.btn` is
   already in all three hover-veil lists, so there is nothing to add there — and
   none of the new classes here is tappable. */

/* ── Login page ──
   A page, not a floating box: brand at the top, card under it, the switch line
   beneath that. `flex: 1` rather than a viewport min-height — #view already
   carries top padding and safe-area insets, so a 100dvh child inside it
   overflows by exactly that much and leaves a scrollbar with nothing to scroll
   to. Filling the row #view has already sized is the same centring, no
   arithmetic. */
.login-wrap {
  flex: 1;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 20px;
  padding: 20px 0 calc(var(--safe-b) + 24px);
}

/* Brand. Sits above the card and outside it — a website says who it is before
   it asks for a password. */
.login-brand {
  display: flex; flex-direction: column; align-items: center; gap: 14px;
}
.login-mark { width: 72px; height: 72px; border-radius: var(--r-lg); display: block; }
.login-word { font-size: 27px; font-weight: 800; letter-spacing: -.5px; color: var(--text); }
/* Only ever shown in the two-panel layout: on a narrow page the card already
   says everything, and a pitch above it just pushes the form off screen. */
.login-pitch {
  display: none;
  font-size: 15px; line-height: 1.5; color: var(--text-2);
  max-width: 320px; margin: 0;
}

.login-card { width: 100%; max-width: 440px; padding: 32px 28px 30px; }
/* The whole card dims while a request is in flight; the fields are disabled at
   the same time, so this only has to SAY that something is happening. */
.login-card { transition: opacity .2s ease; }
.login-card.is-busy { opacity: .6; pointer-events: none; }

/* 19px and 21px, not sizes of my own: both already exist in this file, and the
   brand reading one step larger than the card heading is what gives the page a
   hierarchy without inventing a type scale. */
.login-title {
  font-size: 19px; font-weight: 800; letter-spacing: -.5px;
  color: var(--text); margin: 0;
}
.login-sub {
  font-size: 13.5px; color: var(--text-2);
  margin: 7px 0 20px; line-height: 1.45;
}
.login-card .field { margin: 14px 0; }

/* The failure line. --bad is the token every other error in this file uses. */
.login-err {
  font-size: 13px; font-weight: 600; color: var(--bad);
  margin: 12px 2px 0; line-height: 1.4;
}
.login-card .btn-primary { margin-top: 18px; padding-top: 16px; padding-bottom: 16px; }

/* Hairline with a centred label. Decoration only — nothing to tap, no veil. */
.login-or {
  display: flex; align-items: center; gap: 12px;
  margin: 22px 0 16px;
  font-size: 12.5px; font-weight: 600; color: var(--text-3);
}
.login-or::before, .login-or::after {
  content: ''; flex: 1; height: 1px; background: var(--line);
}

.login-alt { display: flex; flex-direction: column; gap: 10px; }
/* Slot for the Telegram Login Widget. It is an iframe from telegram.org and is
   removed outright when that does not load (i.e. in Russia) — :empty keeps the
   gap from surviving the removal. */
.login-widget { display: flex; justify-content: center; }
.login-widget:empty { display: none; }

/* «Забыли пароль?» and «Отправить код ещё раз», directly under the submit
   button. Positioning only — the button inside is a plain .login-link and
   carries the design language with it, so there is nothing to style here
   beyond where it sits. */
.login-forgot { margin-top: 12px; text-align: center; }

/* Stacked actions at the foot of a code card. Positioning only — the buttons
   inside are ordinary .btn, so they bring the design language with them.
   The 18px is what keeps them off the field above: it is the gap the submit
   button has there, and without it the first button sits flush against the
   input and the pair reads as one control cut in half. */
.login-actions { display: flex; flex-direction: column; gap: 10px; margin-top: 18px; }

/* One box per digit of the e-mailed code.
   Every value here is taken from `.field input`, which is the app's one input
   style — same fill, same radius, same 1.5px transparent border, same focus
   ring. Only the shape changes: square-ish instead of a full-width bar, and the
   digit centred and enlarged. flex:1 with min-width:0 makes six of them share
   whatever width the card has, so nothing overflows on a narrow phone. */
.code-boxes { display: flex; gap: 8px; }
.code-boxes input {
  flex: 1; min-width: 0; width: 100%;
  padding: 14px 0;
  text-align: center;
  border-radius: var(--r-sm);
  border: 1.5px solid transparent;
  background: var(--surface-2);
  font-size: 21px; font-weight: 700;
  outline: none;
  /* transform is in the transition because of the filled state below: without
     it the box would snap, which reads as a glitch rather than a response. */
  transition: border-color .2s ease, box-shadow .2s ease,
              background-color .2s ease, transform .18s var(--ease-spring);
}
.code-boxes input:focus {
  border-color: var(--text);
  box-shadow: 0 0 0 4px color-mix(in srgb, var(--text) 10%, transparent);
}
/* A typed digit lifts very slightly. This is the only feedback that a keypress
   landed — the caret has already moved to the next box by then. */
.code-boxes input.is-filled { transform: scale(1.04); }
@media (prefers-reduced-motion: reduce) {
  .code-boxes input.is-filled { transform: none; }
}
/* Chrome/Safari draw spinners and a strong autofill wash over number-ish inputs;
   both fight the card's own fill. */
.code-boxes input::-webkit-outer-spin-button,
.code-boxes input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }

/* Moving between the login cards — form → code → new password.
   The screen element itself never changes (renderLogin rewrites its contents in
   place), so `screen-in` on `.screen` cannot fire for these; the animation has
   to sit on the wrapper that IS replaced. Same curve and same shape as
   `screen-in`, because this is the same kind of move.
   ⚠️ Suppressed until `app-ready`, exactly like `.screen` in index.html: before
   that the markup on screen is the skeleton, and animating it would turn the
   hand-over into a visible flinch. */
html.app-ready .login-wrap { animation: login-in .34s cubic-bezier(.16, 1, .3, 1); }
@keyframes login-in {
  from { opacity: 0; transform: translateY(6px) scale(.994); }
  to   { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
  html.app-ready .login-wrap { animation: none; }
}

.login-switch { font-size: 13.5px; color: var(--text-2); text-align: center; }
/* Inline text button. It is TAPPABLE, so it carries both responses the design
   language requires: a press transform here, and its class in all three
   hover-veil lists above. The padding gives the veil a pill to fill. */
.login-link {
  color: var(--text); font: inherit; font-weight: 700;
  padding: 4px 7px; margin: -4px -3px;
  border-radius: var(--r-sm);
  transition: transform .16s ease;
}
.login-link:active { transform: scale(.94); }

/* While the login card is up there is no app to navigate: the dock would be a
   row of dead tabs, and #view's bottom padding reserves its height for nothing. */
html.is-login .tabbar-wrap { display: none; }
/* At >=900px .screen becomes a two-column grid with lign-content: start,
   which pinned the login page to the top of the window and stopped it
   stretching — that is why the wide layout was never vertically centred. The
   login screen opts out of BOTH, without touching display (an override there
   is what broke hiding once already). */
html.is-login #view > .screen {
  grid-template-columns: minmax(0, 1fr);
  align-content: stretch;
}
html.is-login #view {
  /* No dead strip above or below: the login page owns its own spacing, and
     #view's page padding here only produced a gap with nothing in it. */
  padding-top: 0;
  padding-bottom: 0;
  /* #view aligns its screens to `start` so a short screen does not stretch to a
     tall neighbour's height. The login screen is the one case that WANTS the
     full row — it is a single card centred in the page. */
  align-items: stretch;
}

/* ── Landscape ──────────────────────────────────────────────────────────────
   The dock STAYS a bottom-centred floating bar. A vertical rail would mean
   rewriting setupTabDrag (it travels on translateX only), re-deriving
   --glass-rim-w (it is computed against element HEIGHT — 30px on the 72px bar,
   and getting it wrong turns the glass opaque), and reworking the drop
   keyframes. None of that buys anything a dock does not already give on a wide
   screen, and all of it is regression risk in the app's signature component.

   900px, not a phone-landscape width: below this the two-column reflow makes
   columns narrower than the components inside them were designed for. A rotated
   phone therefore keeps the phone layout, which is the correct answer for it. */
@media (min-width: 900px) {
  #app { max-width: 1280px; }
  /* ⚠️ `.adm-scroll` KEEPS its own 480px column and is NOT widened with #app.
     That is deliberate, on two grounds. It is an opaque full-screen overlay, so
     a narrower column shows nothing of the page behind it — the reason the two
     480s had to agree was the panel spreading WIDER than home, not narrower.
     And a session cookie gets 403 on every /api/app/admin/* route, so the panel
     is reachable from Telegram only, where the window is phone-width and this
     media query never fires. Reflowing it would be pure regression risk in the
     one place a layout mistake stays invisible until you are deep inside it. */

  /* ⚠️ THE TWO-COLUMN `.screen` RULE THAT USED TO LIVE HERE IS GONE.
     It was written before the dashboard existed, and once the dashboard landed
     at a higher breakpoint the two fought: between 900 and 1100 you got
     two-column pages WITH the dock, and crossing either boundary made the
     layout jump. There is now exactly ONE landscape breakpoint — the bento
     dashboard below. Everything narrower keeps the phone layout, which is the
     right answer for it. */

  /* Sheets are CLAMPED, not stretched. Their internals are a chain of calc()s on
     --x tuned for a ~400px sheet (title 19→31px, .prof-head 54→243px), so a
     1900px-wide sheet would be a phone-width column floating in a void. Clamping
     keeps every one of those measurements exactly as designed. The 32px radius
     is this file's own literal, not --r-xl (34px) — deliberately copied rather
     than "tidied" into the token, which would silently change the shape. */
  /* ⚠️ CENTRED WITH AUTO MARGINS, NEVER WITH A TRANSFORM. The drag handler
     writes `el.style.transform = 'translateY(...)'` straight onto the sheet
     (app.js _makeSheetDraggable), and an inline style beats any rule here — a
     `translate(-50%, …)` centring would be wiped by the first touchmove and the
     sheet would snap to the left edge mid-drag. With left:0, right:0 and a
     definite width, `margin-inline: auto` centres it using no transform at all,
     so the drag maths is untouched. */
  .sheet {
    width: min(100%, 480px);
    margin-inline: auto;
  }
  /* Inner grids are NOT re-columned. Each screen column is now ~500px — barely
     wider than the ~448px these were designed against — so `repeat(3, 1fr)`
     still lands exactly as intended. Widening them here would have made the
     tiles SMALLER, not better. */
}

/* ── Header on a wide screen ──
   Nothing to do any more. The row used to hold a rubbery «О нас» pill that a
   wide screen stretched into a slab; both header controls are now fixed-size
   circles inside .head-tools, so space-between already puts the profile left
   and the tools right at every width. */

/* ── Desktop: the phone, floating ────────────────────────────────────────────
   TEMPORARY, and deliberately so. Four dashboard layouts were tried on this
   surface and none of them was what the owner wanted; the real desktop design
   is being done elsewhere. Until then the honest answer is to show the phone
   app as a phone: a fixed 480px column, centred, with rounded corners, on a
   black backdrop. The dock comes back because nothing is hidden any more, and
   every screen is exactly the layout it was drawn for.

   Only the frame is new. There is no reflow, no column logic, no component is
   resized — which is precisely why this can be thrown away in one commit.

   ⚠️ `transform` on #app is what makes the frame a containing block for its
   fixed children, so the dock, the sheets and the toasts sit INSIDE it rather
   than at the edges of the browser window. The note earlier in this file warns
   that a resting transform breaks nested scrolling on iOS — that applies to the
   phone, and this rule is behind a 1000px min-width, which a phone never meets. */
@media (min-width: 1000px) {
  html.is-web, html.is-web body { background: #000; }
  html.is-web #app {
    width: 480px;
    max-width: 480px;
    margin: 20px auto;
    height: calc(100dvh - 40px);
    min-height: 0;
    border-radius: 46px;
    overflow: hidden;
    background: var(--bg);
    box-shadow: var(--shadow-soft);
    transform: translateZ(0);
  }
  /* The frame has a fixed height, so the content scrolls inside it. */
  html.is-web #view { flex: 1; overflow-y: auto; overscroll-behavior: contain; }
  /* Hiding the track used to live HERE, for the web frame only. It is now a base
     rule (see `::-webkit-scrollbar` next to .svg-defs) and applies everywhere,
     so these two lines are redundant — kept because they are harmless, identical
     in value, and removing them would make this block read as if the web frame
     were an exception, which it no longer is. */
  html.is-web #view { scrollbar-width: none; }
  html.is-web #view::-webkit-scrollbar { width: 0; height: 0; }
  /* The frame is already the height of the viewport, so the PAGE never needs to
     scroll. (`scrollbar-gutter` is no longer set on body at all — the base rules
     hide the bar outright instead of reserving space for it.) */
  html.is-web, html.is-web body { overflow: hidden; }
  /* No safe-area inset on a desktop frame — it would leave a dead strip. */
  html.is-web #app { --safe-t: 0px; --safe-b: 0px; }
}


/* ── Login, GENUINELY wide ───────────────────────────────────────────────────
   Two panels only when the window is both large AND wide-shaped. 1100px was far
   too eager. So was 16/9: a maximised 1920x1080 browser is 1904x985, i.e. 1.93,
   which is WIDER than 16/9 (1.78) — so an utterly ordinary screen kept getting
   the split. Measured, not guessed. 21/9 (2.33) excludes it and admits real
   ultrawides (2560x1080 -> 2.58, 3440x1440 -> 2.40). */
@media (min-width: 1900px) and (min-aspect-ratio: 21/9) {
  .login-wrap {
    display: grid;
    grid-template-columns: 1fr 1fr;
    align-items: center;
    gap: 56px;
    padding: 0;
    max-width: 940px;
    margin: 0 auto;
  }
  .login-brand { flex-direction: column; align-items: flex-start; gap: 18px; }
  .login-mark { width: 96px; height: 96px; }
  .login-word { font-size: 27px; }
  .login-pitch { display: block; }
  .login-card { max-width: none; margin: 0; }
  .login-switch { grid-column: 2; }
}

/* ═══════════════════════════════════════════════════════════════════════════
   RIGHT-TO-LEFT — Arabic, Hebrew, Persian, Urdu
   ═══════════════════════════════════════════════════════════════════════════
   `app.js` sets `document.documentElement.dir`, so the flip itself is free:
   flex rows reverse, and every physical margin/padding/text-align in this file
   was converted to a logical property, which mirrors on its own.

   What follows is only what logical properties CANNOT do. Four kinds of thing:
   directional glyphs, islands that must stay left-to-right, Arabic typography,
   and strings that are technically Latin and must not be reordered by the bidi
   algorithm.

   ⚠️ `[dir="rtl"]`, never `:dir()` — the latter is Chrome 120 / Safari 16.4 and
   would silently do nothing on a phone a year old.                            */

/* ── Directional glyphs ────────────────────────────────────────────────────
   The chevron points the way the reader travels, so it mirrors. Two exceptions,
   and both would be BUGS if the blanket rule caught them:
     · `.period-pill .chev` is rotated 90° to point DOWN — a dropdown caret is
       direction-neutral, and mirroring it would leave it pointing up.
     · `.task-chev` carries `translateY(-50%)` for vertical centring. A bare
       `transform: scaleX(-1)` REPLACES that, dropping the chevron to the top of
       the card. The transforms have to be composed, not overwritten.
   Both are restated below rather than excluded with :not(), because
   `[dir="rtl"] .chev` and `.period-pill .chev` have EQUAL specificity — and
   this block is last in the file, so it would win by source order. */
[dir="rtl"] .chev { transform: scaleX(-1); }
[dir="rtl"] .period-pill .chev { transform: rotate(90deg); }
/* Task nodes replaced task cards; the chevron kept its absolute positioning, so
   it keeps the same two problems. */
[dir="rtl"] .node .chev { transform: scaleX(-1); }
[dir="rtl"] .node .chev { right: auto; inset-inline-end: 0; }

/* Never mirrored: these are not directional. A reversed checkmark, magnifier or
   logo just looks broken, and «Мир» is a brand mark. */
[dir="rtl"] :is(.opt-check, .i-check, .i-search, .i-logo, .i-mir, .i-refresh) {
  transform: none;
}

/* ── Islands that must NOT flip ────────────────────────────────────────────
   🚨 THE ADMIN PANEL IS NOT TRANSLATED (owner's call). It inherits `dir` from
   <html>, so an Arabic-speaking admin would get the entire Russian panel
   mirrored — every table, every chart axis, every row — while still reading
   Russian. One attribute prevents all of it. */
[dir="rtl"] #admStack { direction: ltr; text-align: start; }

/* The payment-method mock is a picture of a physical card: the chip is on the
   left of a real card in every country. */
[dir="rtl"] .pm-card { direction: ltr; }

/* ── Latin strings inside RTL text ─────────────────────────────────────────
   🚨 The access key is a URL. Under RTL the bidi algorithm resolves the trailing
   neutral run to the paragraph direction, and `https://host/uuid` renders with
   its pieces visually reordered — the user copies a link that LOOKS wrong and
   reports it as broken. Same for the referral link and the numeric id.
   `direction: ltr` on the element is the fix; `unicode-bidi: isolate` keeps it
   from leaking into the surrounding sentence. */
[dir="rtl"] :is(.key-link code, .invite-link code, .ac-key code, .token-well code, .mono-copy, .adm-id) {
  direction: ltr;
  unicode-bidi: isolate;
  text-align: start;
}

/* Money and counts are ASCII digits with a trailing currency mark. U+20BD is
   bidi class ET; the space before it breaks adjacency and the mark jumps to the
   wrong end («₽ 3 599»). Isolating the run keeps the number intact without
   forcing the whole line to LTR. */
[dir="rtl"] :is(.ac-days, .figure-num, .row-side, .total-line b, .metric b, .stat-body b, .node-count, .line-val) {
  unicode-bidi: isolate;
}

/* ── Arabic typography ─────────────────────────────────────────────────────
   🚨 Letter-spacing breaks cursive joining. Arabic, Persian and Urdu letters
   connect; adding or removing tracking pulls the joins apart and the word stops
   being a word. This file tunes tracking on ~30 selectors, so the reset is
   deliberately blanket rather than a list that would drift out of date. The
   only cost is losing a little tightening on Latin numerals in RTL locales. */
[dir="rtl"] * { letter-spacing: normal; }

/* `line-height: 1` clips the diacritics that sit above and below the baseline;
   Arabic needs room that Latin does not. */
[dir="rtl"] :is(.ac-days, .figure-num) { line-height: 1.25; }
[dir="rtl"] :is(.hero-plan, .engrave, .slab-label, .figure-label, .ac-brand, .token-label, .plan-title, .sheet-title, .admin-title) { line-height: 1.35; }

/* Urdu is written in NASTALIQ, not Naskh. Without this it renders in the Arabic
   default and reads to an Urdu speaker roughly the way blackletter reads to us —
   legible, wrong, and obviously not designed for them. Nastaliq is also steeply
   cascading, so it needs noticeably more leading than Arabic. */
[lang="ur"] body,
[lang="ur"] input,
[lang="ur"] button { font-family: "Noto Nastaliq Urdu", "Jameel Noori Nastaleeq", serif; }
[lang="ur"] :is(body, .row-main, .sheet-body, .plan-sub) { line-height: 1.9; }

/* ── Поиск в шторке выбора языка ───────────────────────────────────────────
   Липкий, а не строка списка: языков 37, и поле, в которое человек печатает,
   не должно уезжать вверх вместе с результатами.

   Собран из СУЩЕСТВУЮЩЕГО `.field` — своих цветов, радиусов и высот не заводит.
   Здесь только позиционирование и подложка, чтобы список не просвечивал под
   полем на прокрутке. */
.lang-search {
  position: sticky;
  top: calc(-12px - 8px * var(--x));   /* съедает верхний паддинг .sheet-body */
  z-index: 2;
  margin: calc(-12px - 8px * var(--x)) 0 12px;
  padding: calc(12px + 8px * var(--x)) 0 10px;
  background: var(--surface);
}
.lang-search .field { margin: 0; }

/* ── Занятая кнопка шторки ────────────────────────────────────────────────
   Сетевые CTA («Оплатить», «Активировать бесплатно», «Применить», «Вывести»)
   раньше на всё время запроса не менялись НИКАК — ни крутилки, ни блокировки, —
   и по ним, естественно, жали повторно. На «Оплатить» это второй счёт.

   Кольцо на ::before, а НЕ на ::after: ::after занят вуалью наведения (см. её
   блок выше), и перезапись сломала бы отклик на курсор. `position: relative`
   вуаль выдаёт только внутри @media (hover) — здесь он нужен всегда.

   Крутится существующая `adm-spin`; отдельной `spin` в этом файле нет. */
.sheet-cta { position: relative; }
.sheet-cta.is-busy {
  color: transparent;              /* подпись уходит, ширина кнопки не меняется */
  pointer-events: none;
}
.sheet-cta.is-busy::before {
  content: '';
  position: absolute; top: 50%; left: 50%;
  width: 18px; height: 18px; margin: -9px 0 0 -9px;
  border-radius: 50%;
  border: 2px solid currentColor;
  border-top-color: transparent;
  color: #fff;
  animation: adm-spin .8s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
  /* Кольцо остаётся — оно и есть сообщение «идёт запрос», — но не вращается. */
  .sheet-cta.is-busy::before { animation: none; opacity: .6; }
}

/* ═══════════════════════════════════════════════════════════════════════════
   LANGUAGE SWAP — every word becomes a placeholder while the pack downloads
   ═══════════════════════════════════════════════════════════════════════════
   Dictionaries are separate files fetched on demand, so between the tap and the
   new words there is a real network round trip. Without this the screen sits
   there in the old language, which reads as a dead button.

   Two classes, both driven by app.js (langSwapStart / langSwapEnd):
     .lang-swap      placeholders are up
     .lang-swap-out  placeholders are fading out and the new words are fading in

   ⚠️ THE ORDER IS LOAD-BEARING and lives in app.js: repaint first, reveal after.
   Revealing first uncovers the DOM as it still is — in the OLD language — for
   however many frames pass before the re-render lands.

   WHAT GETS BLANKED. Only LEAF elements — `:not(:has(*))` — so a text node
   becomes one clean block instead of nesting a block inside a block. `:not(:empty)`
   keeps spacers from sprouting bars of their own. Containers (`div`) are
   deliberately NOT in the list: a leaf div is usually a rule, a spacer or a
   decorative fill, and blanking those produced stray bars with no text behind.

   `color: transparent` rather than hiding the text, because the glyphs keep
   holding their exact width and height — nothing reflows on the way in or out.

   Icons are left alone: they carry no language, and keeping them preserves the
   sense of place.                                                            */

html.lang-swap :is(#view, .sheet, .tabbar-wrap)
  :is(span, b, i, em, strong, small, p, label, h1, h2, h3, h4, code, li, a, button):not(:has(*)):not(:empty),
html.lang-swap-out :is(#view, .sheet, .tabbar-wrap)
  :is(span, b, i, em, strong, small, p, label, h1, h2, h3, h4, code, li, a, button):not(:has(*)):not(:empty) {
  /* Pill ends, like everything else in this app — the cards run 28-34px, and a
     16-20px line of text reads as "rounded" only when it is a full pill. */
  border-radius: 999px;
  /* A wrapped line gets rounded ends on every fragment, not just the first. */
  -webkit-box-decoration-break: clone;
  box-decoration-break: clone;
  cursor: default;
  /* ⚠️ A DELIBERATE, NARROW EXCEPTION to "animate only transform and opacity".
     That rule exists so gestures and springs can hold 120 Hz on a WKWebView; it
     is about continuous, interactive motion. This is a ONE-SHOT 220 ms paint
     crossfade on ~50 static elements, with no layout involved (colour is paint,
     never layout). The accelerated alternative — an absolutely positioned
     ::after per element — would need `position: relative` on each, which
     silently breaks any leaf that is already absolutely positioned, and would
     collide with the hover veil, which owns ::after on several of these very
     classes. Cheaper in theory, worse in practice. */
  transition: color .22s cubic-bezier(.4, 0, .2, 1),
              background-color .22s cubic-bezier(.4, 0, .2, 1);
}

/* The resting state: text hidden, block shown. `.lang-swap-out` deliberately
   does NOT set these, so removing `.lang-swap` lets both properties fall back to
   their real values and the transition above animates the whole way there. */
html.lang-swap :is(#view, .sheet, .tabbar-wrap)
  :is(span, b, i, em, strong, small, p, label, h1, h2, h3, h4, code, li, a, button):not(:has(*)):not(:empty) {
  color: transparent !important;
  background-color: var(--sk);
}

/* 🚨 The admin panel is NOT translated, so blanking it would be pure noise —
   nothing in it changes when the dictionary lands. */
html:is(.lang-swap, .lang-swap-out) #screen-admin
  :is(span, b, i, em, strong, small, p, label, h1, h2, h3, h4, code, li, a, button) {
  color: inherit !important;
  background-color: transparent;
  transition: none;
}

/* Reduced motion still gets the placeholders — they are what says "wait" — but
   they cut rather than fade. */
@media (prefers-reduced-motion: reduce) {
  html:is(.lang-swap, .lang-swap-out) :is(#view, .sheet, .tabbar-wrap)
    :is(span, b, i, em, strong, small, p, label, h1, h2, h3, h4, code, li, a, button) {
    transition: none;
  }
}
