/* ==========================================================================
   Сайт-приглашение. База: шрифты, переменные, сброс, типографика, ритм секций.
   Оформление блоков — ниже по мере вёрстки Фазы 3.
   ========================================================================== */

/* --------------------------------------------------------------------------
   Шрифты. Самохостинг, без внешних CDN.
   Кириллица и латиница объявлены раздельно с unicode-range: браузер скачивает
   только те сабсеты, которые реально встретились на странице.
   font-display: swap — текст виден сразу, ещё до загрузки гарнитуры.
   -------------------------------------------------------------------------- */

@font-face {
  font-family: 'Lora';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('../fonts/lora-cyrillic-400-normal.woff2') format('woff2');
  unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}

@font-face {
  font-family: 'Lora';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('../fonts/lora-latin-400-normal.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2070-209F, U+20AC, U+2122,
    U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
  font-family: 'Lora';
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url('../fonts/lora-cyrillic-500-normal.woff2') format('woff2');
  unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}

@font-face {
  font-family: 'Lora';
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url('../fonts/lora-latin-500-normal.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2070-209F, U+20AC, U+2122,
    U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

/* Pompadur — рукописная гарнитура от заказчиков. Одним файлом, без разделения на
   сабсеты: в ней всего 92 глифа, а латиницы по сути нет — только I, V, X. Делить
   такую надвое нечего, весь файл весит 20 КБ.

   ВНИМАНИЕ: нет заглавных Ъ, Ы, Ь и нет знаков « » — – ·. Набирать ею капслоком
   нельзя: буква пропадёт молча, без ошибки. Ограничитель — в правиле `.accent`. */
@font-face {
  font-family: 'Pompadur';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('../fonts/pompadur-400-normal.woff2') format('woff2');
}

@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('../fonts/inter-cyrillic-400-normal.woff2') format('woff2');
  unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}

@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('../fonts/inter-latin-400-normal.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2070-209F, U+20AC, U+2122,
    U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url('../fonts/inter-cyrillic-500-normal.woff2') format('woff2');
  unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}

@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url('../fonts/inter-latin-500-normal.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2070-209F, U+20AC, U+2122,
    U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

/* --------------------------------------------------------------------------
   Переменные
   -------------------------------------------------------------------------- */

:root {
  /* PALETTE:START — активный вариант оформления.
     Содержимое между маркерами целиком заменяется файлом из
     assets/variants/<вариант>/palette.css. Состав ролей менять нельзя: на них
     завязана вся вёрстка, переименование потребовало бы прохода по всем блокам.
     Цветовые литералы допустимы ТОЛЬКО здесь — остальной файл работает на ролях.

     Сейчас активен: blush (розоватая пудра — происхождение тонов см. в
     assets/variants/blush/palette.css, исходный снимок в git не хранится),
     но с 2026-08-06 (T082+T063) второй акцентный цвет (розовый) снят: снимки
     стали чёрно-белыми, и золото (--m-gold, MATERIAL ниже) остаётся
     ЕДИНСТВЕННЫМ цветом страницы — роли палитры пересчитаны в его сторону
     (тёплый кремовый + графит + приглушённое золото вместо розового), состав
     ролей не менялся. --c-ink — тёплый графит по решению заказчиков (было
     коричневым #43302a). Контраст (WCAG, посчитан по формуле относительной
     яркости, не на глаз) на обоих фонах страницы — --c-bg и --m-glass
     (стекло реплик, см. MATERIAL): --c-ink 12.06:1 / 10.11:1 · --c-muted
     6.03:1 / 5.05:1 · --c-accent-deep (текстовая роль — ссылки, .accent)
     6.84:1 / 5.73:1 — все ≥ 4.5:1. --c-accent и --c-line — нетекстовая
     графика (рамки, обводки), порог 3:1: 3.97:1 / 3.33:1 и 3.67:1 / 3.08:1.
     --m-gold на обоих фонах см. в комментарии MATERIAL — это единственная
     пара, которая порог не держит на стекле (держит на странице, см. ниже).

     2026-08-06: заказчики попросили «золото чуть ярче». Осветление подняло
     бы яркость ценой контраста (светлее = ближе к светлому --c-bg), поэтому
     --c-accent/--c-accent-deep (и --m-gold в MATERIAL) сдвинуты по
     насыщенности вверх при той же или чуть меньшей светлоте — золото
     читается звонче, а контраст не упал, а вырос: --c-accent 3.68→3.97 (bg),
     3.09→3.33 (glass); --c-accent-deep 5.96→6.84 (bg), 4.99→5.73 (glass). */
  --c-bg: #f9f5ef;
  --c-bg-soft: #ece2d2;
  --c-ink: #33302e;
  --c-muted: #655c53;
  --c-accent: #9b7322;
  --c-accent-deep: #734e12;
  --c-line: #887e70;
  /* Правка заказчиков 2026-08-06: сердце календаря (#date, календарь
     торжества) — «матовый красный», а не золото, единственное сознательное
     отступление от D11 «золото — единственный цвет страницы»
     (memory-bank/resources/decisions.md), по прямой просьбе заказчиков.
     Матовость — тот же принцип, что у золота (MATERIAL ниже, tools/
     gold_extract.py MATTE/GAIN): не яркий чистый тон, а сжатая по
     насыщенности и притемнённая «краска». Источник — цвет листа сердец
     8310.eps #AC1F40 (H 346°, S 0,69, L 0,40): насыщенность ×0,7, светлота
     -0,03, финальный гейн ×0,9 — тот же путь «сжать разброс + притемнить»,
     что и у золота, только без второго шага (нет диапазона пикселей для
     сжатия к телу цвета — это плоская CSS-роль, не растр). Контраст к
     стеклу реплик (--m-glass) 7.04:1, к фону страницы (--c-bg) 8.40:1 —
     далеко выше порога 3:1 для нетекстовой графики (сердце decorative,
     aria-hidden). */
  --c-heart: #7e2b3f;
  /* T118 (замечание пользователя 2026-08-12): плавающая кнопка «К анкете»
     (.rsvp-float) — единственное место, где фон-плашка одновременно (а) на
     ней лежит настоящий читаемый текст (кегль ниже порога WCAG «крупного
     текста») и (б) требуется «золотой, а не тёмный» цвет для привлечения
     внимания. Ни --c-accent (3.97:1 с --c-bg — держит только 3:1-порог для
     нетекстовой графики), ни --c-accent-deep (это и есть тот «тёмный»
     цвет, который заказчик просит заменить) не годятся одновременно. Токен
     — точка на прямой между --c-accent-deep (#734e12) и --c-accent
     (#9b7322) в сторону более светлого/золотого, до которой контраст с
     --c-bg ещё держит 4.5:1 с запасом (расчёт — WCAG относительная
     яркость, не на глаз): 4.84:1. Заметно золотистее --c-accent-deep,
     заметно контрастнее --c-accent — роль узкая (только эта кнопка), не
     общая замена ни одному из двух базовых токенов. */
  --c-cta-gold: #8c651b;
  /* PALETTE:END */

  /* MATERIAL:START — цвета материала: не роли палитры, вариантом не подменяются.
     Роли палитры выше подменяются целиком при смене варианта оформления (три
     тёплых варианта). Голубое стекло и золото рамы зеркала — не роль оформления,
     а свойство самого декоративного растра: золотой орнамент (вензели, разделители)
     напечатан один раз и одинаков во всех вариантах, а стекло — его неизменная пара.
     Значит этот дуэт обязан пережить смену палитры, поэтому вынесен отдельным блоком.
     --m-gold замерен как медиана непрозрачных пикселей готового орнамента
     (site/img/decor/divider.webp) — так рисованная CSS-линия рамы и растровый
     вензель сходятся в одном тоне, а не расходятся на глаз.

     --m-glass (T082, решение заказчиков 2026-08-06): голубое стекло было
     вторым цветовым акцентом наравне с золотом — рядом с чёрно-белой съёмкой
     оно спорило со снимками и с золотом. Уведено в тёплый серый, почти
     бумажный (близко к --c-bg, но заметно отличимо — иначе реплики перестали
     бы читаться стеклом на фоне страницы, bg↔glass 1,19:1 отличия по яркости
     при том же оттенке). Текстовые роли на нём держат 4.5:1 (см. PALETTE
     выше), --c-accent/--c-line — 3:1.

     2026-08-06, та же правка «золото чуть ярче»: --m-gold сдвинут по той же
     насыщенности вверх при чуть меньшей светлоте (см. PALETTE-комментарий про
     путь через насыщенность, не осветление). На --c-bg это неожиданно
     ЗАКРЫЛО старый долг — было 2,90:1 (ниже порога 3:1 для нетекстовой
     графики), стало 3,03:1. На --m-glass контраст тоже вырос (2,43:1 → 2,54:1),
     но порог 3:1 по-прежнему не держит — известное ограничение: стекло не
     может стать значительно темнее бумаги и остаться «почти бумажным», и это
     не менялось этой правкой, только слегка смягчилось. */
  --m-glass: #e6e2d5;
  --m-gold: #ac8939;
  /* Светлая кромка блика на стекле — тоже свойство материала, не палитры:
     блик должен оставаться светлее заливки одинаково во всех вариантах
     оформления. Вынесена сюда, а не литералом внутри градиента. */
  --m-sheen: #ffffff;
  /* Бумага скрапбук-фото (T078) — тоже материал, не роль оформления: лист
     остаётся тем же бумажным оттенком независимо от тёплого варианта
     палитры (как и стекло/золото зеркала выше). Чистый белый заказчики
     забраковали отдельно («рамка плохо заметна, она белая») — сдвинут в
     серую сторону, чтобы читаться на светлом `--c-bg`. */
  --m-paper: #e4e0d8;

  /* Свет заката на тиснении (T122, пожелание пользователя 2026-08-13: «как
     будто лучи солнца, но более мягкие»). Два тона, а не один: у низкого
     солнца прямой луч янтарный, а рассеянный по краю тени уходит в розовое —
     на этом различии тень и читается закатной, а не просто коричневой.
     Почему тона живут в ТЕНИ, а не в блике — причина НЕ в яркости, а в
     различимости; см. замер в комментарии к --m-emboss ниже. */
  --m-sunset: #b4713a;
  --m-sunset-deep: #9d5a44;
  /* Блик тиснения. Тёплый белый, а не чистый: чистый был единственным
     ахроматичным пятном страницы (фон H36°, стекло H46°, золото H42° — всё
     тёплое). Разница по различимости ничтожна (1.53 против 1.58 к фону),
     по попиксельному просчёту — не больше 8 из 255, то есть глазом её нет;
     берётся ради связности палитры, а не ради вида. */
  --m-emboss-light: #fffaf0;

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

     T106 + T117: тиснение — это ОРЕОЛ, а не обводка. Разница вся в размытии:
     узкий и плотный drop-shadow вплотную к контуру читается как нарисованная
     кромка, широкий и слабый — как свечение. Первый заход T117 ошибся именно
     здесь (0.06rem/100% + холодная кромка) — вышла жирная обводка, и декор
     распался на «фигура + линия вокруг» («бросается в глаза и выглядит не
     цельно», 2026-08-12). Яркость набирается СЛОЯМИ, а не плотностью одного
     узкого: плотность при малом размытии и превращает свечение обратно в
     обводку. Холодная кромка (проба «серебряного свечения») убрана — вторая
     контрастная линия у контура ломала цельность сильнее всего.

     T122, замер 2026-08-13: симметричный ореол читался как «свечение», а не
     как тиснение — источника света у него нет, и глазу не за что зацепиться.
     Свет теперь идёт сверху слева, как принято в печати и как уже падает
     тень страницы:
     1) блик — со смещением вверх-влево, узким слоем; это НЕ обводка: он
        смещён, а значит ложится только на одну сторону контура;
     2) широкий разлёт остаётся симметричным (рассеянный свет комнаты, у него
        направления нет) — он и держит мягкость, не давая блику стать кромкой;
     3) тень — вниз-вправо, ровно напротив блика.
     Смещения намеренно КРОШЕЧНЫЕ (0.05rem ≈ 0.8 px): орнамент — тонкая
     филигрань, при заметном сдвиге блик отрывается от контура и читается
     вторым, призрачным орнаментом рядом.

     ВАЖНО ПРО ФОН — и это ловушка, на которой разбор 2026-08-13 споткнулся
     дважды. Декор лежит НЕ на кремовой бумаге. Под всей страницей стоит
     .page-bg — фотография рассвета, `position: fixed`, `z-index: -2`, под
     матовым слоем --c-bg всего 40 % (см. комментарий у .page-bg). Отрицательный
     z-index кладёт её ПОВЕРХ фона body, поэтому обход предков по
     background-color её не видит: она не предок, а фиксированный сосед. Первый
     замер именно так и ошибся — насчитал «кремовую бумагу #f9f5ef, запас до
     белого 6/10/16» и вывел из этого, что закатные тона в блик поставить
     нельзя. Проверять фон под элементом — композитом, а не деревом предков.
     Настоящая поверхность (просчёт фото под матовым слоем): медиана RGB
     193/156/131, запас до белого 62/99/124 — в десять с лишним раз больше.
     Из-за `fixed` она ещё и ЕДЕТ под декором при прокрутке: яркость участка
     гуляет от 0.14 до 0.97 по луминансу. Один рецепт неизбежно ложится на
     разное — отсюда и ощущение «эффект то есть, то нет».
     Почему при этом закат всё же в тени, а блик почти белый: само золото
     отделяется от снимка всего на 1.30-1.93 (ниже порога 3:1 для нетекстовой
     графики) — блик здесь не украшение, а то, что делает филигрань различимой.
     Замер отделяемости блика от фона: белый 1.58-2.25, тёплый белый 1.53-2.17,
     закатный #ffdcae — 1.26-1.78, на светлом небе орнамент начинает
     растворяться в рассвете. Тёплый белый стоит столько же, сколько чистый, —
     его и берём (--m-emboss-light).
     Тень двухслойная: плотный янтарь вплотную к контуру и мягкий розовый
     разлёт шире и ниже — так свет обтекает рельеф, а не кладёт под него одно
     ровное пятно. Прежняя тень цвета --c-ink убрана: нейтрально-коричневая,
     она годилась для «объёма вообще», но закатного тона в ней нет.

     ⚠️ ПОРЯДОК СЛОЁВ ЗДЕСЬ — НЕ КОСМЕТИКА. Цепочка drop-shadow каскадная:
     каждый следующий слой отбрасывает тень от РЕЗУЛЬТАТА предыдущего, а не от
     исходной картинки. Пока закатные слои стояли последними, тень отбрасывал
     не орнамент, а орнамент, уже обёрнутый белым ореолом в 0.95rem: тёплый
     тон уезжал наружу и размазывался в розовую дымку по фону, а у контура
     оставалось белое свечение — пользователь так и сказал, «не вижу
     изменений, белый присутствует». Просчёт четырёх вариантов попиксельно
     (tools scratch, 2026-08-13) показал разницу с исходником: направление —
     max 11 по каналу, закат последними слоями — 26, закат ПЕРВЫМИ — 84.
     Поэтому: сначала обе закатные тени (их отбрасывает чистый силуэт, они
     ложатся вплотную), блик — последним, поверх.
     Широкий белый разлёт 0.95rem убран совсем: он размывал контур сильнее,
     чем добавлял света, и именно он разбавлял закат. */
  --m-emboss:
    drop-shadow(0.05rem 0.12rem 0.28rem color-mix(in srgb, var(--m-sunset) 38%, transparent))
    drop-shadow(0.09rem 0.22rem 0.7rem color-mix(in srgb, var(--m-sunset-deep) 26%, transparent))
    drop-shadow(-0.05rem -0.05rem 0.28rem color-mix(in srgb, var(--m-emboss-light) 85%, transparent));
  /* MATERIAL:END */

  /* Значки альбома (site/js/album-icons.js, лист 80282.eps) — решение
     пользователя 2026-09-03. Значки многоцветные, роль каждого пути задана
     через `var(--icon-<роль>)` (см. комментарий у ALBUM_ICONS), но сам лист
     нарисован в чужой синей гамме — переложить её на палитру сайта нужно
     ГДЕ-ТО, и вынесено сюда токенами, а не в раскладку ролей на
     `.album-icon` (та ссылается на эти два, не хардкодит числа сама).
     Роли `ink`/`paper`/`muted`/`deep` — уже существующие роли палитры
     (`--c-ink`/`--c-bg`/`--c-accent`/`--c-accent`), отдельных токенов не
     заводим, чтобы не плодить синонимы. `mid`/`light` — двум средним
     степеням заливки листа готовой роли на сайте нет (страница
     одноцветная, золото + графит, см. PALETTE выше), поэтому это НЕ роль
     оформления (внутрь PALETTE:START/END не попадают, вариант оформления
     их не подменяет) — два самостоятельных тёплых золотистых тона,
     подобранных на глаз к соседям по листу (`--c-accent`/`--m-gold`), не
     привязанные к формуле контраста: значки декоративны (`aria-hidden`,
     см. `buildAlbumIcon`), к тексту требование WCAG 4.5:1/3:1 не
     применяется. */
  --c-icon-mid: #c9a961;
  --c-icon-light: #e0cfa8;

  --font-display: 'Lora', 'Times New Roman', Georgia, serif;
  /* Рукописная — от заказчиков. Запасные системные скрипты кириллицы почти не
     держат, поэтому последним стоит наш же антиквенный, а не cursive. */
  --font-script: 'Pompadur', 'Lora', cursive;
  /* Основной текст (заказчики, минималистичная эстетика): та же антиква, что
     и в заголовках, а не гротеск — страница должна читаться как единый
     печатный лист, а не как интерфейс с вкраплениями декора. Роль раньше
     называлась --font-body и указывала на Inter — имя стало враньём в
     тот момент, когда решили набирать прозу антиквой, поэтому переименована
     под новое назначение. */
  --font-text: 'Lora', 'Times New Roman', Georgia, serif;
  /* Интерфейсная роль — там, где важна чёткость, а не эстетика: кнопки, поля
     и подписи анкеты, время в программе дня, адреса, цифры отсчёта, мелкие
     служебные подписи. Прежний --font-body занимал эту нишу неявно (был по
     умолчанию для всего текста) — теперь роль явная и уже, а не роль
     умолчания. */
  --font-ui: 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif;

  /* Плавная типографика: размеры тянутся между 320 px и десктопом,
     поэтому промежуточных брейкпоинтов под текст не нужно.

     Cormorant Garamond был заменён на Lora по решению заказчиков 2026-08-05
     («шрифт резкий и острый»): высокий контраст штриха и острые клиновидные
     засечки — свойство рисунка, настройками не смягчается. У Lora засечки
     мягче, контраст спокойнее — но и x-height заметно больше, чем у
     Cormorant (замер canvas TextMetrics, кегль 100px: x-height Cormorant
     400 ≈ 0,40 кегля, Lora 400 ≈ 0,50, Inter 400 ≈ 0,56). Раньше кегли ниже
     были подняты примерно на треть, чтобы компенсировать МЕЛКИЕ строчные
     Cormorant — теперь строчные у Lora сами по себе крупнее, и та же
     инфляция кегля дала бы страницу заметно КРУПНЕЕ прежней, то есть ровно
     то, на что заказчики жаловались изначально («шрифт большой»). Поэтому
     все три роли ниже (--fs-hero/h2/h3/body) умножены на 0,40/0,50 = 0,8 —
     во столько раз у Lora крупнее x-height по сравнению с Cormorant.
     Итог: видимая высота строчных (px) остаётся такой же, как была на
     странице, одобренной заказчиками по масштабу, — меняется гарнитура,
     а не размер. --fs-ui (Inter) в этот пересчёт не входит: метрика Inter
     не менялась. */
  --fs-hero: clamp(1.52rem, 0.42rem + 5.48vw, 4rem);
  --fs-h2: clamp(1.18rem, 0.66rem + 2.6vw, 2.4rem);
  --fs-h3: clamp(0.9rem, 0.66rem + 1.18vw, 1.4rem);
  --fs-body: clamp(1.08rem, 0.96rem + 0.32vw, 1.28rem);
  /* Кегль для Inter-текста НЕ на службе у --fs-body: та формула компенсирует
     маленький x-height антиквы (см. комментарий выше) и в кегле Inter дала бы
     противоположный эффект — адрес/факты стали бы КРУПНЕЕ, чем были, а не
     тише. --fs-ui — прежняя формула --fs-body (Inter не менял метрику),
     уменьшается только вместе с общим плавным --root (html, ниже), как и
     весь остальной интерфейс. */
  --fs-ui: clamp(1rem, 0.95rem + 0.25vw, 1.125rem);
  --fs-small: clamp(0.95rem, 0.88rem + 0.2vw, 1.05rem);

  --space-xs: 0.5rem;
  --space-sm: 1rem;
  --space-md: 1.75rem;
  --space-lg: 3rem;
  --space-xl: clamp(3.5rem, 2rem + 7vw, 6.5rem);

  /* Заказчики 2026-08-06: «фотка маленькая по сравнению с экраном — экран
     можно чуть сузить, а фотку увеличить». --measure сузили с 34rem до
     30rem (порядка 65 знаков в строке — ещё в комфортном диапазоне, ниже
     30rem чтение начинает сбиваться, дальше не уменьшать). --container
     расширен с 60rem до 68rem, вся прибавка (8rem) уходит фотографии —
     колонка переписки как была заперта на полном --measure, так и осталась.
     Потолок для фото задан исходниками (couple-heart 959px, couple-studio
     1280px по длинной стороне): слот шире ~480px на экране с удвоенной
     плотностью потребовал бы апскейла — фото начнёт мылиться. */
  --measure: 30rem; /* комфортная длина строки */
  --container: 76rem;
  --radius: 2px;
  /* T147 (фаза 20): полоса загрузки просит заметное, но сдержанное
     скругление — не общий --radius (2px, почти острый угол, осознанный
     токен палитры на нём стоит вся страница), и не литерал в одном
     правиле (следующий скруглённый элемент завёл бы своё значение, и они
     разошлись бы). Отдельный токен — единственный источник этой величины
     на будущее. */
  --radius-soft: 0.5rem;
}

/* --------------------------------------------------------------------------
   Сброс
   -------------------------------------------------------------------------- */

*,
*::before,
*::after {
  box-sizing: border-box;
}

* {
  margin: 0;
}

/* Атрибут `hidden` обязан побеждать любой наш `display`. В стилях браузера он
   объявлен как `[hidden] { display: none }` — правило с меньшей специфичностью,
   чем `.chat__turn { display: grid }`, поэтому без этой страховки скрытая
   реплика анкеты молча остаётся на экране, и пошаговое раскрытие не работает. */
[hidden] {
  display: none !important;
}

html {
  -webkit-text-size-adjust: 100%;
  scroll-behavior: smooth;
  /* Базовый размер — плавный (заказчики: «сам экран можно сделать меньше и
     масштабировать его вместе с экраном»). Всё, что считается в rem (кегли,
     --space-*, --measure, --container), уезжает согласованно вместе с этой
     величиной — раньше она была жёстко 16px, и только отдельные --fs-*
     тянулись сами по себе, а отступы и ширина колонки стояли колом.
     Проценты, а не px: значение остаётся относительным к настройке размера
     шрифта в браузере гостя, а не переопределяет её. Диапазон 88–92%
     (12% меньше прежнего на 320px, 8% на 1280px, между ними — линейно) —
     «заметно» меньше, примерно на десятую часть, как просили. Коэффициент
     при vw посчитан явно (см. отчёт задачи), а не подобран на глаз: 1vw —
     ЕДИНИЦА ДЛИНЫ (сотая доля ширины окна в px), а не сотая доля числа
     процентов слева, и на глаз коэффициент легко промахивается на порядок
     мимо диапазона min/max клампа (проверено эмпирически: 0.42vw держал
     preferred-ветку клампа выше верхней границы на любой ширине из
     диапазона, и на выходе получался не плавный переход, а плоские 92%
     везде). Хочешь пересчитать диапазон — бери целевые PX-значения на двух
     концах диапазона ширин, дели их разницу на разницу ширин в px — это и
     есть коэффициент при vw; свободный член — px-значение на левом конце
     минус (коэффициент × левая ширина), переведённое в проценты от 16px. */
  font-size: clamp(88%, 86.6667% + 0.0667vw, 92%);
}

/* Гость мог отключить анимации в системе — уважаем это. */
@media (prefers-reduced-motion: reduce) {
  html {
    scroll-behavior: auto;
  }

  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    /* animation-timeline: none здесь НЕ ставим, хотя соблазн есть: она бы
       разом выключила и прокруточный блик. Но `animation-timeline` не
       различает анимации по природе — она отвязывает от шкалы ЛЮБУЮ, и
       обычная временная анимация повисает в своём первом кадре навсегда.
       Для появления реплик (`chat-turn-in`, первый кадр — opacity: 0) это
       означало вечно невидимый вопрос пары: анимация не кончается, событие
       animationend не приходит, класс не снимается. Блик выключаем адресно —
       правилом сразу под его же объявлением. */
  }
}

body {
  min-height: 100vh;
  background-color: var(--c-bg);
  color: var(--c-ink);
  font-family: var(--font-text);
  font-size: var(--fs-body);
  font-weight: 400;
  /* Было 1.65 — заказчики попросили плотнее; такая же плотная выключка, как
     у заголовков (1.15), для абзацного текста была бы тесной — 1.45 держит
     воздух между строк. При замене Cormorant на Lora (см. --fs-body выше)
     значение перепроверено: у Lora строчные и так крупнее, кегль пересчитан
     вниз пропорционально — на глаз и по замеру строки не сдвинулись теснее
     прежнего, 1.45 оставлен без изменений. */
  line-height: 1.45;
  /* Страница не должна ехать вбок ни при какой ширине (FR-008) */
  overflow-x: hidden;
  -webkit-font-smoothing: antialiased;
}

/* --------------------------------------------------------------------------
   Фон страницы (T074) — фотография рассвета над морем под матовым слоем,
   с медленным смещением по прокрутке. `<div class="page-bg">` — первый
   потомок body (см. index.html), не background у самого body: смещаться
   должен только этот слой (transform), а не вся страница.

   Автор фото — muhammad.abdullah, бесплатная лицензия (реестр [[design-assets]]).
   Исходник 8736x4896/5,5 МБ порезан tools/prepare_background.py на пять WebP
   под брейкпоинты ниже (640/960/1440/1920/2560 px) — под матовым слоем
   детализация не видна, картинка работает пятном, резкость не нужна.
   -------------------------------------------------------------------------- */
.page-bg {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  /* Высота больше вьюпорта РОВНО на величину смещения (--bg-shift ниже):
     слой едет вверх на translateY(-var(--bg-shift)), и без запаса снизу
     обнажился бы край под ним. Запас снизу, не сверху — смещение всегда
     в одну сторону (вверх, вслед за прокруткой). */
  height: calc(100vh + var(--bg-shift));
  z-index: -2;
  background-repeat: no-repeat;
  background-position: center top;
  background-size: cover;
  background-image: url('../img/bg-sunrise-640.webp');
  pointer-events: none;
}

@media (min-width: 40em) {
  .page-bg {
    background-image: url('../img/bg-sunrise-960.webp');
  }
}

@media (min-width: 60em) {
  .page-bg {
    background-image: url('../img/bg-sunrise-1440.webp');
  }
}

@media (min-width: 90em) {
  .page-bg {
    background-image: url('../img/bg-sunrise-1920.webp');
  }
}

@media (min-width: 120em) {
  .page-bg {
    background-image: url('../img/bg-sunrise-2560.webp');
  }
}

/* Матовый светлый полупрозрачный слой поверх фотографии — тот же --c-bg, что
   и у бумаги страниц, смешан с прозрачностью через color-mix (тот же приём,
   что у блика зеркала выше по файлу). Держит контраст текста, который лежит
   прямо на фоне без своей карточки (.cover__names, .cover__date, подпись и
   дата в подвале — последняя набрана .muted, самым низкоконтрастным цветом
   из трёх).

   55% (первая прикидка по луминансу самого тёмного пикселя всего исходника)
   на практике не хватило: снимок страницы и замер РЕАЛЬНОГО фона под подписью
   в подвале (там, где сквозь матовый слой проступает тень волны у горизонта)
   дал контраст .muted к фону ~2.3:1 — исходная прикидка считала теоретический
   глобальный минимум по всей фотографии, а не то, что фактически оказывается
   под конкретным текстом. Поднято по факту (снимками, не расчётом — см. отчёт
   задачи T074) до 88%: под подписью в подвале контраст держал 4.5:1 у .muted.

   T090 (правка 1, решение заказчика 2026-08-07): при 88% фон работал ровным
   кремовым пятном — не читались ни диск солнца, ни дорожка отражения на воде,
   ни гребни волн, а именно ради них выбирали фото. Заказчик увидел риск (при
   низкой плотности контраст .muted в подвале снова падает ниже 4.5:1) и принял
   его осознанно: «скрути просто контраст, не выдумывай велосипед, текст виден
   хорошо» — без локальной подложки под подвалом, без градиентов, без
   backdrop-filter, просто ниже процент. Снижено до 40% (подобрано снимками
   страницы, не расчётом): фон читается в полную силу, текст в подвале
   по-прежнему разборчив на глаз, но контраст .muted к фону НИЖЕ 4.5:1 —
   это принятый размен, а не недосмотр. Смена исходника фона или отступов
   подвала — переснять на глаз, что текст остаётся читаемым; порог 4.5:1
   здесь сознательно не цель.

   Правка заказчика 2026-08-07: и имя, и дата в подвале переведены на
   --c-ink (.footer__names/.footer__date, ниже по файлу) — самую контрастную
   роль палитры, и обе строки одного цвета. Замер снимком по факту (тень
   волны под подписью, как описано выше): --c-ink здесь держит ~2,9-3,9:1 —
   лучше, чем было у .muted (~1,9:1) или --c-accent-deep (~1,7-2,2:1), но
   всё ещё ниже 4,5:1 — тот же принятый размен, что и у .page-bg::after
   выше, не новый недосмотр. */
.page-bg::after {
  content: '';
  position: absolute;
  inset: 0;
  background-color: color-mix(in srgb, var(--c-bg) 40%, transparent);
}

/* Смещение — порядка 1/10 полной прокрутки страницы (решение заказчиков:
   «в 100 раз медленнее» на практике неподвижность при высоте страницы в
   несколько тысяч px). Величина подобрана по факту на живой странице, не
   вычислена из описания: замер headless-браузером (2026-08-07) дал полную
   прокрутку ~4150-4300 px (390x844 и 1280x900, до T088/T089/T087 — при их
   реализации высота страницы вырастет, пересверить). 420px — около 1/10 от
   обоих замеров. */
:root {
  --bg-shift: 420px;
}

@keyframes page-bg-parallax {
  from {
    transform: translateY(0);
  }

  to {
    transform: translateY(calc(-1 * var(--bg-shift)));
  }
}

@supports (animation-timeline: scroll()) {
  .page-bg {
    animation: page-bg-parallax linear;
    animation-timeline: scroll(root);
    animation-fill-mode: both;
  }
}

@media (prefers-reduced-motion: reduce) {
  .page-bg {
    animation-name: none;
    transform: none;
  }
}

img,
svg,
picture,
iframe {
  display: block;
  max-width: 100%;
}

img {
  height: auto;
}

input,
button,
textarea,
select {
  font: inherit;
  color: inherit;
}

button {
  cursor: pointer;
}

/* --------------------------------------------------------------------------
   Типографика
   -------------------------------------------------------------------------- */

h1,
h2,
h3 {
  font-family: var(--font-display);
  /* Lora поставляется только в 400 и 500 — начертания 300, каким рисовались
     заголовки у Cormorant, у неё нет. Берём 400: следующее по насыщенности
     после отсутствующего лёгкого, не «жирнее задуманного» 500. */
  font-weight: 400;
  line-height: 1.15;
  text-wrap: balance;
}

h1 {
  font-size: var(--fs-hero);
  letter-spacing: 0.06em;
}

h2 {
  font-size: var(--fs-h2);
  letter-spacing: 0.04em;
}

h3 {
  font-size: var(--fs-h3);
  letter-spacing: 0.02em;
}


p {
  text-wrap: pretty;
}

a {
  color: var(--c-accent-deep);
  text-underline-offset: 0.2em;
}

/* Рукописный акцент: подписи, имена, мелкие подзаголовки.
   `text-transform: none` здесь не косметика, а страховка: в Pompadur нет заглавных
   Ъ, Ы, Ь, и капслок от родителя (например от `.cover__names`) съел бы букву,
   ничего не сообщив. Правило гасит наследование в одном месте. */
.accent {
  font-family: var(--font-script);
  font-weight: 400;
  text-transform: none;
  color: var(--c-accent-deep);
}

.muted {
  color: var(--c-muted);
}

/* Мелкие служебные подписи (согласие на обработку данных, заметка под
   картой) — чёткость важнее эстетики, тот же принцип, что и у кнопок/полей
   (см. --font-ui). Дата в подвале (.footer__date) этот класс больше не
   несёт — правка пользователя 2026-08-07 подтянула её оформление к
   .cover__date (Lora, капслок, разрядка), а не к служебным подписям. */
.small {
  font-family: var(--font-ui);
  font-size: var(--fs-small);
}

/* --------------------------------------------------------------------------
   Раскладка
   -------------------------------------------------------------------------- */

.container {
  width: 100%;
  max-width: var(--container);
  margin-inline: auto;
  padding-inline: var(--space-md);
}

.section {
  padding-block: var(--space-xl);
}

/* Зеркало — блок вместо розовых карточек-заметок с уголками. Голубое стекло
   в золочёной раме, сверху и снизу — растровый вензель. Вензели идут обычными
   <img> в потоке (не абсолютным позиционированием): у картинки своя пропорция,
   и в потоке она сама держит место, без расчёта отступов и риска обрезки. */
.mirror {
  display: grid;
  /* Тот же --measure, под который считалась ширина нарезки вензеля (см.
     комментарий к элементу 47 в tools/gold_extract.py: заказ 1580px — это
     тройной запас именно от 34rem максимального показа). Без потолка
     .mirror растягивается на всю ширину .container (60rem) — вензели
     раздуваются, а .chat__ask, ограниченный процентами, ничего не
     ограничивает от такой ширины. */
  max-width: var(--measure);
  margin-inline: auto;
}

.mirror__crest {
  width: 100%;
  height: auto;
  display: block;
  /* T106/T122: тиснение — общий рецепт --m-emboss (MATERIAL), см. его комментарий. */
  filter: var(--m-emboss);
}

/* Вензель нарисован так, что его нижняя (у верхнего) / верхняя (у нижнего)
   кромка — та самая линия, на которой стоит орнамент. Орнамент должен сесть
   НА линию рамы стекла, а не повиснуть над ней зазором — отсюда нахлёст. */
.mirror__crest:first-child {
  /* У верхнего вензеля (crest-top.webp) линия-подставка не совпадает с
     нижней кромкой картинки: ниже линии остаётся ещё 62 px завитков и
     лепестков (из 1580 px ширины кадра) — печатается tools/gold_extract.py
     при нарезке элемента 47. 62 / 1580 = 3,92%. Процент, а не px: margin
     по вертикали считается от ширины содержащего блока, а вензель всегда
     width: 100%, поэтому нахлёст сам масштабируется с зеркалом. Без него
     линия вензеля виснет НАД рамой стекла второй параллельной чертой. */
  --crest-top-overlap: -3.92%;
  margin-bottom: var(--crest-top-overlap);
}

.mirror__crest:last-child {
  margin-top: -1px;
}

.mirror__glass {
  position: relative;
  /* Не overflow: hidden — при нём Chromium (проверено, версия 151) перестаёт
     обновлять animation-timeline: view() у любого потомка (в т.ч. у ::after
     с живым бликом ниже): прогресс замирает на значении первого кадра и
     больше не следует за прокруткой, хотя сам скролл проходит и getBounding-
     ClientRect меняется корректно. Воспроизведено в изолированном стенде:
     стоит добавить overflow: hidden на предка — и view()-таймлайн потомка
     замирает; стоит заменить на clip-path — снова едет. clip-path обрезает
     по тому же контуру (с тем же скруглением, что border-radius ниже), но
     не создаёт этот баг. */
  clip-path: inset(0 round 1.5rem);
  padding: var(--space-lg) var(--space-md);
  background-color: var(--m-glass);
  /* Блик — диагональная светлая полоса поверх заливки: без неё голубой фон
     читается плашкой, а не стеклом. Это статичный запасной вариант — там, где
     браузер понимает animation-timeline: view() (правило ниже), полоса
     вместо этого едет от прокрутки, а этот background-image просто не
     используется.

     2026-08-06, заказчики: «блики видны, но очень слабо» (важно — у них
     отключены анимации в системе, они видят именно этот случай, а не
     бегущую полосу, см. ту же правку у .mirror__glass::after ниже: там же
     причина, почему поднимать нужно ОБЕ версии). Пик полосы был
     color-mix(--m-glass 40%, --m-sheen) — почти не отличался от заливки
     (245 из 255 по светимости, разница с фоном стекла всего ~19 по яркости
     из физически доступных на этом материале ~29 — 40%-примесь стекла топила
     блик в самой заливке). Поднято до 10% примеси стекла — разница выросла
     до ~26 (+33%), это НЕ равно белому наотмашь (чистый --m-sheen без
     примеси давал 255, полностью убирая тёплый тон стекла из блика, —
     проверено, выглядит как настоящая вспышка, а не отсвет, поэтому
     отклонено). Больше 255 не бывает физически (потолок sRGB) — на этом
     материале (светлое стекло, светимость заливки ~226 из 255) разница
     не может быть намного больше: это неизбежный физический потолок, не
     недоработка. Ширина полосы (стопы 30/48/66%) не менялась — усиление
     только через непрозрачность/яркость, широкая полоса превратила бы блик
     в засветку всего стекла. */
  background-image: linear-gradient(
    115deg,
    transparent 30%,
    color-mix(in srgb, var(--m-glass) 10%, var(--m-sheen)) 48%,
    transparent 66%
  );
  border: 1px solid var(--m-gold);
  /* Скругление мягче общего --radius: 2px — зеркало это стекло в раме,
     острый угол ему противоречит. */
  border-radius: 1.5rem;
}

@media (max-width: 30em) {
  .mirror__glass {
    padding-inline: var(--space-sm);
  }
}

/* Живой блик: там, где браузер понимает scroll-driven анимации, полоса едет
   по стеклу, пока зеркало проходит через вьюпорт — привязана к прокрутке
   через animation-timeline, без обработчика scroll на каждый кадр. Полоса
   вынесена в ::after (а не оживляет background-image самого .mirror__glass)
   ради z-index: -1 — слой должен красться под текстом реплики, но над
   заливкой, а у самой .mirror__glass под неё уже занят background-image.

   Раньше слой был шире и выше стекла (inset: -20% -60%) и ехал через
   translateX — так и было исходно задумано, но это ломало геометрию
   прокрутки: clip-path обрезает такой слой только визуально, а его
   layout-box шире родителя (.mirror__glass не overflow: hidden, см.
   комментарий выше) всё равно расширял scrollWidth страницы на десятки
   пикселей на каждом зеркале. Проверено: ::after заперт в границах стекла
   (inset: 0), а едет не сам слой, а фон внутри него — background-size шире
   контейнера, background-position сдвигается по шкале прокрутки.
   Диагональная полоса под углом при таком сдвиге едет параллельно самой
   себе, без искажения (сдвиг position не масштабирует и не скашивает
   картинку) — прежнее опасение было ошибочным. */
@supports (animation-timeline: view()) {
  .mirror__glass {
    background-image: none;
    /* position: relative само по себе НЕ создаёт контекст наложения — для
       z-index: -1 у ::after ниже нужен явный контекст на самом
       .mirror__glass (isolation: isolate), иначе потомок с отрицательным
       z-index уходит в контекст ближайшего предка, создающего его, и
       красится под заливкой .mirror__glass, а не над ней. isolation не
       трогает раскладку/геометрию и не делает элемент содержащим блоком
       для fixed-потомков — в отличие от transform/filter. */
    isolation: isolate;
  }

  .mirror__glass::after {
    content: '';
    position: absolute;
    /* Строго в границах стекла — никакого вклада в scrollWidth родителя. */
    inset: 0;
    background-repeat: no-repeat;
    /* Втрое шире и в полтора раза выше контейнера — запас, чтобы полосе
       было куда ехать (background-position) и чтобы её диагональные концы
       не обрезались по высоте у самых краёв прохода. */
    background-size: 300% 140%;
    /* Стопы сужены обратно пропорционально растяжению background-size: при
       300%-ширине полоса шириной 30% ВИДИМОЙ ширины стекла должна занимать
       10% длины градиента (30% / 300% × 100). Раньше стопы (30%…66%, разброс
       36 пунктов) были унаследованы от нерастянутого фона — на 300%-фоне это
       превращалось в мягкую заливку почти во всю ширину контейнера
       (36 пунктов × 3 = 108% контейнера), а не в узкую полосу.

       Пик градиента (color-mix) поднят той же правкой 2026-08-06, что и у
       статичного запасного варианта выше (.mirror__glass background-image,
       см. комментарий там про числа и физический потолок) — оба места
       делят одну и ту же переменную яркости не случайно: гость с
       prefers-reduced-motion видит именно ЭТУ полосу, замороженную
       background-position ниже, а не статичный запасной вариант — если
       поднять яркость только там, заказчики изменения вообще не увидят. */
    background-image: linear-gradient(
      115deg,
      transparent 43%,
      color-mix(in srgb, var(--m-glass) 10%, var(--m-sheen)) 48%,
      transparent 53%
    );
    /* Красится поверх заливки .mirror__glass, но под её содержимым: за счёт
       isolation: isolate выше .mirror__glass сама создаёт контекст
       наложения, и z-index: -1 остаётся внутри него — между фоном
       родителя и его контентом. */
    z-index: -1;
    pointer-events: none;
    animation: mirror-sheen linear;
    animation-timeline: view();
  }

  /* Гость отключил анимации в системе. Блик не убираем и не «замораживаем» на
     случайном кадре — ставим полосу намеренно: настройка просит убрать
     движение, а не превратить стекло в плоскую заливку.

     52% по горизонтали — полоса чуть левее середины стекла. Шкала едет справа
     налево (см. @keyframes ниже: 10% — полоса за правым краем, 90% — за
     левым), и связь между процентом и местом полосы неочевидна: фон втрое
     шире стекла, поэтому сдвиг равен -2×ширины×процент. Менять число на глаз
     бесполезно — считать надо от этого.

     Правило стоит ЗДЕСЬ, а не в общем блоке prefers-reduced-motion выше по
     файлу: специфичность у обоих селекторов одинаковая, и победил бы тот,
     что ниже по исходнику. */
  @media (prefers-reduced-motion: reduce) {
    .mirror__glass::after {
      animation-name: none;
      background-position: 52% 50%;
    }
  }
}

@keyframes mirror-sheen {
  from {
    /* 50% по вертикали — центрирует запас высоты (140%) симметрично сверху
       и снизу: (100% - 140%) * 50% = -20%, то есть кадр выступает на 20%
       за верх и низ стекла, как и было при inset: -20% -60% раньше. */
    background-position: 10% 50%;
  }

  to {
    background-position: 90% 50%;
  }
}

/* --------------------------------------------------------------------------
   Оживление зеркал — появление при прокрутке и «печать» перед контентом.
   Класс mirror--reveal ставит ТОЛЬКО site/js/mirror.js (и не ставит вовсе
   при prefers-reduced-motion) — без JS или с уважаемой настройкой гость
   видит зеркала сразу, начальное скрытие в разметке не зашито.
   -------------------------------------------------------------------------- */

.mirror--reveal {
  opacity: 0;
  transform: translateY(1.5rem);
  transition:
    opacity 0.6s ease-out,
    transform 0.6s ease-out;
}

.mirror--reveal.mirror--visible {
  opacity: 1;
  transform: none;
}

/* Накладка «печатает…»: создаёт и удаляет её сам mirror.js, в разметке нет
   и следа — гость без JS и скринридер видят содержимое зеркала сразу. */
.mirror__typing {
  position: absolute;
  inset: 0;
  z-index: 2;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.6em;
  background-color: var(--m-glass);
}

.mirror__typing-dot {
  width: 0.5em;
  height: 0.5em;
  border-radius: 50%;
  background-color: var(--m-gold);
  opacity: 0.25;
  animation: mirror-typing-blink 0.6s ease-in-out infinite;
}

.mirror__typing-dot:nth-child(2) {
  animation-delay: 0.15s;
}

.mirror__typing-dot:nth-child(3) {
  animation-delay: 0.3s;
}

@keyframes mirror-typing-blink {
  0%,
  100% {
    opacity: 0.25;
  }

  50% {
    opacity: 1;
  }
}

/* --c-muted на голубом стекле — 4,04:1, мало для мелкого текста. Роль не
   переопределяем (--c-muted нужен таким на тёплом фоне страницы), а темним
   локально через color-mix — это даёт 5,25:1 без нового цветового литерала. */
.mirror__glass .muted {
  color: color-mix(in srgb, var(--c-muted) 70%, var(--c-ink));
}

/* --------------------------------------------------------------------------
   Служебное
   -------------------------------------------------------------------------- */

/* Видно только скринридеру. */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* Поле-приманка (FR-017): человек его не видит и не заполняет, бот — заполняет.
   Спрятано так, чтобы не выпадать из потока подозрительно очевидным display: none,
   и убрано из обхода клавиатурой и из чтения скринридером. */
.honeypot {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
  opacity: 0;
  pointer-events: none;
}

:where(a, button, input, select, textarea):focus-visible {
  outline: 2px solid var(--c-accent-deep);
  outline-offset: 3px;
}

.decor {
  margin-inline: auto;
}

.decor--divider {
  display: block;
  width: min(18rem, 60%);
  height: auto;
  /* Центрирование берётся из `.decor` (margin-inline: auto). Свой `margin: 0 auto`
     здесь обнулял бы вертикальные поля и молча гасил `margin-bottom` подвального
     вензеля — оба правила равной специфичности, победа зависела бы от порядка строк. */
  /* T106/T122: тиснение — общий рецепт --m-emboss (MATERIAL), см. его комментарий. */
  filter: var(--m-emboss);
}

/* --------------------------------------------------------------------------
   Кнопки
   -------------------------------------------------------------------------- */

.button {
  display: inline-block;
  /* Горизонтальные поля ужаты с 2.2em: кнопки читались как «слишком большие»
     (замечание пользователя 2026-08-12). Вертикальные НЕ трогаем — они держат
     высоту тач-цели, и её уменьшение выбило бы попадание пальцем. */
  padding: 0.85em 1.5em;
  border: 1px solid transparent;
  /* Не --radius (2px): у страницы есть собственный язык скругления — пузыри
     реплик скруглены на 1.1em, и кнопки с прямыми углами из него выпадали.
     Тот же 1.1em, а не «пилюля»: на приглашении с рукописными гарнитурами
     полностью круглый торец читается как элемент современного интерфейса. */
  border-radius: 1.1em;
  /* Кнопка — интерфейс, не проза: чёткость важнее эстетики (см. --font-ui). */
  font-family: var(--font-ui);
  font-size: var(--fs-small);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  text-decoration: none;
  text-align: center;
  transition: background-color 0.2s, color 0.2s, border-color 0.2s;
  /* T031m: кнопка внутри .chat__ask наследует hyphens: auto от пузыря
     реплики (см. .chat__ask) — на узкой ширине это давало разрыв слова
     посреди подписи («ФОТОАЛЬ-БОМ», было у прежней текстовой
     .photos-invite__submit, T031p сделал её значком без подписи). Подписи
     кнопок по всему сайту короткие и фиксированные, переносить их нигде не
     нужно — общее правило на .button, а не точечная правка одного класса.
     hyphenate.js исключает .button из обхода тем же поводом (см.
     SKIP_SELECTOR там). */
  hyphens: none;
}

.button--primary {
  background-color: var(--c-accent-deep);
  border-color: var(--c-accent-deep);
  color: var(--c-bg);
}

.button--primary:hover {
  background-color: var(--c-ink);
  border-color: var(--c-ink);
}

.button--ghost {
  background-color: transparent;
  border-color: var(--c-accent);
  color: var(--c-accent-deep);
}

.button--ghost:hover {
  background-color: var(--c-accent);
  color: var(--c-bg);
}

/* --------------------------------------------------------------------------
   1. Обложка
   -------------------------------------------------------------------------- */

.cover {
  min-height: 100svh;
  display: grid;
  /* Явная колонка, а не auto: auto-трек меряется по содержимому, и длинное
     неразрывное слово растянуло бы его шире вьюпорта. minmax(0, 1fr)
     фиксирует ширину по контейнеру (FR-008). */
  grid-template-columns: minmax(0, 1fr);
  align-content: center;
  padding-block: var(--space-lg);
}

/* Специфичность 0,2,0 намеренно: правило перебивает `padding-inline` из `.container`,
   с которым делит элемент. При равной специфичности победа зависела бы от порядка
   строк в файле — перестановка блоков молча вернула бы широкие поля. */
.cover .cover__inner {
  padding-inline: var(--space-sm);
}

.cover__inner {
  text-align: center;
  /* У grid-элемента min-width по умолчанию равен min-content: длинное слово
     распёрло бы его шире трека. */
  min-width: 0;
}

/* Венок обложки. Сетка в одну ячейку: венок и имена лежат друг на друге и
   масштабируются вместе, поэтому имена не выезжают из просвета ни на какой ширине.
   Венок идёт целиком, без обрезки: композиция задумана кольцом, отрезанный край
   оставил бы рубленые листья. */
.cover__frame {
  display: grid;
  place-items: center;
  /* От ширины КОНТЕЙНЕРА, а не вьюпорта: контейнер уже съел свои поля, и 92vw
     поверх них вылезали за экран — на 320 px венок обрезало скрытым переполнением
     как раз по краю листьев, ради целостности которых он и берётся целиком. */
  width: min(100%, 34rem);
  margin-inline: auto;
  /* Кегль внутри считаем от ширины венка, а не от вьюпорта: на десктопе венок
     упирается в 34rem и расти перестаёт, а vw продолжал бы — имена вылезли бы
     за просвет на широком экране. */
  container-type: inline-size;
}

.cover__frame > * {
  grid-area: 1 / 1;
}

.cover__frame img {
  width: 100%;
  height: auto;
  /* Тиснение — общий рецепт --m-emboss (MATERIAL), там же весь разбор:
     почему ореол, а не обводка; почему свет идёт сверху слева; почему тон
     живёт в тени, а не в блике; и почему порядок слоёв менять нельзя. */
  filter: var(--m-emboss);
}

.cover__inside {
  /* Просвет внутри венка — около 56 % его ширины. */
  width: 56%;
  text-align: center;
}

.cover__names {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.1em;
  /* Имена набраны рукописным Pompadur (см. .cover__name) — гарнитура рассчитана
     на строчные, капслока здесь не будет: заказчики подписывают приглашение
     именно так (assets/source/refs/Пример приглашения + шрифт.jpg). */
  /* Запасной кегль от вьюпорта — на случай, если единицы контейнера не поддержаны.
     Имена — сам заголовок h1, но кегль привязан к ширине венка (cqi ниже), а не к
     --fs-hero, поэтому в общий пересчёт роли --fs-hero не попадает автоматически.
     Множитель 0,8 (см. --fs-body), который здесь раньше компенсировал смену
     Cormorant→Lora, снят по правке заказчика 2026-08-07: имена и дата на макете
     мельче просвета венка, крупный кегль здесь желателен, а не риск. */
  font-size: clamp(0.95rem, 4vw, 1.8rem);
}

.cover__date {
  margin-top: 0.9em;
  /* Множитель 0,8 (как у .cover__names выше) снят по той же правке 2026-08-07. */
  font-size: clamp(0.65rem, 2vw, 0.95rem);
  /* T105: настоящее начертание вместо обычного 400 — файл Lora 500 подключён
     выше (см. @font-face, style.css:34), это не рукописный Pompadur, капслок
     ему не мешает. */
  font-weight: 500;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  /* «19 СЕНТЯБРЯ 2026, СУББОТА» в узком просвете рвётся на две строки —
     balance выравнивает их по длине, иначе вторая заметно длиннее первой. */
  text-wrap: balance;
  /* Роль --c-muted (самый низкоконтрастный цвет палитры) на осветлённом
     фоне-рассвете (матовый слой снижен до 40%, см. .page-bg::after) гасила
     дату почти до невидимости — правка заказчика 2026-08-07. --c-ink — тот
     же цвет, что у имён выше (наследуется от body), и самый контрастный
     в палитре. */
  color: var(--c-ink);
  /* T097: дату переносить нельзя, см. комментарий у .cover__name. */
  hyphens: none;
}

@supports (width: 1cqi) {
  .cover__names {
    font-size: 6.4cqi;
  }

  .cover__date {
    /* Дата стоит ниже середины венка, а там просвет уже сужается — кегль меньше,
       чем позволяла бы ширина имён. T105: 2.3cqi (~12px при венке 544px) рвал
       текст на мелкие пятна вместе с разрядкой 0.22em — поднято до 3cqi. */
    /* max(), а не голый 3cqi: у cqi-пути нет нижнего предела, а у запасного
       clamp выше он есть. Ниже ~375px они расходились в неправильную сторону —
       на 320px 3cqi давал 8.8px, то есть браузер С поддержкой контейнерных
       единиц показывал дату мельче, чем без неё.
       Пол именно 0.9rem, а не 0.65rem: корневой кегль страницы ОТЗЫВЧИВЫЙ и на
       320px равен 14.08px, поэтому 0.65rem там = 9.15px — прибавка +0.4px,
       которая жалобу на читаемость не лечит. Замер 2026-08-12: коробка даты на
       320px — 163.4px, при 0.9rem (12.67px) самая длинная строка 120.1px,
       запас 43px, две строки. Место есть. */
    font-size: max(0.9rem, 3cqi);
  }
}

.cover__name {
  display: block;
  /* Рукописный Pompadur — как подпись под приглашением заказчиков
     (assets/source/refs/Пример приглашения + шрифт.jpg). Гарнитура рассчитана на
     строчные: text-transform:none страхует от капслока родителя, у Pompadur нет
     заглавных Ъ Ы Ь, и буква пропала бы молча (покрытие для «Владимир»/«Анастасия»
     проверено — обе буквы В и А в гарнитуре есть). */
  font-family: var(--font-script);
  font-weight: 400;
  text-transform: none;
  letter-spacing: 0;
  /* T091 (правка заказчиков 2026-08-11, «сделать чётче/жирнее»): у гарнитуры
     только начертание 400 — font-weight:700 дал бы синтетическую
     псевдожирность, которая смазывает тонкие соединения рукописных букв.
     Утолщаем обводкой того же цвета, что заливка (currentColor = --c-ink,
     наследуется от body): paint-order рисует обводку ПОД заливкой, иначе она
     съедала бы внутренние просветы букв. Толщина подобрана снимком — имена
     стоят в узком просвете растрового венка. */
  paint-order: stroke fill;
  -webkit-text-stroke: 0.4px currentColor;
  /* Тень — под вопросом заказчиков («может, добавить?»). Оставлена
     закомментированной: пользователю показаны снимки обоих вариантов (см.
     отчёт задачи T091), решение ещё не принято. */
  /* text-shadow: 0 0.05em 0.1em rgba(51, 48, 46, 0.35); */
  /* T097: имя переносить нельзя («Владими-р» хуже отсутствия переноса) —
     явный запрет, не завязанный на текущее отсутствие hyphens:auto у
     предков: правило переживёт будущую правку разметки без молчаливой
     регрессии. */
  hyphens: none;
}

/* Амперсанд рядом с рукописными именами (.cover__names на обложке,
   .footer__names в подвале — оба набраны Pompadur, .accent/.cover__name) —
   один общий класс, не два одинаковых правила под разные секции (T090 +
   правка 10, 2026-08-07: подвал подтянут к обложке). У Pompadur (91 глиф)
   амперсанда НЕТ — если оставить его унаследованным, браузер молча
   подставит амперсанд из fallback-стека (cursive), а он у каждого гостя
   свой. Поэтому амперсанд явно набран курсивом Lora (var(--font-text)), а
   не унаследован от родителя. Кегль подбирался снимком под кегль имён в
   КАЖДОМ месте своим модификатором ниже — общего числового кегля тут нет. */
.conj {
  font-family: var(--font-text);
  font-style: italic;
  text-transform: lowercase;
  letter-spacing: 0;
  line-height: 0.85;
}

/* Кегль 1.5em подбирался под мелкий очковый рост Pompadur на обложке — на
   Lora курсивом при том же кегле амперсанд выглядел разбухшим, снижен до
   1.15em (подобрано снимком, не расчётом). */
.cover__conj {
  font-size: 1.15em;
}

/* Подвал крупнее обложки (footer__names — 1.3× --fs-h3, см. ниже по файлу),
   но амперсанд курсивом Lora на этом кегле уже не «разбухает» — снижение
   не нужно, оставлен родной кегль текста (подобрано снимком). */
.footer__conj {
  font-size: 1em;
}

/* --------------------------------------------------------------------------
   2. Отсчёт и факты
   -------------------------------------------------------------------------- */

/* Компактная строка внутри пузыря реплики — раньше были четыре плашки со
   своей заливкой и рамкой (плашка в плашке поверх уже залитого зеркала).
   Теперь просто числа в ряд, разделённые тонкой чертой, без своего фона. */
.countdown {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-xs) var(--space-md);
  margin-top: var(--space-sm);
}

.countdown__cell {
  text-align: center;
}

/* Раньше между ячейками стояла черта (`+`-селектор, border-left на всех,
   кроме первой). На переносе строки (.countdown — flex-wrap: wrap) это
   ломалось: первая ячейка второго ряда — не первый child в разметке, черта
   доставалась и ей, повисая перед «минутами»/«секундами» ни от чего (видно
   на 390px). Гарантированно уместить 4 ячейки в ряд даже на 320px (см. ниже
   — на нём секунды скрыты) можно, но черты для минималистичного вида,
   который просили заказчики, всё равно лишние — ритм держит воздух между
   ячейками (gap), которому на переносе нечего ломать. */
.countdown__value {
  display: block;
  /* Антиква --font-display (раньше Cormorant Garamond, теперь Lora) рисует
     цифры не маюскульными — риск нечёткости в кегле отсчёта. Поэтому у ЧИСЕЛ
     отсчёта гарнитура переключена на --font-ui (Inter, маюскульные цифры —
     чёткость важнее эстетики, тот же принцип, что у кнопок и полей анкеты).
     Подписи (.countdown__label) — мелкая служебная подпись, тот же принцип.
     tabular-nums оставлен: без него цифры прыгают по ширине на каждый тик. */
  font-family: var(--font-ui);
  font-size: clamp(1.5rem, 1.1rem + 1.8vw, 2.25rem);
  line-height: 1;
  color: var(--c-accent-deep);
  font-variant-numeric: tabular-nums;
}

.countdown__label {
  display: block;
  margin-top: 0.35em;
  font-family: var(--font-ui);
  /* Мелкая служебная подпись под цифрой — та же роль, что у .small
     (см. --fs-small выше), а не фиксированный rem: иначе кегль зависит
     только от плавающего html{font-size} (88–92%) и на глаз стоит колом. */
  font-size: var(--fs-small);
  letter-spacing: 0.08em;
  /* Внутри .mirror__glass — тот же затемнённый muted, что и у .avatar--guest
     (см. .mirror__glass .muted): подписи мелкие, им нужен запас контраста. */
  color: color-mix(in srgb, var(--c-muted) 70%, var(--c-ink));
}

.countdown__note {
  font-size: var(--fs-h3);
  text-align: center;
}

/* Календарь месяца торжества (FR-004a, T059) — сменил обратный отсчёт выше
   (.countdown* сохранён неиспользуемым, js/countdown.js осиротел намеренно).
   Сетка строится в js/calendar.js из EVENT.startsAt, здесь только оформление
   готовой <table>. Правка «календарь и адреса внутрь сообщения»: раньше блок
   лежал отдельной плашкой на стекле шире пузыря переписки, margin-top держал
   отступ от предыдущего блока. Теперь .calendar — содержимое ВНУТРИ пузыря
   .chat__ask--calendar, свой отступ и фон пузырю не нужны, отступ снят. */
.calendar {
  position: relative;
}

.calendar__grid {
  width: 100%;
  border-collapse: collapse;
  table-layout: fixed;
}

.calendar__caption {
  font-family: var(--font-display);
  font-size: var(--fs-h3);
  color: var(--c-accent-deep);
  text-align: center;
  margin-bottom: var(--space-xs);
  caption-side: top;
}

.calendar__grid th {
  /* Подписи дней недели — служебный интерфейс, как время программы дня
     и адреса (см. .facts__value/.timeline__time): чёткость важнее
     эстетики антиквы. */
  font-family: var(--font-ui);
  /* Служебная подпись (дни недели), та же роль, что у .countdown__label
     выше — --fs-small, не фиксированный rem (см. комментарий там). */
  font-size: var(--fs-small);
  font-weight: 400;
  letter-spacing: 0.08em;
  text-align: center;
  padding-block: 0.35em;
  color: color-mix(in srgb, var(--c-muted) 70%, var(--c-ink));
}

.calendar__cell {
  text-align: center;
  padding: 0.35em 0;
  vertical-align: middle;
}

.calendar__cell--empty {
  /* Клетка вне месяца — пусто, без числа и без декора. */
}

.calendar__day {
  display: block;
  font-family: var(--font-ui);
  font-size: var(--fs-ui);
  font-variant-numeric: tabular-nums;
  color: var(--c-ink);
}

/* Правка заказчиков (лист рисованных сердец 8310.eps, автор rawpixel.com):
   прямоугольная бежевая подложка клетки 19-го снята — выделение теперь несёт
   контур сердца №1 листа, заливка-плашка в скрапбучном стиле смотрелась
   чужеродно. Позиционный контекст для декоративного сердца — оно рисуется
   абсолютно и вправе выходить за границы клетки (клетка ужимается вплоть до
   ~20px на 320px-экране, сердцу нужно больше места, чем есть в ячейке). */
.calendar__cell--event {
  position: relative;
}

.calendar__cell--event .calendar__day {
  position: relative;
  /* Уточнение заказчиков 2026-08-06: число, обведённое сердцем, — тёмным
     (--c-ink, та же роль, что у обычных чисел календаря), сердце теперь
     матовым красным (--c-heart, см. PALETTE выше): два разных цвета
     читаются раздельно, даже когда контур сердца задевает край цифры.
     Полужирность снята тем же уточнением заказчиков (2026-08-06, третья
     правка) — выделение несёт сердце, дублировать его насыщенностью
     штриха цифры не нужно; начертание числа 19 — то же, что у остальных
     чисел месяца. */
  color: var(--c-ink);
}

/* Оба ассета листа 8310.eps — ЗАЛИТЫЕ фигуры (fill-rule: nonzero), без
   обводки: у сердца №1 «контурность» дана самой геометрией (внешний и
   внутренний подпути кольцом гасят друг друга при заливке), а не толщиной
   линии — stroke тут не рисует ничего. */
.icon-heart {
  fill: currentColor;
  fill-rule: nonzero;
}

/* Сердце №2 листа 222560-P1MBMS-260.eps «Doodle heart collection» — обводит
   число 19 (клетка .calendar__cell--event). Пропорции контура: viewBox
   "0 0 93.102 85.511" → 93.102×85.511.

   Правка заказчиков («сердце перекрывает 19»): сердце №1 листа 8310.eps сюда
   не годилось по геометрии — нарисовано сдвоенной линией, центр viewBox лежал
   ПРЯМО НА штрихе, самый большой чистый просвет внутри — 34,2%×17,1% от
   габарита. Замена размером (множитель 5, старое смещение -74%/-40.6%) делала
   хуже: линия закрывала 33% площади глифов «19» и задевала ещё четыре числа
   сетки — 18 (41%), 26 (25%), 25 (22%), 12 (15%), а само сердце выходило
   вдвое шире клетки календаря.

   Сердце №2 нарисовано эскизно (один прерывистый контур, не кольцо-обводка):
   у него нет единого «чистого просвета» — выемка между лопастями уходит
   глубоко внутрь фигуры и делит середину надвое, так что ориентир вида
   «центр просвета X%/Y%» для него в принципе не работает (ранняя оценка
   45,5%/41,4% была получена как самый большой прямоугольник без чернил и
   вводила в заблуждение — на практике в этой точке контур задевает саму
   «19»). Реальная посадка подобрана прямым перебором на живой странице:
   путь отрисован на canvas в экранном размере/положении (Path2D, fill
   nonzero), рядом растеризованы глифы чисел сетки тем же шрифтом от
   вычисленной базовой линии, перекрытие посчитано попиксельно. Для этого
   эскиза чистой одновременно для «19» и для всех соседей конфигурации нет —
   решение заказчиков: 4% лёгкого касания края «19» (по факту незаметно на
   глаз) вместо полного перекрытия соседних чисел. */
.calendar__heart--outline {
  display: block;
  position: absolute;
  /* Ширина в em от --fs-ui (кегль самого числа), не в rem: --fs-ui растёт от
     320 к 1280 быстрее, чем корневой rem (плавная база html, см. выше) — на
     фиксированных rem сердце на широком экране отставало ростом от цифр и
     обрезало «1» у «19» (замер T059-правки). em привязывает рост сердца
     к росту самого числа, которое оно обводит.

     Множитель 3,4 — решение заказчиков после перебора на живой странице
     (Chromium/CDP, попиксельное сравнение залитого пути сердца и
     растеризованных глифов чисел сетки от вычисленной базовой линии).
     У этого эскиза нет размера/позиции, при которой одновременно чиста и
     «19», и все соседние числа (см. комментарий выше про глубокую выемку
     между лопастями). Перебор дал: множитель 3,8–4,2 полностью очищает
     «19», но линия задевает «12» (0,7–9,0% площади его глифов) и «18»
     (0,7–0,9%); множитель 3,4 с этим же смещением X (-58%) касается только
     самой «19» (около 4% площади её глифов на 1280px — лёгкое касание края,
     не перекрытие) и не задевает ни одного соседа. Заказчики выбрали второй
     вариант: небольшое касание целевого числа приемлемее, чем перечёркнутые
     соседние дни. */
  width: calc(var(--fs-ui) * 3.4);
  /* Общий сброс `svg { max-width: 100% }` иначе клампит сердце шириной
     клетки (её containing block, ~20px на 320px-экране) — деко́р должен
     вправе выйти за границы клетки (см. комментарий у .calendar__cell--event
     выше), поэтому клампу здесь не место. */
  max-width: none;
  aspect-ratio: 93.102 / 85.511;
  left: 50%;
  top: 50%;
  /* Сердце центрируется на числе: -50%/-50% ставит середину контура в середину
     клетки. Прежние -58%/-68% поднимали его на 9px выше числа, и верх контура
     заходил на «12» строкой выше — замечание заказчиков 2026-08-07.

     ВАЖНО про способ проверки. Подбирать эти координаты попиксельным
     пересечением «путь сердца × глифы числа» НЕ надо: такая проверка уже дважды
     дала ложный результат (утверждала перекрытие «19» там, где на снимке экрана
     цифра чистая). Причина — вычисление вертикали глифов от базовой линии
     расходится с реальным рендером. Проверять снимком блока, а не арифметикой. */
  transform: translate(-50%, -50%);
  color: var(--c-heart);
  pointer-events: none;
  /* Уточнение заказчиков 2026-08-06: сердце рисуется ПОВЕРХ числа (раньше
     было наоборот, число лежало выше) — стек не меняем этой правкой, только
     геометрию (width/transform выше): цифра теперь и без стекового порядка
     не перекрыта — целиком в просвете клина.

     Цвет сердца — матовый красный (--c-heart), не золото: правка заказчиков
     2026-08-06 (см. подробный расчёт цвета — комментарий у роли --c-heart в
     PALETTE выше). */
  z-index: 1;
}

/* T102 (2026-08-12): прежняя карточка-список .facts/.facts__item/
   .facts__label (подпись «Регистрация»/«Банкет» + значение в двух <li>)
   удалена — оба адреса теперь звучат репликами от пары внутри .chat__ask,
   текстом, а не карточкой. Вторая итерация (замечание пользователя того же
   дня) убрала и обёртку .facts__value-row: она была display: inline-flex,
   то есть атомарным inline-блоком, — текст обтекал пару «адрес + значок»
   целиком, значок читался как вставка «в поле диалога», а точка после адреса
   срывалась на новую строку. Остался только .facts__value (сам текст адреса),
   значок стоит в строке отдельным inline-элементом (см. index.html, #date).

   T111 (осмотр 2026-08-12): раньше здесь стояли font-family/font-size
   var(--font-ui) — расчёт был на то, что критичная информация (адрес)
   выигрывает от чёткого служебного шрифта. На практике внутри пузыря
   .chat__ask (гарнитура Lora) это читалось разнобоем гарнитур посреди
   одного предложения — ошибкой вёрстки, а не осознанным акцентом. Адрес
   теперь набран той же Lora, что и вся реплика: он и так выделен тем, что
   стоит после тире и перед именованной кнопкой-значком, отдельная гарнитура
   для этого не нужна. */
.facts__value {
  /* T097: адрес переносить нельзя («Екатерин-бург» хуже, чем отсутствие
     переноса вовсе) — гасит hyphens: auto, унаследованный от .chat__ask
     (T102: раньше наследовалось от снятого .facts, источник сменился, само
     правило нужно по-прежнему). */
  hyphens: none;
}

/* Кнопка-глобус — маленькая, круглая, тем же приёмом currentColor, что и
   .chat__composer-send--icon (D12), но контурная (ghost), а не залитая:
   рядом с адресом залитый кружок спорил бы за внимание с текстом. Один
   значок на строку — открывает окно карты с уже проложенным маршрутом
   именно до этого адреса (page.js#setupMapDialog).

   Правка T094 (жалоба заказчиков «плохо видны»): кружок и глиф увеличены
   (снимок строки адреса на 320/390/768/1280px — с прежними 1.75rem/0.95rem
   значок терялся рядом с текстом адреса, с этими числами читается сразу,
   но ещё не спорит с адресом за внимание). Видимый кружок сознательно
   меньше 44px тач-цели — тач-цель добита невидимым hit-area (::before
   ниже), а не раздутым видимым кругом, который на D12 признали слишком
   заметным для места рядом с текстом. */

/* Связка «значок + точка», которую нельзя разорвать переносом (правка
   2026-08-14, дефект найден пользователем на живом сайте). Кнопка ниже —
   display: inline-flex, то есть атомарный строчный блок, а после такого блока
   браузер ВПРАВЕ перенести строку: точка уезжала на следующую, хотя пробела
   перед ней нет. Обычным &nbsp; это не лечится — он держит пробел, а рвётся
   здесь граница блока. Воспроизводилось при ширине пузыря 294–314 px. */
.facts__map-tail {
  white-space: nowrap;
}

.facts__map-btn {
  position: relative;
  display: inline-flex;
  /* Значок стоит ПРЯМО В СТРОКЕ текста реплики (T102, вторая итерация), а не в
     отдельной обёртке-flex, как раньше, — поэтому его надо посадить на базовую
     линию по-человечески: без vertical-align inline-flex выравнивается по
     baseline своего содержимого и кружок «проваливается» ниже строки. */
  vertical-align: middle;
  flex-shrink: 0;
  align-items: center;
  justify-content: center;
  width: 2.25rem;
  height: 2.25rem;
  padding: 0;
  border: 1px solid var(--c-line);
  border-radius: 50%;
  background: none;
  color: var(--c-accent-deep);
  cursor: pointer;
  transition: background-color 0.2s, color 0.2s;
}

/* Невидимая тач-цель 44×44 CSS px (порог тач-доступности) поверх видимого
   кружка меньшего размера — тот же приём с абсолютными px, а не rem, что и
   .map-dialog__close (см. её комментарий про плавающий html{font-size}).
   Кружок остаётся визуально скромным, а палец промахивается реже. */
.facts__map-btn::before {
  content: '';
  position: absolute;
  top: 50%;
  left: 50%;
  width: 44px;
  height: 44px;
  transform: translate(-50%, -50%);
}

.facts__map-btn:hover {
  background-color: var(--c-accent-deep);
  color: var(--c-bg);
}

/* T101 (переделка 2026-08-12): непрерывно вращающийся золотой ободок —
   замена отвергнутому пользователем варианту с бегущей дугой (короткая
   яркая дуга на тусклом кольце — это в точности язык спиннера загрузки,
   от которой уходили ещё в T094). Здесь ободок залит золотом ЦЕЛИКОМ, без
   тёмного фона и без чёткого начала/конца дуги — переливается мягкий
   перепад яркости, а не бежит светлое пятно по тёмному кольцу.

   ::after (не ::before — та невидимая тач-цель 44px выше, трогать нельзя),
   сама кнопка border-box (см. * { box-sizing: border-box } в начале
   файла), поэтому inset: 0 у ::after кладёт его точно в границы кружка со
   стороной 2.25rem, без отдельного расчёта смещения под border: 1px.

   Ring-маска не менялась: radial-gradient с ключевым словом circle
   closest-side — ЕГО радиус равен половине СТОРОНЫ квадратного блока (а не
   половине диагонали, как у дефолтного farthest-corner на квадрате),
   поэтому 100% стопа совпадает точно с окружностью кружка (border-radius:
   50% обрезает квадрат по тому же радиусу).

   Конус-градиент — два плавных пика яркости на противоположных сторонах
   кольца (0%/50%/100% — светлее, 25%/75% — сам --c-accent без подмеса),
   БЕЗ прозрачных или тёмных стопов: перепад мягкий по всей окружности,
   резкой границы светлого/тёмного нет нигде — то, что превращало прошлый
   вариант в дугу-спиннер. Яркость даёт color-mix с white, не сдвиг тона:
   единственный цвет страницы остаётся золото (--c-accent), меняется
   только его светлота. Вращает не сам конус (нужен @property для
   плавной интерполяции angle), а transform: rotate() всего ::after —
   анимируемо нативно везде без регистрации кастомных свойств. */
.facts__map-btn::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: 50%;
  pointer-events: none;
  background: conic-gradient(
    from 0deg,
    color-mix(in srgb, var(--c-accent) 60%, white) 0%,
    var(--c-accent) 25%,
    color-mix(in srgb, var(--c-accent) 60%, white) 50%,
    var(--c-accent) 75%,
    color-mix(in srgb, var(--c-accent) 60%, white) 100%
  );
  -webkit-mask-image: radial-gradient(
    circle closest-side,
    transparent 0,
    transparent calc(100% - 4px),
    #000 calc(100% - 4px),
    #000 100%
  );
  mask-image: radial-gradient(
    circle closest-side,
    transparent 0,
    transparent calc(100% - 4px),
    #000 calc(100% - 4px),
    #000 100%
  );
  /* 8s, НЕ 7s (период globe-spin ниже) — периоды сознательно разведены, и
     разводить их по фазе не требуется: пространственно эффекты не
     пересекаются. Кольцо живёт на ::after и маской обрезано до внешних
     4px кружка; глиф globe-spin — отдельный SVG-элемент 1.3rem внутри
     кружка 2.25rem, полностью внутри немаскированной прозрачной середины
     кольца. Разные элементы, разная область экрана — общий кадр с обоими
     движениями не даёт ряби, потому что накладываться геометрически
     нечему. linear (не ease-in-out, как у globe-spin) — вращение ободка
     ровное и медленное, без ускорений, которые читались бы как рывок. */
  animation: map-btn-shimmer 8s linear infinite;
}

/* При наведении рамку заливает :hover выше — вращающийся ободок на золотом
   кружке был бы шумом поверх заливки, а не акцентом. animation: none (а не
   только opacity: 0) отменяет саму анимацию, а не замораживает её на
   текущем кадре. */
.facts__map-btn:hover::after {
  animation: none;
  opacity: 0;
}

@keyframes map-btn-shimmer {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}

.icon-globe {
  width: 1.3rem;
  height: 1.3rem;
  fill: currentColor;
  /* Центр вращения — визуальный центр самого глифа, а не левый верхний
     угол SVG-вьюпорта (дефолт CSS Transforms для SVG без transform-box). */
  transform-box: fill-box;
  transform-origin: center;
  /* Вращение — периодическое, не непрерывное (T094): постоянно крутящийся
     значок на свадебном приглашении читается как индикатор загрузки, а не
     как декоративная деталь. Пауза зашита ВНУТРИ keyframes (глиф стоит на
     месте от 8% до 100% шкалы), а не через animation-delay — delay просто
     сдвинул бы старт первого прохода, а не создал бы паузу МЕЖДУ проходами
     при animation-iteration-count: infinite. Финальный кадр — те же 360deg
     (визуально неотличимо от 0deg), поэтому виток в начало цикла не даёт
     видимого скачка глифа назад. Кнопка (родитель) в анимации не участвует
     — крутится только сам глиф внутри неподвижной цели нажатия. */
  animation: globe-spin 7s ease-in-out infinite;
}

@keyframes globe-spin {
  0% {
    transform: rotate(0deg);
  }
  10% {
    transform: rotate(360deg);
  }
  100% {
    transform: rotate(360deg);
  }
}

/* Гость отключил анимации в системе. Общий блок @media
   (prefers-reduced-motion: reduce) выше по файлу уже сжимает
   animation-duration до 0.01ms — этого достаточно, чтобы вращение стало
   незаметным, но не гарантирует «полностью выключено»: на этой фиче на
   reduced-motion уже горели (T091/mirror-sheen), поэтому здесь — явный
   animation: none, а не расчёт на общее правило. */
@media (prefers-reduced-motion: reduce) {
  .icon-globe {
    animation: none;
  }

  /* T101: как и у .icon-globe выше — явный animation: none, а не расчёт на
     сжатие animation-duration общим правилом (на reduced-motion в этой
     фиче уже горели дважды, см. комментарий у .icon-globe). opacity: 0
     дублирует финальный кадр keyframes на случай, если анимация всё же
     где-то не отменится. */
  .facts__map-btn::after {
    animation: none;
    opacity: 0;
  }
}

/* --------------------------------------------------------------------------
   4. Программа дня
   -------------------------------------------------------------------------- */

/* Правка T060/T049 (программа дня — внутрь пузыря переписки): раньше
   .timeline лежал отдельной плашкой на стекле, рельс из вертикальной линии
   (--c-line) и обведённых кружков размечал шаги вдоль неё. Внутри пузыря
   .chat__ask своя рамка/фон уже есть (см. .chat__ask--calendar выше),
   рельс не нужен вовсе — пункты разделяет тонкая граница между строками,
   тем же --c-line, что раньше был у линии. */
.timeline {
  list-style: none;
  padding: 0;
}

.timeline__item {
  padding-block: var(--space-xs);
}

.timeline__item + .timeline__item {
  border-top: 1px solid var(--c-line);
}

.timeline__time {
  display: block;
  /* Время программы дня — критичная информация, чёткость важнее эстетики
     (см. --font-ui), в отличие от остального декоративного текста блока. */
  font-family: var(--font-ui);
  font-size: var(--fs-h3);
  color: var(--c-accent-deep);
}

.timeline__title {
  display: block;
  color: var(--c-ink);
}

/* --------------------------------------------------------------------------
   5. Карта (T061, решения пользователя 2026-08-07):
   — отдельный блок «Место проведения» убран — оба адреса названы строками
     выше в блоке даты (.venue/.venue__name/.venue__address сняты вместе с
     разметкой);
   — инлайн-раскрытие карты в пузыре отклонено по факту осмотра («карта
     зажата шириной реплики») — карта переехала во всплывающее окно
     .map-dialog, тот же нативный <dialog>, что у .rsvp-success;
   — вместо двух текстовых кнопок («Показать карту» + «Построить маршрут») —
     по одному значку-глобусу у каждой строки адреса, см. .facts__map-btn.
   -------------------------------------------------------------------------- */

.map {
  width: 100%;
  aspect-ratio: 4 / 3;
  border: 1px solid var(--c-line);
  border-radius: var(--radius);
  overflow: hidden;
  background-color: var(--c-bg-soft);
}

.map__frame {
  width: 100%;
  height: 100%;
  border: 0;
  /* Виджет Яндекс.Карт перехватывает вертикальный свайп под панорамирование
     карты — на телефоне это глушит прокрутку страницы прямо над пузырём.
     pan-y оставляет браузеру только вертикальный жест страницы, горизонтали
     и жесты масштабирования карты (пинч, drag вбок) остаются у виджета. */
  touch-action: pan-y;
}

@media (min-width: 40em) {
  .map {
    aspect-ratio: 16 / 9;
  }
}

/* Окно карты — тот же приём, что у .rsvp-success (width: min(…, 90vw), своя
   ::backdrop через opacity, а не color-mix — см. её комментарий). Шире
   .rsvp-success: карте нужна ширина, чтобы маршрут читался, а не только
   текст. */
.map-dialog {
  max-width: 40rem;
  width: min(40rem, 90vw);
  margin: auto;
  padding: var(--space-md);
  border: none;
  border-radius: var(--radius);
  background-color: var(--c-bg);
}

.map-dialog::backdrop {
  background-color: var(--c-ink);
  opacity: 0.55;
}

/* Окно политики конфиденциальности — та же ширина/фон/бордер-радиус, что у
   .rsvp-confirm, и по той же причине max-height + overflow-y: юридический
   текст длиннее вьюпорта на узких экранах, листаться должно содержимое
   диалога, а не страница под затемнением. max-height был заперт на
   min(34rem, 85vh) — те же 34rem, что и ширина, хотя высота и ширина не
   связаны: на обычном настольном экране 34rem (~544px) ощутимо ниже
   доступных 85vh, окно скроллилось намного раньше, чем позволял экран.
   Ограничиваем только 85vh — окно использует доступную высоту, а ширина
   и так не даёт ему растянуться шире содержимого. */
.privacy-dialog {
  max-width: 34rem;
  width: min(34rem, 90vw);
  max-height: 85vh;
  overflow-y: auto;
  margin: auto;
  padding: var(--space-lg) var(--space-md);
  border: none;
  border-radius: var(--radius);
  background-color: var(--c-bg);
}

.privacy-dialog::backdrop {
  /* Тот же приём через opacity, что у .map-dialog::backdrop выше. */
  background-color: var(--c-ink);
  opacity: 0.55;
}

.privacy-dialog__close {
  /* Та же правка, что у .map-dialog__close ниже: px, не rem — независимо от
     плавающего html{font-size} кнопка не должна проседать ниже 44px тач-цели. */
  display: flex;
  align-items: center;
  justify-content: center;
  min-width: 44px;
  min-height: 44px;
  margin: var(--space-md) auto 0;
  padding: 0.4em 1.4em;
  /* Кегль кнопки — та же роль, что у базового .button (--fs-small), см.
     комментарий там; фиксированный rem не тянулся за шириной экрана. */
  font-size: var(--fs-small);
}

.map-dialog__close {
  /* Жалоба заказчика: кнопка «закрыть» визуально доминировала над картой —
     базовый .button рассчитан на кнопки-призывы во весь экран, а тут это
     единственная вспомогательная кнопка внутри модалки. Уменьшаем шрифт и
     паддинги, но не hit-area: min-width/height взяты в px, НЕ в rem — у
     сайта плавающий html{font-size} (клампится вниз до ~88% от 16px, см.
     html выше), и 2.75rem там дал бы ~39px вместо нужных 44 — ниже порога
     тач-цели. 44px — абсолютный порог, привязка к rem его бы подрывала. */
  display: flex;
  align-items: center;
  justify-content: center;
  min-width: 44px;
  min-height: 44px;
  margin: var(--space-sm) auto 0;
  padding: 0.4em 1.4em;
  /* Та же роль, что у .privacy-dialog__close выше — --fs-small. */
  font-size: var(--fs-small);
}

/* --------------------------------------------------------------------------
   6. Опросник
   -------------------------------------------------------------------------- */

.chat {
  /* Уже .mirror__glass: .mirror ограничен var(--measure) = 34rem, минус
     паддинги стекла (--space-md * 2) внутренняя ширина ~30,5rem — этот
     max-width уже ничего не режет сам по себе. Оставлен как явная граница
     на случай, если .chat однажды окажется вне зеркала. */
  max-width: 32rem;
  margin-inline: auto;
  display: grid;
  gap: var(--space-md);
}

/* .chat__turn — «домик» реплики: пока гость до неё не дошёл или уже ответил,
   лежит здесь скрытым (см. chat-form.js). Всегда ровно два прямых потомка —
   .chat__ask (вопрос, переезжает в ленту навсегда) и .chat__control (поле,
   ходит туда-сюда между домиком и строкой ввода). */
.chat__turn {
  display: grid;
  gap: var(--space-xs);
}

/* .chat__control для трёх реплик — <fieldset> (переключатели/чекбоксы/счётчики),
   для остальных — сам <input>: сбрасываем рамку fieldset только там, где она
   есть, иначе это же правило перебило бы padding у .field__input (та же
   специфичность класса, порядок в файле решил бы исход). */
fieldset.chat__control {
  border: 0;
  padding: 0;
  margin: 0;
  min-width: 0; /* fieldset иначе не сжимается на узких экранах */
}

/* Реплика пары — пузырь, прижатый влево, с «хвостиком» снизу-слева.
   Аватарка пары рисуется псевдоэлементом, а не отдельной <img>: у части
   реплик .chat__ask — это <label>, у части — обычный <p> (см. index.html,
   связь с группой полей там держит aria-labelledby, а не соседство в DOM),
   обернуть их в <div> под img+текст было бы лишней вложенностью.
   --avatar-size задаёт размер и слева от пузыря резервируется margin-left
   той же величины: max-width уменьшен на неё же, чтобы правая граница
   пузыря осталась на прежних 85% — аватарка с пузырём вместе занимают ровно
   столько же места, сколько раньше занимал один пузырь, без переполнения на
   узких экранах. */
.chat__ask {
  position: relative;
  --avatar-size: 3rem;
  display: block; /* сбрасывает float у <legend>, которая иначе капризна */
  width: 100%;
  float: none;
  margin-right: auto;
  margin-left: calc(var(--avatar-size) + var(--space-xs));
  /* Не 100 %: на всю ширину пузыри выравниваются по обоим краям, и переписка
     перестаёт читаться — по кому реплика, видно только по сдвигу. 92 %
     оставляют заметный уступ справа при любой ширине экрана, но не режут
     пузырь до нечитаемой узости (было перевёрнуто: 85 % на широком экране
     и 92 % только на телефоне — правка 2026-08-03, T065). */
  max-width: calc(92% - var(--avatar-size) - var(--space-xs));
  /* Правка заказчиков (2026-08-06): абсолютный потолок сообщения — тот же
     --measure, что держит длину строки колонки переписки (34rem по
     исходному расчёту роли; текущее значение переменной — решение T075,
     здесь оно не переопределяется, только используется). На узких экранах
     решает процентный max-width выше, --measure подключается только на
     широких, где 92% сами по себе дали бы пузырь шире комфортной строки. */
  max-width: min(calc(92% - var(--avatar-size) - var(--space-xs)), var(--measure));
  padding: 0.75em 0.9em;
  background-color: var(--c-bg);
  border: 1px solid var(--c-line);
  border-radius: 1.1em 1.1em 1.1em 0.2em;
  color: var(--c-ink);
  font-size: var(--fs-body);
  font-weight: 400;
  /* Жалоба заказчика: длинная строка без пробелов (гость может ввести 300
     символов подряд) раздвигает scrollWidth пузыря шире его max-width и
     вылезает за скруглённый фон — anywhere режет слово посреди, а не только
     на границе слова (обычный break-word её бы не тронул). */
  overflow-wrap: anywhere;
  /* T097: пузырь — узкая колонка, hyphens: auto решает ДРУГУЮ задачу, чем
     overflow-wrap выше — обычные слова с переносом там, где раньше слово
     целиком падало на новую строку или лепилось тире. Оба правила нужны
     вместе, ни одно не отменяет другое (overflow-wrap срабатывает только
     когда одному «слову» самому по себе тесно). */
  hyphens: auto;
}

.chat__ask::before {
  content: '';
  position: absolute;
  left: calc(-1 * (var(--avatar-size) + var(--space-xs)));
  bottom: 0;
  width: var(--avatar-size);
  height: var(--avatar-size);
  border-radius: 50%;
  border: 1px solid var(--m-gold);
  /* Стикер couple-sticker.webp — с прозрачным фоном (PNG/WebP alpha), а не
     сплошной кружок: при матовом слое 40% (было 88%, см. .page-bg::after)
     сквозь него вместо ровной подложки стало видно фотографию — кружок
     читался дырой в странице. Заливка — тот же --c-bg, что у пузыря
     реплики рядом (.chat__ask), поэтому аватарка визуально сидит на той
     же «бумаге», что и текст. Правка заказчика 2026-08-07. */
  background-color: var(--c-bg);
  /* Кадр голов в готовом стикере (couple-sticker.webp, 512×512) — квадрат
     152×152 px с началом в точке (200, 8). Масштаб и смещение выражены через
     отношение размера аватарки S к размеру кадра — коэффициенты читаются
     прямо из чисел кадра, без вручную посчитанных процентов. */
  background-image: url('../img/couple-sticker.webp');
  background-repeat: no-repeat;
  background-size: calc(var(--avatar-size) * 512 / 152) auto;
  background-position: calc(var(--avatar-size) * -200 / 152) calc(var(--avatar-size) * -8 / 152);
  /* T081: тот же couple-sticker.webp нарезан из старого цветного снимка и
     остаётся единственным цветным пятном рядом с чёрно-белой съёмкой —
     обесцвечено фильтром здесь, а не перевыпуском файла: тот же файл ещё
     раз используется как есть (рисованный стикер, не фото) в
     .rsvp-success__sticker на экране благодарности, и там цвет остаётся —
     перевыпуск затронул бы оба места сразу. */
  filter: grayscale(1);
}

/* Аватарка — только у последней реплики серии (в мессенджере она у
   последнего сообщения, не у первого). Раньше это было CSS-правило
   `.chat__ask--grouped:has(+ .chat__ask--grouped)`, но оно видит только
   прямых соседей в одном родителе — в анкете (#rsvp, chat-form.js) реплики
   раскрываются по шагам и на разных стадиях лежат в разных родителях
   (.form.chat/.chat__log/.chat__turn), а «последняя реплика серии» вообще
   движется по ходу диалога. Правка пользователя 2026-08-07 («в последнем
   экране две иконки, когда должна одна») — это была именно та дыра: две
   несгруппированные реплики анкеты подряд несли каждая свою аватарку.
   Единый механизм на всю страницу — JS (chat-avatars.js), см. там же:
   второй механизм рядом не заводим, старое CSS-правило снято целиком. */
.chat__ask:not(.chat__ask--tail)::before {
  content: none;
}

/* Серия реплик от пары стоит теснее, чем реплики разных серий (обычный gap
   между .chat__ask в .chat — var(--space-md)), но граница между соседними
   сообщениями всё равно обязана быть видна — прошлый заход ужимал зазор
   ОТРИЦАТЕЛЬНЫМ margin-top, три реплики серии слипались в один пузырь на
   весь блок (правка заказчиков 2026-08-06: «это читается как одно
   гигантское сообщение»). --space-xs — тот же зазор, что между вопросом и
   полем внутри одной реплики (.chat__turn), заметно меньше обычной паузы
   между сериями, но не отрицательный. */
.chat__ask--grouped {
  margin-top: var(--space-xs);
}

/* Календарь-вложение внутри пузыря: своя рамка/фон блока календаря ниже
   (.calendar) больше не нужна — пузырь .chat__ask уже даёт фон/скругление,
   а .calendar остаётся только для внутренних отступов/позиционирования
   уголка сердца. */
.chat__ask--calendar {
  padding-block: 0.6em;
}

/* Несколько строк внутри одного пузыря (правка заказчиков 2026-08-06 раздала
   бывший монолог знакомства по отдельным репликам, но одна из них — «Но вы и
   сами знаете» + «Будем рады видеть вас» — осталась двумя фразами в одном
   пузыре): без этого правила абзацы внутри .chat__ask схлопнулись бы
   глобальным сбросом `* { margin: 0 }` в сплошной текст. Прежний модификатор
   .chat__ask--lines снят вместе со старым монолитным пузырём — писать
   разбиение внутри пузыря понадобилось только тут, правило переведено на
   голый .chat__ask. */
.chat__ask p + p {
  margin-top: 0.6em;
}

/* Подпись пары (T058, была отдельным .greeting__sign) — теперь своя реплика
   в конце серии знакомства, крупнее обычной фразы. */
.hello__sign {
  font-size: var(--fs-h3);
}

/* Эхо-пузырь ответа гостя в ленте — пузырь, прижатый вправо, зеркальный
   хвостик снизу-справа, ровно один на реплику (строит chat-form.js после
   ответа). Заливку несёт .chat__echo — иначе выйдет пузырь в пузыре.
   Аватарка гостя — последний ребёнок, текст растягивается и делит строку
   с ней. */
.chat__reply {
  display: flex;
  align-items: flex-end;
  gap: var(--space-xs);
  margin-left: auto;
  max-width: 85%;
  width: 100%;
}

.chat__reply > :not(.avatar) {
  flex: 1;
  min-width: 0;
}

.chat__echo {
  padding: 0.75em 0.9em;
  background-color: var(--c-bg);
  border: 1px solid var(--c-line);
  /* Зеркало хвостика .chat__ask — эхо-пузырь читается как исходящее сообщение. */
  border-radius: 1.1em 1.1em 0.2em 1.1em;
  color: var(--c-ink);
  /* Тот же перенос длинного «слова», что у .chat__ask выше — .chat__echo
     дополнительно flex-элемент внутри .chat__reply (flex: 1, min-width: 0
     уже стоит на нём), но одного min-width без overflow-wrap не хватает:
     контент внутри самого пузыря всё равно раздувает scrollWidth. */
  overflow-wrap: anywhere;
  /* T097: то же hyphens: auto, что у .chat__ask — эхо-пузырь такой же узкой
     ширины. */
  hyphens: auto;
}

/* Круглая аватарка — общий размер для аватарки пары (::before выше) и
   аватарки гостя (.avatar--guest ниже), синхронизирован через ту же
   величину 3rem, но как самостоятельная переменная: аватарка гостя не
   вложена внутрь .chat__ask и его --avatar-size ей не наследуется. */
.avatar {
  --avatar-size: 3rem;
  width: var(--avatar-size);
  height: var(--avatar-size);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  border-radius: 50%;
}

.avatar--guest {
  border: 1px solid var(--m-gold);
  /* Тот же провал, что у .chat__ask::before выше (аватарка пары): силуэт
     лежит на прозрачном кружке, и при матовом слое 40% сквозь него
     читалась фотография вместо ровной подложки. */
  background-color: var(--c-bg);
  overflow: hidden; /* силуэт срезан по кругу исходника, но кромку страхуем */
}

/* T112: раньше внутри кружка стояло слово «гость» кеглем 0.55rem. На 320 px
   аватарка сжата до 2.2rem ≈ 31 px, а слово при читаемых 11-12 px требует
   ≈ 34 px — шире самого кружка; поднять --avatar-size нельзя, она общая с
   пузырями реплик (см. брейкпоинт ниже). Решение пользователя 2026-08-13 —
   безликий силуэт вместо надписи. Читается на любом размере: смысл несёт
   форма, а не кегль. Доступности не теряем — .avatar--guest и раньше был
   aria-hidden, гостя в ленте называет сам текст эхо-пузыря.

   Силуэт подключён МАСКОЙ, а не готовой картинкой: файл несёт только альфу
   (tools/guest_avatar_extract.py), цвет задаётся здесь — тот же затемнённый
   muted, каким была надпись, и он останется в палитре при любой её правке. */
.avatar--guest::before {
  content: '';
  width: 100%;
  height: 100%;
  background-color: color-mix(in srgb, var(--c-muted) 70%, var(--c-ink));
  mask-image: url('../img/decor/avatar-guest.webp');
  mask-size: contain;
  mask-position: center;
  mask-repeat: no-repeat;
}

.field__input {
  width: 100%;
  padding: 0.75em 0.9em;
  /* Поле ввода — чёткость важнее эстетики (см. --font-ui). Кегль — тот же
     --fs-body, что и у реплик диалога: композер и есть строка сообщения,
     она обязана жить в одной шкале с пузырями, а не отдельной жизнью.
     Замечание заказчика 2026-08-15: застывшие 16px не уменьшались на узких
     экранах, где весь остальной текст ужимается, и поле выпирало.

     Порог 16px нужен ТОЛЬКО на iOS: Safari при фокусе на поле с кеглем
     меньше 16px самопроизвольно увеличивает всю страницу зумом, и гость
     проваливается в него посреди анкеты. Раньше этот порог стоял для всех
     подряд — то есть ограничение одной платформы держало вёрстку везде.
     Ниже он возвращается точечно, под @supports, а на остальных
     устройствах поле масштабируется свободно. */
  font-family: var(--font-ui);
  font-size: var(--fs-body);
  background-color: var(--c-bg);
  /* --c-line на голубом стекле (--m-glass) — 1,1:1, рамка почти не видна
     (просьба заказчиков 2026-08-03: поле должно читаться рамкой, как в
     мессенджере). Роль не переопределяем — тот же приём, что у .muted
     чуть выше: темним локально через color-mix, это даёт 3,57:1 без
     нового цветового литерала. */
  border: 1px solid color-mix(in srgb, var(--c-line) 40%, var(--c-ink));
  /* Зеркало хвостика .chat__ask — поле читается как исходящий пузырь. */
  border-radius: 1.1em 1.1em 0.2em 1.1em;
  transition: border-color 0.2s;
}

/* Порог 16px — точечно для iOS, где он единственно и нужен (см. комментарий
   у .field__input). `-webkit-touch-callout` поддерживает только WebKit на
   iOS/iPadOS — это самый дешёвый признак нужной платформы, не требующий ни
   разбора user-agent, ни скрипта. Внутри — тот же размер из шкалы, поднятый
   до пола: на узком iPhone поле останется 16px и Safari не дёрнет зумом, а
   на широком вырастет вместе с остальным текстом.

   Почему не запрет масштабирования (`maximum-scale=1` / `user-scalable=no`)
   — второй известный способ снять авто-зум: он отнимает у гостя возможность
   приблизить страницу пальцами. Ради вёрстки ломать доступность нельзя, тем
   более на приглашении, которое будут открывать пожилые родственники. */
@supports (-webkit-touch-callout: none) {
  .field__input {
    font-size: max(var(--fs-body), 16px);
  }
}

.field__input:focus {
  border-color: var(--c-accent);
}

/* T181 (решение пользователя 2026-09-12, замечание приёмки 1): подписью
   `.field__label` уже пользовались .photos-removal__form (photos.html) и
   селектор гостя в архиве (photos-archive.html), но сам `.field` не нёс НИ
   ОДНОГО правила — подпись и поле падали друг на друга инлайновым потоком
   `<label>`. Пока внутри стоял только `.field__input` (тот сам растягивался
   на всю ширину блоком), разница была не видна; для `.field__select` ниже
   отсутствие вертикальной раскладки стало заметно сразу. Подпись сверху,
   поле снизу — тот же порядок, что у .counter__label/.counter__input чуть
   ниже по файлу. */
.field {
  display: flex;
  flex-direction: column;
  gap: 0.35em;
}

.field__label {
  font-family: var(--font-ui);
  font-size: var(--fs-small);
  color: color-mix(in srgb, var(--c-muted) 70%, var(--c-ink));
}

/* T181: `<select>` — единственный на сайте, до этой правки ловил лишь общий
   сброс (`select { font: inherit; color: inherit }` в самом начале файла) и
   рисовался нативным виджетом браузера с системной стрелкой — на бумажном
   листе среди рукописных гарнитур читался чужеродно (осмотр пользователя,
   T181). `appearance: none` снимает и стрелку, и остальное родное
   оформление — свою стрелку рисуем фоновым SVG-треугольником того же
   золотого голоса, что и лупа поиска рядом (--c-accent-deep, #734e12 — тот
   же цвет, зашитый литералом, потому что background-image data-URI не умеет
   читать CSS-переменную). Рамка/скругление/фон/паддинг — калька с
   .field__input (T181, «не изобретать свой стиль»): тот же бордер в 40%
   --c-line, то же скругление 1.1em, тот же фон --c-bg. Запас справа больше,
   чем у .field__input — место под саму стрелку, иначе текст выбранного
   варианта наезжал бы на неё.
   Доступность: appearance убирает только рисунок, не поведение — открытие
   списка по клику/Space/стрелкам и клавиатурная навигация остаются
   встроенными в <select>; фокусное кольцо — общее правило
   `:where(...):focus-visible` (см. выше по файлу), здесь его не трогаем. */
.field__select {
  width: 100%;
  padding: 0.75em 2.2em 0.75em 0.9em;
  font-family: var(--font-ui);
  /* T181 (замечание финальной сверки, решение пользователя 2026-09-12):
     было --fs-body — рядом в .photos-archive__controls (T181, ниже) стоит
     .photos-find__input на --fs-small, разница кегля читалась как «текст
     на разной высоте». .field__select — единственный на сайте потребитель
     этого класса (см. комментарий выше про единственный select), править
     здесь безопасно ни на одну другую страницу это не влияет. Приведено к
     --fs-small, а не наоборот: строка стоит на бумажном листе форзаца, где
     уже прижился мелкий служебный кегль поиска (T176), плюс --fs-small —
     общий кегль подписи `.field__label` рядом, так пара «подпись + поле»
     держит одну ступень по всей строке. */
  font-size: var(--fs-small);
  background-color: var(--c-bg);
  border: 1px solid color-mix(in srgb, var(--c-line) 40%, var(--c-ink));
  border-radius: 1.1em;
  transition: border-color 0.2s;
  appearance: none;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23734e12' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 0.9em center;
  background-size: 0.65em;
}

.field__select:focus {
  border-color: var(--c-accent);
}

/* T110: поле переписки-зеркала после первой отправки — read-only (не
   `disabled`), чтобы гость всё ещё мог дойти до него табом и скринридер
   зачитал и само поле, и explaining aria-label в mirror-reply.js: молчаливый
   серый инпут ничего не объясняет, а `disabled` вдобавок выкидывает элемент
   из таб-порядка. Курсор и приглушённый фон — тот же сигнал «не трогай»,
   что и на выключенной кнопке .chat__composer-send--icon:disabled ниже. */
.chat__control--locked {
  cursor: not-allowed;
  background-color: color-mix(in srgb, var(--c-bg) 85%, var(--c-line));
  color: var(--c-muted);
}

/* Обёртка вокруг поля с maxlength (index.html) — несёт класс .chat__control
   вместо самого <input>/<textarea>: chat-form.js переносит между репликой и
   строкой ввода именно .chat__control целиком (appendChild), и если счётчик
   символов (char-counter.js) сидит внутри неё соседом поля, он переезжает
   вместе с полем, а не остаётся сиротой в опустевшей реплике. position:
   relative — точка отсчёта для .field__counter ниже. */
.field__wrap {
  position: relative;
}

/* Тот же приём для узкой строки «Написать нам» под зеркалами
   (mirror-reply.js) — там своей обёртки нет, поле лежит прямо в
   .chat__composer-control, и эта же строка ввода никогда не переезжает
   (в отличие от анкеты, у зеркал одно-единственное статичное поле), так что
   отдельная обёртка не нужна — сам слот и служит точкой отсчёта. */
.chat__composer-control {
  position: relative;
}

/* Счётчик «10/100» — декоративная подсказка (aria-hidden в char-counter.js:
   реальный лимит уже несёт maxlength, зачитывать число на каждый символ
   скринридеру ни к чему). pointer-events: none — не должен подставляться
   под клик по полю или мешать выделению текста. */
.field__counter {
  position: absolute;
  top: 0.5em;
  right: 0.8em;
  font-family: var(--font-ui);
  font-size: var(--fs-small);
  color: color-mix(in srgb, var(--c-muted) 70%, var(--c-ink));
  pointer-events: none;
  user-select: none;
}

/* Запас справа под сам счётчик — иначе текст в поле при заполнении верхней
   строки залезал бы под цифры. Выбирается по
   соседству со счётчиком (:has), а не заведён на все .field__input подряд —
   у полей без maxlength (например, ещё не встречавшихся в разметке) запаса
   не будет, потому что и цифр там не появится. */
.field__input:has(+ .field__counter) {
  padding-right: 3.6em;
}

/* Счётчики «сколько вас будет»: две узкие колонки, а не поля во всю ширину —
   двузначное число в поле на 32rem читается как ошибка вёрстки. */
.counters {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
  gap: var(--space-sm);
}

.counter__label {
  display: block;
  margin-bottom: var(--space-xs);
  /* Подпись поля анкеты — чёткость важнее эстетики (см. --font-ui). */
  font-family: var(--font-ui);
  font-size: var(--fs-small);
  /* Внутри .mirror__glass — тот же затемнённый muted, что и у .avatar--guest
     (см. .mirror__glass .muted): подпись мелкая, ей нужен запас контраста. */
  color: color-mix(in srgb, var(--c-muted) 70%, var(--c-ink));
}

.counter__input {
  /* Стрелки спиннера в Firefox сжимают поле и по-разному выглядят в браузерах;
     ввод с клавиатуры и inputmode="numeric" на телефоне работают без них. */
  appearance: textfield;
}

.counter__input::-webkit-outer-spin-button,
.counter__input::-webkit-inner-spin-button {
  appearance: none;
  margin: 0;
}

.choices {
  display: grid;
  gap: var(--space-xs);
}

/* Реплика «имена спутников» (T096) — до девяти полей «Гость N» подряд,
   не сеткой в несколько колонок как .choices--grid: узкий экран (320 px)
   не даёт двум полям с подписью встать рядом без переноса. */
.chat__control--stack {
  display: grid;
  gap: var(--space-sm);
}

.choices--grid {
  grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
}

/* T121: поле «какой напиток» лежит в том же fieldset, что и .choices--grid
   выше, но fieldset — обычный block-элемент без gap (в отличие от
   .chat__turn/.choices), поэтому отступ сверху задаём явно. */
[data-drink-other-field] {
  margin-top: var(--space-sm);
}

.choice {
  display: flex;
  align-items: center;
  gap: 0.6em;
  padding: 0.65em 0.9em;
  background-color: var(--c-bg);
  border: 1px solid var(--c-line);
  /* Зеркало хвостика .chat__ask — вариант ответа читается как исходящий пузырь. */
  border-radius: 1.1em 1.1em 0.2em 1.1em;
  cursor: pointer;
  /* У flex-элемента min-width по умолчанию — auto, то есть «не уже своего
     содержимого» — грид-ячейка (auto-fit, minmax(9rem, 1fr)) может выделить
     чипу меньше места, чем нужно тексту, и «Шампанское» вылезало за рамку
     стекла, а не переносилось. min-width: 0 разрешает чипу сжаться до ширины
     ячейки — дальше перенос строки у текста берёт на себя обычный word-wrap. */
  min-width: 0;
}

.choice span {
  /* Тот же приём, что и у .chat__ask (см. её overflow-wrap: anywhere выше) —
     «Шампанское» само по себе одно слово без пробелов, обычный перенос по
     границе слова его не спасает: без anywhere слово вылезало бы из чипа,
     даже когда сама чипа уже готова сжаться (min-width: 0 выше). */
  overflow-wrap: anywhere;
  /* T097: та же пара правил, что у .chat__ask/.chat__echo — chip тоже узкая
     колонка, и обычные многосложные названия («Безалкогольные») выигрывают
     от переноса раньше, чем сработает anywhere. */
  hyphens: auto;
}

.choice:has(:checked) {
  border-color: var(--c-accent);
  background-color: color-mix(in srgb, var(--c-accent) 12%, var(--c-bg));
}

.choice input {
  accent-color: var(--c-accent-deep);
  /* Цель нажатия не меньше 20 px — на телефоне в чекбокс надо попадать */
  width: 1.25em;
  height: 1.25em;
  flex-shrink: 0;
}

/* Лента переписки — вопросы пары и эхо-пузыри гостя друг под другом. Своя
   величина отступа чуть плотнее, чем gap самой .chat (var(--space-md)) —
   реплики одного разговора должны читаться теснее, чем домики-заглушки
   вокруг них. */
.chat__log {
  display: grid;
  gap: var(--space-sm);
}

/* Строка ввода: сюда chat-form.js переносит .chat__control текущего вопроса.
   Верхняя черта отделяет её от ленты — читается как граница поля ввода
   мессенджера, а не просто «ещё одна реплика». */
.chat__composer {
  display: flex;
  align-items: flex-end;
  gap: var(--space-xs);
  padding-top: var(--space-xs);
  border-top: 1px solid var(--c-line);
}

.chat__composer-control {
  flex: 1;
  min-width: 0;
}

.chat__composer-send {
  flex-shrink: 0;
}

/* T066: поле ответа под зеркалами без анкеты (#hello/#date/#schedule,
   mirror-reply.js) — тот же .chat__composer, что и в анкете, но кнопка
   отправки здесь значок, а не текст «Ответить» (правка заказчиков
   2026-08-07: «нынешняя кнопка велика»). Круглая кнопка с иконкой
   currentColor — тот же приём, что у .icon-heart в calendar.js (D12). Тот
   же класс переиспользуют кнопка «Ответить» composer'а анкеты
   (chat-form.js) и настоящая кнопка отправки анкеты (.form__submit,
   index.html) — правка заказчиков 2026-08-07 распространилась на все три
   кнопки-«самолётика» на странице. */
.chat__composer-send--icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.75rem;
  height: 2.75rem;
  padding: 0;
  border: 1px solid transparent;
  border-radius: 50%;
  background-color: var(--c-accent-deep);
  color: var(--c-bg);
  cursor: pointer;
  transition: background-color 0.2s;
}

.chat__composer-send--icon:hover {
  background-color: var(--c-ink);
}

.chat__composer-send--icon:disabled {
  opacity: 0.5;
  cursor: default;
}

.icon-send {
  width: 1.1rem;
  height: 1.1rem;
  fill: currentColor;
}

/* Обёртка ленты ответа + composer под зеркалом (T066, mirror-reply.js):
   .mirror__glass сам не grid и не даёт gap, поэтому лента и строка ввода
   едут в собственном grid-контейнере — та же величина отступа, что и между
   репликами в .chat анкеты, плюс отступ сверху от уже готовых реплик из
   разметки. */
.chat__reply-wrap {
  display: grid;
  gap: var(--space-md);
  margin-top: var(--space-md);
}

/* Точки «печатает» между ответом и следующим вопросом — тот же .chat__ask
   пузырь пары, что и у обычного вопроса (тот же хвостик и аватарка), внутри
   которого мигают точки. Сами точки — .mirror__typing-dot, тот же класс
   и анимация, что у зеркал (mirror.js): не плодим второй вариант. */
.chat__typing {
  display: inline-flex;
  align-items: center;
  gap: 0.5em;
  min-height: 0.5em;
}

/* T116: список гостей внутри одного эхо-пузыря реплики "guests" — строка на
   спутника, крестик убирает конкретного без режима редактирования и без
   возврата к «Изменить анкету». Список — block-элемент внутри .chat__echo
   (та же заливка/хвостик, что у обычного эхо-текста, чтобы не заводить
   второй вид пузыря), .chat__echo--list снимает паддинг у самого пузыря —
   отступы переезжают на строки списка, иначе крестик оказался бы слишком
   близко к правому краю пузыря для 44px тач-цели. */
.chat__echo--list {
  padding: 0.4em 0;
}

.chat__guest-list {
  display: grid;
  gap: 1px;
  /* Список — не нумерованный, роль каждой строки очевидна из текста, а
     маркер только сузил бы и без того тесную строку на 320px. */
  list-style: none;
}

.chat__guest-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-xs);
  /* min-height, не просто padding: крестик несёт невидимую тач-цель 44px
     (::before ниже) — если строка ниже 44px, зоны нажатия соседних строк
     на 320px перекрываются и палец промахивается мимо нужного гостя. */
  min-height: 44px;
  padding: 0.2em 0.9em;
  overflow-wrap: anywhere;
  hyphens: auto;
}

.chat__guest-row--empty {
  color: var(--c-muted);
}

/* Крестик «убрать гостя» — видимый кружок меньше тач-цели (тот же приём,
   что у .facts__map-btn::before: 44px добирается невидимым hit-area, а не
   раздутым видимым значком, который на плотном списке строк наезжал бы на
   соседние). Соседние строки на 320px стоят вплотную (T116, критично) —
   без ::before крестики соседних строк перекрывали бы зону нажатия друг
   друга. */
.chat__guest-remove {
  position: relative;
  flex-shrink: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.5rem;
  height: 1.5rem;
  padding: 0;
  border: 1px solid var(--c-line);
  border-radius: 50%;
  background: none;
  color: var(--c-accent-deep);
  /* Глиф «×» — интерфейсный символ, та же роль, что у .calendar__day
     (числа) — --fs-ui, не фиксированный rem. */
  font-size: var(--fs-ui);
  line-height: 1;
  cursor: pointer;
  transition: background-color 0.2s, color 0.2s;
}

.chat__guest-remove::before {
  content: '';
  position: absolute;
  top: 50%;
  left: 50%;
  width: 44px;
  height: 44px;
  transform: translate(-50%, -50%);
}

.chat__guest-remove:hover {
  background-color: var(--c-accent-deep);
  color: var(--c-bg);
}

.form__consent {
  /* Раньше строка стояла НАД кнопкой отправки и отрицательный отступ
     подтягивал её ближе к кнопке. Правка «строка согласия перекрывает
     кнопку» (2026-08-16) переставила согласие ПОД кнопку — здесь нужен
     обычный положительный отступ, а не сокращающий расстояние. */
  margin-top: var(--space-sm);
}

.form__submit {
  /* Кнопка — как исходящее сообщение гостя, поэтому у правого края. Размер
     и форма — значок-самолётик (.chat__composer-send--icon, правка
     заказчиков 2026-08-07: «нынешняя кнопка велика»), тут только позиция. */
  justify-self: end;
}

/* Реплика только что раскрылась — короткий сдвиг снизу и проявление.
   Общее правило @media (prefers-reduced-motion: reduce) выше по файлу уже
   глушит все animation-duration через `*` — здесь не нужно дублировать. */
.chat__turn--fresh {
  animation: chat-turn-in 0.35s ease-out;
}

@keyframes chat-turn-in {
  from {
    opacity: 0;
    transform: translateY(0.6em);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

@media (max-width: 30em) {
  /* max-width теперь 92% на любой ширине (см. базовое правило .chat__ask
     выше) — здесь остаётся только своя аватарка, под которую пересчитывается
     тот же calc(). */
  .chat__ask {
    --avatar-size: 2.2rem; /* иначе поле ввода уже кнопок под ним */
  }

  .chat__reply {
    max-width: 92%;
  }

  .avatar {
    --avatar-size: 2.2rem;
  }
}

/* Всплывающее окно благодарности после отправки анкеты. Открывает серверная
   часть вызовом showModal() (см. комментарий в HTML). */
.rsvp-success {
  max-width: 30rem;
  width: min(30rem, 90vw);
  margin: auto;
  padding: var(--space-lg) var(--space-md);
  border: none;
  border-radius: var(--radius);
  background-color: var(--c-bg);
  text-align: center;
}

.rsvp-success::backdrop {
  /* Через opacity, а не color-mix: тот моложе и на старом браузере молча даёт
     прозрачную подложку — окно повисает над страницей без затемнения. Здесь же
     цвет остаётся ролью палитры, без литерала. */
  background-color: var(--c-ink);
  opacity: 0.55;
}

.rsvp-success__sticker {
  /* Исходник 512x512 — растягивать выше нельзя, размоется. */
  width: min(512px, 100%);
  margin-inline: auto;
}

/* Окно подтверждения перед отправкой анкеты (T087, переверстано T082). Та же
   ширина, что у .rsvp-success — по смыслу это соседнее окно той же переписки.
   display: flex + column: заголовок/согласие/кнопки — фиксированная «рама»,
   а между ними растягивается только список сводки (flex ниже, у него своя
   прокрутка) — так у самого длинного отклика (несколько спутников, дети, все
   напитки) едет не всё окно целиком, а только список, и кнопки с согласием
   остаются на виду всегда (жалоба заказчика T082: окно не помещалось на
   экран телефона). max-height — только от vh (см. её же комментарий у
   .privacy-dialog про двойное ограничение): без фото окно обычно ниже
   доступной высоты и так, отдельный rem-потолок только заставлял бы список
   скроллиться на экранах, где место ещё есть. overflow-y на самом окне не
   нужен — прокрутка ушла в .rsvp-confirm__summary. */
/* ВНИМАНИЕ (найдено пользователем 2026-08-15 при осмотре живой страницы):
   display задаётся ТОЛЬКО открытому диалогу — `.rsvp-confirm[open]`, а не
   `.rsvp-confirm`. Закрытый <dialog> прячет правило браузера
   `dialog:not([open]) { display: none }`, но авторский стиль сильнее
   браузерного: безусловный `display: flex` перебивает его, и содержимое окна
   (сводка ответов, согласие, кнопки «Изменить»/«Отправить») вываливается
   прямо в тело главной страницы. Ошибка тем коварнее, что сам диалог при
   этом продолжает открываться и работать правильно. */
.rsvp-confirm[open] {
  display: flex;
  flex-direction: column;
}

.rsvp-confirm {
  max-width: 30rem;
  width: min(30rem, 90vw);
  max-height: 90vh;
  margin: auto;
  padding: var(--space-md);
  border: none;
  border-radius: var(--radius);
  background-color: var(--c-bg);
  text-align: center;
}

.rsvp-confirm::backdrop {
  /* Тот же приём через opacity, что у .rsvp-success::backdrop выше. */
  background-color: var(--c-ink);
  opacity: 0.55;
}

/* T082: кегль и меж­строчный интервал заголовка уменьшены (заказчик — окно
   целиком не влезало на экран телефона) — var(--fs-h3) на верхней границе
   клампа (1.4rem) и унаследованные от body 1.45 строки были рассчитаны на
   окно с фото, без него это уже избыточный воздух.
   Фиксированный 1.1rem заменён обратно на var(--fs-h3): нижняя граница
   клампа --fs-h3 на узких экранах (~13 px на 320 px) НЕ больше прежних
   фиксированных ~15.5 px — окно на телефоне не начнёт переполняться заново
   (см. .rsvp-success__title — тот же токен для заголовка соседнего диалога).
   На широких экранах текст снова растёт вместе со шкалой --fs-*. */
.rsvp-confirm__title {
  font-size: var(--fs-h3);
  line-height: 1.2;
  margin-bottom: var(--space-xs);
}

/* Заголовок несёт tabindex="-1" + autofocus (настоящий <dialog>, фокус нужен
   для озвучивания при showModal()) — рамка на нём лишняя, гость её не
   выбирал табом. */
.rsvp-confirm__title:focus {
  outline: none;
}

/* T082: список — единственный участок окна, который растёт с содержимым
   (переменная длина отклика — от «без спутников» до девяти строк), поэтому
   именно на нём flex + собственная прокрутка, а не на диалоге целиком (см.
   комментарий у .rsvp-confirm выше). min-height: 0 обязателен — без него
   flex-элемент не сжимается меньше высоты своего содержимого, и
   overflow-y: auto не срабатывает вплоть до переполнения самого диалога. */
.rsvp-confirm__summary {
  display: grid;
  gap: var(--space-xs);
  margin: 0 0 var(--space-sm);
  /* Правое поле — под полосу прокрутки, а не для воздуха: значения строк
     выключены вправо (.rsvp-confirm__item, space-between) и упирались точно
     в край списка, а плавающая полоса (overlay scrollbar на телефонах и в
     новых версиях настольных браузеров) ложится ПОВЕРХ содержимого, не
     занимая своей ширины, — и перечёркивала собой хвост телефона. Раньше
     это почти не всплывало: сводка начала прокручиваться регулярно только
     когда у спутников появились свои блоки. */
  padding: 0 var(--space-xs) 0 0;
  list-style: none;
  text-align: left;
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
}

/* Дефект: сводка выросла (до 9 строк спутников, дальше — ещё их напитки), а
   у окна не было намёка, что снизу есть ещё. Класс ставит JS (chat-form.js,
   refreshSummaryFade) только пока внизу правда есть непоказанное — mask-image
   на самом <ul> не требует лишнего узла в разметке и не завязан на цвет фона
   окна (без него понадобилась бы подложка в var(--c-bg)). */
.rsvp-confirm__summary--fade {
  mask-image: linear-gradient(to bottom, #000 calc(100% - 1.5em), transparent 100%);
}

/* T082: своя, более плотная строка для согласия внутри окна — тот же приём,
   что и у .rsvp-confirm__item ниже (унаследованные от body 1.45 строки на
   двух-трёх словах вокруг ссылки давали лишнюю высоту). flex-shrink: 0 —
   рамка окна (заголовок/согласие/кнопки) не должна сжиматься, когда список
   выше растягивается на длинном отклике; сжимается только сам список
   (min-height: 0 + overflow-y на .rsvp-confirm__summary выше).
   Правка «строка согласия перекрывает кнопку» (2026-08-16): согласие
   переставлено ПОСЛЕ .rsvp-confirm__actions в разметке (теперь последний
   flex-потомок диалога) — нужен отступ сверху от кнопок и кегль мельче,
   чтобы служебная строка не спорила с кнопками за внимание. В шкале
   --fs-* нет роли мельче --fs-small (её уже несёт класс .small на этом
   узле) — по этой же схеме (--fs-small * коэффициент), что и у
   .footer__date/.footer__names выше по файлу, берём 0.85 от неё, а не
   вводим новый токен шкалы. */
.rsvp-confirm .form__consent {
  line-height: 1.3;
  flex-shrink: 0;
  margin-top: var(--space-sm);
  font-size: calc(var(--fs-small) * 0.85);
}

/* Подзаголовок спутника («Спутник N») внутри .rsvp-confirm__summary —
   отдельный li, а не label обычной пары (.rsvp-confirm__item): группирует
   несколько строк одного спутника (имя, телефон, следующей задачей —
   напитки), поэтому НЕ наследует их flex space-between раскладку и черту
   снизу — та обозначает конец пары label/value, а не конец группы, две
   черты подряд (тут и у последней пары внутри группы) выглядели бы как
   двойной разделитель. margin-top отделяет группы друг от друга поверх
   основного .rsvp-confirm__summary gap; :first-child его гасит — до первого
   спутника список уже начинается с отступа от .rsvp-confirm__summary. */
.rsvp-confirm__group {
  margin-top: var(--space-xs);
}

.rsvp-confirm__group:first-child {
  margin-top: 0;
}

.rsvp-confirm__item {
  display: flex;
  justify-content: space-between;
  gap: var(--space-sm);
  padding-block: 0.3rem;
  border-bottom: 1px solid var(--c-line);
  /* T082: .rsvp-confirm__label уже нёс --fs-small классом .small (JS,
     buildConfirmSummary) — .rsvp-confirm__value кегля не имел вовсе и
     набирался крупным --fs-body, унаследованным от диалога. На девяти
     строках сводки (спутники + дети + напитки) это было заметно больше
     воздуха, чем нужно; кегль и плотность строки задаём на общем родителе,
     чтобы обе колонки строки были одного масштаба. */
  font-size: var(--fs-small);
  line-height: 1.25;
}

.rsvp-confirm__value {
  text-align: right;
  /* Имена спутников/номер телефона — им случается быть длиннее подписи,
     перенос строкой лучше горизонтального сжатия соседней колонки. */
  overflow-wrap: anywhere;
}

.rsvp-confirm__actions {
  /* T082: заказчик — «кнопки меньше и в один ряд, обе видны одновременно»
     (раньше .button под .button стопкой). grid на две равные колонки: первая
     кнопка разметки (Изменить) слева, вторая (Отправить) справа — порядок
     задаёт сама разметка (index.html), тут только раскладка. */
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--space-sm);
  margin-top: var(--space-sm);
  flex-shrink: 0;
}

.rsvp-confirm__actions .button {
  /* Базовые боковые поля кнопки (1.5em, см. .button) рассчитаны на кнопку на
     всю ширину окна — в два столбца тут они съедали половину доступной
     ширины впустую. Вертикальные поля (высота тач-цели) не трогаем. */
  padding-inline: 0.75em;
}

/* Фото пары во всех четырёх блоках-переписках (T058/T083/T088): знакомство,
   дата, программа дня, анкета — все используют один и тот же компонент
   .scrapbook-photo (см. ниже), сюда вынесена только общая ширина ячейки,
   отдельная от блока, пока колонки стоят одна под другой. Пятый снимок
   (couple-embrace, был в окне подтверждения анкеты, T087) убран из диалога
   заявкой заказчика T082 — второй копии этого кадра на странице больше нет. */
.hello__photo,
.date__photo,
.schedule__photo,
.rsvp__photo {
  max-width: 24rem;
  margin-inline: auto;
}

/* На двухколоночной раскладке (>=60em, см. .hello/.rsvp__body и парные
   .date__body/.schedule__body ниже) ячейка фото — уже полноценная колонка
   грида (1fr, остаток после --measure), и потолок 24rem — наследие
   одноколоночной раскладки — становится лишним ограничением: без снятия
   фото не смогло бы вырасти при расширении --container (T-задача
   «фотография крупнее», 2026-08-06). На узких экранах (одна колонка) 24rem
   остаётся — там он не даёт фото раздуться на всю ширину контейнера. */
@media (min-width: 60em) {
  .hello__photo,
  .date__photo,
  .schedule__photo,
  .rsvp__photo {
    max-width: none;
    /* Заказчики 2026-08-06: «я бы опустил фотку чуть ниже» — на широком
       экране снимок стоял вровень с верхом пузыря переписки, отступ сдвигает
       его вниз, чтобы композиция не начиналась одной линией с текстом.
       Только здесь (>=60em, там же, где фото становится полноценной
       колонкой грида): на узкой раскладке снимок и так стоит НАД перепиской
       (одна колонка), сдвигать там нечего. */
    margin-top: var(--space-md);
  }
}

/* --------------------------------------------------------------------------
   Скрапбук-фото (T078) — переиспользуемый компонент снимка с рваным бумажным
   полем под ним, булавкой у верхнего края (T080, изначально была лента — см.
   ниже) и статичным наклоном. Заказчики отказались от книжного разворота
   (T076) и от скруглений (T048) в пользу «фото приклеено в альбом». Сейчас
   компонент подключён во всех четырёх оставшихся блоках-переписках —
   знакомство (T058), дата (T088), программа дня (T088) и анкета (T083).
   Пятый снимок (couple-embrace) стоял в окне подтверждения анкеты (T087),
   без .scrapbook-photo (см. T114 ниже) — тот снимок убран из диалога
   заявкой заказчика T082, второй копии кадра на странице больше нет.

   Правка по ходу задачи (заказчики, тот же день): изначально маска клипала
   сам снимок «встык» — без поля вокруг. Заказчики попросили другое: рваный
   лист бумаги ВИДЕН вокруг снимка, а снимок внутри него — обычный
   прямоугольник с отступом от края листа. Отсюда разделение слоёв:

   - `.scrapbook-photo` — сам не маскируется (если бы маска висела на нём,
     она клипировала бы и снимок-потомка — `mask` группирует элемент со всеми
     потомками в один слой, как `opacity`/`filter`). Он только держит
     пропорцию МАСКИ (=пропорция листа) через `aspect-ratio`, поворачивает
     композицию на `--scrapbook-tilt` и даёт `padding`, отступающий снимок от
     края листа. `width: calc(100% - 2 * var(--scrapbook-margin))` — запас под
     поворот и высунутую над снимком булавку: без него боундинг-бокс
     повёрнутого угла и головка булавки подпирали бы край контейнера, и на
     переполненной сетке
     (`.hello`/`.schedule__body` на узких экранах = вся ширина `.container`)
     страница поехала бы вбок (FR-008, `body{overflow-x:hidden}` маскирует
     именно такую ошибку, а не чинит).
   - `.scrapbook-photo::before` — лист бумаги: отдельный слой на всю
     `inset: 0` площадь родителя (то есть на всю пропорцию маски, включая
     поле под будущим снимком), именно на нём висит `mask-image` (силуэт
     рваного края), заливка `--m-paper` и зерно `paper-grain.webp` поверх.
     `filter: drop-shadow(...)` — тоже здесь: drop-shadow считается по альфе
     уже замаскированной фигуры, поэтому тень повторяет рваный контур листа,
     а не его исходный прямоугольник. `z-index: -1` внутри стека, который
     создаёт `transform` на `.scrapbook-photo`, — лист гарантированно уходит
     под снимок, а не поверх (без этого позиционированный слой красился бы
     после обычного потока и лёг бы поверх фото).
   - `.scrapbook-photo__frame img` — сам снимок: обычный прямоугольник,
     `object-fit: cover`, `border-radius: 0` (скруглений нет по прямому
     запросу). Никакой маски на нём больше нет.
   - Набор `--scrapbook-*` переменных — единственное, что меняется между
     блоками. Все они собраны в модификаторах `--variant-N`: подключить
     фото в новом блоке — обернуть `<picture>` в
     `<div class="scrapbook-photo scrapbook-photo--variant-N">`. Порядок
     блоков сверху вниз — знакомство (variant-1), дата (variant-4), программа
     дня (variant-3), анкета (variant-2); знак наклона и цвет булавки
     чередуются по позиции в колонке (-,+,-,+ / слейт,графит,слейт,графит),
     а не по номеру варианта — номер только указывает на пару маска+булавка.
     variant-4 — не пятый самостоятельный набор, а альбомная ротация
     variant-2 (photo-mask-5 — та же маска photo-mask-2, повёрнутая на 90°,
     T085): единственный горизонтальный кадр (couple-floor) обязан идти
     только в горизонтальную маску, растягивать портретную под альбом
     запрещено — рваный край смазывается. */
.scrapbook-photo {
  --scrapbook-margin: 1rem; /* запас под поворот + высунутую над снимком булавку */
  --scrapbook-inset: 10%; /* поле листа вокруг снимка */
  position: relative;
  width: calc(100% - 2 * var(--scrapbook-margin));
  margin-inline: var(--scrapbook-margin);
  aspect-ratio: var(--scrapbook-ratio);
  padding: var(--scrapbook-inset);
  transform: rotate(var(--scrapbook-tilt));
}

/* Поле листа на широком экране (T-задача «фотография крупнее», 2026-08-06):
   на одной колонке (узкие экраны) 10% остаётся — там снимок и так некрупный,
   и рваный край листа должен читаться полем в полную силу. На двухколоночной
   раскладке (>=60em, тот же порог, что у .hello/.rsvp__body) поле сужено до
   7% — рваный край всё ещё виден, но не съедает прирост от расширенного
   --container. Убирать поле совсем нельзя: оно и есть смысл компонента
   «бумага под снимком». */
@media (min-width: 60em) {
  .scrapbook-photo {
    --scrapbook-inset: 7%;
  }
}

/* Бумажный лист — фон, зерно и (по умолчанию, без поддержки masking) просто
   прямоугольное поле того же цвета и с той же тенью, что и в маскированном
   виде: запасной вид не «исчезает», а остаётся читаемым полем вокруг фото. */
.scrapbook-photo::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  background-color: var(--m-paper);
  background-image: url('../img/decor/paper-grain.webp');
  background-repeat: repeat;
  background-size: 14rem auto;
  filter: drop-shadow(0 0.6rem 0.9rem color-mix(in srgb, var(--c-ink) 32%, transparent));
}

/* Маска — только там, где браузер её реально поддержит (Baseline широкий,
   но запасной вид обязателен по заданию): без @supports старый браузер
   молча игнорирует mask-image, и лист остаётся прямоугольным полем — это и
   есть нужный запасной вид, тут страховка не по отрисовке, а по
   документации намерения. */
@supports (mask-image: none) or (-webkit-mask-image: none) {
  .scrapbook-photo::before {
    mask-image: var(--scrapbook-mask);
    -webkit-mask-image: var(--scrapbook-mask);
    mask-mode: alpha;
    mask-repeat: no-repeat;
    -webkit-mask-repeat: no-repeat;
    mask-size: 100% 100%;
    -webkit-mask-size: 100% 100%;
    mask-position: center;
    -webkit-mask-position: center;
  }
}

.scrapbook-photo__frame {
  position: relative;
  display: block;
  width: 100%;
  height: 100%;
}

.scrapbook-photo__frame img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center top;
  border-radius: 0;
}

/* Булавка (T080) — заменила ленту по замечанию заказчиков: «я заметил, что
   ты масштабируешь ленточки, давай сделаем булавки» — лента растягивалась
   под размер снимка, а это предмет постоянного размера. Один псевдоэлемент
   у верхнего края снимка, воткнутый в лист (не два, как было у ленты —
   булавка одна). Размер — `height` в `rem` (не в `%` и не привязан ни к
   какой величине снимка), ширина считается из него через `aspect-ratio` в
   натуральной пропорции файла: на любой ширине экрана булавка остаётся
   одного и того же размера относительно страницы, а не относительно фото
   (проверка — playwright, отчёт задачи). Цвет — спокойный (белый/графит),
   яркая точка на чёрно-белом снимке била бы в глаза сильнее, чем на цветном
   (решение заказчиков). Чередуется вместе с маской и знаком наклона через
   тот же `--variant-N`. */
.scrapbook-photo__frame::before {
  content: "";
  position: absolute;
  top: var(--scrapbook-pin-top);
  left: 50%;
  height: var(--scrapbook-pin-size, 3.5rem);
  width: auto;
  aspect-ratio: var(--scrapbook-pin-ratio);
  background-image: var(--scrapbook-pin);
  background-repeat: no-repeat;
  background-size: contain;
  pointer-events: none;
  z-index: 2;
  transform: translateX(-50%) rotate(var(--scrapbook-pin-rotate));
}

/* Варианты чередования. Маска/лента/знак наклона меняются вместе одной
   заменой класса — следующий блок просто берёт другой --variant-N. */
.scrapbook-photo--variant-1 {
  --scrapbook-mask: url('../img/decor/photo-mask-1.webp');
  --scrapbook-ratio: 652 / 747;
  --scrapbook-tilt: -3deg;
  /* pin-white.webp снят с сайта (T080a): игла срезана нижней границей кадра
     (брак нарезки, замечен по остающемуся внизу широкому обрубку силуэта
     вместо острия). pin-slate.webp — резкая, игла целая, грифельно-синяя,
     годится под спокойную ЧБ-гамму. */
  --scrapbook-pin: url('../img/decor/pin-slate.webp');
  --scrapbook-pin-ratio: 211 / 383;
  /* Булавка крепится к бумаге НАД снимком, а не поверх его края (T080a):
     остриё (нижний край графики — обе `pin-*` обрезаны по силуэту вплотную,
     bbox проверен PIL) должно лечь чуть выше верхней границы `__frame`,
     а не внутрь фото. top = -(высота + напуск 0.3rem), напуск — чтобы
     остриё казалось воткнутым в лист, а не висело над ним. */
  --scrapbook-pin-top: -3.8rem;
  --scrapbook-pin-rotate: -8deg;
}

.scrapbook-photo--variant-2 {
  --scrapbook-mask: url('../img/decor/photo-mask-2.webp');
  --scrapbook-ratio: 631 / 722;
  --scrapbook-tilt: 3deg;
  --scrapbook-pin: url('../img/decor/pin-graphite.webp');
  --scrapbook-pin-ratio: 220 / 395;
  --scrapbook-pin-top: -3.8rem;
  --scrapbook-pin-rotate: 6deg;
}

/* T088: третий силуэт (photo-mask-3, вырезан вместе с mask-4/mask-5 из
   6354246.psd, T085) — под couple-dress.webp в #schedule. Третья позиция
   колонки повторяет знак наклона и цвет булавки variant-1 (слейт, -3deg) —
   чередование по позиции в колонке, а не по номеру варианта. */
.scrapbook-photo--variant-3 {
  --scrapbook-mask: url('../img/decor/photo-mask-3.webp');
  --scrapbook-ratio: 693 / 794;
  --scrapbook-tilt: -3deg;
  --scrapbook-pin: url('../img/decor/pin-slate.webp');
  --scrapbook-pin-ratio: 211 / 383;
  --scrapbook-pin-top: -3.8rem;
  --scrapbook-pin-rotate: -8deg;
}

/* T088: альбомная ротация variant-2 (photo-mask-5 = photo-mask-2, повёрнута
   на 90°, T085) — единственный горизонтальный кадр couple-floor.webp в
   #date, растягивать портретную маску под него запрещено. Тилт и булавка —
   те же, что у variant-2 (та же пара маска+акцент, только другая
   ориентация холста). */
.scrapbook-photo--variant-4 {
  --scrapbook-mask: url('../img/decor/photo-mask-5.webp');
  --scrapbook-ratio: 722 / 631;
  --scrapbook-tilt: 3deg;
  --scrapbook-pin: url('../img/decor/pin-graphite.webp');
  --scrapbook-pin-ratio: 220 / 395;
  --scrapbook-pin-top: -3.8rem;
  --scrapbook-pin-rotate: 6deg;
}

/* T076: откат книжного разворота — .hello снова просто грид из двух блоков
   (фото и зеркало-переписка), без бумажной обёртки, сгиба и рамок поверх
   .mirror__glass. Раскладка ширин (T071) уцелела: на широком экране
   переписке достаётся её полный var(--measure) = 34rem (а не доля вроде
   6fr, как было в регрессии T058), фото — остаток соседней колонки. */
.hello {
  display: grid;
  gap: var(--space-lg);
  align-items: start;
}

/* Точка разреза подобрана замером (playwright, ширины 320…1600, отчёт
   T067/T071): при 60em (960px) контент .container достигает своего потолка
   904px (60rem минус паддинги), и на нём после вычета 34rem зеркала и
   зазора var(--space-lg) фото ещё получает разумный остаток — не теснее
   одной трети экрана. Ниже 60em то же вычитание уводит фото в неприлично
   узкую полосу — там блоки остаются один под другим. */
@media (min-width: 60em) {
  .hello {
    grid-template-columns: minmax(0, 1fr) minmax(0, var(--measure));
    gap: var(--space-xl);
    /* Правка пользователя 2026-08-08: снимок сидел заметно выше центра
       блока (замер playwright — центр фото на ~216px выше центра секции
       при 1280px). align-items: start прижимал фото к верху ряда, а
       переписка выше и заметно длиннее снимка — на всю высоту строки
       фото просто повисало сверху. center ставит фото по центру строки
       грида (=по высоте зеркала, самого высокого элемента строки), не
       трогая ни зеркало (оно и задаёт высоту строки, центрирование на
       нём незаметно), ни узкую раскладку (там элементы уже в разных
       строках, align-items там ни на что не влияет). */
    align-items: center;
  }
}

/* Анкета (T083) заводит второе место под скрапбук-фото — та же раскладка
   ширин, что у .hello, но колонки в обратном порядке: там фото слева,
   переписка справа, здесь наоборот (заказчики: снимки по замыслу
   чередуются сторонами между блоками). Разметка идёт в естественном
   порядке (сначала .mirror, потом .rsvp__photo), поэтому меняются местами
   именно доли колонок, а не порядок элементов через `order`. */
.rsvp__body {
  display: grid;
  gap: var(--space-lg);
  align-items: start;
}

@media (min-width: 60em) {
  .rsvp__body {
    grid-template-columns: minmax(0, var(--measure)) minmax(0, 1fr);
    gap: var(--space-xl);
  }
}

/* T088 (шашка, решение пользователя 2026-08-07): #date продолжает
   чередование сторон — третья позиция в колонке, фото справа, как у
   .rsvp__body (разметка «сначала .mirror, потом .date__photo», доли
   колонок как у .rsvp__body). */
.date__body {
  display: grid;
  gap: var(--space-lg);
  align-items: start;
}

@media (min-width: 60em) {
  .date__body {
    grid-template-columns: minmax(0, var(--measure)) minmax(0, 1fr);
    gap: var(--space-xl);
    /* Правка пользователя 2026-08-08 — то же центрирование, что у .hello
       выше (couple-floor тоже сидел заметно выше центра блока, замер —
       ~195px). .schedule__body/.rsvp__body не трогаем: там снимок и так
       близок к центру (замер — 11-13px), новых искажений вносить незачем. */
    align-items: center;
  }
}

/* T088: #schedule — четвёртая позиция в колонке, фото слева, как у .hello
   (разметка «сначала .schedule__photo, потом .mirror», доли колонок как
   у .hello). */
.schedule__body {
  display: grid;
  gap: var(--space-lg);
  align-items: start;
}

@media (min-width: 60em) {
  .schedule__body {
    grid-template-columns: minmax(0, 1fr) minmax(0, var(--measure));
    gap: var(--space-xl);
  }
}

.rsvp-success__title {
  margin-top: var(--space-md);
  font-size: var(--fs-h3);
}

.rsvp-success__text {
  margin-top: var(--space-xs);
}

.rsvp-success__close {
  margin-top: var(--space-md);
}

/* Постоянная плавающая кнопка «Заполнить анкету» (T109, index.html —
   заменяет T099/T100/T104: подсказку-карточку и exit-intent; текст и цвет
   правились в T118 — см. отчёт задачи). position: fixed в правом нижнем
   углу, видна с загрузки страницы — на 320px это то же место, где стоит
   парадный первый экран с венком, поэтому кнопка нарочно компактная (не
   полноразмерная карточка T104), чтобы не спорить с композицией обложки.
   rsvp-float.js прячет/показывает через `hidden` (не opacity — иначе кнопка
   осталась бы кликабельной/в таб-порядке поверх анкеты). z-index ниже, чем
   был у T104 (20): нативные <dialog> карты и успеха всегда в top layer
   браузера и выше любого z-index на странице — конфликтов с ними нет в
   любом случае.

   Фон — --c-cta-gold (T118, замечание пользователя 2026-08-12): прежний
   --c-accent-deep читался тёмным и сливался с кремовой страницей, не звал
   на анкету. --c-cta-gold — узкая роль именно под эту кнопку (обоснование
   и расчёт контраста — комментарий у объявления токена, PALETTE выше). */
/* Два плавающих чипа теперь позиционируются каждый сам по себе (T031q,
   отменяет общий .float-stack из T031d) — левый нижний угол у альбома,
   правый нижний у анкеты (см. комментарии в разметке, index.html). Общего
   родителя больше нет, поэтому position/z-index у каждого свои — те же
   значения, что были у снятого .float-stack. */
.rsvp-float {
  position: fixed;
  right: var(--space-sm);
  bottom: var(--space-sm);
  z-index: 15;
  display: flex;
  align-items: center;
  gap: var(--space-xs);
  padding: 0.25rem 0.9rem 0.25rem 0.25rem;
  border: none;
  /* 999px, не var(--radius) (2px, общий язык скруглений страницы) — компактный
     плавающий чип читается ближе к .button (border-radius: 1.1em), пилюля
     здесь выразительнее прямых углов на мелком элементе. */
  border-radius: 999px;
  background-color: var(--c-cta-gold);
  color: var(--c-bg);
  box-shadow: 0 0.4rem 1rem rgb(0 0 0 / 0.25);
  cursor: pointer;
  font-family: var(--font-ui);
  font-size: var(--fs-small);
  line-height: 1;
  transition: background-color 0.2s, transform 0.15s;
}

.rsvp-float:hover {
  background-color: var(--c-ink);
}

.rsvp-float[hidden] {
  display: none;
}

/* Обёртка фото+бейджа — квадрат ровно по размеру круглого кадра, точка
   отсчёта для абсолютно спозиционированного бейджа-значка (ниже). */
/* Размер кадра: 4.4rem, а не 2.4rem. Замечание пользователя 2026-08-13 —
   при 2.4rem кадр рендерился 35x35 px, на нём лиц молодожёнов не разглядеть,
   а значок-бейдж выходил 17x17 и читался пятном. Приоритет здесь смещён
   намеренно: кнопка должна ЗВАТЬ, компактность вторична. */
.rsvp-float__avatar {
  position: relative;
  flex-shrink: 0;
  width: 4.4rem;
  height: 4.4rem;
}

.rsvp-float__photo {
  width: 100%;
  height: 100%;
  border: 2px solid var(--c-bg);
  border-radius: 50%;
  object-fit: cover;
  object-position: top center;
}

/* Бейдж «лист с галочкой» (T118) — силуэт из assets/extracted/dfd8b779-…
   (tools/rsvp_icon_extract.py), залит --c-accent. НЕ на золотом фоне
   кнопки напрямую (тот же контраст-конфликт, что решался для текста, —
   см. --c-cta-gold выше), а на собственной кремовой монете внахлёст на
   фото, тем же языком, что и кремовая обводка фото рядом. */
.rsvp-float__badge {
  position: absolute;
  right: -0.25rem;
  bottom: -0.25rem;
  /* 1.95rem вместо 1.15rem — соразмерно увеличенному кадру (см. выше):
     при прежнем размере значок рендерился 17x17 px и не читался. */
  width: 1.95rem;
  height: 1.95rem;
  padding: 0.3rem;
  border-radius: 50%;
  background-color: var(--c-bg);
  box-shadow: 0 0 0 2px var(--c-bg);
  object-fit: contain;
}

.rsvp-float__label {
  white-space: nowrap;
}

/* ПОСТОЯННОЕ покачивание, без пауз — прямое решение пользователя 2026-08-13:
   «сделай пульсацию постоянной, пока открыта страница».
   ВНИМАНИЕ, это осознанная отмена прежнего правила фичи. Дважды (T094, T101)
   здесь уходили от непрерывного движения именно потому, что оно читается как
   «индикатор загрузки»; вариант с паузой в 88% цикла пользователь посмотрел
   живьём и отверг — движение показалось незаметным. Не возвращать паузу без
   его нового слова.
   Амплитуда оставлена малой (0.35rem): при непрерывном цикле она и решает,
   получится мягкое дыхание или назойливое дёрганье. */
.rsvp-float {
  animation: rsvp-float-breathe 2.8s ease-in-out infinite;
}

@keyframes rsvp-float-breathe {
  0%,
  100% {
    transform: translateY(0);
  }
  50% {
    transform: translateY(-0.35rem);
  }
}

/* Однократный импульс внимания (T118) — НЕ постоянная пульсация: на этой
   фиче дважды уходили от эффекта «индикатор загрузки» (T094, T101).
   Класс добавляет rsvp-float.js ровно один раз за сеанс — когда гость
   впервые проскроллил мимо анкеты, не заполнив её (см. модуль). Конечное
   состояние анимации (100%) равно исходному — после отыгрыша кнопка
   визуально не отличается от состояния до импульса.
   Обе анимации правят transform, поэтому перечислены вместе и складываются
   через animation-composition: add. Где свойство не поддержано, импульс на
   0.7с просто перекрывает покачивание — деградация безобидная. */
.rsvp-float--pulse {
  animation:
    rsvp-float-breathe 2.8s ease-in-out infinite,
    rsvp-float-pulse 0.7s ease-in-out 2;
  animation-composition: add;
}

@keyframes rsvp-float-pulse {
  0%,
  100% {
    transform: scale(1);
  }
  50% {
    transform: scale(1.08);
  }
}

/* Явный animation: none (не расчёт на общее сжатие animation-duration) — на
   prefers-reduced-motion в этой фиче горели дважды, см. комментарий у
   .icon-globe. rsvp-float.js эту ветку не проверяет отдельно: медиа-запрос
   гасит анимацию сам, класс можно добавлять безусловно. */
@media (prefers-reduced-motion: reduce) {
  .rsvp-float,
  .rsvp-float--pulse {
    animation: none;
  }
}

/* Чип «Фотоальбом» (site/js/photos-float.js) — T031q, правка пользователя
   2026-09-01 отменяет T031d: «сделай кнопки одинаковыми по стилю, можно
   разнести по разным углам». Раньше чип нарочно был контурным и тише
   .rsvp-float («альбом — вторичный переход»); теперь та же заливка, та же
   пилюля, тот же кегль и тот же силуэт «круглый медальон + подпись» — те
   же значения свойств, что у .rsvp-float выше (правила продублированы,
   а не объединены общим селектором: два независимых чипа в разных углах
   экрана — не один компонент с вариантами, а совпадение стиля разумно
   держать явным и читаемым по каждому классу отдельно, без грепа по
   правилам .rsvp-float в поисках общего). Левый нижний угол — .rsvp-float
   остался в правом (см. комментарий в разметке). */
.photos-float {
  position: fixed;
  left: var(--space-sm);
  bottom: var(--space-sm);
  z-index: 15;
  display: flex;
  align-items: center;
  gap: var(--space-xs);
  padding: 0.25rem 0.9rem 0.25rem 0.25rem;
  border: none;
  border-radius: 999px;
  background-color: var(--c-cta-gold);
  color: var(--c-bg);
  box-shadow: 0 0.4rem 1rem rgb(0 0 0 / 0.25);
  cursor: pointer;
  font-family: var(--font-ui);
  font-size: var(--fs-small);
  line-height: 1;
  transition: background-color 0.2s, transform 0.15s;
  /* Анимации «дыхания» здесь намеренно НЕТ (в отличие от .rsvp-float ниже:
     rsvp-float-breathe) — одинаковая стилистика не значит одинаковое
     поведение. Анкета — единственная цель, которую от гостя активно ждут,
     это её сигнал внимания; два синхронно качающихся золотых чипа на
     экране спорили бы друг с другом за взгляд. Если этот комментарий
     когда-нибудь захочется «починить», добавив чипу то же покачивание —
     не надо, это разошлось бы с прямым решением пользователя 2026-09-01. */
}

.photos-float:hover {
  background-color: var(--c-ink);
}

.photos-float[hidden] {
  display: none;
}

/* Медальон под значок камеры — тот же круглый силуэт, что у аватарки
   .rsvp-float__avatar рядом, но без фото: содержимое кладёт page.js
   (js/camera-icon.js, buildCameraIcon()) прямо в этот span. Кремовая
   заливка медальона — та же роль, что у кремовой подложки
   .rsvp-float__badge (значок не читается на золотом фоне чипа напрямую,
   тот же контраст-конфликт, что решался для --c-cta-gold, см. её
   объявление). --icon-hole переопределён в цвет ЭТОГО медальона — вырезы
   глифа (окуляр, разрыв корпус/объектив) должны сливаться с кругляшом под
   собой, а не с дефолтным кремовым фоном страницы (module camera-icon.js
   объясняет приём подробнее). */
.photos-float__medallion {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  width: 2.2rem;
  height: 2.2rem;
  border-radius: 50%;
  background-color: var(--c-bg);
  color: var(--c-accent-deep);
  --icon-hole: var(--c-bg);
}

/* Правка пользователя 2026-09-01 («иконку на кнопке "фотоальбом" можно
   сделать чуть больше»): раньше значок камеры делил роль с кнопкой формы
   #photos-invite (T031p) и оба места держали один и тот же кегль под общий
   вызов buildCameraIcon(). Теперь глиф остался только в этом медальоне
   (2.2rem, .photos-float__medallion выше) — кегль можно подрастить без
   оглядки на другую подложку. 1.5rem — тот же круг, что и раньше (силуэт
   чипа не меняется, .rsvp-float рядом остаётся тем же эталоном), но камера
   в нём читается заметнее, оставляя видимый запас поля медальона по краю. */
.icon-camera {
  width: 1.5rem;
  height: 1.5rem;
}

.photos-float__label {
  white-space: nowrap;
}

/* --------------------------------------------------------------------------
   Подвал
   -------------------------------------------------------------------------- */

.footer {
  padding-block: var(--space-lg);
  text-align: center;
}

/* T216: архив и корзина гостя (site/photos-archive.html, site/photos-bin.html)
   не заводят .photos-panel-bar — правило `.photos-panel-bar ~ .footer` ниже
   на них не срабатывает вовсе, а плавающая строка-итог (.photos-panel-toast,
   переиспользована с этих же страниц T209) всё равно может лечь поверх
   последней подписи подвала, когда появляется. Тот же запас, что и там, но
   БЕЗ слагаемого --photos-panel-height (панели нет, переменная не объявлена).
   Специфичность ниже, чем у `.photos-panel-bar ~ .footer` (один класс против
   класса+комбинатора) — на photos.html эта строка молча уступает более
   точному правилу, второй раз число не считается. На страницах без самого
   узла .photos-panel-toast (index.html и т.п.) переменная тоже не объявлена
   — fallback 0px, поведение не меняется. */
.footer {
  padding-bottom: calc(var(--space-lg) + var(--photos-panel-toast-height, 0px));
}

/* T165: на странице альбома (photos.html) фиксированная панель действий
   (.photos-panel-bar, ниже) лежит поверх подвала и перекрывала подпись пары
   («Владимир & Анастасия») в самом низу прокрутки — запас, раньше висевший
   на .photos (см. комментарий там), не доставал сюда: <footer> идёт ПОСЛЕ
   </main>, вне .photos. `~` (не `+`): между панелью и подвалом в разметке
   лежат диалоги (photos-intro и т.п.), непосредственными соседями по DOM они
   не являются. --photos-panel-height здесь — не calc()-прикидка, а
   ФАКТИЧЕСКАЯ высота панели, измеренная её собственным offsetHeight в
   photos.js (ResizeObserver, см. syncPanelBarHeight) и записанная туда же на
   :root — один источник числа вместо двух независимых, которые разошлись в
   T148 (тот же класс проблемы, что D38). Селектор ничего не делает на других
   страницах — там .photos-panel-bar просто нет, переменная не объявлена, а
   fallback 0px ничего не двигает. Панель скрыта (hidden) до входа гостя —
   тогда offsetHeight ещё не измерен (JS не успел или гость не вошёл), тот же
   fallback 0px не оставляет внизу лишней пустой полосы. env(safe-area-inset-
   bottom) уже учтён внутри самого измерения (собственный padding-bottom
   панели, см. .photos-panel-bar) — повторно прибавлять его здесь не нужно. */
/* ⚠️ Именно calc(), а не одна высота панели: `padding-bottom` перебивает
   нижнюю половину базового `padding-block: var(--space-lg)` у .footer. Без
   слагаемого --space-lg подпись пары при скрытой панели (гость ещё не вошёл,
   переменная 0px) прилипла бы к нижнему краю страницы, а при видимой —
   вплотную к её золотой границе: перекрытие сменилось бы касанием. Запас
   подвала = собственное дыхание подвала ПЛЮС место под панелью ПЛЮС место
   под строкой итога модерации (--photos-panel-toast-height, находка 2
   ревью фазы 26): .photos-panel-toast (ниже) — отдельный fixed-блок НАД
   панелью, bottom: var(--photos-panel-height) — без своего слагаемого
   здесь он ложился бы поверх последнего ряда плитки, когда появляется.
   Источник числа — тот же приём, что и у самой панели: ФАКТИЧЕСКИЙ
   offsetHeight строки, измеренный её собственным ResizeObserver в photos.js
   (syncPanelMessageHeight) — не calc()-прикидка. Пока строка скрыта или
   временно живёт внутри <dialog> просмотра (находка 1 — там подвал страницы
   не виден, отступ ей не нужен), переменная равна 0px. */
.photos-panel-bar ~ .footer {
  padding-bottom: calc(
    var(--space-lg) + var(--photos-panel-height, 0px) + var(--photos-panel-toast-height, 0px)
  );
}

.footer__divider {
  margin-bottom: var(--space-sm);
}

.footer__names {
  /* --fs-h3 — общая роль, её нельзя было просто увеличить (задело бы все
     остальные элементы на странице). Подвал — отдельная подпись без своей
     карточки, укрупнена локально по правке заказчика 2026-08-07. */
  font-size: calc(var(--fs-h3) * 1.3);
  /* .accent красит имя в --c-accent-deep — на реальном фоне под подвалом
     (тень волны у горизонта, тот же провал, что описан у .cover__date выше)
     замер снимком дал контраст ~1.7:1. --c-ink того же фона держит ~2.9:1 —
     всё ещё ниже 4.5:1 (тот же принятый размен .page-bg::after, см. его
     комментарий), но заметно лучше, и та же роль, что у имён на обложке. */
  color: var(--c-ink);
  /* T097: имя переносить нельзя, см. комментарий у .cover__name. */
  hyphens: none;
}

.footer__date {
  margin-top: 0.4em;
  /* Та же локальная надбавка к --fs-small, что у .footer__names к --fs-h3
     выше — правка заказчика 2026-08-07 («подпись в подвале мелкая»). */
  font-size: calc(var(--fs-small) * 1.3);
  /* Правка пользователя 2026-08-07 («шрифт даты в подвале не соответствует
     обложке»): класс .small (font-family: var(--font-ui), Inter) снят из
     разметки — здесь остаётся Lora по умолчанию (font-family не переопределён,
     как и у .cover__date), плюс те же капслок и разрядка, что на обложке —
     обе даты набраны одинаково, отличается только кегль (подвал крупнее,
     см. надбавку выше). */
  letter-spacing: 0.22em;
  text-transform: uppercase;
  /* Тот же приём, что у .cover__date — на узких экранах строка рвётся
     на две неровные части, balance выравнивает их по длине. */
  text-wrap: balance;
  /* Раньше — .muted (самый низкоконтрастный цвет палитры, тот же провал,
     что был у .cover__date, см. её комментарий): дата отличалась цветом от
     имени строкой выше. --c-ink — та же роль, что теперь и у .footer__names
     и у .cover__date (замер контраста см. там), все три строки одного
     цвета. */
  color: var(--c-ink);
}

/* Ссылка на политику конфиденциальности в подвале (правка «строка согласия
   перекрывает кнопку», 2026-08-16) — как принято на сайтах: мелкая,
   приглушённая служебная строка, та же роль .small/.muted, что и у копий
   согласия в анкете/окне подтверждения (см. .form__consent там же). */
.footer__privacy {
  margin-top: var(--space-xs);
}

/* --------------------------------------------------------------------------
   Приглашение в фотоальбом на главной (#photos-invite, T031l/T031q/T031r) —
   поле кода и кнопка идут строкой ответа ПОД пузырём-репликой .chat__ask (не
   внутри неё, см. комментарий у index.html/#photos-invite — «пузырь на
   пузыре»).

   Своих классов раскладки здесь больше нет (были .photos-invite__reply/
   .photos-invite__field — правка пользователя 2026-09-01, осмотр «внешний
   вид форм отличается»: «похожие» классы давали ту же flex-раскладку, что и
   .chat__composer, но без верхней линии-разделителя и её отступа — отсюда и
   расхождение). Форма теперь ДОСЛОВНО несёт .chat__composer/
   .chat__composer-control/.chat__composer-send--icon — те же правила, что и
   у зеркал (mirror-reply.js) и анкеты (chat-form.js), см. их описание выше
   (.chat__composer и соседи). .photos-invite__submit остался в разметке
   только JS-крючком для page.js (setupCameraIcons → значок-самолётик), без
   единого собственного правила CSS.
   -------------------------------------------------------------------------- */

/* --------------------------------------------------------------------------
   Страница политики конфиденциальности (T025) — служебная страница,
   отдельная вкладка (index.html ссылается на неё дважды, target="_blank").
   Оформление то же (шрифты, палитра, .page-bg), но без декоративной вёрстки
   приглашения: обычный текст в измеренную колонку.
   -------------------------------------------------------------------------- */

.privacy {
  max-width: var(--measure);
  margin-inline: auto;
  /* Длинные неразрывные токены (адрес почты, телефон в неразрывных
     пробелах, ник автора) не должны раздвигать колонку и вызывать
     горизонтальную прокрутку. break-word, а не anywhere: с
     text-align: justify ниже anywhere режет слова слишком охотно, ломая
     обычный текст там, где хватило бы переноса на границе слога. */
  overflow-wrap: break-word;
}

.privacy__back {
  margin-bottom: var(--space-md);
}

.privacy h2 {
  margin-top: var(--space-lg);
}

.privacy h3 {
  margin-top: var(--space-md);
}

/* Выравнивание по ширине — только у абзацев и пунктов списка, заголовки
   выключены сами по себе (text-align на h2/h3 не задаётся). Без переносов
   выравнивание в колонке --measure (30rem) даёт огромные пробелы между
   словами — переносы расставляет hyphenate.js (мягкие &shy;, T107, см. его
   шапку), hyphens: auto здесь — подстраховка на случай, если скрипт по
   какой-то причине не отработал (тот же приём у .chat__ask, см. там). */
.privacy p,
.privacy__credits li {
  text-align: justify;
  hyphens: auto;
}

.privacy__credits {
  padding-left: 1.2em;
}

.privacy__credits li + li {
  margin-top: var(--space-xs);
}

/* --------------------------------------------------------------------------
   Страница фотоальбома (003-guest-photos, T024) — служебная страница для
   гостей, отдельная вкладка, тот же приём, что у .privacy выше: шрифты,
   палитра, .page-bg сохранены, декоративная вёрстка приглашения (мензели,
   пузыри-реплики) не тянется целиком — здесь гость не ведёт диалог с парой,
   а входит по коду и смотрит ленту, это другой сценарий.
   -------------------------------------------------------------------------- */

/* Центр по умолчанию — тот же приём, что у .cover__inner на index.html:
   заголовок и лид страницы-приглашения выключены влево нигде не бывают,
   а .photos этот text-align не задавал вовсе, поэтому вся колонка липла
   к левому краю (осмотр пользователя 2026-09-01). Блокам, которым лево
   нужно предметно — форма входа (.photos-gate) — по-прежнему переопределяет
   себя точечно ниже (служебная ссылка назад T143 переехала в постоянную
   нижнюю панель, .photos__back эту роль больше не держит). */
/* T031s: .photos сам больше не заперт в --measure (30rem — ширина колонки
   для чтения прозы, осмысленная для абзаца, но не для альбома-ленты:
   заперев в неё всю страницу, .photos-feed до сих пор не мог вырасти шире
   узкой колонки ни на каком экране). Полная ширина контейнера — только
   лента (.photos-feed) реально ей пользуется; страничные абзацы, которым
   длина строки всё ещё нужна, сами берут --measure точечно ниже
   (.photos__greeting/.photos__message — .photos__lead T142 убрала вместе с
   отдельным лидом форзаца). text-align: center
   остаётся общим — им пользуются заголовок и лента (карточки центрируются
   в своей колонке грида через .photo-card margin-inline: auto). */
.photos {
  margin-inline: auto;
  text-align: center;
  /* T165: запас под фиксированную панель (.photos-panel-bar) отсюда УБРАН —
     стоял не на том элементе. Подвал <footer> лежит ПОСЛЕ </main>, то есть
     вне .photos, и запаса под ним не было вовсе (панель перекрывала подпись
     пары). Запас теперь общий для конца страницы — на .footer, см. правило
     .photos-panel-bar ~ .footer ниже. */
}

/* T143 (фаза 20): «← Назад к приглашению» переехала в постоянную нижнюю
   панель действий (.photos-panel-bar ниже) — своей отдельной кнопки-строки
   наверху страницы (.photos__back) больше нет. */

/* T167: photos-bin.html — отдельная страница, у неё нет постоянной нижней
   панели (.photos-panel-bar), поэтому возврат к альбому — видимая кнопка
   сразу под заголовком, тем же приёмом, что .privacy__back на
   privacy.html (единственная точка входа сюда — значок футера альбома,
   выход должен быть так же на виду, не тонуть в подвале страницы). */
.photos-bin-page__back {
  margin-bottom: var(--space-md);
}

/* T141 (замечание пользователя 2026-09-08): единая стеклянная поверхность
   для абзацев, которые иначе лежали прямо на фоновом снимке заката
   (.page-bg) и читались наклейкой (T142, стенд, вариант C: форзац из числа
   потребителей класса выведен — плашка для него отклонена отдельно, см.
   комментарий у .photos__frontispiece ниже; из разметки страницы класс
   сейчас держит только .photos__message) — то же стекло реплик (--m-glass,
   MATERIAL выше в :root), что несёт переписку на index.html (.mirror__glass):
   у сайта уже есть готовый ответ именно на задачу «текст поверх фоновой
   фотографии», второй материал не заводим. Три варианта осмотрены рендером
   и отклонены отдельно (венок с обложки — крупный и парадный для страницы,
   куда заходят по десять раз за вечер; две золотые линейки без плашки —
   воздушнее, но текст снова на голом снимке; бумага — пользователь
   забраковал, «надпись смотрится хуже, рамка толще, чем надо»).
   Рамка НАМЕРЕННО тоньше зеркальной (.mirror__glass, border: 1px solid
   var(--m-gold), 1.5rem): здесь нет ни крестов-навершия (.mirror__crest),
   ни бегущего по прокрутке блика (форзац — не переписка, живой блик тут
   был бы не к месту) — только заливка и волосяная рамка в полтона
   (color-mix с прозрачностью вместо сплошного --m-gold), это и делает её
   «тоньше, элегантнее» в терминах правки пользователя. Скругление 1.1em —
   общий язык страницы (кнопки/поля/.photos-gate), а не 1.5rem зеркала: тот
   радиус читается именно рамой стекла В РАМЕ, здесь рамы нет.
   Контраст текста к --m-glass — тот же материал, замерян один раз в
   PALETTE-комментарии выше (:root, «--c-ink 12.06:1 / 10.11:1 …»):
   --c-ink 10,11:1, --c-muted 5,05:1, --c-accent-deep (.accent, имя гостя)
   5,73:1 — все ≥ 4,5:1, порог держит с запасом на обоих контекстах. */
.photos-panel {
  background-color: var(--m-glass);
  /* Блик — та же диагональная полоса, что у .mirror__glass (см. её
     комментарий выше): без него заливка читается глухой ПЛАШКОЙ, а не
     стеклом — проверено рендером 2026-09-08, первая редакция T141 сняла
     блик «за ненадобностью» и получила ровно этот результат. Бегущего по
     прокрутке варианта здесь по-прежнему нет (форзац — не переписка),
     только статичная полоса. Примесь стекла 10 % в --m-sheen — то же
     число, что у зеркала, поднятое там по замечанию заказчиков
     2026-08-06; повторяем его, а не подбираем своё. */
  background-image: linear-gradient(
    115deg,
    transparent 30%,
    color-mix(in srgb, var(--m-glass) 10%, var(--m-sheen)) 48%,
    transparent 66%
  );
  /* Волосок в 70 % золота. Замер (2026-09-08, композит золота с заливкой
     стекла): 45 % первой редакции дают 1,48:1 к заливке — на рендере край
     не читался, а «тоньше, элегантнее» в правке пользователя значит тоньше
     зеркальной рамы, а не невидимее. 70 % дают 1,87:1 — край виден и всё
     ещё легче сплошного --m-gold зеркала (2,54:1). */
  border: 1px solid color-mix(in srgb, var(--m-gold) 70%, transparent);
  border-radius: 1.1em;
  padding: var(--space-md);
}

@media (max-width: 30em) {
  .photos-panel {
    padding: var(--space-sm);
  }
}

/* T176 (решение пользователя 2026-09-11, дословно: «поле для поиска в
   ПРАВОМ ВЕРХНЕМ УГЛУ фотоальбома»): своя строка НАД форзацем, а не внутри
   него — форзац ниже центрированный титульный блок (text-align: center у
   родителя .photos), а поле садится строго в правый угол колонкой
   flex-direction: column + align-items: flex-end, чтобы сообщение об итоге
   поиска и кнопка «Перейти к кадру» тоже прижимались к правому краю, а не
   растягивались на всю ширину. Это ОТДЕЛЬНЫЙ блок ПЕРЕД
   .photos__frontispiece (в потоке страницы, не абсолютное позиционирование
   поверх неё) — при любой ширине окна заголовок остаётся ниже и не может
   быть перекрыт (замер узкой ширины — 400px, отчёт задачи T176). Видимость
   — только роль couple: форма скрыта атрибутом hidden в разметке,
   activate() (site/js/photos-find.js) его снимает, гость узла не видит
   вовсе. */
.photos__topbar {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 0.4em;
  margin: 0 auto var(--space-sm);
}

/* Компактная форма — намеренно уже .field/.field__input (те держат подписи
   форм ниже по странице шириной во весь контейнер, здесь нужен именно
   маленький узел в углу). На photos.html подпись поля — visually-hidden
   (см. её разметку): на узком топбаре в углу место есть только для
   placeholder, доступное имя всё равно на месте для скринридера. На
   photos-archive.html та же строка живёт иначе — там подпись сделана
   видимой (`.field__label`, T181, замечание финальной сверки) через
   контекстный селектор .photos-archive__controls .photos-find ниже по
   файлу; здесь, в общем правиле, трогать нечего — это разметка про строку,
   а не про подпись. */
.photos-find {
  display: inline-flex;
  align-items: center;
  gap: 0.35em;
}

.photos-find__input {
  width: 9rem;
  padding: 0.5em 0.75em;
  font-family: var(--font-ui);
  font-size: var(--fs-small);
  background-color: var(--c-bg);
  border: 1px solid color-mix(in srgb, var(--c-line) 40%, var(--c-ink));
  border-radius: 1.1em;
  transition: border-color 0.2s;
}

.photos-find__input:focus {
  border-color: var(--c-accent);
}

/* Значок лупы — факультативен по решению пользователя («можно добавить
   иконку, но не обязательно»), добавлен: круглая кнопка того же контура,
   что и поле рядом, значок — та же золотая семья, что у кнопок панели
   футера (.photos-panel-bar__button .album-icon выше). */
.photos-find__submit {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.4em;
  height: 2.4em;
  padding: 0;
  flex-shrink: 0;
  background-color: var(--c-bg);
  border: 1px solid color-mix(in srgb, var(--c-line) 40%, var(--c-ink));
  border-radius: 999px;
  cursor: pointer;
}

.photos-find__submit .album-icon {
  width: 1.1em;
  height: 1.1em;
  --icon-ink: var(--c-accent-deep);
  --icon-deep: var(--c-accent-deep);
  --icon-muted: var(--c-accent-deep);
  --icon-mid: var(--c-accent);
  --icon-light: var(--c-accent);
}

/* Итог поиска — тот же приглушённый голос, что и .photos__message везде на
   странице, только выровнен по правому краю колонки топбара и не растянут
   во всю ширину (иначе длинная строка итога «наплывала» бы на форзац). */
.photos-find__message {
  max-width: 18rem;
  text-align: right;
}

.photos-find__jump {
  align-self: flex-end;
}

/* T181 (решение пользователя 2026-09-12, замечание приёмки 2): на
   photos-archive.html поиск кадра (.photos-find выше, T176) раньше висел
   НАД бумажным листом, а фильтр по гостю (.field выше) — ВНУТРИ него — два
   родственных органа управления жили на разных этажах, композиция
   разъезжалась. Здесь оба сведены в одну строку ВНУТРИ листа:
   align-items: flex-end равняет их по нижнему краю (обе подписи теперь
   видны и стоят сверху, см. .photos-archive__controls .photos-find ниже —
   поля выравниваются по низу, а не по верху разной высоты блоков). Это
   ТОЛЬКО для архива — .photos__topbar (правый верхний угол photos.html,
   T176) эта строка не трогает, здесь у поиска другой контейнер.
   Перенос в колонку — 505px (замер приёмки T181): на этой ширине компактное
   поле поиска (9rem, .photos-find__input) и подписанный селект уже не
   помещаются бок о бок без переноса. */
.photos-archive__controls {
  display: flex;
  align-items: flex-end;
  gap: var(--space-sm);
  margin-bottom: var(--space-sm);
}

/* T181 (замечание финальной сверки, решение пользователя 2026-09-12): у
   поиска в архиве появилась видимая подпись (`.field__label`, была
   visually-hidden) — из-за неё .photos-find внутри архива больше не может
   оставаться строкой (подпись легла бы слева от поля инлайном). Переводим
   ИМЕННО архивный экземпляр в колонку через контекстный селектор — общий
   `.photos-find` (строка) остаётся как есть для .photos__topbar на
   photos.html (T176, там подпись по-прежнему visually-hidden, менять её
   облик не просили и незачем). Поле+кнопка внутри архивного поиска сведены
   в .photos-find__group — это то, что раньше рисовал сам .photos-find
   (inline-flex-строка), просто теперь на уровень ниже: подпись сверху,
   строка «поле+лупа» под ней — тот же порядок, что и у .field/.field__label
   рядом. */
.photos-archive__controls .photos-find {
  flex-direction: column;
  align-items: flex-start;
  gap: 0.35em;
}

.photos-find__group {
  display: inline-flex;
  align-items: center;
  gap: 0.35em;
}

@media (max-width: 31.5625em) {
  /* 31.5625em = 505px при базовых 16px — см. комментарий выше. */
  .photos-archive__controls {
    flex-direction: column;
    align-items: stretch;
  }
}

/* Форзац — стенд 2026-09-08 (вариант C, ВЫБРАН пользователем, см. tasks.md
   «Согласование облика фазы 20», T142): плашка-стекло (.photos-panel,
   T141) для форзаца ОТМЕНЕНА — на рендере читалась блёклой служебной
   строкой поверх снимка заката. Титул стоит прямо на .page-bg, читаемость
   держит text-shadow ниже (та же задача, что решала плашка, без нового
   материала — «плашку не возвращать», уточнение пользователя к варианту C).
   T142: форзац больше не заперт в --measure — та колонка была шириной
   абзаца прозы (30rem), а форзац несёт титул, не прозу, ему тесно даже на
   узких экранах (титул переносился на «Общий фото-\nальбом» уже при
   ~340px). Ширина — как у .photos-feed (родительский .container, полная
   доступная ширина); строку приветствия внутри по-прежнему держит свой
   --measure (.photos__greeting ниже) — это разные ограничения, титул шире
   прозы, прямо по замечанию пользователя. */
.photos__frontispiece {
  margin: 0 auto var(--space-lg);
  /* Мягкая тень вместо плашки — тот же уровень, что был осмотрен и не
     отклонён для .cover__name на index.html (там решение ещё не принято,
     см. её комментарий), но тут не факультатив: фон меняется по вертикали
     страницы, и без тени титул на телефоне может лечь на тёмную воду. */
  text-shadow: 0 0.05em 0.15em rgba(51, 48, 46, 0.45);
  /* T152: титул держит 9,6:1 и без вуали (замер) — облако ему не нужно,
     text-shadow тут единственная защита. Вуаль-облако стоит ниже, на самом
     тексте приветствия и подсказки (.photos__veil) — общий приём на весь
     форзац описан там. */
}

/* T152 (ПЕРЕРЕШЕНО пользователем по рендеру, см. tasks.md): вуаль стала
   ОБЛАКОМ вокруг текста, а не прямоугольной подложкой во всю ширину блока
   (прежний linear-gradient на .photos-upload снят целиком). Приём —
   box-decoration-break: clone на инлайновом элементе: когда строка
   переносится, каждый фрагмент строки получает СВОЮ копию фона/тени, а не
   общий прямоугольник абзаца — ровно то, что требует многострочная
   подсказка под кнопкой при узкой ширине. Скругление 999px + мягкий
   размытый box-shadow того же цвета дают лёгкое облако, тающее по всему
   периметру строки, а не только сверху/снизу, как было у linear-gradient.
   Тот же язык, что у .photos-feed__group-label (бумажная плашка под
   именем гостя) — «текст на облегающем фрагменте фона», только здесь
   фрагмент бесплотный (без paper-grain, без поворота, без тени плашки) —
   класть текст форзаца на бумагу пользователь отклонил.
   Плотность подобрана ЗАМЕРОМ по снимку с погашенным текстом
   (_stand-p20-bgonly.html) — числа в отчёте задачи T152. */
.photos__veil {
  display: inline;
  box-decoration-break: clone;
  -webkit-box-decoration-break: clone;
  padding: 0.12em 0.55em;
  border-radius: 999px;
  background-color: rgba(255, 253, 248, 0.62);
  box-shadow: 0 0 0.55em 0.35em rgba(255, 253, 248, 0.62);
}

/* Вензель между титулом и приветствием — та же разметка/тиснение, что у
   .decor--divider в подвале, только свои вертикальные поля (подвальный
   вариант не подходил бы — там нет текста над и под вензелем). */
.photos__frontispiece-divider {
  margin-block: var(--space-sm);
}

/* Карточка входа по коду — единственное, что видит гость до успешного
   входа (FR-002): ни ленты, ни имён, ни счётчиков рядом с ней быть не
   должно. Рамка и скругление те же, что у .field__input/.button
   (1.1em — общий язык скругления страницы, см. комментарий у .button). */
.photos-gate {
  display: flex;
  flex-direction: column;
  gap: var(--space-sm);
  max-width: 22rem;
  /* Карточка как блок центрируется на странице (margin-inline: auto — text-align
     родителя блочную коробку не двигает, только её инлайновое содержимое).
     Само содержимое (подпись, поле, кнопка) — текст формы, а не проза
     заголовка: выключаем унаследованный center обратно влево. */
  margin-inline: auto;
  padding: var(--space-md);
  background-color: var(--c-bg);
  border: 1px solid var(--c-line);
  border-radius: 1.1em;
  text-align: left;
}

.photos-gate label {
  font-family: var(--font-ui);
  font-weight: 600;
}

.photos-gate__hint {
  margin: 0;
  font-size: var(--fs-small);
  color: color-mix(in srgb, var(--c-muted) 70%, var(--c-ink));
}

/* T171: панели кабинета, которые несут КАДРЫ (сетку .photos-feed из
   photos-moderation.js/photos-guest-bin.js — очередь одобрения, архив,
   корзина гостя), лежат в той же разметке .photos-gate, что и форма входа,
   и наследуют её потолок 22rem — на 1280 px это резало сетку до одной
   колонки в 270 px (замер зондом на стенде, обращение пользователя
   2026-09-10). Сам .photos-gate трогать нельзя — этот потолок нужен форме
   входа/полям (длинная строка текста там вредна), поэтому здесь отдельный
   модификатор, снимающий именно центрирующий потолок ширины и ничего
   больше (рамка/отступы/фон остаются общими с .photos-gate). */
.photos-gate--wide {
  max-width: none;
  width: 100%;
  margin-inline: 0;
}

/* Код набран интерфейсным --font-ui, как остальное поле формы — четвёртой
   гарнитуры здесь не нужно (осмотр пользователя 2026-09-01: на экране входа
   уже сходятся Lora, Inter и Pompadur, моноширинная была лишней). Раньше
   тут стоял монослив «по образцу .summary__guest-code» — оба довода не
   держатся: неспутываемость символов уже решает алфавит Crockford base32
   (functions/photos/lib/access.js) — из него выброшены I, L, O, U ровно
   ради этого, гарнитура дублировала бы уже решённую задачу; а
   .summary__guest-code — inline-стиль служебного кабинета пары
   (summary.html), гость его не видит и прецедента для гостевой страницы
   он не создаёт. Разрядку и капслок оставляем — они помогают вводу и не
   тянут шрифт. */
.photos-gate__code {
  letter-spacing: 0.12em;
  text-transform: uppercase;
  /* T180: класс был описан, но нигде не применялся — коды в кабинете пары
     (photos-invites.js) складывались в один textContent и рвались переносом
     ПОСРЕДИ группы символов (`groupCode` делит код пробелами по 4 символа
     для читаемости, а обычный пробел — законная точка переноса браузера).
     nowrap держит группу одним куском; текст вокруг кода (имя, дата) этим
     не связан — переносится как обычно, тут своего класса нет. */
  white-space: nowrap;
}

/* Сообщения статуса — обычным текстом реплики, без тревожного красного:
   так же ведёт себя showObjection() в анкете (chat-form.js) — пара «не
   ругается», а объясняет, что не так. Единый голос страницы. */
/* --measure — тем же приёмом, что у .photos__lead выше: короткие статусные
   строки не обязаны тянуться на всю ширину контейнера, но центр .photos
   (text-align) их не двигает как блок — margin-inline: auto нужен отдельно,
   иначе строка просто прижмётся к левому краю на всю доступную ширину. */
.photos__message {
  max-width: var(--measure);
  margin: 0 auto;
  font-size: var(--fs-small);
}

.photos__greeting {
  max-width: var(--measure);
  margin: 0 auto var(--space-sm);
}

/* Счётчик квоты (T030, «Загружено N из 100») — своя строка под приветствием
   ради читаемости на узких экранах.
   🔴 Найдено замером 2026-09-09 (сразу после T142, чинится здесь в заходе
   T143): приглушённый --c-muted (color-mix 70%) держит порог 4,5:1 только
   НА ПЛАШКЕ (там и был замерян — см. комментарий у .photos-panel выше). У
   форзаца плашки больше нет (T142, вариант C) — на голом снимке заката тот
   же цвет даёт 3,61–3,91:1, ниже порога. Титул рядом (--c-ink) держит
   9,6–9,95:1 с запасом — тот же цвет и здесь, плашку НЕ возвращаем (C ценен
   её отсутствием), text-shadow .photos__frontispiece защищает читаемость
   как и у титула. Правило на будущее: --c-muted не ставить на фоновый
   снимок без подложки — порог он держит только на плашке. */
.photos__quota {
  /* T152: счётчики — свои строки, и облако им нужно ровно так же, как строке
     приветствия: замер по фону с погашенным текстом дал у них 2,1:1 при
     пороге 4,5 (они стоят ниже имени, где закат темнее). Первая редакция
     сознательно оставила их без облака — замер это опроверг.
     display: table держит блочный перенос строки, но фон облегает ТЕКСТ,
     а не всю ширину колонки (иначе вернётся прямоугольное пятно). */
  display: table;
  margin-top: 0.2em;
  margin-inline: auto;
  padding: 0.12em 0.55em;
  border-radius: 999px;
  background-color: rgba(255, 253, 248, 0.62);
  box-shadow: 0 0 0.55em 0.35em rgba(255, 253, 248, 0.62);
  font-size: var(--fs-small);
  color: var(--c-ink);
}

/* Загрузка кадров (T035-T038) — минимальная функциональная разметка, без
   притязаний на облик: тем же приёмом, что и остальная страница до осмотра
   T031s, механику и внешний вид разводят намеренно (см. задачу T031s).
   T152: прямоугольная вуаль во всю ширину блока (linear-gradient) снята —
   текст .photos-upload__hint/__quota несёт своё облако сам (.photos__veil,
   см. её комментарий у .photos__frontispiece). */
.photos-upload {
  margin-bottom: var(--space-md);
}

/* T147 (фаза 20): кнопка загрузки перерисована с нуля — вертикальный проём
   1/2.06 с крупным плюсом (заход C3) отклонён пользователем ДВАЖДЫ (форма и
   цвет), заливка --c-accent-deep не возвращается ни в каком виде. Новая
   форма — вытянутая вдоль полоса, заметно уже прежней по высоте, значок
   загрузки слева от подписи (buildAlbumIcon('upload') в photos.js, тот же
   значок, что и в постоянной панели). Материал — та же бумага, что несёт
   вся страница (--m-paper + paper-grain.webp + волосок inset box-shadow) —
   отклонена ПРОПОРЦИЯ прежней кнопки, а не материал. Класса
   .button/.button--primary на узле больше нет — они
   несли круглые углы 1.1em и тёмную заливку языка строчных кнопок, здесь
   свой язык (бумага, --radius-soft). */
.photos-upload__trigger {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.6em;
  /* Полоса, не проём: широкая и низкая, а не квадратная/вытянутая вверх.
     T151: во всю колонку полоса читалась полем ввода, а не кнопкой —
     ограничиваем ширину тем же --measure, что держит строку приветствия
     (.photos__greeting) и абзацы прозы страницы, вытянутость вдоль (просьба
     пользователя) остаётся за счёт низкой высоты, не полной ширины. */
  width: 100%;
  max-width: var(--measure);
  padding: var(--space-xs) var(--space-md);
  margin: var(--space-lg) auto;
  background-color: var(--m-paper);
  background-image: url('../img/decor/paper-grain.webp');
  background-repeat: repeat;
  background-size: 14rem auto;
  box-shadow: inset 0 1px 3px color-mix(in srgb, var(--c-ink) 18%, transparent);
  border-radius: var(--radius-soft);
  color: var(--c-ink);
  font-family: var(--font-ui);
  font-size: var(--fs-small);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  text-align: center;
  cursor: pointer;
}

.photos-upload__trigger .album-icon {
  width: 1.4rem;
  height: 1.4rem;
  flex-shrink: 0;
}

/* T147 (замечание 4, 2026-09-09): на узкой ширине крупной кнопки быть не
   должно вовсе — точка входа там значок «Загрузить» в постоянной панели
   (photos-panel-bar__button[data-photos-panel-upload]), видный всегда без
   прокрутки. Порог — тот же, на котором лента переходит на auto-fill
   (.photos-feed выше, 30em/480px): ниже него сетка держит явные
   repeat(2, 1fr), выше — auto-fill. Третью контрольную точку не заводим.
   Строка остатка квоты и короткая строка про одобрение остаются видимыми
   на любой ширине (решение пользователя, правка 7 варианта C) — прячется
   только сама полоса. */
@media (max-width: 29.99em) {
  .photos-upload__trigger {
    display: none;
  }
}

/* 🔴 T151: цвет был приглушённым color-mix(--c-muted 70%, --c-ink) — держал
   порог 4,5:1 только НА ПЛАШКЕ (.photos-panel, снята этой же задачей).
   Строка теперь лежит прямо на .page-bg — T152 закрывает контраст не
   text-shadow (снят, недостаточно на самом тёмном участке заката), а
   облаком .photos__veil на внутреннем span разметки (photos.html); сам
   <p> остаётся блоком ради вертикального отступа между строками. */
.photos-upload__hint {
  margin: var(--space-xs) 0;
  font-size: var(--fs-small);
  color: var(--c-ink);
}

/* Остаток квоты (T041) — T152: та же замена text-shadow на облако
   .photos__veil, что у .photos-upload__hint выше. */
.photos-upload__quota {
  margin: var(--space-xs) 0;
  font-size: var(--fs-small);
  color: var(--c-ink);
}

.photos-upload__list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 0.2em;
  font-size: var(--fs-small);
}

/* Очередь загрузки (data-photos-upload-list) — карточки лежат плотнее, чем
   строки кабинета (те делят общий .photos-upload__list с другим зазором,
   T180): бумажная плашка ниже (--queue) нуждается в воздухе между собой, а
   не в 0,2em общего списка. Селектор по data-атрибуту (не классу списка) —
   тот же приём, что уже держит остальную разметку JS-хуков страницы. */
[data-photos-upload-list] {
  gap: var(--space-xs);
}

/* Строка очереди (T186r: сводка пачки + строки проблемных кадров, поле
   подписи снято — см. комментарий у photos.js#renderUploadBatch). */
.photos-upload__item {
  display: grid;
  gap: 0.2em;
  padding-bottom: var(--space-xs);
}

/* T186 (решение пользователя 2026-09-16, «фронтенд под очередь загрузки не
   предусмотрен, выбивается из общего стиля») ⇒ второй заход того же дня
   («текст написан на прямоугольных плашках с закруглёнными краями, а тут
   по-другому»): первая редакция одевала строку в бумагу (--m-paper), но
   канонический носитель ТЕКСТА в альбоме — стекло `.photos-panel`
   (заливка --m-glass, диагональный блик, золотой волосок, border-radius
   1.1em) — она и переиспользуется классом (см. photos.js#ensureUploadBatchRow
   и #renderProblemItem, добавляют 'photos-panel' рядом с --queue), а не
   копируется свойствами: второй копии стекла в проекте не заводят. Здесь,
   поверх стекла, остаётся только СВОЙ модификатор — более тесный отступ
   (--space-xs/--space-sm против --space-md/--space-sm панели): строка
   очереди компактнее обычной текстовой плашки. */
.photos-upload__item--queue {
  padding: var(--space-xs) var(--space-sm);
}

/* Шапка строки — значок стадии (buildAlbumIcon, photos.js) вместе с текстом
   статуса, той же парой «значок слева + подпись», что и у
   .photos-upload__trigger выше. */
.photos-upload__item-head {
  display: flex;
  align-items: center;
  gap: 0.5em;
}

.photos-upload__item-icon {
  width: 1.3em;
  height: 1.3em;
}

.photos-upload__item-status {
  display: block;
  flex: 1 1 auto;
}

/* Полоса хода загрузки — процент из renderUploadItem пишет ширину заливки
   напрямую в style (число меняется на каждый onUpdate, дублировать его в
   CSS-переменную ради того же присваивания незачем). Золото заливки — та
   же роль --c-accent-deep, что несёт «Подтверждаем…»/крупные числа кабинета
   (.photos-summary__total-value выше) — не изобретаем цвет прогресса. */
.photos-upload__item-bar {
  height: 0.3em;
  border-radius: 999px;
  background-color: color-mix(in srgb, var(--c-line) 35%, transparent);
  overflow: hidden;
}

.photos-upload__item-bar-fill {
  display: block;
  height: 100%;
  background-color: var(--c-accent-deep);
  transition: width 0.2s ease;
}

.photos-upload__item--error {
  color: var(--c-accent);
}

/* Значок «close» (контурный кружок с крестиком, тот же глиф, что закрывает
   крупный просмотр) перекрашен в акцент ошибки — та же роль-переменная
   держит и текст строки (.photos-upload__item--error выше), и её значок. */
.photos-upload__item--error .photos-upload__item-icon {
  --icon-ink: var(--c-accent);
  --icon-deep: var(--c-accent);
  --icon-muted: var(--c-accent);
  --icon-mid: var(--c-accent);
  --icon-light: var(--c-accent);
}

/* Дубль (T042, 409 duplicate) — не отказ гостя, а факт «уже здесь»: тот же
   приглушённый тон, что у служебных строк выше, НЕ акцентный цвет ошибки.
   Значок — «checkQuiet» (контурная галочка, T076 «уже скачано») вместо
   залитого «check» готового кадра ниже: тот же глиф-семья, что и различие
   заливки/контура у самих значков (album-icons.js), приглушённая версия
   «да, это уже здесь» вместо бодрого «готово». */
.photos-upload__item--duplicate {
  color: color-mix(in srgb, var(--c-muted) 70%, var(--c-ink));
}

.photos-upload__item--duplicate .photos-upload__item-icon {
  --icon-ink: color-mix(in srgb, var(--c-muted) 70%, var(--c-ink));
}

/* T224б (живой тест 2026-09-22, «найти неудавшиеся кадры по одним именам
   файлов трудно») — миниатюра + «Повторить» у строки проблемного кадра
   (photos.js#renderProblemItem). Отдельная строка ПОД шапкой (head несёт
   значок+текст выше), а не третья колонка head — на узком экране (то же
   устройство, для которого T224г режет параллелизм) шапка и так занимает
   всю ширину текстом ошибки. */
.photos-upload__item-actions {
  display: flex;
  align-items: center;
  gap: var(--space-sm);
  margin-top: 0.3em;
}

/* Квадратная миниатюра — тот же приём, что у карточек ленты
   (object-fit: cover, T164 --photo-card-*), но без их декоративной обвязки
   (булавка/наклон/маска): здесь роль ровно одна — узнать кадр, не украсить
   его. --icon-* ниже красит заглушку HEIC тем же приглушённым тоном, что у
   служебных значков строки (см. --duplicate выше). */
.photos-upload__item-thumb {
  display: flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  width: 2.6rem;
  height: 2.6rem;
  border-radius: var(--radius-soft);
  overflow: hidden;
  background-color: color-mix(in srgb, var(--c-line) 20%, transparent);
}

.photos-upload__item-thumb img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

.photos-upload__item-thumb-icon {
  width: 1.3em;
  height: 1.3em;
  --icon-ink: color-mix(in srgb, var(--c-muted) 70%, var(--c-ink));
}

/* Кнопка компактная (не во всю ширину строки) — тот же приём, что у T180
   ниже (align-self снимает grid-stretch), но точечно на самой кнопке, а не
   переопределением display у .photos-upload__item целиком (та стопка нужна
   и здесь — миниатюра и кнопка идут РЯДОМ друг с другом внутри .actions,
   не всей строкой). */
.photos-upload__item-retry {
  align-self: center;
}

/* T180 (облик кабинета альбома, по образцу кабинета анкет — summary.html:
   действие лежит компактной кнопкой РЯДОМ с данными строки, а не плашкой во
   всю ширину колонки). Общий `.photos-upload__item` (display: grid, один
   столбец) здесь НЕ трогаем — тем же классом рисуются строки очереди
   загрузки кадра с полем подписи под статусом (photos.js), там нужна именно
   стопка; переопределяем только внутри трёх списков раздела «Приглашения»,
   где строка — это код гостя/приглашения и ровно одна кнопка. Без правки
   кнопка (`.button`, display: inline-block) как элемент grid/flex
   блокифицируется и получает stretch по умолчанию — оттуда и была плашка на
   всю ширину. */
.photos-invites__create .photos-upload__item,
.photos-invites__list .photos-upload__item,
.photos-invites__guest-codes .photos-upload__item {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.3em var(--space-sm);
}

.photos-invites__create .photos-upload__item-status,
.photos-invites__list .photos-upload__item-status,
.photos-invites__guest-codes .photos-upload__item-status {
  /* Растёт первым — кнопка занимает только свою ширину и переносится на
     отдельную строку сама, если текста и кнопки вместе не хватает места
     (flex-wrap выше), без ручных точек перелома. */
  flex: 1 1 auto;
  min-width: 12rem;
}

.photos-invites__create .photos-upload__item > .button,
.photos-invites__list .photos-upload__item > .button,
.photos-invites__guest-codes .photos-upload__item > .button {
  flex: 0 0 auto;
}

/* T180: «Расширить альбом» — тот же дефект (button внутри .photos-gate,
   display: flex/column, растягивается на всю ширину по умолчанию). align-self
   не меняет поток и не требует правки разметки — снимает только stretch у
   самой кнопки. */
[data-photos-expand-button] {
  align-self: flex-start;
}

/* T180 (пункт 3, заход 2): «Сводка альбома» — карточки с крупными числами
   вместо абзацев, тем же приёмом, что и кабинет анкет (summary.html
   [data-summary-totals]/.summary__totals + site/js/summary.js#renderTotals,
   T089) — заимствуем ПРИЁМ (крупное число + подпись во флекс-карточке), не
   селекторы: summary.html живёт на своей изолированной таблице стилей
   (комментарий в файле объясняет почему). Токены — общие с остальной
   страницей (токены отступов, --c-line, --radius-soft), не изобретаем
   новых (I-9). */
.photos-summary__totals {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-sm);
  margin: var(--space-sm) 0;
}

.photos-summary__total {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.2em;
  padding: var(--space-xs) var(--space-sm);
  background-color: var(--m-paper);
  border: 1px solid var(--c-line);
  border-radius: var(--radius-soft);
  min-width: 9rem;
}

.photos-summary__total-value {
  font-family: var(--font-ui);
  font-size: var(--fs-h3);
  font-weight: 700;
  line-height: 1;
  color: var(--c-accent-deep);
}

.photos-summary__total-label {
  font-size: var(--fs-small);
  color: color-mix(in srgb, var(--c-muted) 70%, var(--c-ink));
}

/* Разбивка по гостям — таблица (тот же приём, что summary.js#renderRows).
   T089 нашла на кабинете анкет ровно этот дефект узкой ширины: таблица (там
   10 колонок) была шире main и уезжала за край, доступная только сквозной
   прокруткой страницы. Решение оттуда же — overflow-x: auto держит
   прокрутку ВНУТРИ обёртки, не пуская её на всю страницу; .photos-gate--wide
   уже снял потолок колонки (T180, заход 1), здесь у таблицы всего 6 колонок,
   но приём тот же на случай узкого экрана/длинных имён гостей. */
.photos-summary__table-wrap {
  overflow-x: auto;
  margin: var(--space-sm) 0;
  border: 1px solid var(--c-line);
  border-radius: var(--radius-soft);
  background-color: var(--m-paper);
}

.photos-summary__table {
  border-collapse: collapse;
  width: 100%;
  min-width: 32rem;
  font-size: var(--fs-small);
}

.photos-summary__table caption {
  text-align: left;
  padding: var(--space-xs) var(--space-sm) 0;
  color: color-mix(in srgb, var(--c-muted) 70%, var(--c-ink));
}

.photos-summary__table th,
.photos-summary__table td {
  text-align: left;
  padding: 0.4em var(--space-sm);
  border-bottom: 1px solid var(--c-line);
  white-space: nowrap;
}

.photos-summary__table th {
  font-weight: 600;
}

.photos-summary__table tbody tr:last-child td {
  border-bottom: none;
}

.photos-summary__empty {
  margin: var(--space-sm) 0;
  padding: var(--space-sm);
  background-color: var(--m-paper);
  border: 1px solid var(--c-line);
  border-radius: var(--radius-soft);
  color: color-mix(in srgb, var(--c-muted) 70%, var(--c-ink));
}

/* T143: переключатель «только мои» (FR-024) переехал в постоянную нижнюю
   панель действий (.photos-panel-bar__button[data-photos-mine-toggle] ниже)
   — своей строки-панели над лентой (.photos-feed-toolbar) больше нет. */

/* Пустая лента (свежий альбом или «только мои» без своих кадров) —
   приглашение, а не тишина. */
.photos__empty {
  margin: var(--space-md) 0;
  color: color-mix(in srgb, var(--c-muted) 70%, var(--c-ink));
}

/* T161: кнопка возврата на «Все кадры» внутри текста пустой ленты при
   непустом срезе — обычная ссылка-кнопка без своей рамки, тот же приём,
   что у прочих текстовых действий страницы (не отдельная плашка). */
.photos__empty-back {
  margin-top: var(--space-xs);
  background: none;
  border: none;
  padding: 0;
  font: inherit;
  /* T161: --c-accent — нетекстовая роль (см. комментарий у палитры выше,
     контраст под текст не хватает) — для ссылки-текста берём
     --c-accent-deep, как обычные ссылки страницы. */
  color: var(--c-accent-deep, currentColor);
  text-decoration: underline;
  cursor: pointer;
}

/* T161: строка-признак текущего среза ленты — встаёт НАД первым заголовком
   группы (photos-cards.js#buildGroupLabel), не рядом с каждым: сама она не
   меняется от карточки к карточке, значит и место ей одно на всю ленту.
   Тот же приглушённый тон, что у .photos__empty/.photos__message —
   служебная строка состояния, не заголовок раздела. */
.photos-feed__slice {
  margin: var(--space-md) 0 var(--space-sm);
  color: color-mix(in srgb, var(--c-muted) 70%, var(--c-ink));
  font-weight: 600;
}

/* Сетка превью (T027, облик — T031s). auto-fill + minmax — колонки сами
   подстраиваются под ширину экрана от широких мониторов и планшетов, без
   медиа-запросов на каждый брейкпоинт. 7rem (плоская сетка до T031s) стало
   мало под рваный край листа .photo-card — на такой колонке лист съедал бы
   собственное поле-кайму. 12rem — минимум, на котором булавка (см. её
   расчёт у .photo-card__pin ниже) остаётся в пределах «не больше четверти
   ширины плитки» и не смазывается в пятно.
   На узких экранах (обзор T031s) от auto-fill с minmax(12rem, …) в
   .container с полями --space-md (1.75rem) на 320–390px остаётся 264–334px —
   этого хватает ровно на одну колонку 12rem, и лист на сотни кадров
   превращается в бесконечную ленту в один кадр на экран. Ниже 30em (480px,
   контейнеру принадлежит 424px — авто-заполнению 12rem там уже физически
   тесно для трёх колонок, но ровно двух хватает) сетка держит явные
   repeat(2, 1fr): при этом на 320px карточка получает ~7.75rem колонки
   (264px − column-gap 1rem, пополам), на 390px — ~9.9rem, обеих значений
   достаточно, чтобы после --photo-card-margin остался читаемый снимок.
   От 30em и шире возвращаем auto-fill — там минимума 12rem с запасом хватает
   на 2+ колонки без обрезки полей.
   row-gap и column-gap разведены (раньше был один `gap`): row-gap считан от
   размера булавки (см. --photo-card-pin-size ниже, комментарий там же) —
   иначе остриё соседнего верхнего ряда въезжало бы в лист нижнего;
   column-gap может быть меньше — по горизонтали карточкам мешает только
   собственное поле листа (--photo-card-margin), которое уже заложено в
   ширину каждой карточки. */
.photos-feed {
  /* Булавка верхнего ряда высовывается НАД сеткой (top: -2.7rem у
     .photos-feed__preview::before ниже), а row-gap защищает только ряды
     МЕЖДУ собой — верхний край сетки ничем не прикрыт, и булавка первого
     ряда лезла бы на то, что стоит над лентой (в разметке — панель
     .photos-feed-toolbar или заголовок раздела; T146 — на .photos__empty/
     .photos__message над лентой, когда фильтр показывает карточки «ждут
     одобрения» тем же узлом [data-photos-feed], см. photos.js). Считаем
     честно: padding у .photo-card задан
     процентом (--photo-card-inset: 9%), а проценты у padding всегда берутся
     от ШИРИНЫ содержащего блока (не от высоты, спецификация CSS), поэтому
     от аспекта карточки (4/5, 1/1, 5/4) вылет не зависит вовсе — только от
     ширины карточки. Самая узкая карточка — на 320px (двухколоночная сетка
     выше): колонка ~7.75rem, минус --photo-card-margin 2×0.5rem = ширина
     карточки ~6.75rem (108px). Инсет сверху на такой ширине — 9% × 108px =
     9.72px ≈ 0.61rem, вылет булавки над карточкой = 2.7rem − 0.61rem ≈
     2.09rem. На более широких карточках (390px и auto-fill от 30em) инсет в
     пикселях растёт вместе с шириной, а отступ булавки — нет, поэтому вылет
     там меньше: 320px — худший случай. Берём с запасом 2.2rem. */
  padding-top: 2.2rem;
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  grid-auto-flow: row dense;
  row-gap: 3rem;
  column-gap: var(--space-sm);
}

/* T175: своя сетка плитки НА КАЖДУЮ группу гостя (buildFeedGroupGrid,
   photos-cards.js; собирает renderFeedGroup, photos.js), а не общая
   .photos-feed на всю страницу, которой она была до T175. Причина —
   `grid-auto-flow: row dense`: алгоритм плотной укладки на каждой новой
   карточке ищет свободную ячейку ЗАНОВО ОТ НАЧАЛА сетки, а не продолжает
   от места последней уложенной карточки. Полноширинный заголовок
   (.photos-feed__group-label, grid-column: 1/-1) ломает раскладку по
   разметке — новый ряд, — но dense это не останавливает: дыра, оставшаяся
   в СТРОКАХ ГРУППЫ A от горизонтальных/вертикальных плиток
   (--tile-h/--tile-v, photos-tiles.js), не заполненная квадратными
   плитками той же группы, охотно принимает первую же плитку ГРУППЫ B —
   визуально кадр чужого гостя оказывается под именем предыдущего.
   Стенд поймал это фактом (зонд, не глазами): `groups:1 | ?=2 | badges:2` —
   два имени, одна сетка, оба кадра под первым. Единственная сетка на
   страницу физически не может дать группам такую изоляцию: dense видит
   ВСЮ сетку целиком. Отдельный узел `.photos-feed__group-grid` на каждую
   группу устраняет саму возможность утечки — dense-алгоритм одной сетки не
   видит ячеек другой, кадру группы B попросту негде оказаться в сетке
   группы A. Правило и медиа-запрос ниже — тот же набор тайловых свойств,
   что раньше жил только на .photos-feed, теперь общий для обоих узлов:
   внешний `.photos-feed` остаётся сеткой для устаревшего плоского режима
   без группировки (appendPhotosToFeed/loadOwnPendingOnly, photos.js — там
   имени автора нет и группы не нужны), новый `.photos-feed__group-grid` —
   для сгруппированных срезов (по автору/только мои/очередь одобрения).
   `grid-column: 1 / -1` у самого group-grid — та же граница плотной
   укладки ВНЕШНЕЙ сетки, что у заголовка: сама группа-сетка занимает
   целый ряд внешней .photos-feed (актуально только когда группа рисуется
   внутрь другой сетки — сейчас не происходит, но делает узел безопасным
   при таком использовании и не совпадающим по умолчанию auto-placement с
   соседними ярлыками). row-gap внешней .photos-feed (3rem) между
   заголовком и его группой-сеткой уже превышает требуемый запас под
   булавку верхнего ряда (2.2rem, см. комментарий выше) — отдельная
   padding-top у group-grid не нужна. */
.photos-feed__group-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  grid-auto-flow: row dense;
  row-gap: 3rem;
  column-gap: var(--space-sm);
  grid-column: 1 / -1;
}

@media (min-width: 30em) {
  .photos-feed,
  .photos-feed__group-grid {
    grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
  }
}

/* --------------------------------------------------------------------------
   Карточка ленты «плитка альбома» (T163, правка по замечанию пользователя
   2026-09-10 — «инстаграмный вид», НЕ рваный лист .scrapbook-photo) — облик
   кадра ленты и раздела «Ждут одобрения» (buildPendingCard делит разметку и
   класс, см. photos-cards.js). Раньше здесь стоял тот же приём слоёв, что у
   .scrapbook-photo (маска рваного листа, наклон, булавка через 12 вариантов
   photo-card--v0…v11) — решение стенда 2026-09-10 «кадр стоит на тонком
   бумажном поле» это явно исключает: рваный край и наклон — примета письма
   на главной, плитка модульного альбома должна быть ровной. Разметка ленты
   не изменилась (figure.photos-feed__item > button.photos-feed__preview >
   img): бумажное поле (::before, зерно + тень) — на figure, кадр — на
   preview. */
.photos-feed__item {
  margin: 0;
}

/* Отмена скрапбучного аппарата .photo-card ИМЕННО для плитки альбома —
   не переписывая саму .photo-card и её 12 вариантов --v0…v11 ниже (общий
   материал, его несёт и .scrapbook-photo-совместимая разметка на других
   экранах — трогать нельзя). Специфичность селектора двух классов (0,2,0)
   выше одиночного variant-/tile-модификатора (0,1,0), поэтому исторически
   он «перебивал» их — T164 снял мёртвые --photo-card-mask/-tilt/-pin
   из всех 12 вариантов (маска/наклон/булавка НИКОГДА не были видны на
   плитке ленты: .photo-card в её разметке всегда идёт с классом
   .photos-feed__item, значит это правило применялось всегда), и теперь это
   ЕДИНСТВЕННОЕ место, где эти три переменные вообще объявлены — не
   перебивание, а прямое определение.
   --photo-card-mask: none — при mask-image: none (@supports-блок ниже)
   ::before остаётся ПРЯМОУГОЛЬНЫМ бумажным полем (см. запасной вид без
   поддержки masking в комментарии у .photo-card::before) — то есть ровно
   то же самое поведение, что и деградация без masking, но теперь как
   основной вид, а не запасной. --photo-card-tilt: 0deg — карточка не
   повёрнута. --photo-card-pin: none — фон булавки пуст, сама обвязка
   (top: -2.7rem, height, pointer-events: none) остаётся, но невидимой
   пустой рамкой — переписывать .photos-feed__preview::before ради этого
   не стали: изменение JS/разметки не требуется. --photo-card-inset
   уменьшен с 9% (запас под рваный край) до тонкого ровного поля. */
.photos-feed__item.photo-card {
  --photo-card-mask: none;
  --photo-card-tilt: 0deg;
  --photo-card-pin: none;
  --photo-card-inset: 0.5rem;
}

/* Скругление и обрезка кадра — маски-webp больше не режут силуэт (см.
   выше), поэтому и лист, и превью-кнопку скругляем сами, тем же
   --radius-soft, что уже держит общий язык мягких скруглений на странице
   (0.5rem, style.css:399). У листа скругление даёт форму под drop-shadow
   (::before ниже) — тень посчитана по альфа-силуэту, повторит скруглённый
   прямоугольник вместо рваного контура. У превью — своё скругление плюс
   overflow: hidden, иначе кадр (object-fit: cover) вылезал бы прямыми
   углами поверх скруглённого листа. */
.photos-feed__item.photo-card::before {
  border-radius: var(--radius-soft);
}

.photos-feed__item .photos-feed__preview {
  overflow: hidden;
  border-radius: var(--radius-soft);
}

/* Заголовок группы «имя гостя» (T163, buildGroupLabel — photos-cards.js) —
   один на группу кадров одного автора, во всю ширину сетки. `1 / -1` здесь
   — облик (плашка шире одной ячейки), а НЕ граница плотной укладки между
   авторами, как считалось до T175: полноширинный блок ломает раскладку
   только ПО РАЗМЕТКЕ (следующий элемент начинает новый ряд), но
   `grid-auto-flow: row dense` ищет свободную ячейку заново от начала
   сетки на каждой карточке и такую границу не уважает — плитка следующего
   гостя всё равно может всплыть в дыру предыдущей группы (T175, поймано
   зондом на стенде). Настоящая граница между авторами теперь структурная
   — своя `.photos-feed__group-grid` на каждую группу (её комментарий у
   .photos-feed выше) — этот заголовок просто предшествует ей визуально.
   Облик и материал — тот же приём, что был у снятого T164 ярлычка листа
   .album-sheet__label (бумажная табличка + рукописная гарнитура): тот же
   вывод со стенда T138 «рукопись без бумажной подложки не читается на
   закате», тут действует ровно так же — заголовок ленты. */
.photos-feed__group-label {
  grid-column: 1 / -1;
  max-width: max-content;
  margin: 0;
  padding: 0.15em 0.8em 0.25em;
  font-size: var(--fs-ui);
  font-family: var(--font-script);
  color: var(--c-ink);
  background-color: var(--m-paper);
  background-image: url('../img/decor/paper-grain.webp');
  background-repeat: repeat;
  background-size: 14rem auto;
  box-shadow: 0 0.2rem 0.4rem color-mix(in srgb, var(--c-ink) 20%, transparent);
  transform: rotate(-0.6deg);
}

.photo-card {
  /* Запас по ширине карточки под боундинг-бокс поворота и высунутую над
     листом булавку — тот же смысл, что --scrapbook-margin, но меньше:
     плитка сетки сама по себе мельче колонки переписки, где рвётся
     .scrapbook-photo, раздувать поле карточки на 1rem с каждой стороны
     означало бы заметно ужать снимок в тесной колонке (12rem минимум). */
  --photo-card-margin: 0.5rem;
  /* Поле листа вокруг снимка — тот же принцип, что --scrapbook-inset:
     рваный край должен быть виден каймой, а не тонкой линией. */
  --photo-card-inset: 9%;
  position: relative;
  width: calc(100% - 2 * var(--photo-card-margin));
  margin-inline: auto;
  aspect-ratio: var(--photo-card-ratio);
  padding: var(--photo-card-inset);
  transform: rotate(var(--photo-card-tilt));
}

/* Лист бумаги — см. подробный разбор слоёв у .scrapbook-photo::before выше,
   приём идентичен: маска висит на замаскированном ::before (не на самой
   карточке — иначе клипнула бы снимок-потомка), drop-shadow посчитан по уже
   вырезанному силуэту, поэтому повторяет рваный контур, а не исходный
   прямоугольник. Без поддержки masking (@supports ниже) остаётся простым
   прямоугольным полем того же цвета — читаемый запасной вид, не «дыра». */
.photo-card::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  background-color: var(--m-paper);
  background-image: url('../img/decor/paper-grain.webp');
  background-repeat: repeat;
  background-size: 14rem auto;
  /* Опора листа под кадром на светлом фоне (обзор T031s): 0.4rем/0.6rem при
     32% альфы читались плоско на светлом закате — карточка сливалась С
     фоном вместо того, чтобы лежать НА нём. Ориентир — .scrapbook-photo
     стоит на той же подложке и держит опору именно этими величинами:
     0.6rem/0.9rem при тех же 32% (само значение прозрачности не трогаем —
     это не про темноту тени, а про её размер/растяжку). */
  filter: drop-shadow(0 0.6rem 0.9rem color-mix(in srgb, var(--c-ink) 32%, transparent));
}

@supports (mask-image: none) or (-webkit-mask-image: none) {
  .photo-card::before {
    mask-image: var(--photo-card-mask);
    -webkit-mask-image: var(--photo-card-mask);
    mask-mode: alpha;
    mask-repeat: no-repeat;
    -webkit-mask-repeat: no-repeat;
    mask-size: 100% 100%;
    -webkit-mask-size: 100% 100%;
    mask-position: center;
    -webkit-mask-position: center;
  }
}

/* Превью — кнопка, а не <a>/<img> напрямую: клик открывает крупный просмотр
   (T029). position: relative — точка отсчёта для .photos-feed__caption
   (T028) и булавки (::before ниже) поверх картинки. Внутри .photo-card
   кнопка заполняет остаток листа после --photo-card-inset (padding задан на
   родителе-figure) — рваный край листа остаётся снаружи, снимок внутри
   обычным прямоугольником, как __frame img у .scrapbook-photo. Скругление и
   overflow:hidden сняты — это уже не самостоятельная плитка со своей формой,
   форму задаёт лист под ней. */
.photos-feed__preview {
  position: relative;
  display: block;
  width: 100%;
  height: 100%;
  padding: 0;
  border: none;
  background-color: var(--c-line);
  cursor: pointer;
}

/* Булавка (T031s) — тот же приём, что .scrapbook-photo__frame::before, но
   размер продиктован плиткой сетки, а не переписки. Расчёт: минимальная
   колонка сетки — 12rem (см. .photos-feed выше), «не больше примерно
   четверти ширины плитки» — потолок ширины булавки 3rem. Ширина считается
   из `height` через `aspect-ratio` в натуральной пропорции файла (0.55 у
   обоих pin-*.webp) — при высоте 2.4rem ширина ≈1.3rem (≈21px при 16px
   корне), это меньше потолка почти вчетверо: разгон до 3rem не нужен —
   pin-графика узкая по своей природе (аспект ~0.55), и от роста высоты
   ширина растёт вместе с ней в фиксированной пропорции, но выше 2.4rem
   булавка на плитке 12rem уже спорит по площади с самим снимком. Отступ
   сверху — -(высота + напуск 0.3rem), как у .scrapbook-photo, «остриё
   воткнуто в лист»: row-gap сетки (3rem, см. .photos-feed) обязан
   перекрывать именно эту величину (2.7rem) с запасом, иначе остриё нижней
   карточки въезжало бы в лист карточки над ней. */
.photos-feed__preview::before {
  content: "";
  position: absolute;
  top: -2.7rem;
  left: 50%;
  height: 2.4rem;
  width: auto;
  aspect-ratio: var(--photo-card-pin-ratio);
  background-image: var(--photo-card-pin);
  background-repeat: no-repeat;
  background-size: contain;
  pointer-events: none;
  z-index: 2;
  transform: translateX(-50%) rotate(var(--photo-card-pin-rotate));
}

/* Двенадцать вариантов «фотокарточки» (T031s) — выбор варианта детерминирован
   по photoId (см. hashPhotoVariant в photos-cards.js). Раньше тройка
   пропорция+маска+наклон+булавка решалась здесь же на каждый номер — T164
   (ретайр фазы 19) снял маску/наклон/фон булавки из всех 12 вариантов:
   плитка ленты (T163) гасит их правилом `.photos-feed__item.photo-card`
   (mask: none, tilt: 0deg, pin: none — оно теперь ЕДИНСТВЕННЫЙ источник
   этих трёх переменных, .photo-card в разметке ленты без этого класса не
   встречается) — 12 разных значений мёртвым кодом лежать не должны. Остался
   разброс пропорции (--photo-card-ratio, три штуки почти поровну — 4
   портрет 4:5, 4 квадрат 1:1, 4 альбом 5:4) и геометрия невидимой рамки
   булавки (--photo-card-pin-ratio/--photo-card-pin-rotate — фон пуст, но
   бокс её ::before остаётся в разметке). */
.photo-card--v0 {
  --photo-card-ratio: 4 / 5;
  --photo-card-pin-ratio: 220 / 395;
  --photo-card-pin-rotate: 5deg;
}

.photo-card--v1 {
  --photo-card-ratio: 1 / 1;
  --photo-card-pin-ratio: 211 / 383;
  --photo-card-pin-rotate: -9deg;
}

.photo-card--v2 {
  --photo-card-ratio: 5 / 4;
  --photo-card-pin-ratio: 211 / 383;
  --photo-card-pin-rotate: 7deg;
}

.photo-card--v3 {
  --photo-card-ratio: 4 / 5;
  --photo-card-pin-ratio: 220 / 395;
  --photo-card-pin-rotate: -4deg;
}

.photo-card--v4 {
  --photo-card-ratio: 1 / 1;
  --photo-card-pin-ratio: 211 / 383;
  --photo-card-pin-rotate: 9deg;
}

.photo-card--v5 {
  --photo-card-ratio: 5 / 4;
  --photo-card-pin-ratio: 220 / 395;
  --photo-card-pin-rotate: -6deg;
}

.photo-card--v6 {
  --photo-card-ratio: 4 / 5;
  --photo-card-pin-ratio: 220 / 395;
  --photo-card-pin-rotate: 3deg;
}

.photo-card--v7 {
  --photo-card-ratio: 1 / 1;
  --photo-card-pin-ratio: 211 / 383;
  --photo-card-pin-rotate: -8deg;
}

.photo-card--v8 {
  --photo-card-ratio: 5 / 4;
  --photo-card-pin-ratio: 211 / 383;
  --photo-card-pin-rotate: 6deg;
}

.photo-card--v9 {
  --photo-card-ratio: 4 / 5;
  --photo-card-pin-ratio: 220 / 395;
  --photo-card-pin-rotate: -3deg;
}

.photo-card--v10 {
  --photo-card-ratio: 1 / 1;
  --photo-card-pin-ratio: 220 / 395;
  --photo-card-pin-rotate: 8deg;
}

.photo-card--v11 {
  --photo-card-ratio: 5 / 4;
  --photo-card-pin-ratio: 211 / 383;
  --photo-card-pin-rotate: -5deg;
}

/* Три span-модификатора плитки (T163, конечный набор — tileSizeFor,
   photos-tiles.js): span в терминах ячеек .photos-feed (grid-auto-flow:
   row dense, см. выше) — как раз то, что предыдущий заход не успел
   дописать. Каждый модификатор попутно переопределяет --photo-card-ratio,
   заданный variant-классом --v0…v11 чуть выше: сама переменная — та же
   custom property, у обоих правил одинаковая специфичность (один класс),
   побеждает объявленное позже в каскаде — то есть именно span-модификатор,
   а не variant. Без этого переопределения квадрат/полосу строил бы не
   размер ЯЧЕЙКИ, а собственный аспект варианта (4/5, 1/1 или 5/4) —
   implicit-ряды из комментария у .photos-feed выше держались бы только
   тогда, когда они совпадают случайно (маска/наклон/булавка variant-класса
   сюда больше не относятся — T164 снял их из --v0…v11, см. комментарий
   там же: гасятся одним местом, `.photos-feed__item.photo-card`). */
.photo-card--tile-sq {
  --photo-card-ratio: 1 / 1;
  grid-column: span 1;
  grid-row: span 1;
}

.photo-card--tile-h {
  --photo-card-ratio: 2 / 1;
  grid-column: span 2;
  grid-row: span 1;
}

.photo-card--tile-v {
  --photo-card-ratio: 1 / 2;
  grid-column: span 1;
  grid-row: span 2;
}

/* Узкие экраны (320px, .photos-feed выше держит на них repeat(2, 1fr)):
   горизонтальная плитка (span 2) занимает ОБЕ колонки целиком — это не
   поломка сетки, а вырожденный случай «на всю ширину», тот же исход, что
   у .photos-feed__group-label (тоже 1 / -1 по факту при двух колонках).
   Вертикальная плитка (span 1 col × 2 row) на двух колонках ведёт себя
   штатно — соседняя колонка той же пары строк остаётся доступна для
   dense-упаковки других кадров. */

.photos-feed__img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Заглушка кадра без превью (gridUrl: null, например HEIC) — кадр не
   выбрасываем из ленты, но и не притворяемся, что там есть картинка. */
.photos-feed__placeholder {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100%;
  padding: var(--space-xs);
  font-size: var(--fs-small);
  color: var(--c-muted);
  text-align: center;
}

/* Метка конца ленты для IntersectionObserver — не декоративная (это не img),
   но и не должна быть заметна. */
.photos-feed__sentinel {
  height: 1px;
}

/* Карточки «ждут одобрения» (T031h, FR-024a-b → T146) — раньше стояли
   отдельным разделом над лентой (заголовок + рамка/панель, класс
   .photos-pending удалён вслед за разметкой в T146: раздел поглощён
   третьим положением фильтра ленты, .photos-feed уже несёт нужные отступы
   сама). Здесь остались только классы самой карточки (.photos-pending__item
   ниже, через photo-card, и .photos-pending__badge) — их строит
   buildPendingCard (photos-cards.js) независимо от того, куда её вставляют. */

/* Статичное превью раздела ожидающих — без кнопки: кадр не открывается
   крупным просмотром, он не часть ленты и её слайдов (photos.js). */
.photos-feed__preview--static {
  cursor: default;
}

/* Пометка «Ждёт одобрения» — обычный видимый текст поверх превью, читается
   и без цвета (не полагается только на оттенок фона/текста), в отличие от
   .photos-feed__caption всегда на виду, а не только при наведении: гость
   должен сразу понимать, что кадр ещё не в общей ленте. */
.photos-pending__badge {
  position: absolute;
  inset: auto 0 0 0;
  margin: 0;
  padding: 0.3em 0.5em;
  font-size: var(--fs-small);
  font-weight: 600;
  text-align: center;
  color: var(--c-bg);
  background: color-mix(in srgb, var(--c-ink) 70%, transparent);
}

/* Подпись и имя автора в сетке (T028, FR-023): по умолчанию (в т.ч. на
   сенсорных экранах, где наведения не бывает) убрана из потока вовсе, не
   просто прозрачна — FR-023 отправляет такого гостя за подписью в крупный
   просмотр (T029), держать невидимый блок в разметке незачем. aria-hidden в
   разметке (photos.js) в любом случае убирает его из дерева доступности
   независимо от display. Медиа-запрос ниже, у которого выше специфичность
   правил ровно та же, но объявлен позже в каскаде, — единственное место, где
   это правило переопределяется для указывающих устройств. */
.photos-feed__caption {
  display: none;
}

@media (hover: hover) and (pointer: fine) {
  .photos-feed__caption {
    position: absolute;
    inset: auto 0 0 0;
    margin: 0;
    padding: 0.4em 0.5em;
    font-size: var(--fs-small);
    line-height: 1.2;
    color: var(--c-bg);
    background: linear-gradient(to top, color-mix(in srgb, var(--c-ink) 75%, transparent), transparent);
    opacity: 0;
    transition: opacity 0.15s ease;
    /* Многострочная подпись в тесной плитке (7rem, .photos-feed) не должна
       распирать превью — обрезаем по высоте, полный текст всё равно есть в
       крупном просмотре и в aria-label. */
    display: -webkit-box;
    -webkit-line-clamp: 3;
    -webkit-box-orient: vertical;
    overflow: hidden;
  }

  .photos-feed__preview:hover .photos-feed__caption,
  .photos-feed__preview:focus-visible .photos-feed__caption {
    opacity: 1;
  }
}

/* --------------------------------------------------------------------------
   Углы и низ плитки (T163) — четыре узла делят предсказуемые места на
   ОДНОЙ и той же ячейке .photos-feed__item/.photo-card (position: relative
   уже задан выше), чтобы будущие задачи не гадали, где встать:
     • верхний левый угол   — .photos-feed__select (T154: перенесена сюда
       решением пользователя 2026-09-10, освободила правый верхний угол);
     • верхний правый угол  — .photos-feed__card-menu (T154, «три точки»);
       на карточке очереди модерации в тот же угол претендует ещё и
       .photos-feed__pending-badge — см. правило `:has()` у неё ниже,
       уступает место левее меню (бейдж декоративен и pointer-events:
       none, ему проще подвинуться, чем интерактивной кнопке меню);
     • низ плитки целиком   — резерв под будущие реакции (полоса шире
       одного угла, встанет НИЖЕ пары кнопок из нижних углов, если меню и
       выбор к тому моменту всё ещё видны).
   .photos-feed__unapprove (тоже ниже) — не «будущий», а уже рабочий
   элемент, поэтому под свой угол не садится: текст кнопки длинный
   («Снять одобрение (останется у тех, кто скачал)»), в круглый бейдж не
   влезает никак, а сокращать его нельзя — FR-048a требует честной
   формулировки без обещаний про уже розданное. Ставим его ПОД плиткой
   обычным потоком (см. правило ниже), а не поверх кадра. */

/* Круглый бейдж «ждёт одобрения» (T163) — заменяет прежний текстовый
   ярлык buildPendingCard: тот текст («Ждёт одобрения») на одноколоночной
   плитке (1 колонка на узком экране) переносился по словам и терял вид
   бейджа. Круг работает на любой ширине плитки. Видимое содержимое — значок
   `hourglass` (site/js/album-icons.js, решение пользователя 2026-09-10:
   платформенный эмодзи ⏳ заменён вывернутыми векторными песочными часами),
   весь смысл несёт aria-label самого узла (role="img"). */
.photos-feed__pending-badge {
  position: absolute;
  top: 0.5rem;
  right: 0.5rem;
  z-index: 3;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 1.7rem;
  height: 1.7rem;
  border-radius: 50%;
  color: var(--c-bg);
  background: color-mix(in srgb, var(--c-ink) 70%, transparent);
  box-shadow: 0 0 0 1px color-mix(in srgb, var(--m-paper) 80%, transparent);
  pointer-events: none;
}

/* Значок часов вывернут (T163): подложка тёмная (--c-ink 70%), поэтому
   единственный путь значка (роль `ink`, см. album-icons.js) красится
   светлым --c-bg — тем же токеном, что уже задаёт `color` бейджа выше. */
.photos-feed__pending-badge .album-icon {
  --icon-ink: var(--c-bg);
  width: 0.9rem;
  height: 0.9rem;
}

/* T154: на карточке очереди модерации пары бейдж делит верхний правый угол
   с кнопкой меню «три точки» (.photos-feed__card-menu, ниже) — сдвигаем
   бейдж левее на ширину кнопки-значка + зазор, а не наоборот: кнопка меню
   интерактивна и должна остаться на предсказуемом месте угла. */
.photos-feed__item:has(.photos-feed__card-menu) .photos-feed__pending-badge {
  right: 2.6rem;
}

/* Круг отметки «для скачивания» (T046) — верхний левый угол плитки (решение
   пользователя 2026-09-10; раньше стоял в нижнем левом, см. карту углов в
   комментарии блока выше). Правый верхний угол занят меню кадра «три
   точки» (T154) — углы разведены намеренно, чтобы кружки не перекрывались.
   Не чекбокс, а кнопка: галочка — заливка круга акцентным золотом при
   --checked, без нее — прозрачная заливка на тёмной подложке поверх любого
   (в т.ч. светлого) кадра. */
/* T150: кружок виден ТОЛЬКО в режиме выбора — .photos-feed--selecting
   ставит photos.js на сам контейнер ленты (data-photos-feed) при входе/
   выходе из режима. Кнопка в разметке карточки остаётся всегда (второй
   источник правды на клик по кадру не заводим — buildPhotoCard не
   перестраивается на вход/выход из режима), скрыта именно CSS.
   Исключение (2026-09-12): страницы архива (photos-archive-section.js) и
   корзины гостя (photos-guest-bin.js) режима выбора не заводят вовсе —
   панель действий там висит постоянно, отметка кадра и есть основное
   занятие. Им сетка ставит .photos-feed--select-always вместо
   .photos-feed--selecting — тот же CSS-гейт, но включён без входа/выхода
   из режима. */
.photos-feed__select {
  display: none;
  position: absolute;
  top: 0.5rem;
  left: 0.5rem;
  z-index: 3;
  width: 1.7rem;
  height: 1.7rem;
  padding: 0;
  border: 2px solid var(--m-paper);
  border-radius: 50%;
  background-color: color-mix(in srgb, var(--c-ink) 55%, transparent);
  cursor: pointer;
}

.photos-feed--selecting .photos-feed__select,
.photos-feed--select-always .photos-feed__select {
  display: block;
}

.photos-feed__select--checked {
  background-color: var(--c-accent-deep);
  border-color: var(--c-accent);
}

.photos-feed__select--checked::after {
  content: "✓";
  display: block;
  font-size: var(--fs-small);
  line-height: 1.3rem;
  color: var(--c-bg);
}

/* Меню «три точки» кадра (T154) — верхний правый угол плитки (см. карту
   углов в комментарии блока выше). Обёртка — точка отсчёта для попапа,
   тем же приёмом, что .photos-panel-bar__filter: `position: relative`
   держит .photos-feed__card-menu-list абсолютным именно относительно
   кнопки-значка, а не всей ячейки, поэтому список не съезжает, даже если
   у кадра появится вертикальная/горизонтальная плитка (photo-card--tile-h/v)
   иной ширины. */
.photos-feed__card-menu {
  position: absolute;
  top: 0.5rem;
  right: 0.5rem;
  z-index: 4;
}

.photos-feed__card-menu-toggle {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 1.7rem;
  height: 1.7rem;
  padding: 0;
  border: 2px solid var(--m-paper);
  border-radius: 50%;
  background-color: color-mix(in srgb, var(--c-ink) 55%, transparent);
  cursor: pointer;
}

.photos-feed__card-menu-toggle .album-icon {
  width: 1rem;
  height: 1rem;
}

.photos-feed__card-menu-toggle[aria-expanded="true"] {
  background-color: var(--c-accent-deep);
  border-color: var(--c-accent);
}

/* Список пунктов — та же стеклянная панель и те же классы облика строки,
   что и у попапов футера (.photos-filter-menu/.photos-filter-menu__option,
   ниже в файле) — но СВОИМИ именами: у плитки список должен уместиться в
   ширину узкой ячейки (photo-card--tile-sq на телефоне), поэтому раскрыт
   не вверх от значка, а вниз и прижат к правому краю ячейки, чтобы не
   вылезать за него на узкой ширине и не перекрывать плашку с именем
   гостя (.photos-feed__group-label) над плиткой.
   T190: `right: 0`/`min-width: 12rem` ниже — БЕЗОПАСНЫЙ ДЕФОЛТ, не
   единственный источник истины. У левой колонки двухколоночной плитки на
   телефоне (320-390px) правый край ячейки лежит у середины экрана — левее
   12rem места нет, список выезжал за левый край окна и срезался
   `overflow-x: hidden` (строка 488, её ослаблять нельзя). Перед каждым
   открытием applyCardMenuPlacement (site/js/photos-cards.js, расчёт —
   site/js/photos-card-menu-position.js) меряет реальное место у краёв
   экрана и перебивает left/right/width/min-width инлайновым стилем —
   `popover` не применяется (нет в iOS Safari < 17, браузер заказчиков).
   T202 (живой тест, жалоба на перелив текста): список раньше держал
   `white-space: nowrap` — на узкой левой колонке двухколоночной плитки
   applyCardMenuPlacement честно ужимает ширину списка ниже preferredWidth
   (12rem), а самая длинная подпись («Попросить убрать этот кадр») физически
   не помещалась в одну строку и вылезала за рамку списка — обрезка была бы
   менее честной (пункт про просьбу убрать кадр важно прочитать целиком, не
   угадывая по хвостику). Правило `nowrap` снято здесь; перенос слов —
   `.photos-feed__card-menu-option`/`-note` ниже. */
.photos-feed__card-menu-list {
  position: absolute;
  top: calc(100% + var(--space-xs));
  right: 0;
  z-index: 16;
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: var(--space-xs);
  min-width: 12rem;
  max-width: calc(100vw - 2rem);
  background-color: var(--m-glass);
  border: 1px solid color-mix(in srgb, var(--m-gold) 70%, transparent);
  border-radius: var(--radius);
  /* Находка 3 (фаза 25): у пары до 5-6 пунктов, а T202 снял nowrap — список
     мог не влезать в оставшееся под кнопкой место у карточек нижнего ряда
     плитки. applyCardMenuPlacement (photos-cards.js, расчёт —
     computeCardMenuVerticalPlacement в photos-card-menu-position.js) перед
     каждым открытием перебивает top/bottom инлайновым стилем (раскрытие
     вверх, если снизу не хватает места) и, если список не помещается целиком
     ни в одну сторону, ставит max-height/overflow-y — `overscroll-behavior:
     contain` ниже держит прокрутку колесом/свайпом внутри списка, чтобы она
     не «протекала» на страницу под ним, когда список дополз до своего
     предела. */
  overscroll-behavior: contain;
}

.photos-feed__card-menu-list[hidden] {
  display: none;
}

.photos-feed__card-menu-option {
  display: flex;
  align-items: center;
  gap: 0.5em;
  padding: 0.35em 0.6em;
  border: none;
  border-radius: var(--radius);
  background-color: transparent;
  color: var(--c-ink);
  font-family: var(--font-ui);
  font-size: var(--fs-small);
  text-align: left;
  cursor: pointer;
  min-height: 44px;
  /* T202: перенос вместо обрезки (см. комментарий у .photos-feed__card-menu-list
     выше) — `min-height` растёт по контенту (это `min-`, не фиксированная
     высота), двухстрочный пункт не режется. `overflow-wrap` — защита на
     случай одного слова шире всей ужатой ширины списка (запаса ради, не
     наблюдалось расчётом на 320-390px, см. отчёт T202). */
  white-space: normal;
  overflow-wrap: break-word;
}

.photos-feed__card-menu-option .album-icon {
  width: 1.1rem;
  height: 1.1rem;
  flex-shrink: 0;
}

/* T202: подпись — flex-элемент без своего класса (см. photos-cards.js);
   без min-width:0 flex-контейнер отказывается сжимать текстовый узел ниже
   его «естественной» ширины даже при overflow-wrap: break-word — перенос
   выше сработал бы только на широких карточках, а именно на узкой левой
   колонке (T190/T202) он и нужен. */
.photos-feed__card-menu-option span,
.photos-feed__card-menu-note span {
  min-width: 0;
}

.photos-feed__card-menu-option:hover {
  background-color: color-mix(in srgb, var(--c-bg) 60%, transparent);
}

.photos-feed__card-menu-option--danger {
  color: var(--c-accent-deep);
}

/* T179 (пачка C2): текст просьбы гостя в меню карточки — запись-пояснение,
   не пункт-действие (по ней нечего нажимать). Курсив и приглушённый цвет
   вместо cursor:pointer/hover .photos-feed__card-menu-option — визуально не
   должна выглядеть нажимаемой. */
.photos-feed__card-menu-note {
  display: flex;
  align-items: flex-start;
  gap: 0.5em;
  padding: 0.35em 0.6em;
  margin: 0;
  color: var(--c-ink);
  opacity: 0.75;
  font-family: var(--font-ui);
  font-size: var(--fs-small);
  font-style: italic;
  text-align: left;
  /* T202: тот же перенос, что у .photos-feed__card-menu-option — текст
     просьбы (T179) гостевой и заведомо длиннее любого пункта-действия. */
  white-space: normal;
  overflow-wrap: break-word;
}

.photos-feed__card-menu-note .album-icon {
  width: 1.1rem;
  height: 1.1rem;
  flex-shrink: 0;
}

/* Кнопка пары «снять одобрение» (T085/FR-048) — под плиткой обычным
   потоком (см. разбор в комментарии блока выше, почему не в углу).
   .photos-feed__item — figure/div без display:flex собственного, поток
   после .photo-card просто добавляет кнопке свою строку под плиткой, не
   трогая grid-row плитки (кнопка сама по себе не участник сетки — она
   внутри той же ячейки, что и .photo-card, растягивает её высоту, что и
   нужно: пара соглашается на более высокую строку ради доступа к кнопке
   на каждом одобренном кадре). */
.photos-feed__unapprove {
  display: block;
  width: 100%;
  margin-top: 0.4rem;
  font-size: var(--fs-small);
  text-align: center;
}

/* --------------------------------------------------------------------------
   Крупный просмотр (T029) — нативный <dialog>, тот же приём, что у
   .map-dialog/.privacy-dialog (index.html): showModal() открывает поверх
   ::backdrop, Esc и фокус-трэп — стандартное поведение диалога, здесь
   ничего этого не переопределяем.
   -------------------------------------------------------------------------- */

/* На весь экран, а не карточкой по центру, как .map-dialog/.privacy-dialog:
   тем двум важна читаемость строк текста/карты в разумной колонке, здесь —
   наоборот, кадру нужно как можно больше места, а рамка вокруг только
   мешала бы разглядеть фотографию. */
.photos-viewer {
  width: 100vw;
  height: 100vh;
  max-width: none;
  max-height: none;
  margin: 0;
  padding: 0;
  border: none;
  background-color: var(--c-ink);
  color: var(--c-bg);
}

.photos-viewer::backdrop {
  background-color: var(--c-ink);
}

.photos-viewer__close-form {
  position: absolute;
  top: var(--space-xs);
  right: var(--space-xs);
  z-index: 1;
}

/* T031s (замечание пользователя 2026-09-11, оценка стенда): было «ЗАКРЫТЬ ×»
   капслоком в золотой плашке — на сайте капса нет нигде (Pompadur его не
   поддерживает), а плашка со словом читалась инородно поверх фотографии.
   Заменена на компактный значок — тот же круглый приём материала, что у
   .photos-feed__card-menu-toggle (стеклянная заливка на тёмном, кайма
   золотом, а не белым по контуру): полотно просмотра тёмное намеренно
   (снимок читается лучше), но управление на нём обязано звучать тем же
   материалом сайта, что и на ленте. */
.photos-viewer__close {
  display: flex;
  align-items: center;
  justify-content: center;
  /* Тот же приём px-порога тач-цели, что у .map-dialog__close выше. */
  width: 2.4rem;
  height: 2.4rem;
  padding: 0;
  border: 2px solid var(--m-gold);
  border-radius: 50%;
  background-color: color-mix(in srgb, var(--c-ink) 55%, transparent);
  color: var(--c-accent);
  cursor: pointer;
}

.photos-viewer__close .album-icon {
  --icon-ink: currentColor;
  width: 1rem;
  height: 1rem;
}

.photos-viewer__close:hover {
  background-color: color-mix(in srgb, var(--c-accent) 35%, transparent);
}

/* T204 (живой тест 2026-09-18): каналов ввода для ПК не было вовсе — гость
   листал стрелками клавиатуры, до трека они не доходили. Кнопки «‹ ›» тем
   же материалом, что .photos-viewer__close выше (стеклянная заливка на
   тёмном, золотая кайма) — управление просмотра звучит одним языком.
   Позиция — по вертикальному центру у боковых краёв кадра, НАД треком
   (z-index), сам трек и его scroll-snap не трогаем — кнопка лишь
   прокручивает его (см. photos-viewer-nav.js + подключение в photos.js). */
.photos-viewer__nav-button {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 2.4rem;
  height: 2.4rem;
  padding: 0;
  border: 2px solid var(--m-gold);
  border-radius: 50%;
  background-color: color-mix(in srgb, var(--c-ink) 55%, transparent);
  color: var(--c-accent);
  cursor: pointer;
}

.photos-viewer__nav-button--prev {
  left: var(--space-xs);
}

.photos-viewer__nav-button--next {
  right: var(--space-xs);
}

.photos-viewer__nav-button:hover {
  background-color: color-mix(in srgb, var(--c-accent) 35%, transparent);
}

/* На первом/последнем кадре кнопка НЕактивна (не пропадает, T204) — тусклее
   и без указателя, но геометрию не меняет, чтобы соседний кадр не «прыгал». */
.photos-viewer__nav-button:disabled {
  opacity: 0.35;
  cursor: default;
}

.photos-viewer__nav-button .album-icon {
  --icon-ink: currentColor;
  width: 1rem;
  height: 1rem;
}

/* Тот же глиф arrowLeft, что у кнопки «назад» — «вперёд» получает его же,
   развёрнутым CSS'ом (глиф вертикально симметричен, поворот на 180°
   визуально равен горизонтальному отражению): второй зеркальный SVG в
   album-icons.js не заводим. */
.photos-viewer__nav-button--next .album-icon {
  transform: rotate(180deg);
}

/* Счётчик «N из M» (T204) — тот же материал, что подпись автора/текст кадра
   ниже (.photos-viewer__caption/__author): светлый текст на тёмном полотне
   просмотра, без капса (Pompadur его не поддерживает, T031s). Верх, по
   центру — не спорит с кнопкой закрытия (верх-право) и кнопками листания
   (по бокам, центр по высоте). */
.photos-viewer__counter {
  position: absolute;
  top: var(--space-xs);
  left: 50%;
  transform: translateX(-50%);
  z-index: 1;
  margin: 0;
  padding: 0.15em 0.6em;
  color: var(--c-bg);
  font-size: var(--fs-small);
}

/* Лента слайдов — горизонтальный scroll-snap (T029): свайп между кадрами
   отдаём браузеру целиком, ни один обработчик touch/pointer тут не висит.
   touch-action НЕ задан нигде в этом блоке намеренно (остаётся auto) —
   любое ограничение (даже pan-x) отбирало бы у изображения нативный
   pinch-zoom (FR из задания T029: зум ломать нельзя). */
.photos-viewer__track {
  display: flex;
  width: 100%;
  height: 100%;
  overflow-x: auto;
  overflow-y: hidden;
  scroll-snap-type: x mandatory;
}

.photos-viewer__slide {
  flex: 0 0 100%;
  width: 100%;
  height: 100%;
  margin: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-xs);
  padding: var(--space-lg) var(--space-md) var(--space-md);
  scroll-snap-align: start;
  scroll-snap-stop: always;
}

.photos-viewer__img {
  max-width: 100%;
  max-height: 100%;
  min-height: 0;
  /* flex-shrink по умолчанию уже 1, но flex-basis auto у <img> в некоторых
     браузерах трактуется по intrinsic-размеру и не даёт сжаться ниже
     естественной высоты — 0 снимает это ограничение, картинка ужимается
     ровно до соседних отступов слайда. */
  flex: 1 1 0;
  object-fit: contain;
}

/* Долг [photos-4k-view] (решение пользователя 2026-09-16): без этого
   правила заглушка — обычный <span> с текстом внутри flex-колонки
   .photos-viewer__slide (justify-content: center) — схлопывалась по
   содержимому и висела крошечной табличкой посреди тёмного экрана вместо
   проёма кадра (стенд поймал это на снимке). .photos-viewer__img — сосед по
   тому же слайду — получает свой проём через `flex: 1 1 0` + `max-width:
   100%`/`max-height: 100%` (см. правило выше); заглушка переиспользует
   ТЕ ЖЕ величины (не выдумывает своих), чтобы претендовать на тот же объём,
   что получил бы настоящий кадр. `aspect-ratio: 1 / 1` — тот же нейтральный
   квадрат по умолчанию, который берёт плитка ленты для кадра с неизвестными
   width/height (TILE_SIZES.square, photos-tiles.js) — единственная
   уже существующая в проекте величина на случай «пропорция кадра
   неизвестна», второй не заводим. */
.photos-viewer__placeholder {
  display: flex;
  align-items: center;
  justify-content: center;
  flex: 1 1 0;
  min-height: 0;
  width: 100%;
  max-width: 100%;
  max-height: 100%;
  aspect-ratio: 1 / 1;
  border-radius: var(--radius-soft);
  background-color: color-mix(in srgb, var(--c-bg) 12%, transparent);
  color: var(--c-bg);
  text-align: center;
  padding: var(--space-md);
}

.photos-viewer__caption {
  flex: 0 0 auto;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.35em;
  max-width: 100%;
  text-align: center;
  font-size: var(--fs-small);
}

/* T031s (замечание пользователя 2026-09-11): подпись — не служебная строка,
   а часть облика. Тот же приём, что у .photos-feed__group-label (ярлык
   гостя над плиткой ленты) — рукописная гарнитура без бумажной подложки не
   читается на тёмном (тот же вывод стенда T138), поэтому имя автора лежит
   на маленькой бумажной плашке с зерном, а не голым светлым текстом по
   чёрному. */
.photos-viewer__author {
  display: inline-block;
  max-width: 100%;
  padding: 0.15em 0.8em 0.25em;
  font-family: var(--font-script);
  font-size: var(--fs-ui);
  color: var(--c-ink);
  background-color: var(--m-paper);
  background-image: url('../img/decor/paper-grain.webp');
  background-repeat: repeat;
  background-size: 14rem auto;
  box-shadow: 0 0.2rem 0.4rem color-mix(in srgb, var(--c-ink) 40%, transparent);
  transform: rotate(-0.6deg);
}

.photos-viewer__text {
  color: var(--c-bg);
}

/* T179 (пачка C2): текст просьбы гостя убрать кадр — виден только паре, на
   срезе «Просьбы» (ярлык-конверт сам текста не несёт, см. постановку). */
.photos-viewer__removal-note {
  flex: 0 0 auto;
  max-width: 28rem;
  margin: 0.35em auto 0;
  padding: 0 var(--space-sm);
  color: var(--c-bg);
  font-style: italic;
  font-size: var(--fs-small);
  text-align: center;
}

/* Номер кадра и действия (T095, FR-051) — под подписью, на тёмной подложке
   просмотра. Тонкая золотая линия сверху отделяет их от фотографии тем же
   приёмом, что border-top нижней панели (.photos-panel-bar) — просмотр
   читается родиной той же страницы, а не отдельным экраном. Колонкой, а не
   строкой: на 320px оба действия рядом не встают, а переносить подпись
   внутри кнопки хуже, чем поставить их одну под другой. */
.photos-viewer__tools {
  flex: 0 0 auto;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-xs);
  max-width: 100%;
  margin-top: var(--space-xs);
  padding-top: var(--space-sm);
  border-top: 1px solid color-mix(in srgb, var(--m-gold) 55%, transparent);
  font-size: var(--fs-small);
}

.photos-viewer__id {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 0.4em;
  margin: 0;
  color: var(--c-bg);
}

/* Моноширинный — номер диктуют и переписывают вручную (гость присылает его
   паре в мессенджере, FR-051b), а в пропорциональной гарнитуре 0/O и 1/l
   сливаются. Разрядка по той же причине. */
.photos-viewer__id-value {
  font-family: ui-monospace, "SFMono-Regular", "Menlo", "Consolas", monospace;
  letter-spacing: 0.08em;
  user-select: all;
}

.photos-viewer__id-label {
  opacity: 0.8;
}

/* T031s: действия просмотра — язык значков альбома (значок + короткая
   подпись под ним), тот же приём, что .photos-panel-bar__button/-label
   (T148). Компактно и без капслока: рядом два разных по смыслу действия
   (просьба паре / немедленное удаление своего кадра), одного значка без
   слова тут мало — гость обязан различить их не глядя в документацию. */
.photos-viewer__action {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  min-width: 44px;
  min-height: 44px;
  padding: 6px 10px;
  border: 1px solid transparent;
  border-radius: var(--radius);
  background-color: transparent;
  color: var(--c-accent);
  cursor: pointer;
  transition: background-color 0.12s ease;
}

.photos-viewer__action:hover {
  background-color: color-mix(in srgb, var(--c-bg) 15%, transparent);
}

.photos-viewer__action .album-icon {
  width: 1.3rem;
  height: 1.3rem;
  --icon-ink: var(--c-accent);
  --icon-deep: var(--c-accent);
  --icon-muted: var(--c-accent);
  --icon-mid: var(--c-accent);
  --icon-light: var(--c-accent);
}

.photos-viewer__action-label {
  font-family: var(--font-ui);
  font-size: var(--fs-small);
  line-height: 1.1;
  color: currentColor;
}

/* --------------------------------------------------------------------------
   Окно просьбы убрать кадр (T095, FR-051a/FR-051c) — тот же облик, что у
   .photos-intro ниже; отдельные правила нужны только там, где текст обязан
   читаться как просьба, а не как подтверждение удаления.
   -------------------------------------------------------------------------- */

.photos-removal {
  max-width: 32rem;
  width: calc(100% - 2 * var(--space-sm));
  /* Живой тест 2026-09-18: глобальный сброс `* { margin: 0 }` (выше) гасит
     стандартное `margin: auto`, которым браузер центрирует модальный
     <dialog>, — без этой строки окно прилипало к левому верхнему углу.
     Тот же возврат, что у .rsvp-success/.photos-intro/.map-dialog. */
  margin: auto;
  border: 1px solid var(--c-line);
  border-radius: var(--radius);
  background: var(--c-bg);
  color: var(--c-ink);
  padding: var(--space-md);
}

.photos-removal::backdrop {
  background: rgb(0 0 0 / 55%);
}

.photos-removal__lead {
  margin: 0 0 var(--space-sm);
  font-size: var(--fs-small);
}

.photos-removal__id {
  font-family: ui-monospace, "SFMono-Regular", "Menlo", "Consolas", monospace;
  letter-spacing: 0.08em;
  user-select: all;
}

.photos-removal__actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-xs);
  margin-top: var(--space-sm);
}

.photos-removal__message {
  margin: var(--space-xs) 0 0;
  font-size: var(--fs-small);
}

/* --------------------------------------------------------------------------
   Подтверждение успешного скачивания (T205) — тот же приём и почти та же
   разметка, что у .rsvp-success (index.html): нативный <dialog>, без формы
   с полями, закрывается одной кнопкой. Собственный класс, а не общий с
   .rsvp-success/.photos-removal — они живут в разных документах (index.html/
   photos.html), общий CSS-файл, но общий класс между несвязанными окнами
   двух страниц был бы совпадением по имени, а не переиспользованием.
   -------------------------------------------------------------------------- */

.photos-download-success {
  max-width: 26rem;
  width: calc(100% - 2 * var(--space-sm));
  /* См. комментарий у .photos-removal: без возврата `margin: auto` окно
     стояло в левом верхнем углу (жалоба пользователя 2026-09-18). T205
     повторил приём .rsvp-success, но потерял именно эту строку. */
  margin: auto;
  border: 1px solid var(--c-line);
  border-radius: var(--radius);
  background: var(--c-bg);
  color: var(--c-ink);
  padding: var(--space-lg) var(--space-md);
  text-align: center;
}

.photos-download-success::backdrop {
  background: rgb(0 0 0 / 55%);
}

.photos-download-success__title {
  margin: 0 0 var(--space-md);
  font-size: var(--fs-h3);
}

/* --------------------------------------------------------------------------
   Постоянная нижняя панель действий (T143/T144, фаза 20, вариант C —
   решение пользователя 2026-09-08) — заменяет собой прежнюю плавающую
   кнопку справки в углу (.photos-help) и все панели, разбросанные по
   странице (.photos__back/.photos-feed-toolbar/.photos-download-bar над
   лентой/текстовый тумблер корзины гостя). Тихая, всегда на виду
   (position: fixed) — ни одно действие альбома не требует прокрутки наверх.
   T148 (замечание пользователя 2026-09-09, по рендеру собранного футера):
   решение «панель — только значки, без слов» ОТМЕНЕНО — под каждым значком
   короткая подпись (см. .photos-panel-bar__button-label ниже), а сами
   значки и промежутки между ними укрупнены. Панель по-прежнему держится по
   центру, не тянется на всю ширину 1280 — ряд подписанных значков во весь
   экран перестал бы читаться группой.
   -------------------------------------------------------------------------- */
.photos-panel-bar {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 15;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-wrap: wrap;
  /* T148: значки были слеплены вплотную («кнопки прилеплены друг к
     другу») — промежуток укрупнён с --space-xs до --space-sm. */
  gap: var(--space-sm);
  padding: var(--space-sm) var(--space-md);
  /* env(safe-area-inset-bottom): на iPhone панель не должна лечь под
     системную полосу жестов. T165: эта высота (вместе с safe-area) целиком
     входит в offsetHeight панели, которым photos.js измеряет запас для
     подвала (.photos-panel-bar ~ .footer, см. «Подвал» выше) — второй раз
     env() там прибавлять не нужно. */
  padding-bottom: calc(var(--space-sm) + env(safe-area-inset-bottom));
  background-color: var(--m-glass);
  border-top: 1px solid color-mix(in srgb, var(--m-gold) 70%, transparent);
}

.photos-panel-bar[hidden] {
  display: none;
}

/* T209 (заявка пользователя 2026-09-18): итог модерации — своя строка НАД
   постоянной нижней панелью (не внутри неё — второй раз не заводим
   арифметику ширины ряда T149/T150 выше, у этого узла нет соседей-кнопок).
   `position: fixed`, тот же довод, что у .photos-panel-bar («ни одно
   действие альбома не требует прокрутки наверх») — до T209 итог уходил в
   строку НАД лентой, а действие нажимается почти всегда со дна длинной
   прокрутки. z-index как у самой панели — обе живут в одном верхнем слое.
   Скрыта (hidden), пока сообщения нет — setMessage (photos-message.js)
   снимает hidden, только когда есть что сказать.

   T216 (решение пользователя 2026-09-18 «уведомления по центру делай»):
   тот же класс переиспользован ещё двумя страницами без нижней панели —
   архивом (site/photos-archive.html) и корзиной гостя (site/photos-bin.html,
   её крупным просмотром), второго вида уведомления не заводим ([[constraints]]
   I-9). Из-за этого:
   — `left: 50%; transform: translateX(-50%)` вместо `left/right: 0` —
     строка стала плавающей центрированной полосой (читабельная ширина на
     ПК — `width: min(…, var(--measure))`, та же переменная, что держит
     длину строки статусных сообщений альбома, .photos__message выше), а
     не полосой во весь экран: на ПК текст читался бы одной короткой
     строкой посреди широкого пустого поля.
   — `bottom: max(var(--photos-panel-height, 0px), …)` — на photos.html
     панель есть и её ФАКТИЧЕСКАЯ высота (offsetHeight, тот же источник,
     что и у .photos-panel-bar ~ .footer ниже) всегда больше второго
     слагаемого, `max()` его и выбирает — поведение для альбома не
     изменилось, строка садится вплотную над кнопками панели. На
     archive/bin панели нет вовсе (переменная не объявлена, читается
     fallback 0px) — `max()` выбирает второе слагаемое:
     `var(--space-sm) + env(safe-area-inset-bottom, 0px)` — тот же приём
     учёта выреза iPhone, что и у padding-bottom самой панели
     (.photos-panel-bar выше), небольшой отступ от нижнего края, когда
     safe-area равна нулю.
   — `border-top` заменён на `border` с `border-radius: var(--radius)` —
     плавающая полоса теперь не примыкает к краю экрана, а лежит островом,
     кайма нужна со всех сторон, не только сверху. */
.photos-panel-toast {
  position: fixed;
  left: 50%;
  transform: translateX(-50%);
  bottom: max(
    var(--photos-panel-height, 0px),
    calc(var(--space-sm) + env(safe-area-inset-bottom, 0px))
  );
  z-index: 15;
  margin: 0;
  width: min(calc(100% - var(--space-md) * 2), var(--measure));
  padding: var(--space-xs) var(--space-md);
  /* T218 (живой тест 2026-09-18: строка висела навсегда, закрыть было
     нельзя): узел несёт крестик закрытия рядом с текстом
     (photos-panel-toast.js) — flex-ряд вместо чистого текстового блока,
     текст сам остаётся центрированным (flex: 1 у .photos-panel-toast__text
     ниже), крестик — фиксированного размера сбоку. */
  display: flex;
  align-items: center;
  gap: var(--space-xs);
  text-align: center;
  font-size: var(--fs-small);
  background-color: var(--m-glass);
  border: 1px solid color-mix(in srgb, var(--m-gold) 70%, transparent);
  border-radius: var(--radius);
}

.photos-panel-toast[hidden] {
  display: none;
}

.photos-panel-toast__text {
  flex: 1;
}

/* Компактная круглая кнопка тем же материалом, что и .photos-viewer__close
   (стеклянная заливка/золотая кайма на тёмном) — здесь фон светлый
   (--m-glass), поэтому заливка не нужна, кайма и цвет текста те же токены. */
.photos-panel-toast__close {
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 1.75rem;
  height: 1.75rem;
  padding: 0;
  border: 1px solid color-mix(in srgb, var(--m-gold) 70%, transparent);
  border-radius: 50%;
  background: transparent;
  color: var(--c-ink);
  font-size: var(--fs-small);
  line-height: 1;
  cursor: pointer;
}

.photos-panel-toast__close:hover {
  background-color: color-mix(in srgb, var(--m-gold) 20%, transparent);
}

/* T150: обе группы панели (обычные значки/значки режима выбора) не должны
   стать собственными flex-боксами — их кнопки обязаны остаться прямыми
   flex-детьми .photos-panel-bar, иначе арифметика ширины ряда (T149,
   getBoundingClientRect на 320px) считает уже не то. display: contents
   убирает сам div из рендер-дерева, оставляя только детей. [hidden]
   переопределяет это на display: none явным правилом ниже — атрибут и
   класс равны по специфичности, порядок в файле решает, какое побеждает. */
.photos-panel-bar__group {
  display: contents;
}

.photos-panel-bar__group[hidden] {
  display: none;
}

/* Кнопка-значок панели — область нажатия минимум 44px (тач-цель), но выше
   сама кнопка выросла за счёт подписи (T148, решение пользователя
   2026-09-09: «либо значок, либо название» у панели отменено — подписаны
   ВСЕ значки). Прежний круг сменился на прямоугольник со скруглением угла
   сайта (--radius) — подпись под кругом смотрелась бы чужеродно. */
.photos-panel-bar__button {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 2px;
  min-width: 44px;
  min-height: 44px;
  padding: 6px 10px;
  border: 1px solid transparent;
  border-radius: var(--radius);
  background-color: transparent;
  /* Значки — золотом (замечание пользователя 2026-09-09: «изначально кнопки
     должны быть как золото»), тот же токен, что несёт текстовую роль золота
     на сайте (.accent, ссылки) — --c-accent-deep, не новый оттенок. Подпись
     ниже наследует тот же цвет через currentColor. */
  color: var(--c-accent-deep);
  cursor: pointer;
  text-decoration: none;
  transition: background-color 0.12s ease;
}

.photos-panel-bar__button:hover {
  background-color: color-mix(in srgb, var(--c-bg) 60%, transparent);
}

/* Отклик на касание (T148, замечание 2: «непонятно, сработало ли нажатие»)
   — на телефоне нет наведения, это единственная обратная связь. */
.photos-panel-bar__button:active {
  background-color: color-mix(in srgb, var(--c-accent) 35%, transparent);
}

/* Нажатое состояние тумблера (мои/кабинет) — читается не только по
   aria-pressed (для клавиатуры/чтеца), но и глазами: та же роль акцента,
   что держала .photos-feed-toolbar__mine[aria-pressed] до переезда сюда.
   T148 (замечание 3): открытое меню фильтра обязано быть видно по значку —
   тот же приём применён к aria-expanded, а не заведён класс-двойник. */
.photos-panel-bar__button[aria-pressed="true"],
.photos-panel-bar__button[aria-expanded="true"] {
  background-color: var(--c-accent);
  color: var(--c-bg);
}

.photos-panel-bar__button[hidden] {
  display: none;
}

/* Подпись под значком (T148, решение пользователя 2026-09-09) — короткое
   слово, мелкий кегль, золото сайта через currentColor (наследуется от
   .photos-panel-bar__button, включая состояния hover/active/expanded).
   aria-hidden в разметке — доступное имя несёт aria-label кнопки, подпись и
   имя не должны звучать дважды. Ширина cлова не форматируется под текущий
   набор (4 значка гостя) — цельная колонка кнопки подстраивается под
   ЛЮБОЕ слово, включая будущее пятое «Мультивыбор» (T150), без переверстки
   панели.
   T166: было голое число 0.65rem (мимо шкалы --fs-*). Замер на 320px (headless,
   до/после в tasks.md T166) показал, что --fs-small панель НЕ ломает — ряд
   остаётся один, подписи не обрезаются, высота панели растёт всего на ~5px,
   поэтому седьмая роль шкалы не понадобилась. */
.photos-panel-bar__button-label {
  font-family: var(--font-ui);
  font-size: var(--fs-small);
  line-height: 1.1;
  white-space: nowrap;
  color: currentColor;
}

/* Числовой бейдж отметки (T046-T048) — не текст кнопки (T144), а значок
   рядом со значком: доступное имя несёт aria-label самой кнопки. T150
   заменил единственную кнопку скачивания режимом выбора (несколько кнопок
   разом) — счётчик там отдельным узлом (.photos-panel-bar__count ниже);
   этот класс в текущей разметке photos.html больше не встречается. */
.photos-panel-bar__badge {
  position: absolute;
  top: 0;
  right: 0;
  min-width: 1.1em;
  padding: 0.05em 0.3em;
  border-radius: 999px;
  background-color: var(--c-accent-deep);
  color: var(--c-bg);
  font-family: var(--font-ui);
  /* Доля em от родительской кнопки (44px), не своя точка шкалы --fs-* — тот
     же приём, что у .album-icon-badge (решение пользователя 2026-09-03,
     см. font-scale-guard.test.js): метка мельче значка, которому принадлежит.
     T166: осмотрено намеренно — это не голое число мимо шкалы, а
     задокументированное исключение (font-scale-guard.test.js), уже
     оставлено в силе для .album-icon-badge; переводить в var(--fs-*) не
     нужно, т.к. бейдж обязан масштабироваться вместе со значком-родителем
     (button), а не с ролью текста страницы. */
  font-size: 0.7em;
  line-height: 1.3;
  text-align: center;
}

/* Место под будущее слайд-шоу (T079/фича 004, решение пользователя
   2026-09-08: кнопку пока не делать, место оставить) — тот же промежуток,
   что занял бы значок с подписью (T148), без содержимого. */
.photos-panel-bar__placeholder {
  width: 44px;
  height: 44px;
  align-self: stretch;
}

/* Режим выбора (T150) — панель ЗАМЕЩАЕТ обычные значки набором действий
   photos-selection-actions.js (renderSelectionBar, photos.js), поэтому
   новых правил геометрии кнопки не заводим — те же .photos-panel-bar__button
   /-button-label, что и у обычных значков (арифметика ширины T149 остаётся
   верна и в режиме выбора: число значков не растёт, оно ЗАМЕНЯЕТСЯ). Ниже —
   только то, чего не было у обычных значков: недоступное действие
   (0 отмечено/T172 «Одобрить все») и опасное действие («В корзину»). */
.photos-panel-bar__button:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}

.photos-panel-bar__button:disabled:hover,
.photos-panel-bar__button:disabled:active {
  background-color: transparent;
}

/* «В корзину» — семантический маркер, тем же приёмом, что
   .photos-feed__card-menu-option--danger (T154): сайт не заводит отдельный
   красный токен «опасности», danger-действия остаются в золоте страницы,
   разница — в тексте подтверждения (confirm), не в цвете. Класс существует
   для будущего единообразного места, если решение изменится. */
.photos-panel-bar__button--danger {
  color: var(--c-accent-deep);
}

/* Счётчик отметки (T046/T150) — декоративный узел рядом с действиями
   режима выбора, не текст ни одной из кнопок (у каждой уже есть своё
   доступное имя, см. renderSelectionBar). Тот же кегль и цвет, что и
   подпись значка ниже него, чтобы строка не выбивалась из общего строя
   панели. */
.photos-panel-bar__count {
  display: flex;
  align-items: center;
  min-height: 44px;
  padding: 0 6px;
  font-family: var(--font-ui);
  font-size: var(--fs-small);
  color: var(--c-accent-deep);
  white-space: nowrap;
}

/* Компактный значок-тумблер вне панели (архив кабинета пары, T144) — та же
   геометрия и правило «либо значок, либо название», что у кнопок панели
   выше, но встроен в поток раздела, а не зафиксирован. */
.photos-icon-toggle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  border: 1px solid var(--c-accent);
  border-radius: 50%;
  background-color: transparent;
  color: var(--c-accent-deep);
  cursor: pointer;
}

.photos-icon-toggle:hover,
.photos-icon-toggle[aria-pressed="true"] {
  background-color: var(--c-accent);
  color: var(--c-bg);
}

/* Фильтр ленты (T146) — заменяет прежний двухпозиционный тумблер «Только
   мои». Обёртка задаёт точку отсчёта для попапа с тремя положениями:
   position: absolute у .photos-filter-menu не участвует в потоке страницы,
   поэтому открытие/закрытие меню не двигает панель и не меняет высоту
   документа (требование задачи — переключение фильтра не должно «дёргать
   раскладку»). */
.photos-panel-bar__filter {
  position: relative;
  display: inline-flex;
}

/* Само меню — стеклянная панель того же материала, что и остальная страница
   (var(--m-glass), см. .photos-panel), плавает НАД панелью действий, а не
   раздвигает её.
   T211: `left: 50%; transform: translateX(-50%)` (центрирование от кнопки)
   снято — кнопка фильтра стоит четвёртой из пяти значков панели, список,
   растущий от центра поровну в обе стороны, на 320-390px уезжал за боковой
   край окна (никакого расчёта под края экрана раньше не было вовсе).
   Прижатие к ближнему краю кнопки и ужатие по месту теперь считает та же
   пара чистых функций, что у меню карточки (computeCardMenuPlacement/
   computeCardMenuVerticalPlacement, photos-card-menu-position.js) — DOM-шов
   applyPopupMenuPlacement (photos-popup-menu.js) перебивает left/right/
   bottom/top/width/max-height инлайновым стилем на каждое открытие.
   `right: 0` ниже — безопасный дефолт на случай, если этот шов почему-то не
   отработает (тот же приём, что у .photos-feed__card-menu-list в style.css).
   `white-space: nowrap` снят — самая длинная подпись («Мои, ждущие
   одобрения») переносится, не срезается (см. .photos-filter-menu__option
   ниже, тот же приём T202). */
.photos-filter-menu {
  position: absolute;
  bottom: calc(100% + var(--space-xs));
  right: 0;
  z-index: 16;
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: var(--space-xs);
  min-width: 12rem;
  max-width: calc(100vw - 2rem);
  background-color: var(--m-glass);
  border: 1px solid color-mix(in srgb, var(--m-gold) 70%, transparent);
  border-radius: var(--radius);
  overscroll-behavior: contain;
}

.photos-filter-menu[hidden] {
  display: none;
}

/* Положения фильтра — обычные подписанные кнопки (не значки): три состояния
   короткой подписью не выразить одним пиктограммой без риска путаницы, а
   меню и так вне обычного строя панели (T144 «либо значок, либо название»
   относится к самой панели, не к раскрытому попапу). Текущее положение
   читается не только цветом фона — role="radio"/aria-checked озвучивает его
   для клавиатуры и чтеца экрана (см. photos.js#applyFilter). */
/* T148 (замечание 4: «меню громоздкое, блок шире своего содержимого») —
   поля ужаты, высота строки НЕ ужата — 44px остаётся тач-целью, единственной
   защитой от промаха пальцем (min-height ниже).
   T166: кегль был голым числом 0.85rem мимо шкалы --fs-*, а не своей
   формулой — поднят до --fs-small (разница с прежним значением мала, пункты
   меню только выиграли в читаемости, попап всё равно плавает над панелью и
   не завязан на её узкую раскладку). */
.photos-filter-menu__option {
  padding: 0.35em 0.6em;
  border: none;
  border-radius: var(--radius);
  background-color: transparent;
  color: var(--c-ink);
  font-family: var(--font-ui);
  font-size: var(--fs-small);
  text-align: left;
  cursor: pointer;
  /* Тач-цель по высоте (44px) без раздувания ширины меню под все три
     подписи одинаково широкими кнопками. */
  min-height: 44px;
  /* T211: перенос вместо обрезки/выезда за край — тот же приём, что у
     .photos-feed__card-menu-option (T202). Список — flex-column
     (align-items: stretch по умолчанию), кнопка и так растянута на всю
     ширину списка, min-width:0 у вложенного span тут не нужен — сама
     подпись лежит прямо в textContent кнопки, не в отдельном span
     (buildFilterMenu/buildCabinetMenu, photos.js). */
  white-space: normal;
  overflow-wrap: break-word;
}

.photos-filter-menu__option:hover {
  background-color: color-mix(in srgb, var(--c-bg) 60%, transparent);
}

.photos-filter-menu__option[aria-checked="true"] {
  background-color: var(--c-accent);
  color: var(--c-bg);
}

/* Кабинет пары (T149, заменяет T143) — попап переиспользует ровно те же
   классы .photos-filter-menu/.photos-filter-menu__option, что и фильтр
   выше (тот же материал, тот же тач-размер, второй набор правил не
   заводим), только aria-роль в разметке другая (menu/menuitem, не
   radiogroup/radio — пункты не взаимоисключающие положения). Пять её
   разделов раньше прятались единым hidden на общей обёртке
   ([data-photos-cabinet-body], снята целиком) — теперь у каждого свой
   независимый флаг: роль (activate() в photos.js) решает «есть
   содержимое», [hidden]; collapsed ниже — «сейчас показано или нет»,
   до перехода из этого списка. Два флага независимы и оба обязаны
   отсутствовать, чтобы раздел стал виден — HTML [hidden] и так уже
   display:none нативно, здесь просто второе условие тем же эффектом. */
[data-photos-cabinet-collapsed] {
  display: none;
}

/* Окно знакомства — тот же приём карточки по центру с прокруткой, что у
   .privacy-dialog (index.html): текст (особенно у пары, T104) может быть
   длиннее вьюпорта на узких экранах. */
.photos-intro {
  max-width: 34rem;
  width: min(34rem, 90vw);
  max-height: 85vh;
  overflow-y: auto;
  margin: auto;
  padding: var(--space-lg) var(--space-md);
  border: none;
  border-radius: var(--radius);
  background-color: var(--c-bg);
}

.photos-intro::backdrop {
  /* Тот же приём через opacity, что у .map-dialog::backdrop выше. */
  background-color: var(--c-ink);
  opacity: 0.55;
}

.photos-intro__list {
  display: flex;
  flex-direction: column;
  gap: var(--space-sm);
  margin-top: var(--space-md);
  padding-left: 1.2em;
}

.photos-intro__close {
  display: flex;
  align-items: center;
  justify-content: center;
  min-width: 44px;
  min-height: 44px;
  margin: var(--space-md) auto 0;
  padding: 0.4em 1.4em;
  font-size: var(--fs-small);
}

/* --------------------------------------------------------------------------
   Значки альбома (site/js/album-icons.js) — решение пользователя 2026-09-03.
   Раскладка ролей ЛИСТА на палитру сайта: `ink`/`paper`/`muted`/`deep` —
   существующие роли (графит/фон/золото/золото — muted и deep сознательно
   совпадают, у листа это два разных по смыслу, но не по тону соседа),
   `mid`/`light` — токены --c-icon-mid/--c-icon-light (объявлены в :root
   рядом с MATERIAL, см. комментарий там же — не роль оформления, у листа
   нет аналога среди уже существующих ролей палитры). Раскладка держится
   ЗДЕСЬ, не в JS (site/js/album-icons.js уже поясняет это в шапке модуля):
   один `.album-icon`, применённый где угодно, красится одинаково.
   -------------------------------------------------------------------------- */
.album-icon {
  --icon-ink: var(--c-ink);
  --icon-paper: var(--c-bg);
  --icon-muted: var(--c-accent);
  --icon-deep: var(--c-accent);
  --icon-mid: var(--c-icon-mid);
  --icon-light: var(--c-icon-light);
  width: 1.2em;
  height: 1.2em;
  flex-shrink: 0;
}

/* Значки кнопок панели (T143/T144) — размер задан в rem, не унаследован от
   --fs-body через 1.2em по умолчанию. */
.photos-icon-toggle .album-icon {
  width: 1.4rem;
  height: 1.4rem;
}

/* T148 (замечание пользователя 2026-09-09): 1 — «сделать крупнее», значок
   панели укрупнён с 1.4rem до 1.6rem (кнопка под подпись всё равно выросла
   в высоту, лишнего сжатия нет); 2 — «изначально кнопки должны быть как
   золото». Набор значков читался пёстро (контур чернилами --c-ink + золотые
   акценты), в панели он становится ОДНОЙ золотой семьёй: контур и акценты
   сведены к --c-accent-deep/--c-accent (текстовая и нетекстовая роли золота
   сайта), бумажные прорези (--icon-paper) не трогаем — без них силуэт
   значка пропадает. Область не подчиняется требованию контраста 4.5:1/3:1 —
   сами значки декоративны (aria-hidden, см. album-icons.js), но токены
   золота и так далеко выше порога. */
.photos-panel-bar__button .album-icon {
  width: 1.6rem;
  height: 1.6rem;
  --icon-ink: var(--c-accent-deep);
  --icon-deep: var(--c-accent-deep);
  --icon-muted: var(--c-accent-deep);
  --icon-mid: var(--c-accent);
  --icon-light: var(--c-accent);
}

/* T144 (правка фазы 20): крупные кнопки со значком И длинной подписью рядом
   («Показать корзину», «Показать удалённые кадры», «Показать архив») ушли
   под нож целиком — действие несёт либо значок (.photos-panel-bar__button/
   .photos-icon-toggle выше), либо текстовое название, но не оба сразу.
   Модификатор остался без потребителей в photos.html: единственная кнопка
   «Найти», его носившая, была формой поиска кадра в кабинете пары — T176
   (решение пользователя 2026-09-11) перенёс поиск в правый верхний угол
   страницы своей компактной разметкой (.photos-find ниже, без этого
   класса). Правило не удаляем без отдельного повода — оно нейтрально и
   может понадобиться следующей кнопке «значок + длинная подпись». */
.button--icon-label {
  display: inline-flex;
  align-items: center;
  gap: 0.5em;
}
