/* Раскладка экрана. Швейцарская сетка: подпись над значением, всё по левому краю
   своей колонки, центрируется только содержимое кольца.

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

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

html {
    /* html остаётся плоским: он красит холст ЗА пределами body (оттяг прокрутки,
       поля десктопа). Свет живёт на body, чтобы ехать вместе с прибором. */
    background: var(--ink-0);
    /* Тянуть страницу вбок нечему: горизонтальная прокрутка — это всегда дефект. */
    overflow-x: hidden;
}

/* Слои освещённой базы вынесены в переменные: на десктопе сверху ложится ещё сетка,
   и без переменных пришлось бы дублировать всю стопку с риском разойтись. */
:root {
    /* Плёночное зерно. Оно тут не для красоты: два радиальных градиента ниже на
       8-битной панели дают кольцевые полосы Маха, и зерно работает дизером.
       feColorMatrix переводит яркость шума в АЛЬФУ, а RGB прибивает к белому —
       на тёмном зерно обязано только ПРИБАВЛЯТЬ свет: вычитать из почти-чёрного
       нечего, получается грязь. Отрицательный сдвиг срезает нижнюю половину
       распределения, остаются редкие светлые крупинки. Множители уже умножены
       на 0.055 — плёнка, а не шум сжатия. Плитка 256 при размере 128 px даёт
       крупинку в полпикселя CSS: при DPR 3 это 1.5 px устройства, ровно плёнка. */
    --bg-film: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='256' height='256'%3E%3Cfilter id='n' x='0' y='0'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 .0209 .0209 .0209 0 -.01155'/%3E%3C/filter%3E%3Crect width='256' height='256' filter='url(%23n)'/%3E%3C/svg%3E");
    /* Пятно света над центром кольца. Шесть стопов с нелинейным спадом — не ради
       красоты, а против полос Маха: на трёх стопах граница видна кольцом.
       Потолок .075 посчитан по контрасту: в максимуме фон становится #121619, и
       --txt-3 даёт на нём 4.59:1 при норме 4.5. Выше поднимать нельзя. */
    --bg-light: radial-gradient(118% 46% at 50% var(--light-y),
        rgba(var(--light), .075) 0%,
        rgba(var(--light), .055) 16%,
        rgba(var(--light), .032) 36%,
        rgba(var(--light), .015) 56%,
        rgba(var(--light), .005) 76%,
        transparent 100%);
    /* Виньетка: свет гаснет к краям и к низу. Она же возвращает контраст мелкому
       тексту правой колонки, который на телефоне уезжает ниже пятна. */
    --bg-vignette: radial-gradient(145% 88% at 50% -6%, transparent 44%, rgba(0, 0, 0, .55) 100%);
}

/* Светлая тема переопределяет обе подложки, и это не подбор оттенка, а другая
   физика.

   Виньетка в 55% чёрного на тёмном фоне — это глубина по краям. На светлом та
   же виньетка кладёт поверх страницы серую пелену: снизу экран становится
   грязно-серым, и никакой цвет уже не спасает. Здесь она сиреневая и в пять
   раз слабее — ровно чтобы низ не спорил с кольцом.

   Зерно на тёмном обязано ПРИБАВЛЯТЬ свет: вычитать из почти-чёрного нечего.
   На светлом ровно наоборот — прибавлять некуда, и крупинки должны быть
   тёмными. Отличие от тёмного варианта одно: три единицы в feColorMatrix,
   прибивавшие цвет шума к белому, заменены нулями.

   Чернила виньетки — зелёно-чёрные rgb(18,33,27), а не фиолетовые: светлая тема
   переехала в семью тёмной, и сиреневая пелена по низу экрана была последним
   местом, где от лавандового оформления оставался тон. */
[data-theme="light"] {
    --bg-film: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='256' height='256'%3E%3Cfilter id='n' x='0' y='0'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .0209 .0209 .0209 0 -.01155'/%3E%3C/filter%3E%3Crect width='256' height='256' filter='url(%23n)'/%3E%3C/svg%3E");
    --bg-vignette: radial-gradient(145% 88% at 50% -6%, transparent 44%, rgba(18, 33, 27, .11) 100%);
}

body {
    /* Своя область наложения. Без неё слои фазы (.bg-fx, z-index: -1) уезжают
       ПОД собственный фон тела: у html есть свой цвет, поэтому фон тела не
       уходит на холст, а красится как обычный блочный фон — то есть позже
       отрицательных слоёв. Один этот признак ставит их на место: над подложкой,
       под содержимым. Ни на раскладку, ни на позиционирование он не влияет —
       в отличие от transform и filter, которыми такое делают чаще. */
    isolation: isolate;
    /* Тряска на волне сдвигает сцену целиком; горизонтальная прокрутка от этого
       появляться не должна ни на кадр. */
    overflow-x: clip;
    margin: 0;
    min-width: 320px;
    background-color: var(--ink-0);
    background-image: var(--bg-film), var(--bg-light), var(--bg-vignette);
    background-repeat: repeat, no-repeat, no-repeat;
    background-size: 128px 128px, 100% 760px, 100% 100%;
    background-position: 0 0, 50% 0, 50% 0;
    color: var(--txt);
    font-family: var(--font-ui);
    font-size: var(--fs-md);
    line-height: 1.35;
    -webkit-font-smoothing: antialiased;
    -webkit-text-size-adjust: 100%;
    text-size-adjust: 100%;
    /* Безопасные зоны сверху и снизу: под чёлкой и полосой жеста контента быть не должно. */
    padding-top: var(--safe-top);
    padding-bottom: calc(var(--safe-bottom) + 12px);
    /* Кольцо не интерактивно, но случайный «оттяг» страницы во время волны сбивает кадр. */
    overscroll-behavior-y: contain;
}

/* Едва заметная сетка прибора. Чистый CSS, никакого растра. Сетка ложится ПОД
   зерно и над светом: она часть подложки, а не плёнка поверх неё. */
@media (min-width: 600px) {
    body {
        background-image:
            var(--bg-film),
            repeating-linear-gradient(to right, rgba(255, 255, 255, .018) 0 1px, transparent 1px 32px),
            repeating-linear-gradient(to bottom, rgba(255, 255, 255, .018) 0 1px, transparent 1px 32px),
            var(--bg-light),
            var(--bg-vignette);
        background-repeat: repeat, repeat, repeat, no-repeat, no-repeat;
        background-size: 128px 128px, auto, auto, 100% 760px, 100% 100%;
        background-position: 0 0, 0 0, 0 0, 50% 0, 50% 0;
    }
}

/* ── шапка ─────────────────────────────────────────────────────────────── */

.hdr {
    position: sticky;
    top: 0;
    z-index: 30;
    display: flex;
    align-items: center;
    gap: var(--s2);
    /* Высота складывается из строки шапки и чёлки ЯВНО, а не через content-box:
       у content-box ширина 100% означала бы «окно плюс поля» и горизонтальную
       прокрутку на ровном месте. */
    height: calc(var(--hdr-h) + var(--safe-top));
    padding: 0 var(--pad-x);
    padding-top: var(--safe-top);
    /* Шапка сама компенсирует чёлку: body уже отступил, но при sticky она
       прилипает к верху окна, а не к верху контента. */
    margin-top: calc(-1 * var(--safe-top));
    /* Шапка — верхняя плоскость сцены, света на ней больше всех. Тень под ней
       становится видимой только на поднятом амбиенте: на #07090B её нет вовсе. */
    background-color: var(--rail-top);
    background-image: linear-gradient(180deg, var(--rail-top) 0%, var(--rail-bot) 100%);
    border-bottom: 1px solid var(--groove);
    box-shadow:
        inset 0 1px 0 rgba(255, 255, 255, .05),
        0 6px 10px -8px rgba(0, 0, 0, .9);
}

.hdr-gap {
    flex: 1 1 auto;
    min-width: 0;
}

/* ── общая колонка ─────────────────────────────────────────────────────── */

.wrap {
    display: block;
    max-width: var(--col-max);
    margin: 0 auto;
    padding: 0 var(--pad-x) 0;
}

.col {
    min-width: 0;
}

/* Поле группы по умолчанию. Границы между группами шире и назначены поимённо
   в components.css (.stats, .chance, .buy) — так экран читается блоками,
   а не построчно. */
.col > * + * {
    margin-top: var(--s3);
}

/* Стойка приборов: показания, шкала, клавиши покупки и строка состояния одним
   блоком. Флекс, а не блок, ради одного свойства — схлопывания полей: у блока
   верхнее поле первого потомка вылезает наружу и становится полем самой стойки,
   и ритм колонки поехал бы на пустом месте. Ступень между её строками та же,
   что была у колонки: обёртка не имеет права ничего сдвинуть в портрете. */
.controls {
    display: flex;
    flex-direction: column;
    min-width: 0;
    /* Система координат для подиума итогов (.podium в components.css): он
       накрывает ровно стойку — приборную строку, клавиши и карточку — и не
       заходит на кольцо. Полки и объяснение fixed от этого не зависят. */
    position: relative;
}

.controls > * + * {
    margin-top: var(--s3);
}

/* Внутри стойки высоту не отдаёт никто — то же правило, что и в колонке: тянется
   только кольцо. Без этого микрошкала в 3 px первой сжималась до нуля, когда
   стойке доставалось меньше места, чем ей нужно (замер: «схлопнут в 300×0» на
   844×390 в полноэкранном режиме). И это же не даёт стойке схлопнуться при
   расчёте строки сетки в альбомной раскладке: её минимальная высота теперь
   равна её содержимому, а не нулю. */
.controls > * {
    flex: 0 0 auto;
}

/* Своего поля у стойки нет: отступ над показаниями назначен самим показаниям
   (.stats в components.css), и второй такой же от обёртки был бы двойным. */
.col-left > .controls {
    margin-top: 0;
}

/* ── сцена ─────────────────────────────────────────────────────────────── */

.scene {
    /* Сцена выходит за поля колонки: кольцу нужен запас под лимб и полку визира,
       а тексту вокруг — нет. Отрицательные поля не создают прокрутку, потому что
       ширина сцены всё равно ограничена шириной окна. */
    margin-left: calc(-1 * var(--pad-x) + 4px);
    margin-right: calc(-1 * var(--pad-x) + 4px);
    display: flex;
    justify-content: center;
    /* Система координат для ленты событий: она лежит накладкой у нижнего края
       сцены, в той пустоте, которая остаётся под кольцом на высоких экранах.
       Без этого она уехала бы к ближайшему позиционированному предку. */
    position: relative;
}

.stage {
    position: relative;
    width: min(100%, calc(var(--stage-side) + var(--stage-pad)));
    aspect-ratio: 1 / 1;
    /* Тряску сцены ставит рендерер через transform. Здесь transform не трогаем. */
    touch-action: pan-y;
    /* В овертайме лимб вращается трансформацией, а у повёрнутого квадрата габаритная
       рамка шире исходной — без обрезки это давало горизонтальную прокрутку всей
       страницы. Углы лимба пустые (в квадрат вписана окружность), поэтому обрезка
       не видна. */
    overflow: hidden;
}

/* Канвасы рендерер кладёт сюда сам; текст лежит поверх них. */
.stage > .ring-layer {
    z-index: 1;
}

.dial,
.me-visor {
    position: absolute;
    z-index: 2;
}

.dial {
    inset: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 2px;
    /* Текст не должен ловить касания: под ним кольцо, а прокрутка идёт по странице. */
    pointer-events: none;
}

/* ── две колонки на десктопе ───────────────────────────────────────────── */

@media (min-width: 760px) {
    .wrap {
        display: grid;
        /* 456 = 404 (сцена) + 2×24 (оправа) + рамка + запас. Уже — и рендерер
           начнёт сжимать кольцо, чтобы лимб влез в габарит сцены. */
        grid-template-columns: 456px minmax(0, 1fr);
        gap: var(--s5);
        max-width: 960px;
    }

    .col-right {
        /* Вертикальное ребро свет сверху не ловит: ему положен нейтральный паз. */
        border-left: 1px solid var(--groove);
        box-shadow: inset 1px 0 0 var(--edge-side);
        padding-left: var(--s5);
        /* Страница не листается, а лента участников бывает на сорок строк:
           прокручивается СПРАВОЧНАЯ колонка, а не весь прибор вместе с ней. */
        min-height: 0;
        overflow-y: auto;
        overscroll-behavior: contain;
    }

    /* Обе колонки живут внутри высоты окна, а не растят документ. */
    .wrap {
        min-height: 0;
        align-items: stretch;
    }

    /* Шапка держится той же сетки, что и колонки: иначе баланс уезжает
       к правому краю окна и теряет связь с содержимым.

       width: 100% обязателен, и это не перестраховка. Тело страницы — флекс-
       колонка (см. «экран игры помещается в окно целиком»), а у флекс-элемента
       авто-поля по бокам ОТМЕНЯЮТ растягивание: без явной ширины шапка и сетка
       сжимались до содержимого — 509 и 757 px, — и на широком окне интерфейс
       выглядел плавающим островком со сплюснутой правой колонкой (замер
       tools/visual/bigshot.mjs, снимок владельца из настольного Telegram). */
    .hdr {
        width: 100%;
        max-width: calc(960px + 2 * var(--pad-x));
        margin-left: auto;
        margin-right: auto;
    }

    .wrap {
        width: 100%;
    }

    /* Оправа прибора вокруг левой колонки: рамка с отступом и четыре уголка. */
    .col-left {
        position: relative;
        /* 24 = --s3 ×2 и жёстко связано с шириной колонки 456 из комментария выше:
           это конструктивный размер оправы, а не шаг ритма. */
        padding: 24px;
        border: 1px solid var(--groove);
        /* Оправа тоже ловит свет сверху и тонет снизу: правило рёбер исключений
           не имеет, иначе сцена рассыпается на набор случайных прямоугольников. */
        box-shadow:
            inset 0 1px 0 var(--edge-top),
            inset 1px 0 0 var(--edge-side),
            inset -1px 0 0 var(--edge-side);
    }

    .col-left::before,
    .col-left::after {
        content: "";
        position: absolute;
        width: 10px;
        height: 10px;
        pointer-events: none;
    }

    .col-left::before {
        top: -1px;
        left: -1px;
        border-top: 1px solid var(--line-hi);
        border-left: 1px solid var(--line-hi);
    }

    .col-left::after {
        bottom: -1px;
        right: -1px;
        border-bottom: 1px solid var(--line-hi);
        border-right: 1px solid var(--line-hi);
    }

    /* Внутри оправы сцене расширяться некуда. */
    .scene {
        margin-left: 0;
        margin-right: 0;
    }
}

/* Большое окно: колонка прибора растёт вместе со ступенью кольца (см.
   tokens.css, 484 + 2×24 оправы + рамка и запас = 536), сетка и шапка — на ту
   же величину. Условие то же самое, что у ступени: 1280×880. */
@media (min-width: 1280px) and (min-height: 880px) {
    .wrap {
        grid-template-columns: 536px minmax(0, 1fr);
        max-width: 1080px;
    }

    .hdr {
        max-width: calc(1080px + 2 * var(--pad-x));
    }
}

/* 600–760: одна колонка, но кольцо уже десктопного размера, а лента сразу развёрнута. */
@media (min-width: 600px) and (max-width: 759.98px) {
    .wrap {
        max-width: 480px;
    }
}

/* ─── полноэкранный режим Telegram ─────────────────────────────────────────
   Мини-приложение занимает весь экран, шторки нет. Кнопки Telegram при этом
   висят ПОВЕРХ содержимого в правом верхнем углу, поэтому шапка обязана
   отступить на их высоту — её отдаёт сам Telegram через contentSafeAreaInset,
   а js/tg.js кладёт в --tg-top. */
.tg-fullscreen body {
    /* Во весь экран высота известна точно, и её можно занять целиком. */
    min-height: 100dvh;
}

/* В полноэкранном режиме справа сверху живут кнопки Telegram: закрыть и меню.
   Содержимое шапки не должно под них залезать. */
.tg-fullscreen .hdr {
    padding-right: max(var(--s3), calc(var(--safe-right) + 92px));
}

/* В полноэкранном режиме высоты больше, и формула размера кольца сама это учтёт:
   она вычитает безопасные зоны из реальной высоты экрана. Отдельного правила
   на размер тут не нужно. */

/* ─── экран игры помещается в окно целиком ─────────────────────────────────
   Требование владельца: страница игры не листается, а раскладывается по высоте
   окна. Раньше высота набиралась суммой блоков в потоке и на телефоне
   переваливала за окно на 150–490 px (замер: tools/visual/fitcheck.mjs).

   Здесь это перестаёт быть возможным в принципе: окно — это и есть высота
   документа, а единственный тянущийся блок — сцена с кольцом. Всё остальное
   (шапка, вкладки, показания, шкала, КЛАВИШИ ПОКУПКИ, строка состояния) свою
   высоту не отдаёт никогда: кольцо красивое, но бесполезное, если участвовать
   нечем. Лишний воздух на высоком экране уходит кольцу, нехватка высоты на
   низком — снимается с кольца же.

   Правила здесь БЕЗ медиазапроса: «не листается» — это про любое устройство,
   включая планшет и телефон, положенный набок, где раскладка двухколоночная. */
html,
body {
    height: 100dvh;
}

body {
    display: flex;
    flex-direction: column;
}

/* Прокрутку запрещаем на html, а НЕ на body, и это не вкусовщина. overflow на
   body делает его контейнером прокрутки, и шапка со своим position: sticky
   начинает липнуть к его ПОЛЮ, а не к верху окна: в полноэкранном режиме она
   уезжала вниз ровно на высоту чёлки и накрывала собой верх кольца. */
html {
    overflow-y: hidden;
}

/* Листать нечего — липнуть не к чему. Шапка остаётся первой строкой потока
   и своим z-index держится над сценой. */
.hdr {
    position: relative;
    /* И высоту не отдаёт. Body — флекс-колонка, а флекс-элемент по умолчанию
       сжимаем: когда содержимое не помещалось в окно, браузер молча срезал
       шапку на недостающие пиксели, и «всё влезло» получалось за её счёт.
       Замер этого не видел — документ равнялся окну. Пусть лучше вылезет
       и будет поймано. */
    flex: 0 0 auto;
}

.wrap {
    flex: 1 1 auto;
    min-height: 0;
}

/* Левая колонка — столбец, в котором тянется ровно один блок: сцена. */
.col-left {
    display: flex;
    flex-direction: column;
    min-height: 0;
}

.col-left > .scene {
    flex: 1 1 0;
    /* ПОЛ ВЫСОТЫ СЦЕНЫ. При min-height: 0 сцена отдавала стойке всё: замер на
       стенде (высокая стойка + подиум) показал кольцо шириной 10 px — подписи в
       его центре налезали друг на друга, и ровно это владелец и увидел на своём
       снимке. Сто двадцать пикселей — это нижняя граница, при которой кольцо
       ещё кольцо: меньше него --stage-side всё равно не опускается (clamp от
       150 px), и лишнее уходит уже не из сцены. */
    min-height: 120px;
    align-items: center;
}

/* Сторона сцены = меньшее из трёх: сколько осталось по высоте, сколько влезает
   по ширине и потолок --stage-side. Базовое правило считает только по ширине —
   этого хватает везде, где --chrome-h сходится с разметкой. */
.col-left > .scene > .stage {
    width: min(100%, calc(var(--stage-side) + var(--stage-pad)));
    height: auto;
}

/* Честный вариант того же: сторона считается по МЕНЬШЕЙ стороне самой сцены,
   поэтому квадрат остаётся квадратом при любом раскладе, а не растягивается,
   когда высоты меньше ширины. Через max-* это не выражается: aspect-ratio
   выводит вторую сторону из первой ОДИН раз и на обрезку максимумом уже не
   отзывается — так сцена и получалась 406×428 на планшете. */
@supports (width: 1cqmin) {
    .col-left > .scene {
        container-type: size;
    }

    .col-left > .scene > .stage {
        width: min(100cqmin, calc(var(--stage-side) + var(--stage-pad)));
    }
}

/* Всё, кроме сцены, высоту не отдаёт. */
.col-left > *:not(.scene) {
    flex: 0 0 auto;
}

@media (max-width: 759.98px) {
    .wrap {
        width: 100%;
        display: flex;
        flex-direction: column;
    }

    .col-left {
        flex: 1 1 auto;
    }
}

/* ─── альбомная ориентация и короткие экраны ───────────────────────────────
   Телефон, положенный набок: ширины много, высоты мало. Кольцо уходит влево,
   приборы и КЛАВИШИ ПОКУПКИ встают колонкой справа от него, а лента участников —
   третьей колонкой, когда ширины на неё хватает. Иначе в фазе сбора игрок видит
   кольцо и ни одной кнопки: участвовать нечем.

   ПОЧЕМУ ПРЕЖНЕЕ ПРАВИЛО НЕ ПОМОГАЛО (замер: tools/visual/landscape.mjs, «до»).
   На 844×390 и 932×430 срабатывали ДВА медиазапроса сразу: десктопный (от 760 px)
   и этот. Десктопный прибивает левую колонку к 456 px и обносит её оправой по
   24 px с каждой стороны — внутри остаётся 408. Этому правилу нужно было кольцо
   (321) + промежуток + минимум 200 на приборы, то есть 531. Лишние 123 px никуда
   не делись: вложенная сетка не обрезается и не переносится, содержимое просто
   вылезло вправо и легло на ленту участников из СОСЕДНЕЙ колонки. Отсюда и
   «текст поверх текста в трёх местах» — вкладки комнат, клавиша покупки и строки
   ленты в одних и тех же точках. Стенд об этом молчал, потому что сравнивал
   только соседей по одному родителю; проверка расширена в harness.mjs.

   ПОЧЕМУ ТЕПЕРЬ ТАК. Левая колонка перестаёт быть коробкой вовсе
   (display: contents), и её содержимое раскладывается прямо по колонкам
   СТРАНИЦЫ. Вложенной сетки внутри чужой фиксированной ширины больше нет —
   значит, и вылезать неоткуда: ширину кольца назначает та же формула, которая
   вычла ширину соседних колонок. */
@media (max-height: 620px) and (min-width: 560px) {
    :root {
        /* Вертикали мало: шаг ритма ужимается. --s2 подрезан отдельно — он стоит
           в этой раскладке трижды подряд, и именно эти шесть пикселей не давали
           сойтись полноэкранной альбомной ориентации. */
        --s2: 6px;
        --s3: 10px;
        --s4: 12px;

        /* Колонка приборов и клавиш. 300 — не круглое число, а замер: приборная
           строка в одну строку («ПОТ 1030.00 · БИЛЕТОВ 103 · ИГРОКОВ 19»)
           требует 302 px по содержимому. На 240 подписи резались до «БИ…»,
           а «ПОТ» уезжал под левый край. */
        --ctl-w: 300px;
        /* Место под ленту участников. Ноль, пока ленты в потоке нет (до 760 px):
           там она остаётся выдвижной шторкой. */
        --aside-w: 0px;

        /* Своя формула стороны кольца, потому что общая считает не про эту
           раскладку: она вычитает --chrome-h — высоту приборов и клавиш, которые
           здесь стоят СБОКУ, а не над кольцом.
           По высоте вычитается только то, что реально над кольцом и под ним:
           безопасные зоны, шапка с её ребром, нижнее поле body и собственный
           запас сцены под лимб (40 = 1 + 12 + 24 и ещё 3 на округления).
           По ширине — соседние колонки. Обе стороны считаются честно, поэтому
           «не влезает» перестаёт быть возможным в принципе. */
        --stage-side: clamp(120px, min(
            calc(100vw - 2 * var(--pad-x) - var(--s3) - var(--ctl-w) - var(--aside-w) - var(--stage-pad)),
            calc(100dvh - var(--safe-top) - var(--safe-bottom) - var(--hdr-h) - 40px)
        ), 380px);
    }

    /* Страница целиком — сетка: кольцо, приборы, (лента). Промежуток задан только
       по горизонтали: gap десктопного правила ставит ещё и межстрочный в 32 px,
       а строк в столбце приборов полдюжины — они разъехались бы на две высоты. */
    .wrap {
        display: grid;
        grid-template-columns: calc(var(--stage-side) + var(--stage-pad)) minmax(0, 1fr);
        gap: 0 var(--s3);
        max-width: none;
    }

    /* Шапка держится ширины окна, а не десктопных 960: под ней теперь три колонки. */
    .hdr {
        max-width: none;
    }

    /* Левая колонка растворяется, её дети становятся ячейками сетки страницы.
       Вместе с коробкой уходит и десктопная оправа — рамка с уголками на экране
       высотой 390 съедала бы 48 px чистой высоты ради украшения. */
    .col-left {
        display: contents;
    }

    /* Уголки десктопной оправы гасятся поимённо. У элемента с display: contents
       своей коробки нет, но псевдоэлементы Chromium ей всё равно рисует — и
       абсолютный `.col-left::after { bottom: -1px }` привязывался уже не к
       колонке, а ко ВСЕМУ окну. Один пиксель под сгибом, страница считалась
       прокручиваемой и уезжала вверх (замер: документ 391 при окне 390). */
    .col-left::before,
    .col-left::after {
        content: none;
    }

    .col-left > .scene {
        grid-column: 1;
        /* Кольцо стоит рядом и с рядом комнат, и со всей стойкой приборов.
           Строк ровно две, и это принципиально: раздавая свою высоту сорока
           строкам, браузер промахивался мимо контейнера на 13 px (замер:
           tools/visual/landscape.mjs) — на двух строках делить нечего. */
        grid-row: 1 / span 2;
        align-self: center;
        justify-self: start;
        width: calc(var(--stage-side) + var(--stage-pad));
        /* Своих отступов у сцены здесь нет: ни отрицательных полей узкого экрана
           (они вылезали на соседнюю колонку и давали наложение на 740×360),
           ни верхнего поля ритма — кольцо стоит сбоку, а не под чем-то. */
        margin: 0;
        flex: none;
    }

    /* Ограничение по контейнеру снимается: у сцены в сетке нет «оставшейся»
       высоты, и 100cqmin посчиталось бы от нуля (замер: сцена 0×0 на 740×400).
       Ведущая сторона здесь — заданная ширина. */
    .col-left > .scene {
        container-type: normal;
    }

    .col-left > .scene > .stage {
        width: 100%;
        height: auto;
    }

    /* Ряд комнат — верхняя строка второй колонки, стойка приборов — нижняя. */
    .col-left > .rooms {
        grid-column: 2;
        grid-row: 1;
        align-self: start;
    }

    .col-left > .controls {
        grid-column: 2;
        grid-row: 2;
        /* Стойка прижата к низу своей строки: телефон держат за края, и клавиша
           покупки в нижней трети экрана достаётся большим пальцем, а в верхней —
           нет. Кольцо при этом стоит по центру всей высоты. */
        justify-content: flex-end;
    }

    /* Полки контекста (возврат и докуп) в потоке колонки, как на десктопе, здесь
       не встают: строку высотой в сто пикселей взять неоткуда — вся высота окна
       четыреста. Они снова накладка снизу, как на телефоне. Селектор с родителем,
       потому что десктопное правило `.shelf { position: static }` живёт в
       components.css, а тот грузится последним. */
    .controls > .shelf {
        position: fixed;
        left: 0;
        right: 0;
        bottom: 0;
        padding: var(--s3) var(--pad-x) 0;
        padding-bottom: calc(var(--safe-bottom) + var(--s3));
    }

    /* Объяснение игры — тоже накладка: в потоке оно занимает больше двухсот
       пикселей при высоте окна в четыреста. */
    .controls > .onb {
        position: fixed;
        left: var(--pad-x);
        right: var(--pad-x);
        top: 50%;
        z-index: 48;
        margin-top: 0;
        max-width: calc(var(--col-max) - 2 * var(--pad-x));
        margin-inline: auto;
        max-height: calc(100dvh - var(--safe-top) - var(--safe-bottom) - 2 * var(--s4));
        overflow-y: auto;
        overscroll-behavior: contain;
        transform: translateY(-50%);
    }
}

/* Альбомная ориентация, ширины хватает на третью колонку: лента участников
   встаёт в поток и читается всегда. Шторкой она тут быть не может — выезжая
   снизу, она накрыла бы клавиши покупки, которые в этой раскладке стоят не
   внизу, а справа. */
@media (max-height: 620px) and (min-width: 760px) {
    :root {
        /* Ширина ленты + промежуток перед ней. 170 — минимум, на котором строка
           «b4fdaa · 9 · 5.5%» ещё читается тремя колонками. Это НИЖНЯЯ граница:
           всё, что осталось от кольца и приборов, достаётся ленте. */
        --aside-w: calc(170px + var(--s3));
    }

    .wrap {
        grid-template-columns:
            calc(var(--stage-side) + var(--stage-pad))
            var(--ctl-w)
            minmax(0, 1fr);
    }

    .wrap > .col-right {
        grid-column: 3;
        grid-row: 1 / span 2;
        /* Десктопные 32 px отбивки здесь роскошь: это ширина двух знаков ленты. */
        padding-left: var(--s3);
        min-height: 0;
        overflow-y: auto;
    }
}

/* Альбомная ориентация на узком экране: третьей колонки не выкроить, лента
   остаётся шторкой. Но выезжает она ПОД КОЛЬЦО, а не на всю ширину: клавиши
   покупки стоят справа, и накрывать их списком значит отнимать ход. */
@media (max-height: 620px) and (min-width: 560px) and (max-width: 759.98px) {
    .wrap > .col-right {
        left: 0;
        right: auto;
        width: calc(var(--pad-x) + var(--stage-side) + var(--stage-pad) + var(--s3));
        bottom: 0;
        max-height: calc(100dvh - var(--safe-top) - var(--hdr-h) - var(--s3));
        transform: translateY(101%);
        border-right: 1px solid var(--groove);
    }
}

/* Узкий И низкий экран (например, альбомная ориентация маленького телефона):
   в две колонки не разложить, поэтому ужимаем и кольцо, и вертикальный ритм,
   чтобы кнопки покупки остались в первом экране. Кольцо важно, но бесполезно,
   если участвовать нечем. */
@media (max-height: 620px) and (max-width: 559.98px) {
    :root {
        --chrome-h: 230px;
        --s3: 10px;
        --s4: 12px;
    }
}


/* ── фон реагирует на фазу ────────────────────────────────────────────────
   Три слоя поверх подложки и ПОД содержимым. Меняется только прозрачность:
   background-image в CSS не анимируется вовсе, а перерисовывать градиент в
   кадре — это краска всего экрана шестьдесят раз в секунду.

   z-index: -1 ставит слои позади содержимого, но ПОВЕРХ собственного фона
   тела — ровно то, что нужно: подложка со светом, зерном и виньеткой остаётся
   на месте, а фаза только подкрашивает её сверху.

   Просьбу системы не двигать интерфейс уважает глобальное правило в tokens.css:
   там все переходы срезаются до миллисекунды, и фаза меняется мгновенно. */
.bg-fx {
    position: fixed;
    inset: 0;
    z-index: -1;
    pointer-events: none;
    overflow: hidden;
}

.bg-fx > i {
    position: absolute;
    inset: 0;
    display: block;
    opacity: 0;
    /* Полсекунды: быстрее читается как мигание, медленнее — как задержка. */
    transition: opacity 500ms var(--ease-out);
}

/* Овертайм: тепло приходит С КРАЁВ, центр не трогает. Время торгуется — экран
   теплеет по периферии, а показание в центре остаётся тем же. */
.bg-warm {
    background: radial-gradient(125% 95% at 50% var(--light-y),
        transparent 42%, rgba(255, 176, 0, .05) 78%, rgba(255, 176, 0, .085) 100%);
}

/* Дуэль: лёгкая виньетка по краям — ровно настолько, чтобы кольцо перестало
   висеть в воздухе. Прежние .42 по краю и сплошные .16 поверх всего экрана
   владелец забраковал вместе с общим затемнением: «всё темнеет — выглядит не
   очень». Свет теперь берёт на себя полка (.shelf в components.css). */
.bg-dim {
    background:
        radial-gradient(140% 92% at 50% var(--light-y), transparent 52%, rgba(0, 0, 0, .16) 100%);
}

/* Победа: тёплый золотой подсвет снизу, полторы секунды. Снизу — потому что
   деньги приходят в баланс, а он в шапке: свет идёт оттуда, куда смотрит взгляд
   после развязки, а не поверх кольца. */
.bg-gold {
    background: linear-gradient(to top, rgba(255, 176, 0, .16) 0%, rgba(255, 176, 0, .05) 34%, transparent 62%);
    transition-duration: 320ms;
}

/* Свой вылет: экран краснеет — ровно тем же приёмом, каким золотеет от победы,
   и с той же логикой «свет идёт снизу». Разница в жизни слоя: золото гаснет
   само через полторы секунды, потому что победа — событие; красное живёт, пока
   игрок вне игры, потому что вылет — состояние. Пульс в такт своему сердцу
   (js/audio.js, HEART_STEP = 1.25 с): звук и свет говорят одно и то же. */
.bg-red {
    background:
        radial-gradient(130% 92% at 50% 108%, rgba(224, 64, 64, .30) 0%, rgba(224, 64, 64, .09) 45%, transparent 74%),
        radial-gradient(150% 110% at 50% var(--light-y), transparent 44%, rgba(120, 0, 0, .22) 100%);
    transition-duration: 420ms;
}

body.ph-overtime .bg-warm { opacity: 1; }
body.ph-duel .bg-dim { opacity: 1; }
body.ph-win .bg-gold { opacity: 1; }
body.me-out .bg-red { opacity: 1; animation: bg-heart 1250ms ease-in-out infinite; }
/* Удар — резкий, спад — долгий: так пульсирует кровь, а не мигает лампа. */
@keyframes bg-heart {
    0%   { opacity: .58; }
    6%   { opacity: 1; }
    22%  { opacity: .72; }
    28%  { opacity: .86; }
    52%  { opacity: .58; }
    100% { opacity: .58; }
}

/* Первая секунда вылета: короткая яркая вспышка поверх постоянного свечения. */
body.me-hit .bg-red { animation: bg-red-hit 900ms var(--ease-out) 1; }
@keyframes bg-red-hit {
    0%   { opacity: 1; filter: brightness(2.2); }
    18%  { opacity: 1; filter: brightness(1.5); }
    100% { opacity: .58; filter: none; }
}

/* ── салют победы ─────────────────────────────────────────────────────────
   Два слоя поверх фона, но ПОД содержимым: вспышка на весь экран и лучи из
   центра кольца. Оба молчат, пока на теле нет класса win-blast; включённые —
   живут одну анимацию и гаснут сами. Ни один пиксель не перерисовывается в
   кадре: меняются прозрачность и поворот, то есть работа композитора. */
.win-flash,
.win-rays {
    position: fixed;
    z-index: -1;
    pointer-events: none;
    opacity: 0;
}

/* will-change держит слой в памяти композитора ВСЁ время, пока стоит, поэтому
   он включается только на время салюта, а не живёт в правиле слоя. Иначе игра
   платит за победу все девяносто секунд раунда, а побеждают раз. */
body.win-blast .win-flash,
body.win-blast .win-rays { will-change: opacity, transform; }

.win-flash { inset: 0; }

/* Лучи — не на весь экран, а квадрат вокруг кольца. Полноэкранный слой с
   конической маской при dpr 2 стоил на замере семь кадров в секунду, а видно
   его всё равно только в середине: за краем маски он прозрачен. */
.win-rays {
    top: 42%;
    left: 50%;
    width: 150vmin;
    height: 150vmin;
    margin: -75vmin 0 0 -75vmin;
}

.win-flash {
    background: radial-gradient(70% 55% at 50% 42%, rgba(255, 226, 150, .55) 0%, rgba(255, 190, 60, .22) 38%, transparent 72%);
}

/* Лучи — коническая «звезда» из центра кольца. Шестнадцать секторов, мягкие
   края: жёсткие выглядели бы как заставка из девяностых. */
.win-rays {
    background: conic-gradient(from 0deg at 50% 50%,
        rgba(255, 208, 96, .20) 0deg, transparent 11deg, transparent 22.5deg,
        rgba(255, 208, 96, .20) 33.5deg, transparent 45deg, transparent 67.5deg,
        rgba(255, 208, 96, .20) 78.5deg, transparent 90deg, transparent 112.5deg,
        rgba(255, 208, 96, .20) 123.5deg, transparent 135deg, transparent 157.5deg,
        rgba(255, 208, 96, .20) 168.5deg, transparent 180deg, transparent 202.5deg,
        rgba(255, 208, 96, .20) 213.5deg, transparent 225deg, transparent 247.5deg,
        rgba(255, 208, 96, .20) 258.5deg, transparent 270deg, transparent 292.5deg,
        rgba(255, 208, 96, .20) 303.5deg, transparent 315deg, transparent 337.5deg,
        rgba(255, 208, 96, .20) 348.5deg, transparent 360deg);
    mask-image: radial-gradient(50% 50% at 50% 50%, #000 14%, rgba(0, 0, 0, .55) 52%, transparent 78%);
    -webkit-mask-image: radial-gradient(50% 50% at 50% 50%, #000 14%, rgba(0, 0, 0, .55) 52%, transparent 78%);
}

body.win-blast .win-flash { animation: win-flash 900ms var(--ease-out) 1; }
body.win-blast .win-rays { animation: win-rays 2400ms linear 1; }

/* Вспышка: мгновенный удар света и долгий спад — так выглядит фотовспышка и
   так же читается «сорвал куш». */
@keyframes win-flash {
    0%   { opacity: 0; }
    6%   { opacity: 1; }
    100% { opacity: 0; }
}

/* Лучи проворачиваются на четверть круга, набирая и теряя силу. Медленно:
   быстрое вращение вокруг неподвижного кольца читается как рябь. */
@keyframes win-rays {
    0%   { opacity: 0; transform: rotate(0deg) scale(1.04); }
    14%  { opacity: .9; }
    70%  { opacity: .55; }
    100% { opacity: 0; transform: rotate(90deg) scale(1.12); }
}

/* На светлой теме прибавлять свет некуда — вспышка слабее, лучи заметно тише. */
[data-theme="light"] .win-flash {
    background: radial-gradient(70% 55% at 50% 42%, rgba(255, 196, 60, .32) 0%, rgba(255, 176, 0, .12) 38%, transparent 72%);
}
[data-theme="light"] .win-rays { opacity: 0; filter: saturate(.8); }

[data-theme="light"] .bg-red {
    background:
        radial-gradient(130% 90% at 50% 108%, rgba(201, 33, 75, .18) 0%, rgba(201, 33, 75, .05) 45%, transparent 72%),
        radial-gradient(150% 110% at 50% var(--light-y), transparent 46%, rgba(201, 33, 75, .10) 100%);
}

/* На светлой теме те же слои, но слабее: там прибавлять свет некуда, и
   потемнение поверх бумаги быстро читается как грязь. */
[data-theme="light"] .bg-warm {
    background: radial-gradient(125% 95% at 50% var(--light-y),
        transparent 42%, rgba(196, 120, 0, .05) 78%, rgba(196, 120, 0, .08) 100%);
}

/* На светлой теме потемнение вдвое слабее: замер показал −29 единиц яркости у
   нижнего края при −5.7 в тёмной теме. На бумаге такая пелена читается как
   грязь, а не как «свет ушёл». */
[data-theme="light"] .bg-dim {
    background:
        radial-gradient(135% 85% at 50% var(--light-y), transparent 38%, rgba(18, 33, 27, .09) 100%),
        rgba(18, 33, 27, .025);
}

[data-theme="light"] .bg-gold {
    background: linear-gradient(to top, rgba(255, 176, 0, .22) 0%, rgba(255, 176, 0, .07) 34%, transparent 62%);
}

/* ── экипаж в альбомной раскладке ≥ 760 ───────────────────────────────────
   Между рядом столов и стойкой приборов во второй колонке пусто 130–200 px.
   Полоса медальонов (js/ui/crew.js) переезжает туда клеткой сетки: строк
   становится три, средняя minmax(0,1fr) поглощает округление, из-за которого
   при сорока строках браузер промахивался мимо контейнера. Кольцо и стойка
   остаются на своих местах: кольцо — на всю высоту, стойка — прижата к низу. */
@media (max-height: 620px) and (min-width: 760px) {
    .wrap {
        grid-template-rows: auto minmax(0, 1fr) auto;
    }
    .col-left > .scene,
    .wrap > .col-right {
        grid-row: 1 / span 3;
    }
    .col-left > .rooms {
        grid-row: 1;
    }
    .col-left > .crew {
        grid-column: 2;
        grid-row: 2;
        position: relative;
        left: auto;
        right: auto;
        bottom: auto;
        height: auto;
        min-height: 0;
        align-self: start;
        margin-top: var(--s3);
        flex-wrap: wrap;
        align-content: flex-start;
    }
    .col-left > .controls {
        grid-row: 3;
    }
}
