/*
 * Слой примитивов UI-кита (спек §4.1) — ЕДИНЫЙ ИСТОЧНИК для Twig-страниц и
 * React-приложений (FE-2, дизайн-спринт Task 3). SSR-страницы подключают
 * этот файл тегом `<link>` (`templates/base.html.twig`), React-бандлы —
 * через `apps/shared/ui/ui.css`, который его импортирует тем же приёмом,
 * каким уже сделан `tokens.css` рядом. Копий нет ни одной: до этой задачи
 * примитивы жили ДВАЖДЫ (здесь и в `apps/shared/ui/ui.css` как исходник, а
 * не импорт), и кнопка в кабинете расходилась с кнопкой на странице работы
 * молча — заметить это можно было только открыв обе рядом.
 *
 * ВЕСЬ ФАЙЛ — ВНУТРИ `@layer primitives`: порядок слоёв объявлен один раз в
 * `assets/css/layers.css` (подключается первым в обоих контурах), и
 * страничный CSS обязан перекрывать примитив ПОРЯДКОМ СЛОЯ, а не гонкой
 * специфичности («потолок специфичности 0,2,0», спек §4.1, принцип 4).
 *
 * ГЕОМЕТРИЯ — ПО ШКАЛАМ TASK 1 (`assets/css/tokens.css`): отступы, радиусы
 * и кегли берутся из `--space-*`/`--r-*`/`--fs-*`, а не своими числами.
 * Физическими остаются только величины, которые обязаны быть физическими:
 * границы (`1px`), полоса прогресса читалки и `--rf-tap` ниже — это размер
 * пальца на экране, а не элемент типографской сетки, и от увеличенного
 * системного шрифта не растёт.
 *
 * АЛИАСЫ СТАРОГО СЛОВАРЯ — ЗДЕСЬ ИХ НЕТ, И ЭТО НЕ ЗАБЫТЬЁ (фикс-раунд 1,
 * Finding 1).
 *
 * До дизайн-спринта в проекте жили два имени на одно и то же:
 * `.chip`/`.rf-chip`, `.badge`/`.rf-badge`, `.toast`/`.rf-toast`. Первый
 * проход задачи свёл их в один группированный селектор («одно правило, два
 * имени») — план так и обещал. Ruling фикс-раунда 1 приём снял: ДО этой
 * задачи `ui.css` подключали ТОЛЬКО React-бандлы, и Twig-страницы старого
 * словаря его не видели вовсе. Задача подключила `ui.css` тегом `<link>` в
 * `templates/base.html.twig` (правильно), но вместе с этим алиас начал
 * ПРОТЕКАТЬ на страницы, где `.chip`/`.badge`/`.toast` по-прежнему
 * полностью объявлены в `app.css`: он не обёрнут ни в один слой, поэтому
 * побеждает там, где сам что-то объявляет, — а там, где молчит, побеждал
 * примитив. Результат — гибрид полей одного компонента из двух
 * независимых источников, которого никто не проектировал: чипы на
 * `/search` получали 44px-таргет вместо унаследованных ~24px, живой тост
 * (`assets/js/social.js`) — одновременно рамку и вторую анимацию поверх
 * старой. «Одно правило, два имени» верно ТОЛЬКО когда старое правило
 * удалено — иначе это не одно правило, а два, склеенных по слоям.
 *
 * Поэтому: `.chip`, `.badge` и `.toast` полностью остаются за `app.css`,
 * пока не найдётся задача, которая перерисовывает соответствующий экран и
 * переводит его разметку на имя `rf-*` (чипы поиска — Task 15, чипы
 * онбординга — Task 17, страница работы — Task 14, и так далее). Тогда
 * старое правило удаляется, и группированный алиас заводится СНОВА, но уже
 * без второго источника под ним — именно в тот момент, а не раньше.
 * `.btn` в эту схему не входит вовсе: такого класса в проекте нет — только
 * `.like-btn`/`.comment-edit-btn`, у которых свои роли и своя вёрстка.
 */

@layer primitives {

/*
 * `--rf-tap` — ОДНО место, где живёт минимальный размер таргета FE-2
 * (44px). Каждый интерактивный класс ниже ссылается на него, а не на своё
 * число: `tap-targets.test.tsx` проверяет ровно это соответствие, и
 * компонент, забывший про таргет, роняет тест, а не ждёт ревью.
 */
:root {
    --rf-tap: 44px;
}

/* Видимый только скринридеру текст: состояние чипа («исключён»), счётчик
 * «показать ещё» и прочее, что глазом читается оформлением, а голосом —
 * никак. */
.rf-vh {
    position: absolute;
    inline-size: 1px;
    block-size: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    border: 0;
}

/*
 * Иконка из спрайта (Task 2) — класс уже ставят оба генератора разметки
 * (`icon.html.twig`, `Icon.tsx`), но правила для него не было нигде.
 * Инлайновый `<svg>` по умолчанию `display: inline`, что оставляет под
 * иконкой фантомный «подвал» строки (место под свисающие хвосты типа «у»)
 * — из-за него иконка на пару пикселей выше своей текстовой пары.
 * Цвет и размер иконка уже несёт сама: `stroke="currentColor"` в спрайте и
 * атрибуты `width`/`height` в разметке (`1em` по умолчанию) — здесь только
 * выравнивание по строке и защита от сжатия во флекс-кнопке.
 */
.rf-icon {
    display: inline-block;
    flex-shrink: 0;
    vertical-align: -0.125em;
}

/* ── кнопки: 3 размера × primary/spark/ghost/danger (FE-4) ──────────── */

.rf-btn {
    min-block-size: var(--rf-tap);
    min-inline-size: var(--rf-tap);
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-2);
    border-radius: var(--r-chip);
    border: 1px solid transparent;
    font-family: inherit;
    font-weight: 600;
    line-height: var(--lh-tight);
    cursor: pointer;
    /*
     * ПОДЧЁРКИВАНИЕ БРАУЗЕРА ГАСИТ САМ ПРИМИТИВ (Дизайн-спринт, Task 17) —
     * та же строка и та же причина, что у `.rf-chip` ниже: подчёркивание
     * внутри пилюли читается как дефект вёрстки, а не как «здесь ссылка».
     * `ui.button()` печатает только `<button>` (его докблок), у которого
     * подчёркивания нет, — но кнопка-ССЫЛКА в проекте штатна и заведена
     * самим китом: «Читать»/«Продолжить» на странице работы, «Показать
     * ещё» в ленте и поиске, «Войти» в шапке, «Пропустить» в онбординге.
     * Task 10 нашла этот пробел на «Войти» и записала: «это ЕГО пробел, не
     * повод трогать `ui.css` (файловый список этой задачи его не
     * касается)», — и погасила подчёркивание страничным правилом
     * `.rf-topbar a.rf-btn`. Файловый список ЭТОЙ задачи `ui.css` касается,
     * и у пробела появился пятый потребитель, поэтому пробел закрыт там,
     * где он живёт; страничный дубль в `app.css` снят тем же коммитом
     * (иначе он остался бы правилом, повторяющим свойство примитива, —
     * ровно то, чего слои и заведены не допускать).
     */
    text-decoration: none;
    transition: background-color var(--dur-fast), border-color var(--dur-fast), color var(--dur-fast);
}

.rf-btn--sm { padding: 0 var(--space-3); font-size: var(--fs-sm); }
.rf-btn--md { padding: 0 var(--space-4); font-size: var(--fs-base); }
.rf-btn--lg { min-block-size: 56px; padding: 0 var(--space-6); font-size: var(--fs-md); }

/*
 * ПОДПИСЬ НА ЦВЕТНОЙ ПЛАШКЕ БЕРЁТСЯ ОТ ПЛАШКИ, А НЕ ОТ ФОНА ТЕМЫ (ревью Task 20).
 *
 * Было `color: var(--bg)` — экономно и неверно: `--bg` описывает светлоту
 * СТРАНИЦЫ, а подпись лежит на кнопке. У сепии фон #F4ECDD, и на янтарной
 * плашке он давал 3.58:1 — главный CTA («Опубликовать») не проходил AA в двух
 * темах из трёх. Теперь краска приходит полюсами `--ink`/`--paper`, а какой
 * полюс годится этой теме, решает `--on-primary` в самих токенах.
 *
 * HOVER УВОДИТ ПЛАШКУ ОТ КРАСКИ, а не «делает темнее». У `--primary` краска
 * зависит от темы, поэтому подмешивается `--text` (он всегда на
 * противоположном от краски полюсе); у `--spark` краска всегда тёмная,
 * поэтому подмешивается `--paper`. В обоих случаях контраст на hover РАСТЁТ,
 * а не переворачивается — прежний `--primary-deep` в тёмной теме делал плашку
 * темнее краски и ронял пару до 3.21:1.
 *
 * Числа по всем парам держит `contrast.test.ts`.
 */
.rf-btn--primary { background: var(--primary); color: var(--on-primary); }
.rf-btn--primary:hover { background: color-mix(in srgb, var(--primary) 86%, var(--text)); }

.rf-btn--spark { background: var(--spark); color: var(--ink); }
.rf-btn--spark:hover { background: color-mix(in srgb, var(--spark) 86%, var(--paper)); }

.rf-btn--ghost {
    background: transparent;
    border-color: var(--border);
    color: var(--text);
}
.rf-btn--ghost:hover { border-color: var(--primary); color: var(--primary); }

/*
 * Подпись — рейтинговый красный, ПОДМЕШАННЫЙ к тексту темы. Чистый
 * `--rating-nc17` брал 3.77:1 на тёмном фоне и 4.20:1 на сепии: палитра
 * рейтингов одна на все темы (DESIGN_BRIEF §2) и трогать её нельзя, а вот
 * увести её от фона на 25% в сторону `--text` — можно, и это работает в любой
 * теме автоматически. Рамка остаётся полной силы: она украшение, смысл несёт
 * подпись.
 */
.rf-btn--danger {
    background: transparent;
    border-color: color-mix(in srgb, var(--rating-nc17) 55%, var(--border));
    color: color-mix(in srgb, var(--rating-nc17) 75%, var(--text));
}
.rf-btn--danger:hover { background: color-mix(in srgb, var(--rating-nc17) 12%, transparent); }

/* Отключённая кнопка ОСТАЁТСЯ в порядке обхода (см. докблок `Button.tsx`):
 * гасим её видом, а не атрибутом `disabled`.
 *
 * `:disabled` В ТОЙ ЖЕ ЗАПИСИ (Дизайн-спринт, Task 17) — НЕ отмена правила
 * выше и не лазейка для нативного атрибута. Кит его по-прежнему не печатает
 * (`ui.button()` умеет только `aria-disabled`), но в проекте есть РОВНО
 * ОДНА кнопка, которой нативный атрибут необходим: «Подтверждаю: мне есть
 * 18 лет» на `/settings/age`. Причина не вкусовая: без JS кросс-доменная
 * отправка в РФ-контур невозможна вовсе, а отключённая кнопка по
 * спецификации не участвует даже в НЕЯВНОЙ отправке формы — то есть
 * `disabled` там единственное, что стоит между читателем без скриптов и
 * 405-м на GET-маршруте; `aria-disabled` не блокирует ничего, а
 * `settings.js` снимает именно `disabled` (`bindAgeForm()`). Правило
 * добавлено потому, что до него эта кнопка выглядела ПОЛНОСТЬЮ доступной,
 * пока не приехал скрипт, — обещание действия, которого в тот момент нет.
 * Селекторы сгруппированы, а не скопированы: гасить их по-разному было бы
 * двумя видами одного состояния. */
.rf-btn[aria-disabled="true"],
.rf-btn:disabled { opacity: .55; cursor: not-allowed; }

/* ── поля ──────────────────────────────────────────────────────────── */

.rf-field { display: block; }

.rf-field__label {
    display: block;
    margin-block-end: var(--space-2);
    font-size: var(--fs-sm);
    font-weight: 600;
    color: var(--text);
}

.rf-field__input {
    min-block-size: var(--rf-tap);
    inline-size: 100%;
    padding: 0 var(--space-4);
    border-radius: var(--r-field);
    border: 1px solid var(--border);
    background: var(--bg);
    color: var(--text);
    font-family: inherit;
    font-size: var(--fs-base);
}

.rf-field__input[aria-invalid="true"] { border-color: var(--rating-nc17); }

/*
 * `--lg` (Дизайн-спринт, Task 15) — «крупный инпут» компактного hero
 * страницы поиска (спека §3.5). РАЗМЕР ЖИВЁТ В ПРИМИТИВЕ, а не в правиле
 * страницы, и это не вкусовщина: `app.css` не обёрнут в каскадный слой
 * (долг отдельной задачи), поэтому любое его правило про кегль и высоту
 * поля перебило бы примитив на ВСЁМ сайте, а не «на этой странице» —
 * ровно тот гибрид из двух источников, которым закончился алиас старого
 * словаря в Task 3.
 *
 * `3.25rem`, а не `--space-*`: это ВЫСОТА ТАРГЕТА (как `--rf-tap`, только
 * крупнее), а не элемент отступной сетки. В `rem`, а не в px — поле обязано
 * расти вместе с кеглем читателя (спек §2.7).
 */
.rf-field__input--lg {
    min-block-size: 3.25rem;
    font-size: var(--fs-md);
}

/*
 * Многострочное поле наследует ОФОРМЛЕНИЕ `.rf-field__input` (класс ставится
 * рядом), а здесь — только то, чем текстовая область от строки отличается:
 * высота задаётся строками, а не таргетом, и тянуть её можно вертикально.
 * `padding` переопределяется целиком: у однострочного поля вертикальные
 * отступы нулевые (текст центрируется высотой), у области это срезало бы
 * первую строку.
 */
.rf-field__textarea {
    padding: var(--space-3) var(--space-4);
    line-height: var(--lh-base);
    resize: vertical;
}

.rf-field__hint { margin: var(--space-2) 0 0; font-size: var(--fs-sm); color: var(--text-muted); }
.rf-field__error { margin: var(--space-2) 0 0; font-size: var(--fs-sm); color: var(--rating-nc17); }

/*
 * ПОДСКАЗКА, КОТОРУЮ НАПОЛНЯЕТ СКРИПТ, ДЕРЖИТ СВОЮ СТРОКУ ПУСТОЙ
 * (Дизайн-спринт, Task 17). Признак — `aria-live`: атрибут стоит ровно там,
 * где текст приезжает позже (занятость ника от РФ-контура, IDN-6; статус
 * отправки подтверждения 18+). Без резерва первый же вердикт «Ник свободен.»
 * сдвигал бы всё, что ниже, на строку — в момент, когда человек ещё набирает
 * в поле над ней, а на экране возраста уводил бы кнопку из-под курсора.
 *
 * `1em`, а не `--space-*`: это ВЫСОТА ОДНОЙ СТРОКИ текста этой подсказки, а
 * не элемент отступной сетки, и меняться она обязана вместе с её кеглем.
 * Правило переехало сюда из `.auth-hint { min-height: 1em }` (`app.css`,
 * Sprint 2) вместе с двумя своими экранами; старый класс остался нетронутым
 * — его держат `/settings` и удаление аккаунта.
 */
.rf-field__hint[aria-live] { min-block-size: 1em; }

/* ── чипы тегов: обычный / включён / исключён (FE-4) ────────────────── */

/*
 * `.chip` СЮДА НЕ ВХОДИТ (фикс-раунд 1, Finding 1) — только `.rf-chip`.
 * До фикс-раунда здесь был группированный алиас `.chip, .rf-chip`, но
 * `.chip` в `app.css` — самостоятельное, полностью объявленное правило со
 * своими числами (`5px 13px`, без 44px-таргета), и алиас поверх него не
 * заменял его, а СМЕШИВАЛСЯ: страницы, которые этот спринт не
 * перерисовывает (каталог фандомов, тегов, поиск, страница работы),
 * получали гибрид обоих правил — см. докблок файла выше. `.rf-chip` здесь
 * ничего не знает про `.chip`: тот, кто переведёт страницу на `rf-chip` и
 * удалит правило `.chip` в `app.css`, вправе завести алиас заново.
 */
.rf-chip {
    min-block-size: var(--rf-tap);
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    padding: 0 var(--space-4);
    border-radius: var(--r-chip);
    border: 1px solid var(--border);
    background: var(--surface);
    color: var(--text-muted);
    font-family: inherit;
    font-size: var(--fs-sm);
    font-weight: 500;
    /* Чип бывает ссылкой (`ui.chip(label, {href})`, Task 15: снятый фильтр
     * и редакционный фандом) — та же строка и по той же причине стоит у
     * `.rf-filter-chip` ниже: подчёркивание внутри пилюли читается как
     * дефект вёрстки, а не как «здесь ссылка». */
    text-decoration: none;
    cursor: pointer;
    transition: border-color var(--dur-fast), color var(--dur-fast), background-color var(--dur-fast);
}

.rf-chip:hover { border-color: var(--primary); color: var(--primary); }

/*
 * ЧИП-ЧЕКБОКС (Дизайн-спринт, Task 17) — форменный вариант чипа: `<label
 * class="rf-chip">` с настоящим `input[type="checkbox"]` внутри
 * (`ui.chip_checkbox()`), первый потребитель — сетка фандомов онбординга.
 *
 * СОСТОЯНИЕ ЧИТАЕТСЯ У БРАУЗЕРА, А НЕ У КЛАССА: `:has(:checked)` вместо
 * `--included` — тот же приём и та же причина, что у `:checked` форменного
 * сегмента (`.rf-segmented--form`, Task 15). Класс-модификатор здесь был бы
 * второй правдой об одном флаге: нативный чекбокс меняет `:checked` сам, а
 * класс переставить некому — страница обязана работать без единой строки JS
 * (FE-7), и на онбординге это не фигура речи, а способ, которым читатель
 * без скриптов вообще подписывается.
 *
 * ВИД — ТОТ ЖЕ, ЧТО У `--included`, И ПОТОМУ СЕЛЕКТОР ГРУППИРОВАН, а не
 * скопирован: «отмеченный чип» и «включённый фильтр» — одно понятие,
 * увиденное с двух страниц, и разъехаться их оформлению нельзя.
 */
.rf-chip input[type="checkbox"] { accent-color: var(--primary); margin: 0; }

.rf-chip--included,
.rf-chip:has(input[type="checkbox"]:checked) {
    border-color: color-mix(in srgb, var(--primary) 55%, var(--border));
    background: color-mix(in srgb, var(--primary) 12%, transparent);
    color: var(--text);
}

.rf-chip--excluded {
    border-color: color-mix(in srgb, var(--rating-nc17) 55%, var(--border));
    background: color-mix(in srgb, var(--rating-nc17) 10%, transparent);
    color: var(--rating-nc17);
    text-decoration: line-through;
}

.rf-chip__mark { font-weight: 700; }

/*
 * `.rf-chip--static` (дизайн-спринт, Task 11) — НЕИНТЕРАКТИВНЫЙ вариант,
 * найдено при переносе карточки работы на примитивы: перевод тега карточки
 * со старого `.chip` на `.rf-chip` меняет высоту с 25px на 44px, потому что
 * `.rf-chip` несёт обязательный тап-таргет `--rf-tap` (FE-2). Таргет верен
 * для того, чем НАЖИМАЮТ — фильтры (`.rf-chip--included/--excluded`), чипы
 * онбординга, ссылки на теги, — но вреден для тегов на карточке: это
 * `<span>` с `cursor: default` (их не нажимают, докблок `work_card.html.twig`),
 * и ряд из шести тегов высотой 44px растянул бы карточку втрое (канон
 * `design/prototypes/01-styleguide.html` рисует их компактными,
 * `.tags .chip{padding:3px 10px;font-size:.72rem}`).
 *
 * Имя — `--static`, а не, скажем, `--label`: модификатор гасит именно
 * ИНТЕРАКТИВНОСТЬ (таргет, курсор, hover-обещание), а не меняет роль чипа
 * на «подпись» — тег карточки формально остаётся тем же понятием, что
 * фильтр-чип, просто сейчас не нажимается.
 */
.rf-chip--static {
    min-block-size: 0;
    padding: var(--space-1) var(--space-3);
    font-size: var(--fs-xs);
    cursor: default;
}

.rf-chip--static:hover { border-color: var(--border); color: var(--text-muted); }

/* ── бейджи: рейтинг / направленность / предупреждение / статус / нейтральный (FE-4) ── */

/*
 * `.badge`/`.b-*` СЮДА НЕ ВХОДЯТ (фикс-раунд 1, Finding 1) — только
 * `.rf-badge`/`.rf-badge--*`. Числа исторически совпадали байт-в-байт с
 * `.badge` в `app.css` (оба взяты из одного прототипа), но группированный
 * алиас поверх двух живых, независимых объявлений давал не одно правило,
 * а склейку по слоям — см. докблок файла выше. `.badge` остаётся полностью
 * за `app.css` до задачи, которая переводит соответствующую страницу на
 * `rf-badge`.
 */

/*
 * КЕГЛЬ ЧИТАЕТСЯ ИЗ РУЧКИ `--rf-badge-fs` (Дизайн-спринт, Task 18) —
 * САНКЦИОНИРОВАННЫЙ ШОВ, а не лазейка. Спека §4.3 заводит его ярусом
 * системы токенов: «Уровень 3 — переменные компонента»
 * (`docs/superpowers/specs/2026-09-07-design-sprint-design.md:264-268`)
 * печатает буквально этот приём с `--rf-btn-bg` прецедентом — примитив
 * читает величину из переменной с фоллбэком с Уровня 2, страница
 * переменную ставит. Тот же ярус, что `--rf-sheet-max` у листа (докблок
 * ниже по файлу).
 *
 * ПОЧЕМУ РУЧКА — ЕДИНСТВЕННО ВЕРНАЯ ФОРМА ЗДЕСЬ. КРУПНЫЙ бейдж
 * понадобился одному экрану — карточке возрастного гейта, где рейтинг
 * работы и есть причина, по которой читатель на этой странице (спека
 * §3.2). Причина не в каскаде, а в собственности: правило страницы не
 * переобъявляет СОБСТВЕННОЕ свойство примитива — ни бареным `.rf-badge`,
 * ни локальным `.rf-gatepage__mark .rf-badge`, — потому что кегль бейджа
 * принадлежит бейджу; а поставить снаружи его объявленную переменную —
 * ровно то, для чего она объявлена.
 *
 * Класс-модификатор `--lg` был вторым допустимым решением и проиграл по
 * цене РАЗМЕТКИ: бейдж печатает макрос `ui.badge_rating()`, и модификатор
 * потребовал бы либо расширить его сигнатуру ради одного экрана, либо
 * набрать бейдж разметкой мимо макроса — то есть завести второй генератор
 * бейджа.
 *
 * ФИКС-РАУНД 1 (I-1) СНЯЛ ДВЕ ПРЕЖНИЕ ЗАПИСАННЫЕ ПРИЧИНЫ, обе неверные, —
 * они стояли здесь и сбивали бы следующего автора с толку. (1) «Правило
 * `app.css` поменяло бы кегль на ВСЁМ сайте, файл вне слоя» — верно
 * только для БАРЕНОГО `.rf-badge`, которого никто не предлагал: секция
 * гейта в `app.css` сама обёрнута в `@layer components`, и потомковый
 * селектор остался бы локальным. (2) «Демо модификатора на витрине
 * показывало бы неверный размер из-за stale-бандла» — правда про бандл, но
 * довод НЕРАЗЛИЧАЮЩИЙ: бандл объявляет `.rf-badge` без ручки, тем же
 * слоем и позже по порядку источника, значит на витрине и ручка —
 * молчаливый no-op. Довод осуждал оба варианта одинаково.
 *
 * ФОЛЛБЭК РАВЕН ПРЕЖНЕМУ ЗНАЧЕНИЮ, поэтому нигде, кроме гейта, вид бейджа
 * не изменился — ни на карточке работы, ни в шапке, ни на витрине
 * (замерено: `.rf-badge` на `/dev/styleguide` и на странице работы даёт те
 * же 12px до и после правки; на витрине их даёт бандл, на странице работы
 * — этот фоллбэк). Отступы ручки не имеют нарочно: у бейджа они шаг шкалы
 * (`--space-1`/`--space-2`), а не производная кегля, и заводить вторую
 * ручку «на всякий случай» значило бы описать API под задачу, которой
 * нет.
 *
 * РУЧКА ЗАРЕГИСТРИРОВАНА (фикс-раунд 1, I-2):
 * `apps/shared/ui/component-knobs.test.ts` утверждает и эту половину
 * контракта (примитив читает ручку с фоллбэком), и вторую (страница её
 * ставит). Удалить любую из двух молча больше нельзя.
 */
.rf-badge {
    display: inline-block;
    padding: var(--space-1) var(--space-2);
    border-radius: var(--space-2);
    font-size: var(--rf-badge-fs, var(--fs-xs));
    font-weight: 700;
    letter-spacing: .03em;
}

/* Рейтинговая палитра — одна на все темы (DESIGN_BRIEF §2), поэтому
 * фиксированные токены, а не тематические. */
.rf-badge--g { background: color-mix(in srgb, var(--rating-g) 18%, transparent); color: var(--rating-g); }
.rf-badge--pg13 { background: color-mix(in srgb, var(--rating-pg13) 18%, transparent); color: var(--rating-pg13); }
.rf-badge--r { background: color-mix(in srgb, var(--rating-r) 18%, transparent); color: var(--rating-r); }
.rf-badge--nc17 { background: color-mix(in srgb, var(--rating-nc17) 18%, transparent); color: var(--rating-nc17); }

/*
 * ОДИН ВИД, ДВА ИМЕНИ — И ЭТО ГРУППИРОВКА, А НЕ КОПИЯ (Дизайн-спринт,
 * Task 19). `--direction` печатает значение перечисления `WorkDirection` и
 * несёт скрытое «Направленность: » (`ui.badge_direction()`); `--neutral` —
 * готовую подпись, за которой НЕТ машинного словаря вовсе
 * (`ui.badge_neutral()`: «приватный» у сборника, «вы заблокировали этого
 * автора» на странице автора). Роли разные, вид один и тот же —
 * обведённая пилюля приглушённым по поверхности, — поэтому селектор
 * сгруппирован: «одно определение, два имени, без второй копии значений»
 * (спек §4.4). Второе объявление тех же четырёх свойств под новым именем
 * разъехалось бы с первым на первой же правке рамки.
 *
 * ПОЧЕМУ ЭТИМ ТРЁМ ПОДПИСЯМ НЕ ГОДИТСЯ `--status` (реестр их прежнего
 * словаря — в докблоке `.rf-badge--status` ниже). Статус-бейдж несёт
 * скрытый префикс «Статус: » и машинный ключ в атрибуте, по которому
 * красится точка. «Приватный» — это видимость сборника, «вы замьютили
 * этого автора» — отношение зрителя; ни у одной из фраз нет ни состояния
 * работы, ни ключа, по которому точку было бы чем красить. Поставить их
 * под статус значило бы сообщить скринридеру неправду ради того, чтобы
 * рядом с подписью стояла серая точка, которая ничего не значит.
 */
.rf-badge--direction,
.rf-badge--neutral {
    background: var(--surface);
    border: 1px solid var(--border);
    color: var(--text-muted);
    font-weight: 600;
}

.rf-badge--warning {
    background: color-mix(in srgb, var(--rating-r) 12%, transparent);
    color: var(--rating-r);
    border: 1px dashed color-mix(in srgb, var(--rating-r) 50%, transparent);
}

/*
 * ── ПИЛЮЛЯ СОСТОЯНИЯ — ПЯТЬ МОДИФИКАТОРОВ БЕЙДЖА (Task 21, фикс-раунд 1) ──
 *
 * Обведённая капсула приглушённым кеглем: «опубликована»/«черновик» у работы,
 * четыре состояния модерации, гео-бейджи («скрыта в РФ», «18+ в Беларуси»),
 * три статуса главы и роль соавтора — девять подписей одного вида на четырёх
 * экранах кабинета (`apps/studio/src`, единственный потребитель на сегодня).
 *
 * ПЕРВЫЙ ПРОХОД TASK 21 ЗАВЁЛ ЭТО ВТОРЫМ БЛОКОМ (`.rf-pill` в
 * `apps/studio/src/studio.css`) И НАЗВАЛ ТРИ ПРИЧИНЫ. Все три оказались
 * неверными, и контроллер фикс-раунда 1 велел свести словарь к одному:
 *
 * 1. «Обведённых модификаторов у бейджа нет» — обводка у бейджа объявлена и
 *    именно модификатором: `.rf-badge--neutral` выше и есть обведённая
 *    приглушённая пилюля (так её называет её собственный докблок).
 * 2. «Красить „скрыта в РФ“ рейтингом NC-17 — соврать классом» — верно, но
 *    запрещает переиспользовать ИМЯ `--nc17`, а не токен: `--blocked` ниже
 *    красится ровно тем же `var(--rating-nc17)`, каким красилась прежняя
 *    пилюля блокировки, — цвет тот же и до, и после, разошлись только имена.
 * 3. «Новые модификаторы могли бы оказаться на витрине молчаливым no-op из-за
 *    gitignored-бандла» — довод НЕРАЗЛИЧАЮЩИЙ, и это уже записано в докблоке
 *    `--rf-badge-fs` выше (строки 417-424), на который первый проход
 *    сослался как на основание: бандл объявляет `.rf-badge` тем же слоем и
 *    позже по порядку источника, поэтому затеняет он не «новое имя», а
 *    только СВОИ СОБСТВЕННЫЕ свойства базы — и одинаково при любом из двух
 *    решений.
 *
 * ЧТО БАНДЛ ВИТРИНЫ ДЕЙСТВИТЕЛЬНО ЗАТЕНЯЕТ У ЭТОГО МОДИФИКАТОРА — замерено
 * живьём на `/dev/styleguide` (обе копии слоя в `document.styleSheets`,
 * дев-стенд, фикс-раунд 1): из шести объявлений `--pill` на витрине
 * применяются два (`border`, `color`), а четыре показываются значениями базы
 * бандла — `border-radius` 8px вместо капсулы, `font-weight` 700 вместо 400,
 * `letter-spacing` .03em вместо `normal`, `padding-inline` 8px вместо 12px
 * (у базы бандла это шорткат `padding`, он перекрывает и лонгхенд). Краски
 * состояний применяются все четыре. Это буква в букву ситуация
 * `.rf-field__input--lg` (44px на витрине против живых 52px), которую
 * контроллер велел ЗАПИСАТЬ, а не проектировать вокруг: перегенерация
 * бандла — Task 24 (DEBT, п. 1), запись — строкой в `IDEAS.md`. На живых
 * страницах кабинета бандла нет вовсе (`main.tsx` подключает `layers`,
 * `tokens`, `ui`, `studio`), поэтому вид кабинета от этого не зависит: тем же
 * замером с ОТКЛЮЧЁННЫМ бандлом (`styleSheets[…].disabled = true` — ровно
 * набор файлов кабинета) пилюля даёт `border-radius: 999px`,
 * `padding-inline: 12px`, `font-weight: 400`, `letter-spacing: normal`,
 * `font-size: 12px`, рамку 1px и высоту 28px, то есть значение к значению то
 * же, что было у `.rf-pill` до переезда; краски состояний — те же
 * `rgb(63,166,98)`/`rgb(255,180,84)`/`rgb(196,69,60)`/`rgb(224,122,46)`, что
 * замерила Task 21 до фикс-раунда.
 *
 * ПОЧЕМУ НЕ В ГРУППУ `--direction, --neutral`, ХОТЯ ТРИ ОБЪЯВЛЕНИЯ ТАМ ТЕ ЖЕ.
 * Группа объявляет ещё и `background: var(--surface)`, а у пилюли фона нет
 * (она стоит на подложке карточки строки и была прозрачной до переезда).
 * Войти в группу можно было бы только раздав пилюле фон, которого у неё
 * никогда не было, либо разбив одно документированное правило на три ради
 * «те же три, эти два — разные». Оба дороже трёх повторённых объявлений,
 * значения у которых — токены, то есть правка рамки или приглушённого цвета
 * по-прежнему в одном месте. И роль у пилюли ОБРАТНАЯ `--neutral`: та
 * объявлена для подписи, за которой НЕТ машинного словаря, а состояние
 * работы — ровно машинный словарь (разбор — в докблоке группы выше).
 *
 * `font-weight: 400` — СОХРАНЕНИЕ ВИДА, А НЕ ВЫБОР: у `.rf-pill` веса не
 * было вовсе, он наследовался от строки списка (400), и без явного числа
 * пилюля потолстела бы до 700 базы бейджа. `letter-spacing: normal` — там же
 * и по той же причине.
 *
 * `white-space: nowrap` НЕ СТАВИТСЯ (§2.7): «отложена — 10 сентября 2026» и
 * «снята модерацией» коротки не гарантированно, а при увеличенном кегле
 * читателя они обязаны переноситься, а не выезжать за карточку.
 */
.rf-badge--pill {
    padding-inline: var(--space-3);
    border: 1px solid var(--border);
    border-radius: var(--r-chip);
    color: var(--text-muted);
    font-weight: 400;
    letter-spacing: normal;
}

/* Четыре краски состояния — те же токены и те же значения, что были у
   `.rf-pill--*`: ни один цвет кабинета переездом не изменился. Порядок
   важен: `color` они перебивают у `--pill` выше равной специфичностью. */
.rf-badge--ok { color: var(--rating-g); border-color: var(--rating-g); }
.rf-badge--attention { color: var(--spark); border-color: var(--spark); }

/* «скрыта в РФ» — самое сильное из состояний строки: работы нет для части
   читателей. Краска та же, что у `.rf-btn--danger`. */
.rf-badge--blocked { color: var(--rating-nc17); border-color: var(--rating-nc17); }
.rf-badge--gated { color: var(--rating-r); border-color: var(--rating-r); }

/*
 * `--status` — точка-индикатор («● в процессе»/«● заброшено»), КРАШЕННЫЙ
 * ПО СОСТОЯНИЮ (фикс-раунд 1, Task 12, Finding 2 — было наоборот, см.
 * ниже). Прежний словарь статуса жил целиком за `app.css` (фикс-раунд 1
 * Task 3, Finding 1) — тот же приём, та же точка, но своё НЕЗАВИСИМОЕ
 * объявление, без склейки по слоям с этим примитивом.
 *
 * ОБНОВЛЕНИЕ (Дизайн-спринт, Task 14): `templates/works/show.html.twig`
 * мигрировала сюда, и правило `app.css` перестало быть единственным
 * источником цвета точки.
 *
 * ОБНОВЛЕНИЕ (Дизайн-спринт, Task 19): прежний словарь УДАЛЁН из
 * `app.css` целиком — вместе с последними тремя потребителями его
 * бареного варианта (страница автора и два экрана сборников). Ни один из
 * тех трёх не был статусом: они несли подписи без машинного словаря за
 * ними и переехали на `.rf-badge--neutral` выше, а не сюда, — разбор
 * причины там же. Модификаторы цвета точки к тому дню не печатал ни один
 * живой шаблон (проверено грепом по `templates/` перед удалением), то
 * есть удалено было мёртвое правило плюс три мигрировавших потребителя, а
 * не работающее оформление.
 *
 * БЫЛА РЕГРЕССИЯ: первый проход Task 12 переписал карточку работы на
 * `ui.badge_status()`, который печатает `data-status`, но не завёл под него
 * цветового правила — считая (докблок макроса, тогда) это осознанным
 * решением «у статуса нет своего цвета, только состояние». Ревью и
 * контроллер фикс-раунда 1 нашли: решение придумано задним числом под
 * пробел, а не наоборот. Цвет точки — быстрый сигнал состояния работы,
 * назван семантикой в каноне (`design/prototypes/01-styleguide.html`,
 * `.b-status.done/wip/frozen`) и в `DESIGN_BRIEF §2` (рейтинговая палитра
 * фиксирована, и точка того же рода — цвет несёт смысл, не украшение); без
 * него все три статуса на всех шести экранах с карточками читались
 * одинаково приглушёнными. Значения — те же три, что уже проверены каноном
 * и старым `.b-status.*`, не новые: `done` берёт рейтинговый зелёный (тот
 * же токен, что «G»), `wip` — `--spark` (тот же токен, что «⚡ Поддержать»),
 * `frozen` — `--text-muted` (визуально «без цвета», что и есть смысл
 * заморозки).
 */
.rf-badge--status { color: var(--text-muted); font-weight: 500; }

.rf-badge--status::before { content: "●"; margin-inline-end: var(--space-1); font-size: .6rem; }

.rf-badge--status[data-status="done"]::before { color: var(--rating-g); }
.rf-badge--status[data-status="wip"]::before { color: var(--spark); }
.rf-badge--status[data-status="frozen"]::before { color: var(--text-muted); }

/* ── табы ──────────────────────────────────────────────────────────── */

/*
 * Полоса вкладок ПРОКРУЧИВАЕТСЯ ВБОК, а не растягивает страницу.
 *
 * Найдено ручным прогоном кабинета на 390px (Sprint 2, Task 23): пять
 * вкладок «Все / Черновики / Опубликованные / Отложки / Корзина» занимают
 * ~500px, и `display: flex` без прокрутки делал их МИНИМАЛЬНОЙ шириной
 * контейнера — то есть растягивал весь экран до 500px и заставлял страницу
 * ездить вбок целиком. На телефоне это выглядит как сломанная вёрстка
 * везде, кроме самих вкладок.
 *
 * `overflow-x: auto` заодно снимает `min-width: auto` у грид/флекс-элемента
 * (по спецификации переполнение отличное от `visible` обнуляет минимальный
 * вклад содержимого) — именно поэтому одной строки достаточно, и обёртка не
 * нужна. Перенос на вторую строку (`flex-wrap`) вместо прокрутки не
 * выбран сознательно: вкладки — один горизонтальный ряд, и разорванный
 * пополам он читается как два разных набора.
 *
 * Полоса прокрутки скрыта: она перекрыла бы подчёркивание активной вкладки,
 * а сама прокрутка на телефоне делается пальцем. На десктопе вкладки
 * помещаются целиком, и прокручивать нечего.
 */
.rf-tabs {
    display: flex;
    gap: var(--space-1);
    overflow-x: auto;
    scrollbar-width: none;
    border-block-end: 1px solid var(--border);
}

.rf-tabs::-webkit-scrollbar {
    display: none;
}

.rf-tab {
    flex: 0 0 auto;
    min-block-size: var(--rf-tap);
    padding: 0 var(--space-4);
    border: 0;
    border-block-end: 2px solid transparent;
    background: transparent;
    color: var(--text-muted);
    font-family: inherit;
    font-size: var(--fs-base);
    font-weight: 600;
    cursor: pointer;
    transition: color var(--dur-fast), border-color var(--dur-fast);
}

.rf-tab:hover { color: var(--text); }
.rf-tab[aria-selected="true"] { color: var(--primary); border-block-end-color: var(--primary); }

.rf-tabpanel { padding-block-start: var(--space-4); }

/* ── тосты ─────────────────────────────────────────────────────────── */

/*
 * `.toast` СЮДА НЕ ВХОДИТ (фикс-раунд 1, Finding 1) — только `.rf-toast`.
 * До фикс-раунда алиас `.toast, .rf-toast` нёс только оформление (`.toast`
 * в `app.css` и так уже задаёт своё позиционирование — `position: fixed`,
 * сдвиг от края, переход видимости классом `.is-visible` от
 * `assets/js/social.js`), но живой тост получал ОБА оформления сразу:
 * рамку и вторую анимацию `rf-toast-in` из примитива поверх собственного
 * перехода `app.css` — см. докблок файла выше. `.toast` остаётся полностью
 * за `app.css` до задачи, которая переводит `social.js` на `rf-toast`.
 */
.rf-toast {
    display: flex;
    align-items: center;
    gap: var(--space-3);
    max-inline-size: 420px;
    padding: var(--space-3) var(--space-4);
    border-radius: var(--r-card);
    border: 1px solid var(--border);
    background: var(--surface);
    color: var(--text);
    font-size: var(--fs-base);
    animation: rf-toast-in var(--dur-fast) ease-out;
}

.rf-toast--success { border-color: color-mix(in srgb, var(--rating-g) 45%, var(--border)); }
.rf-toast--error { border-color: color-mix(in srgb, var(--rating-nc17) 45%, var(--border)); }

.rf-toast__close {
    min-block-size: var(--rf-tap);
    min-inline-size: var(--rf-tap);
    margin-inline-start: auto;
    border: 0;
    background: transparent;
    color: var(--text-muted);
    font-size: var(--fs-lg);
    cursor: pointer;
}

@keyframes rf-toast-in {
    from { opacity: 0; transform: translateY(6px); }
    to { opacity: 1; transform: none; }
}

/* ── модалка ───────────────────────────────────────────────────────── */

.rf-modal-backdrop {
    position: fixed;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: var(--space-5);
    background: color-mix(in srgb, var(--bg) 78%, transparent);
    z-index: 50;
}

/*
 * `--rf-modal-max` — РУЧКА ШИРИНЫ (Дизайн-спринт, Task 23), тем же приёмом и
 * по той же причине, что `--rf-sheet-max` у листа: геометрию поверхности
 * настраивает СТРАНИЦА, а сам примитив держит умолчание в фоллбэке. Заведена
 * первым потребителем, которому 520px мало по существу, а не по вкусу: экран
 * конфликта версий главы (`apps/editor/src/editor.css`) показывает ДВЕ панели
 * текста рядом, и на 520px они встали бы стопкой на любой ширине окна — то
 * есть сравнить версии, ради чего экран и существует, было бы нельзя. Ручка
 * зарегистрирована в `apps/shared/ui/component-knobs.test.ts`: гейт требует,
 * чтобы примитив её ЧИТАЛ, а хотя бы одна страница СТАВИЛА.
 */
.rf-modal {
    inline-size: min(var(--rf-modal-max, 520px), 100%);
    max-block-size: 84vh;
    overflow: auto;
    padding: var(--space-5);
    border-radius: var(--r-card);
    border: 1px solid var(--border);
    background: var(--surface);
    color: var(--text);
}

.rf-modal__head {
    display: flex;
    align-items: center;
    gap: var(--space-3);
    margin-block-end: var(--space-3);
}

.rf-modal__title { margin: 0; font-size: var(--fs-lg); }

.rf-modal__close {
    min-block-size: var(--rf-tap);
    min-inline-size: var(--rf-tap);
    margin-inline-start: auto;
    border: 0;
    background: transparent;
    color: var(--text-muted);
    font-size: var(--fs-xl);
    cursor: pointer;
}

/* ── скелетоны ─────────────────────────────────────────────────────── */

.rf-skeleton-stack { display: grid; gap: var(--space-2); }

.rf-skeleton {
    display: block;
    border-radius: var(--space-2);
    background: linear-gradient(
        90deg,
        var(--surface) 0%,
        color-mix(in srgb, var(--text-muted) 22%, var(--surface)) 50%,
        var(--surface) 100%
    );
    background-size: 200% 100%;
    /* 1.3s — скорость бегущего блика, а не отклик на действие: это не
       переход, которым управляют `--dur-*` (интерфейс не «переходит» между
       состояниями, а бесконечно повторяет один и тот же кадр), поэтому
       число остаётся своим, а не подгоняется под шкалу длительностей. */
    animation: rf-skeleton-slide 1.3s linear infinite;
}

@keyframes rf-skeleton-slide {
    from { background-position: 200% 0; }
    to { background-position: -200% 0; }
}

/* ── спарклайн (FE-4, Sprint 3.1, Task 21) ────────────────────────────── */

/*
 * `color: var(--primary)` — ЗДЕСЬ, а не на каждой карточке, которая
 * рисует спарклайн: `stroke="currentColor"` в разметке компонента
 * (докблок `Sparkline`) наследует ЛЮБОЙ цвет текста родителя, и без
 * умолчания линия красилась бы в основной цвет текста карточки —
 * незаметно на фоне остальных цифр. Потребитель вправе переопределить этот
 * цвет у себя (`color:` каскадом), спарклайну об этом знать не нужно.
 *
 * Прежде здесь стояло имя `.rf-stats-card` — карточки дашборда, которая
 * красила линию сама. Класса больше нет: Дизайн-спринт, Task 20 свёл
 * `StatsCard` кабинета в обёртку над `StatTile`, и единственный потребитель
 * спарклайна сегодня — `.rf-stat` ниже в этом же файле.
 */
.rf-sparkline {
    display: block;
    inline-size: 100%;
    block-size: 2rem;
    color: var(--primary);
}

/* ── «показать ещё» ────────────────────────────────────────────────── */

/*
 * ОДНО РЕШЕНИЕ «ПОКАЗАТЬ ЕЩЁ» — И У НЕГО ЕСТЬ SSR-ПОТРЕБИТЕЛИ (финальный
 * фикс-раунд по сквозному ревью, I-5).
 *
 * Примитив завёл React-близнец (`LoadMore.tsx`: кнопка плюс живая строка
 * `__status`), и до этого раунда спека числила его среди блоков, у которых
 * «на SSR-страницах потребителя пока нет». По имени класса это было верно, а
 * по существу — нет: у самого ПОНЯТИЯ на SSR семь мест, и они говорили
 * четырьмя разными способами. Три перестроенные ленты (фандом, поиск, автор)
 * переведены на этот примитив; ещё четыре — легаси-словарь `.more`
 * (`app.css`), адреса перечислены в `IDEAS.md`, и он не сводится сюда одной
 * правкой, потому что `.more` носит не только «показать ещё», а любую
 * второстепенную ссылку («К уведомлениям», «Нет, вернуться в настройки»,
 * кнопка подписки).
 *
 * Колонка, а не строка: под кнопкой стоит `__status` — живая область, в
 * которой React-половина объявляет «Загружаем ещё…» и «Это всё.». На SSR
 * подгрузка — обычный переход по курсору, объявлять нечего, и обёртка
 * работает просто центровкой. Внешнего отступа у примитива нет и быть не
 * должно (принцип 2): место ему даёт страница (`app.css`).
 */
.rf-loadmore { display: flex; flex-direction: column; align-items: center; gap: var(--space-2); }
.rf-loadmore__status { font-size: var(--fs-sm); color: var(--text-muted); }

/* ── лист / поповер (Sheet, дизайн-спринт Task 4) ─────────────────────── */

/*
 * ПОЗИЦИОНИРОВАНИЕ — ЕДИНСТВЕННОЕ ИСКЛЮЧЕНИЕ из правила «примитив не знает о
 * странице». У кнопки, чипа, поля есть место в потоке, которое задаёт
 * страница; у листа его нет — оно и есть его смысл: лист перекрывает всё и
 * всегда прибит либо к низу экрана, либо к его центру. Поэтому
 * `position: fixed` и `inset` — часть контракта самого примитива, а не
 * разметки, которая его использует. `--rf-sheet-max` остаётся исключением
 * из исключения: ШИРИНУ поповера настраивает снаружи страница (дропдаун
 * фасета уже, оглавление читалки — шире), а не сам примитив, — фолбэк в
 * `var()` ниже подставляется, только пока страница ничего не задала.
 * Ручка ЗАРЕГИСТРИРОВАНА (Task 18, фикс-раунд 1, I-2):
 * `apps/shared/ui/component-knobs.test.ts` утверждает, что этот примитив
 * её читает, а `assets/css/reader.css` — ставит; до того гейта не было ни
 * одного, и снять любую из двух половин можно было молча.
 *
 * ЧЕМ ЭТОТ КОНТРАКТ ЛОМАЕТСЯ СНАРУЖИ (Task 15.6, замер там же). `position:
 * fixed` считается от вьюпорта, но ЛЮБОЙ предок с `filter`/`backdrop-filter`/
 * `transform`/`perspective`/`contain: paint`/`will-change` на них становится
 * для панели CONTAINING BLOCK'ОМ — и `inset` начинает считаться от него.
 * Панель тогда уезжает туда, где её никто не проектировал. Так и вышло с
 * меню аккаунта: у `.rf-topbar` размытие шапки, панель считала `inset` от
 * коробки 71px и вставала на y=-360 — ВЫШЕ экрана, поверх собственного
 * аватара. Починка — на стороне ПРЕДКА (размытие переехало на его
 * псевдоэлемент, `app.css`), а не панели: подстраивать геометрию под
 * каждого предка значило бы завести второе решение той же геометрии (запрет
 * — в докблоке `.rf-sheet-host` ниже). Кто кладёт лист внутрь размытой или
 * сдвинутой полосы — обязан проверить панель замером; гейт эффекта:
 * `tests/Visual/sheet-position.spec.ts`.
 *
 * ЛОВУШКА СРАБОТАЛА ДВАЖДЫ, И ВТОРОЙ РАЗ ГЕОМЕТРИЯ ЕЁ НЕ ВЫДАЛА. Второй
 * случай — `#toc-sheet` внутри `.chrome` читалки (`reader.css`, размытие
 * полос главы): на 1200 панель уезжала на 39px НИЖЕ окна, а на 390 лежала в
 * окне ПРАВИЛЬНЫМИ числами, потому что полоса главы прибита к низу и во всю
 * ширину — её padding-box совпал краями с вьюпортом. Поэтому гейт требует
 * ДВУХ следствий: панель в окне И `offsetParent === null`. У панели с
 * `position: fixed` `offsetParent` обязан быть `null` — это containing block
 * в одном значении, и он краснеет там, где числа случайно сошлись. Оба
 * случая закрыты одним приёмом на стороне предка (Task 15.6).
 *
 * ОБА МОДИФИКАТОРА СТОЯТ В РАЗМЕТКЕ ОДНОВРЕМЕННО — `Sheet.tsx` ставит оба
 * класса всегда, а какой из них проявится, решает ЕДИНСТВЕННЫЙ медиа-запрос
 * ниже (`min-width: 800px`). Брейкпоинт живёт в одном месте: JS не измеряет
 * вьюпорт и не выбирает класс сам, поэтому его физически нечем
 * рассинхронизировать с CSS.
 */
.rf-sheet {
    overflow: auto;
    padding: var(--space-5);
    border-radius: var(--r-card);
    border: 1px solid var(--border);
    background: var(--surface);
    color: var(--text);
}

.rf-sheet--bottom {
    position: fixed;
    inset-inline: 0;
    inset-block-end: 0;
    max-block-size: 84vh;
    border-end-start-radius: 0;
    border-end-end-radius: 0;
    border-block-end: 0;
    box-shadow: var(--shadow-3);
}

@media (min-width: 800px) {
    /*
     * `--shadow-3` («0 -12px 48px») построена для тени, растущей ВВЕРХ, — под
     * лист, выезжающий снизу. У поповера тень обычная, поэтому здесь
     * возвращается `--shadow-2`. Углы и нижнюю границу `.rf-sheet--bottom`
     * гасит явно (а не полагается, что `.rf-sheet` «и так» их не трогал) —
     * оба модификатора активны в разметке одновременно, и на этой ширине
     * побеждает именно этот блок порядком объявления в файле.
     */
    .rf-sheet--popover {
        position: fixed;
        inset-inline-end: auto;
        inset-block-end: auto;
        inset-inline-start: 50%;
        inset-block-start: 50%;
        transform: translate(-50%, -50%);
        inline-size: min(var(--rf-sheet-max, 360px), calc(100vw - var(--space-7) * 2));
        max-block-size: 84vh;
        border-end-start-radius: var(--r-card);
        border-end-end-radius: var(--r-card);
        border-block-end: 1px solid var(--border);
        box-shadow: var(--shadow-2);
    }
}

.rf-sheet-backdrop {
    position: fixed;
    inset: 0;
    background: color-mix(in srgb, var(--bg) 78%, transparent);
    z-index: 60;
}

.rf-sheet__head {
    display: flex;
    justify-content: flex-end;
    margin-block-end: var(--space-2);
}

.rf-sheet__close {
    min-block-size: var(--rf-tap);
    min-inline-size: var(--rf-tap);
    border: 0;
    background: transparent;
    color: var(--text-muted);
    cursor: pointer;
}

/*
 * `.rf-sheet-host` — SSR-вариант того же примитива (`<details>`/`<summary>`,
 * дизайн-спринт Task 5, макрос `ui.sheet()`). Task 3 зарезервировала
 * селектор `.rf-sheet-host > summary` в правиле кольца фокуса заранее, Task
 * 4 сознательно оставила тело пустым (см. «Проблемы и сомнения» её отчёта):
 * CSS приезжает вместе с первым потребителем, и `ui.sheet()` — он.
 *
 * Публичные страницы обязаны работать без единой строки JS; `<details>` даёт
 * раскрытие, фокус и клавиатуру браузером бесплатно (React `Sheet.tsx` несёт
 * то же самое руками — см. его докблок). ПАНЕЛЬ ПЕРЕИСПОЛЬЗУЕТ
 * `.rf-sheet`/`.rf-sheet--bottom`/`.rf-sheet--popover` БЕЗ ИЗМЕНЕНИЙ (макрос
 * ставит на раскрываемый `<div>` все три класса) — тот же медиа-запрос
 * `@media (min-width: 800px)` выше решает «низ экрана или поповер» для обоих
 * генераторов разметки, а не заводит для SSR второе, независимое решение той
 * же геометрии.
 *
 * Здесь остаётся только триггер. Геометрия — СВОЯ, не заимствованная у
 * `.rf-btn`: `.rf-segmented button` рядом уже показывает тот же приём —
 * примитив с интерактивным потомком красит его своим элементным селектором,
 * а не чужим классом чужого примитива.
 *
 * ── ЗАКРЫТИЕ: УСЛОВИЕ РУЛИНГА TASK 4/5 СТАЛО ЛОЖНЫМ (Task 15.5) ──────────
 *
 * Тот рулинг снял с SSR-варианта и подложку (`.rf-sheet-backdrop`), и
 * крестик (`.rf-sheet__close`) на одном основании: `<details>` закрывается
 * ПОВТОРНЫМ ТАПОМ ПО `<summary>` бесплатно, поэтому рисовать элемент,
 * который без скрипта ничего не делает, — обещание, которого SSR не сдержит.
 * Основание верно ровно пока `<summary>` ДОСТИЖИМ: для поповера рядом с
 * триггером — да, для нижнего листа во всю ширину — нет.
 *
 * ЗАМЕР (`elementFromPoint` в центре `<summary>` при открытом листе, скролл
 * 0, скрипты страницы выключены; ряд фасетов `/search`, семь чипов):
 *
 *   390×844   накрыты собственной панелью ШЕСТЬ чипов из семи (доступен
 *             только «Рейтинг»); СОСЕДНИЙ чип накрыт у всех семи;
 *   1200×900  накрыты ЧЕТЫРЕ из семи; настоящий клик по «Статусу» при
 *             открытом «Рейтинге» перехватывает содержимое панели.
 *
 * То есть закрыть лист было НЕЧЕМ: без скрипта выход измерен и остаётся
 * («Применить фильтры» внутри листа отправляет форму, страница возвращается
 * со всеми листами закрытыми), а со скриптом выхода не было вовсе, потому
 * что обработчика не существовало.
 *
 * ЗАМЕР ПРОДУКТОВЫХ ЭКРАНОВ (фикс-раунд 1, тем же способом; аноним) —
 * оглавление читалки `#toc-sheet`:
 *
 *   390×844   чип (195,816) под собственной панелью y=710..844;
 *   1200×900  чип (600,872) под панелью y=804..939,
 *
 * и так ПРИ ЛЮБОМ скролле — триггер стоит в прибитой к низу полосе главы,
 * страница из-под него уезжает, а полоса нет. То есть лист не закрывался
 * НИЧЕМ на самом посещаемом экране платформы, и он анонимный.
 *
 * ВТОРАЯ СТРОКА ЭТОГО ЗАМЕРА БОЛЬШЕ НЕ ВЕРНА (Task 15.6, второй раунд), и
 * рулинг это не отменяет. Поповер стоял на y=804..939 не по замыслу, а
 * потому, что размытие полосы главы перехватывало `inset` панели: она
 * центрировалась по полосе, прибитой к низу, — вот и накрывала свой чип,
 * вдобавок уезжая на 39px за край окна. Размытие переехало на
 * `.chrome::before`, панель встала по центру ОКНА (y=383..517 на той главе,
 * что берёт `openReader()`; высота зависит от длины оглавления), чип
 * (600,872) свободен. Строка 390 в силе: там накрытый чип — ЗАДУМАННАЯ геометрия
 * (§2.4/§3.5), и на ней рулинг стоит. То есть Escape и тап вне нужны
 * по-прежнему, но на ≥800px они теперь удобство, а не единственный выход.
 *
 * ЧТО СДЕЛАНО: Escape и указатель ВНЕ открытого `<details>` закрывают его —
 * прогрессивное улучшение (`assets/js/social.js`), а не обязанность
 * разметки. Свободная область под этот тап гарантирована САМОЙ панелью:
 * `max-block-size: 84vh` оставляет непокрытыми минимум 16vh (135px на 844),
 * а поповер ≥800px центрирован и оставляет поля с обеих сторон. Разметка не
 * изменилась ни на атрибут — без скрипта страница ровно та же.
 *
 * ESCAPE ИЗ ПОЛЯ, КОТОРОЕ ЕГО САМО ОБРАБАТЫВАЕТ, ЛИСТ НЕ ЗАКРЫВАЕТ
 * (`escapeOwner()`): у фасета «Фандом» внутри листа лежит
 * `input[type="search"]` — фильтр по списку (`assets/js/search.js`), и
 * родное поведение браузера чистит там набранное. Без этой ветки один
 * Escape делал бы ТРИ дела разом (чистил запрос, закрывал лист, переносил
 * фокус на чип), из которых читатель просил одно. Конвенция списочных
 * фильтров: первый Escape чистит поле, второй — уже с пустым полем —
 * закрывает лист. Пустое поле хозяином не считается: чистить нечего.
 *
 * ЦЕНА ФАЗЫ ПЕРЕХВАТА, КОТОРУЮ СТОИТ ЗНАТЬ: закрытие висит на
 * `pointerdown` в фазе перехвата (клик по пути к `document` может
 * остановить чужой обработчик), и поэтому одно и то же намерение
 * «проскроллить страницу» лист ЗАКРЫВАЕТ пальцем (тач шлёт `pointerdown`)
 * и НЕ закрывает колесом мыши. Асимметрия сознательная: цена промаха —
 * один повторный тап, `open = false` переживают и отмеченные чекбоксы, и
 * набранный запрос.
 *
 * ЧТО ОТВЕРГНУТО И ПОЧЕМУ:
 *
 *  - СВОЯ ГЕОМЕТРИЯ ПАНЕЛИ ДЛЯ SSR (в том числе привязка поповера к чипу на
 *    ≥800px, которую рисует спека §3.5) — запрещено абзацем выше: любая
 *    привязка означает переопределить для SSR `position`/`inset`/`transform`
 *    панели, то есть завести второе решение той же геометрии; а сделать то
 *    же в ОБЩЕМ правиле значило бы сдвинуть пятерых потребителей React-
 *    двойника, у которых триггеры совсем другие;
 *  - ПОДНЯТЬ ЧИП НАД ПАНЕЛЬЮ `z-index`'ом — при `max-block-size: 84vh`
 *    панель занимает y≈135..844, а чип стоит на y≈563..667, ВНУТРИ её
 *    коробки: поднятый чип висел бы над её же содержимым и закрывал
 *    чекбоксы. Приём годится поповеру и не годится нижнему листу;
 *  - ВЕРНУТЬ ПОДЛОЖКУ, чтобы «тап вне» был виден глазом, — у
 *    `.rf-sheet-backdrop` `z-index: 60`, а у `.rf-sheet` его нет вовсе:
 *    подложка легла бы ПОВЕРХ листа, и починка потребовала бы задать
 *    `z-index` общей панели — снова её геометрия;
 *  - УКОРОТИТЬ ЛИСТ, чтобы он не доставал до чипа, — замерено: чтобы не
 *    накрыть ряд чипов на 390×844, панели осталось бы ~155px высоты, чего
 *    не хватает списку чекбоксов; и это прямо противоречит §2.4/§3.5,
 *    которые требуют нижний лист.
 *
 * ГДЕ ЖИВЁТ ОБРАБОТЧИК: `assets/js/social.js` — общий скрипт, который
 * подключает блок `javascripts` в `base.html.twig` (то есть все тридцать
 * страниц, наследующих базовый шаблон) и, отдельной строкой,
 * переопределяющий этот блок `works/chapter.html.twig` — единственный,
 * кто его переопределяет. Так покрыты ВСЕ потребители примитива:
 * меню аватара (`base.html.twig`), оглавление читалки, «⋯» страницы
 * работы, статусы закладки в карточке ленты, «⋯» комментария, фасеты
 * поиска и витрина. Проверять это утверждение — гейтом
 * `PublicJsBudgetTest::testDerivationMatchesKnownPageCombinations()`, он
 * выводит соответствие «шаблон → скрипты» из самих шаблонов.
 *
 * ПЕРВЫЙ ЗАХОД TASK 15.5 ПОЛОЖИЛ ОБРАБОТЧИК В `search.js` — и это была
 * ошибка выбора области, а не кода. Обоснованием служил замер ВИТРИНЫ
 * (`/dev/styleguide`: панели короткие, триггеры достижимы, повторный тап
 * закрывает сам) — но витрина не продуктовый экран, а единственное место,
 * где лист короткий и стоит посреди длинной страницы. На продукте отказ
 * шире: замер читалки выше. Цена общего обработчика замерена и мала —
 * 13 329 → 13 975 байт gzip у `social.js`, читалка 65% бюджета FE-7,
 * худшая страница 73%, сумма файлов 34 658 из 34 816 (анти-bloat-потолок
 * вернулся с 35 КБ на 34 КБ, см. `PublicJsBudgetTest`).
 */
.rf-sheet-host > summary {
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    min-block-size: var(--rf-tap);
    padding: 0 var(--space-4);
    border-radius: var(--r-chip);
    border: 1px solid var(--border);
    background: var(--surface);
    color: var(--text);
    font-family: inherit;
    font-size: var(--fs-base);
    font-weight: 600;
    cursor: pointer;
    list-style: none;
    transition: border-color var(--dur-fast), color var(--dur-fast);
}

/* Родной треугольник раскрытия WebKit — отдельный псевдоэлемент, не
   `::marker`: смена `display` на `list-style: none` его не гасит, поэтому
   снимается явно (тот же приём, что `.bookmark-control summary` в
   `app.css`, — SSR-раскрытие в проекте уже есть, здесь только его примитив). */
.rf-sheet-host > summary::-webkit-details-marker {
    display: none;
}

.rf-sheet-host > summary:hover {
    border-color: var(--primary);
    color: var(--primary);
}

/*
 * Открытое состояние — атрибут `[open]` самого браузера, а не класс: тот же
 * приём, что `aria-checked`/`aria-selected` у соседних примитивов, только
 * источник истины здесь — нативное состояние `<details>`, а не ARIA. Красить
 * его ещё и классом значило бы завести вторую правду об одном флаге.
 */
.rf-sheet-host[open] > summary {
    border-color: color-mix(in srgb, var(--primary) 55%, var(--border));
    color: var(--primary);
}

/* ── переключатель сегментов (Segmented, дизайн-спринт Task 4) ─────────── */

/*
 * КНОПКИ БЕЗ СВОЕГО КЛАССА — СОЗНАТЕЛЬНО. Состояние уже несёт
 * `aria-checked`, тот же приём, что `.rf-tab[aria-selected="true"]` и
 * `.rf-field__input[aria-invalid="true"]` выше: значение живёт в одном
 * атрибуте, а не в атрибуте и классе одновременно, которые правка может
 * развести. `.rf-segmented button` — обычный элементный селектор: он уже
 * стоит в правиле кольца фокуса (Task 3) и в `tap-targets.test.tsx`, второе
 * имя ему не нужно.
 */
/*
 * `flex-wrap: wrap` — НА БАЗОВОМ ПРИМИТИВЕ (фикс-раунд 1 Task 16
 * дизайн-спринта), а не только у форменного варианта, как было заведено
 * Task 15. Причина замерена, а не предположена: ряд четырёх статусов
 * закладки (`bookmark_control.html.twig` печатает БАЗОВЫЙ вариант) внутри
 * листа на 390px занимает 362.7px при контентной коробке панели 350px —
 * то есть на 12.7px шире места, которое ему дано, и последний сегмент
 * заезжает в правый отступ панели. Прежний довод «кнопочный вариант стоит
 * в просторных местах и остаётся однострочным» неверен ровно потому, что
 * лист закладки — не просторное место, а панель на всю ширину телефона, и
 * рисуется он в КАЖДОЙ карточке `full`/`mid` на каждом экране с лентой.
 * Перенос — единственный ответ, не требующий выбирать между обрезанным
 * четвёртым статусом и прокруткой внутри панели.
 */
.rf-segmented {
    display: inline-flex;
    flex-wrap: wrap;
    gap: var(--space-1);
    padding: var(--space-1);
    border-radius: var(--r-chip);
    border: 1px solid var(--border);
    background: var(--surface);
}

/*
 * ДВА ГЕНЕРАТОРА РАЗМЕТКИ, ОДНО ПРАВИЛО ВИДА (Дизайн-спринт, Task 15):
 * кнопочный вариант (`ui.segmented()`, `Segmented.tsx`) и ФОРМЕННЫЙ
 * (`ui.segmented_form()` — `input[type=radio].rf-vh` + `<label>`).
 * Форменный заведён потому, что кнопка НЕ ПОЛЕ ФОРМЫ: GET-форма поиска не
 * передала бы ею `scope`, а публичная страница обязана работать без JS
 * (FE-7). Группированный селектор — «одно правило, два имени»: копия
 * значений под `label` разошлась бы с кнопкой при первой же правке
 * (в точности урок алиасов Task 3, только внутри одного файла).
 */
.rf-segmented button,
.rf-segmented label {
    min-block-size: var(--rf-tap);
    display: inline-flex;
    align-items: center;
    padding: 0 var(--space-4);
    border: 0;
    border-radius: var(--r-chip);
    background: transparent;
    color: var(--text-muted);
    font-family: inherit;
    font-size: var(--fs-sm);
    font-weight: 600;
    cursor: pointer;
    transition: background-color var(--dur-fast), color var(--dur-fast);
}

.rf-segmented button:hover,
.rf-segmented label:hover { color: var(--text); }

/*
 * `.rf-segmented--form` БОЛЬШЕ НЕ НЕСЁТ СВОЕГО ПРАВИЛА (фикс-раунд 1 Task 16
 * дизайн-спринта). Единственным его объявлением был `flex-wrap: wrap`, и он
 * переехал на базовый примитив выше — вместе с доводом, который оказался
 * верен и для кнопочного варианта. Оставить его здесь значило бы завести
 * модификатор, повторяющий свойство базы: мёртвое объявление, которое
 * следующая правка базы молча рассинхронизирует.
 *
 * Сам КЛАСС из разметки не снят и снят не будет: он остаётся признаком
 * «этот переключатель собран из настоящих радиокнопок, а не кнопок»
 * (`ui.segmented_form()`), и по нему адресуются `UiMacrosTest` и
 * `SearchLayoutTest`. Различие в разметке при этом красит не он, а правило
 * активного радио ниже.
 *
 * (Селектор того правила здесь НЕ ПРИВОДИТСЯ дословно нарочно:
 * `segmented-state-attr.test.tsx` мутирует `ui.css` подстрокой и первым
 * попаданием оказался бы этот комментарий — негативный контроль теста
 * перестал бы краснеть.)
 */

/* `--on-primary` на `--primary` — уже проверенная пара (`contrast.test.ts`,
 * «кнопка primary»): активный сегмент не заводит новую.
 *
 * ОБА атрибута статуса: React-приложения (Segmented.tsx, Task 4) используют
 * `aria-checked` (обязательно для `role="radio"`), SSR-формы со статусами
 * (bookmark_control.html.twig и фильтры) используют `aria-pressed` (верно для
 * `<button type="submit">` в форме, который отправляет данные, а не переключает
 * радиокнопку). Оба варианта законны: роль определяет обязательства, разметка
 * остаётся корректной в обоих случаях. Селектор ниже охватывает оба.
 */
.rf-segmented button[aria-checked="true"],
.rf-segmented button[aria-pressed="true"],
/* Форменный вариант ключуется на `:checked` — состояние нативного радио
 * ведёт САМ БРАУЗЕР, и второй, ARIA-источник той же правды разъехался бы
 * с ним при первом же клике (докблок `ui.segmented_form()`). Оба ключа
 * проверяет `apps/shared/ui/segmented-state-attr.test.tsx`. */
.rf-segmented input:checked + label {
    background: var(--primary);
    color: var(--on-primary);
}

/* ── чип фасета (FilterChip, дизайн-спринт Task 4) ──────────────────────── */

/*
 * ТОЛЬКО CSS, БЕЗ REACT-БЛИЗНЕЦА (решение брифа Task 4). Фасеты живут на
 * SSR-страницах поиска и ленты фандома, React-экранов с фильтрами в
 * продукте нет; макрос `ui.filter_chip(label, options)` заводит Task 5.
 *
 * Состояние — `aria-pressed`, тем же приёмом, что `.rf-segmented
 * button[aria-checked]` выше: источник истины один, разъезжаться нечему.
 */
.rf-filter-chip {
    min-block-size: var(--rf-tap);
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    padding: 0 var(--space-4);
    border-radius: var(--r-chip);
    border: 1px solid var(--border);
    background: var(--surface);
    color: var(--text-muted);
    font-family: inherit;
    font-size: var(--fs-sm);
    font-weight: 500;
    text-decoration: none;
    cursor: pointer;
    transition: border-color var(--dur-fast), color var(--dur-fast), background-color var(--dur-fast);
}

.rf-filter-chip:hover { border-color: var(--primary); color: var(--primary); }

/* ── фасет: чип, раскрывающий лист (FilterChip ▾, Task 15) ─────────────── */

/*
 * `.rf-facet` — КОМПОЗИЦИЯ, А НЕ ТРЕТИЙ ЧИП. Разметка (`ui.facet()`) несёт
 * на одном `<details>` оба класса: `rf-sheet-host` даёт геометрию триггера
 * и панели, `rf-facet` — чиповый кегль, треугольник и активный вид.
 * Собственного набора «рамка + радиус + таргет» здесь нет и не должно
 * быть: он уже объявлен у `.rf-sheet-host > summary` выше, а копия
 * разошлась бы с ним при первой правке (`no-duplicate-primitive.test.ts`).
 *
 * Селектор `.rf-facet > summary` (0,1,1) специфичнее `.rf-sheet-host >
 * summary` (0,1,1) НЕ БЫВАЕТ — они равны, и побеждает порядок в файле:
 * этот блок объявлен ниже, поэтому три перекрывающихся свойства (кегль,
 * насыщенность, цвет) читаются отсюда. Потолок 0,2,0 соблюдён.
 */
.rf-facet > summary {
    font-size: var(--fs-sm);
    font-weight: 500;
    color: var(--text-muted);
}

/*
 * `▾` — НЕ ГЛИФ, А РАМКА. `U+25BE` лежит вне сабсетов `fonts.css` (спек
 * §2.6), и системный шрифт нарисовал бы его по-разному на macOS и в
 * Ubuntu-контейнере CI — вечный ложный диф снимков TST-10. Треугольник
 * рисуется границами псевдоэлемента цветом `currentColor` (следует за
 * состоянием чипа), размеры — в `em`: растёт вместе с кеглем читателя.
 */
.rf-facet > summary::after {
    content: "";
    border-block-start: .32em solid currentColor;
    border-inline: .28em solid transparent;
    transition: transform var(--dur-fast);
}

.rf-facet[open] > summary::after { transform: rotate(180deg); }

/*
 * ВЫБРАННОЕ В ФАСЕТЕ — тот же вид, что у нажатого `.rf-filter-chip`
 * (значения не повторяются, селектор дописан в его правило ниже по этому
 * же блоку). Состояние приезжает классом `--on`, а не атрибутом: у
 * `<summary>` уже есть нативное состояние `[open]` («раскрыт»), и второе,
 * ARIA-подобное («в фасете что-то выбрано») на том же элементе читалось бы
 * как одно и то же — `aria-pressed` на раскрывающем элементе к тому же
 * не разрешён ARIA.
 */
.rf-filter-chip[aria-pressed="true"],
.rf-facet--on > summary {
    border-color: color-mix(in srgb, var(--primary) 55%, var(--border));
    background: color-mix(in srgb, var(--primary) 12%, transparent);
    color: var(--text);
}

.rf-facet__count { font-weight: 700; color: var(--primary); }

/* Поле поиска по списку — печатается с `hidden`, показывает его
 * `assets/js/search.js` (прогрессивное улучшение, докблок скрипта). */
.rf-facet__filter { margin-block-end: var(--space-3); }

/* `<fieldset>` инвентаря §7 остаётся ради группы для скринридера
 * (легенда скрыта — видимая подпись это сам чип), но браузерные рамка и
 * отступы поля-группы здесь только мешают. */
.rf-facet__group {
    border: 0;
    margin: 0;
    padding: 0;
    min-inline-size: 0;
}

/*
 * Список значений прокручивается ВНУТРИ листа, а не тянет лист вниз:
 * фандомов в справочнике сотни, и `max-block-size` у самой `.rf-sheet`
 * прокручивал бы вместе с ними режим и подфандомы, до которых читателю
 * пришлось бы доскроллить весь список.
 */
.rf-facet__options {
    display: flex;
    flex-direction: column;
    margin: 0;
    padding: 0;
    list-style: none;
    max-block-size: 44vh;
    overflow: auto;
}

.rf-facet__option {
    min-block-size: var(--rf-tap);
    display: flex;
    align-items: center;
    gap: var(--space-3);
    font-size: var(--fs-base);
    cursor: pointer;
}

.rf-facet__option input { accent-color: var(--primary); }

.rf-facet__note {
    margin-block-start: var(--space-3);
    font-size: var(--fs-xs);
    color: var(--text-muted);
}

.rf-facet__mod {
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
    margin-block-start: var(--space-4);
    font-size: var(--fs-xs);
    color: var(--text-muted);
}

/*
 * Действие ВНУТРИ листа (`.rf-facet__actions` — не путать с
 * `.rf-facets__actions`, строкой действий всей формы в `app.css`: там
 * «фасеты» во множественном числе). Заведено измерением на 390px:
 * открытый лист прибит к низу экрана и накрывает строку действий формы,
 * поэтому «Применить фильтры» обязано быть и здесь — иначе выбранное
 * пальцем некому отправить, пока лист открыт (докблок
 * `search/_facets.html.twig` описывает находку подробно).
 *
 * ЛИПКАЯ — И ЭТО НЕ УКРАШЕНИЕ, А ВТОРАЯ ПОЛОВИНА ТОЙ ЖЕ ПОЧИНКИ.
 * Измерено на 390×844 (отчёт Task 15, «фасет фандома по мере роста
 * справочника»): у листа `max-block-size: 84vh` = 709px и `overflow: auto`,
 * а содержимое фасета «Фандом» — список (44vh = 371px) плюс поле поиска,
 * ссылка на справочник, «Режим», «Подфандомы» и сама строка действий —
 * набирает 789px УЖЕ НА ДВЕНАДЦАТИ ФАНДОМАХ. Лист упирается в потолок и
 * начинает прокручиваться внутри себя, а строка действий, будучи последней
 * в потоке, уезжает на y=861 — НИЖЕ И ЭКРАНА (844), И видимой коробки
 * самого листа. То есть кнопка в разметке есть, а дотянуться до неё нельзя,
 * пока не проскроллишь мимо всего справочника: ровно тот отказ, который она
 * пришла лечить, только этажом ниже. На дев-стенде с ТРЕМЯ фандомами этого
 * не видно — список короткий, лист в потолок не упирается.
 *
 * `position: sticky` + `inset-block-end: 0` внутри прокручиваемого листа
 * делает достижимость СВОЙСТВОМ КОНСТРУКЦИИ, а не следствием арифметики:
 * ужать `max-block-size` списка так, чтобы сумма всегда влезала в 84vh,
 * значило бы держать равенство, которое ломается от переноса подписи, от
 * крупного шрифта читателя (спек §2.7) и от низкого экрана.
 *
 * Отрицательные боковые поля и снятый `padding-block-end` у листа — чтобы
 * полоса легла ВРОВЕНЬ с его краями. Щель шириной с `padding` листа была бы
 * не косметикой: `padding` прокручиваемой коробки входит в её скроллпорт, и
 * сквозь оставленные снизу 20px просвечивал бы ползущий список (измерено:
 * полоса вставала на 824 при низе листа 844, и в этой щели было видно
 * «Подфандомы»). Отрицательным полем щель СНИЗУ не убрать — липкий блок
 * нельзя вынести за содержимое своего контейнера, и он упирался ровно в
 * край content-box; поэтому нижний отступ снимается у листа, а взамен его
 * даёт сама полоса своим `padding`. Значение то же `--space-5`, что у
 * `.rf-sheet` выше: связь именованная, а не подобранная на глаз.
 * `--surface` — фон самого листа: полоса обязана быть непрозрачной, иначе
 * строки списка читались бы сквозь неё.
 */
.rf-facet > .rf-sheet { padding-block-end: 0; }

.rf-facet__actions {
    position: sticky;
    inset-block-end: 0;
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-3);
    margin-block-start: var(--space-4);
    margin-inline: calc(var(--space-5) * -1);
    padding: var(--space-4) var(--space-5);
    border-block-start: 1px solid var(--border);
    background: var(--surface);
}

/* ── плашка сообщения (Notice, дизайн-спринт Task 4) ────────────────────── */

/*
 * ТЕКСТ ВСЕГДА `--text`, тон красит только рамку и подложку — тот же приём,
 * что `.rf-toast--success`/`--error` выше: три тона обязаны различаться
 * глазом, но не ценой контрастной пары, которой нет в `contrast.test.ts`
 * (список пар курируется вручную, заводить новую ради 8%-подмеса — забег
 * впереди спецификации). Подмес — в `--surface`, а не в `transparent`:
 * плашка остаётся НЕПРОЗРАЧНОЙ, читается в потоке страницы, а не поверх
 * произвольного фона под ней.
 */
.rf-notice {
    display: flex;
    align-items: flex-start;
    gap: var(--space-3);
    padding: var(--space-4);
    border-radius: var(--r-card);
    border: 1px solid var(--border);
    background: var(--surface);
    color: var(--text);
    font-size: var(--fs-base);
    line-height: var(--lh-base);
}

/*
 * `hidden` НА ПЛАШКЕ ОБЯЗАН ЕЁ СКРЫВАТЬ (Дизайн-спринт, Task 17) — и без
 * этой строки НЕ СКРЫВАЛ. Ловушка общая для любого примитива, который сам
 * задаёт `display`: правило браузера `[hidden] { display: none }` живёт в
 * UA-таблице стилей, а АВТОРСКОЕ объявление её перебивает всегда,
 * независимо от специфичности, — то есть `display: flex` строкой выше
 * молча отменяет атрибут.
 *
 * Найдено на живой странице, а не в разборе: до переезда область сообщений
 * входа была `<p class="notice" hidden>`, а `.notice` в `app.css` `display`
 * не задавала — атрибут работал. Первый же снимок `/login` с
 * `ui.notice(…, {hidden: true})` показал на карточке пустую красную рамку
 * там, где не должно было быть ничего.
 *
 * Правило адресное (`.rf-notice[hidden]`, 0,2,0), а не общее
 * `[hidden] { display: none }` — и не только «в этом файле» (фикс-раунд 1,
 * M-3, докблок дополнен): даже РОБАСТНАЯ форма общего правила,
 * `@layer utilities { [hidden] { display: none } }` в конце `layers.css`
 * (побеждает `@layer primitives` этого файла ПОРЯДКОМ СЛОЯ, а не порядком
 * строк — значит не хрупкая к следующему примитиву своим `display`, как
 * было написано здесь раньше), сегодня закрыла бы `.rf-notice`, но НЕ
 * `.bell-count` (`app.css`, тот же дефект: свой `display: inline-flex`
 * поверх `[hidden]`) — потому что `app.css` частично НЕСЛОЁНЫЙ, а неслоёный
 * CSS побеждает ЛЮБОЙ слой независимо от специфичности и порядка слоёв (то
 * же правило каскада, которым `base.css:21-33` объясняет, почему сброс
 * движения не может стать слоёным). Общее правило станет единой точкой
 * правды только вместе с задачей, которая доложит `app.css` в слои
 * целиком, — до тех пор оно закрывало бы часть семейства и создавало
 * ложное чувство, что скрывать примитивы через `[hidden]` больше не нужно
 * адресно. Правило для примитива заводится вместе с примитивом: у
 * остальных (`.rf-empty`, `.rf-chip`, `.rf-segmented`) потребителя, который
 * их скрывает атрибутом, сегодня нет, и появится он вместе со своей
 * строкой здесь.
 */
.rf-notice[hidden] { display: none; }

.rf-notice--info { border-color: color-mix(in srgb, var(--primary) 45%, var(--border)); background: color-mix(in srgb, var(--primary) 8%, var(--surface)); }
.rf-notice--success { border-color: color-mix(in srgb, var(--rating-g) 45%, var(--border)); background: color-mix(in srgb, var(--rating-g) 8%, var(--surface)); }
.rf-notice--warn { border-color: color-mix(in srgb, var(--rating-nc17) 45%, var(--border)); background: color-mix(in srgb, var(--rating-nc17) 8%, var(--surface)); }

/* ── пустое состояние (Empty, дизайн-спринт Task 4) ─────────────────────── */

/*
 * ЦВЕТ ПРИГЛУШЁН ВЕЗДЕ, КРОМЕ ЗАГОЛОВКА. Пустая лента или пустой результат
 * поиска — не ошибка: ни рамки тревоги, ни рейтингового красного, только
 * приглушённый текст и опциональное действие тем же `.rf-btn`, что и везде.
 */
.rf-empty {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--space-3);
    padding: var(--space-7) var(--space-4);
    text-align: center;
    color: var(--text-muted);
}

.rf-empty__icon { font-size: var(--fs-2xl); color: var(--text-muted); }
.rf-empty__title { margin: 0; font-size: var(--fs-md); font-weight: 600; color: var(--text); }
.rf-empty__hint { margin: 0; font-size: var(--fs-sm); }
.rf-empty__action { margin-block-start: var(--space-2); }

/* ── тайл статистики (StatTile, дизайн-спринт Task 4) ───────────────────── */

/*
 * ЗНАЧЕНИЕ — САМЫЙ КРУПНЫЙ КЕГЛЬ ШКАЛЫ (`--fs-2xl`), не изобретённое число:
 * тайл держит крупную цифру, и шкала Task 1 уже даёт для этого верхнюю
 * ступень — заводить своё значение мимо неё означало бы второй, ничем не
 * обоснованный кегль в файле.
 */
.rf-stat {
    display: flex;
    flex-direction: column;
    gap: var(--space-1);
    padding: var(--space-4);
    border-radius: var(--r-card);
    border: 1px solid var(--border);
    background: var(--surface);
}

.rf-stat__label { font-size: var(--fs-sm); color: var(--text-muted); }
.rf-stat__value { font-size: var(--fs-2xl); font-weight: 700; line-height: var(--lh-tight); color: var(--text); }
.rf-stat__hint { font-size: var(--fs-xs); color: var(--text-muted); }

/* Спарклайн — необязательный последний ряд тайла: `.rf-sparkline` уже несёт
 * свой размер и цвет (докблок секции спарклайна выше), здесь только отступ
 * от цифры над ним. */
.rf-stat .rf-sparkline { margin-block-start: var(--space-2); }

/* ── аватар (Avatar, дизайн-спринт Task 4) ──────────────────────────────── */

/*
 * РАЗМЕРЫ — ФИЗИЧЕСКИЕ ПИКСЕЛИ, И ЭТО НЕ НАРУШЕНИЕ (`no-magic-space.test.ts`,
 * пример «ширина в px не про ритм»): круг аватара — не элемент типографской
 * сетки, которому положено расти с шрифтом читателя, а фиксированная
 * миниатюра. Кегль буквы внутри — НАОБОРОТ, шкала (`--fs-*`): это уже текст.
 *
 * `lg` — единственный размер с `min-block-size: var(--rf-tap)`: это размер
 * кликабельного аватара шапки (меню аватара, `Sheet`), `sm`/`md`/`xl` —
 * декоративные миниатюры и портрет профиля, ими не нажимают.
 *
 * `xl` (Дизайн-спринт, Task 19) — 72px портрета в шапке страницы автора,
 * ровно та мера, что называет спека §3.2. Ручкой (`var(--rf-…)`) он
 * СОЗНАТЕЛЬНО не сделан, хотя ярус переменных компонента для этого и
 * заведён: размер аватара — это ПАРА величин (круг и кегль инициала
 * внутри), и ручка на одну из них дала бы странице возможность раздуть
 * круг, оставив букву прежней. Четвёртый именованный размер держит пару
 * вместе — тем же приёмом, что три предыдущих.
 */
.rf-avatar {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
    overflow: hidden;
    border-radius: 50%;
    background: var(--primary);
    color: var(--on-primary);
    font-weight: 700;
    line-height: 1;
}

.rf-avatar--sm { inline-size: 28px; block-size: 28px; font-size: var(--fs-xs); }
.rf-avatar--md { inline-size: 36px; block-size: 36px; font-size: var(--fs-sm); }

.rf-avatar--lg {
    inline-size: var(--rf-tap);
    block-size: var(--rf-tap);
    min-block-size: var(--rf-tap);
    font-size: var(--fs-md);
}

.rf-avatar--xl { inline-size: 72px; block-size: 72px; font-size: var(--fs-xl); }

.rf-avatar__image {
    inline-size: 100%;
    block-size: 100%;
    object-fit: cover;
}

/* ── липкая панель действий (Task 17; переехала сюда Task 22) ───────────── */

/*
 * ПОЧЕМУ ЭТА КОРОБКА ЖИВЁТ В ФАЙЛЕ ПРИМИТИВОВ, А НЕ В `app.css`, ГДЕ БЫЛА
 * ЗАВЕДЕНА (Дизайн-спринт, Task 22). У панели стало ДВА потребителя на
 * разных поверхностях: онбординг (`templates/users/welcome.html.twig`,
 * Twig) и массовые действия экрана импорта в кабинете
 * (`apps/studio/src/import/BulkActions.tsx`, React). `apps/studio` не
 * подключает `app.css` вовсе — его точка входа берёт `layers.css`,
 * `tokens.css`, `ui.css` и свой `studio.css`, — поэтому вариантов было два:
 * копия правил в `studio.css` (вторая копия одной коробки — то, что спринт
 * вылавливает пятый раз) или один переезд сюда. `ui.css` — единственный
 * файл, который подключают ОБЕ поверхности.
 *
 * ЭТО НЕ ПРИМИТИВ КИТА В СМЫСЛЕ КОМПОНЕНТА: React-обёртки у панели нет и
 * не заводится — она чистая раскладка, которую страница надевает на свой
 * узел (`<div class="rf-import__bulk rf-sticky-actions" role="group">`).
 * Тем же образом здесь уже живут `.rf-vh` и `.rf-sheet-host`. Имя стоит в
 * реестре `no-duplicate-primitive.test.ts` и показано на витрине
 * (`/dev/styleguide`), как того требует правило «добавленное в кит
 * появляется и в витрине, и в `UiMacrosTest`».
 *
 * ПЕРЕЕЗД ОСЛАБИЛ ПРАВИЛО НА ОДИН СЛОЙ (`components` → `primitives`), и
 * это проверено замером, а не рассуждением: второго правила на этот класс в
 * дереве нет (грепом), а живая разметка `/welcome` (выгружена из
 * `AuthLayoutTest`, отрисована Chromium'ом с полной цепочкой из семи
 * таблиц) даёт ОДИНАКОВЫЕ 503 вычисленных свойства панели до и после
 * переезда, включая `position: sticky` и `padding-block: 16px`, — и ту же
 * коробку 358×77 на 390px (подробности и числа — в отчёте Task 22).
 */
/*
 * ЛИПКАЯ ПАНЕЛЬ ДЕЙСТВИЙ — `sticky`, НЕ `fixed`, и три следствия этого
 * выбора все нужны сразу:
 *
 * 1. панель участвует в потоке — последнему чипу не нужен искусственный
 *    `padding-bottom` под высоту панели, которую пришлось бы держать
 *    синхронно с её содержимым;
 * 2. `sticky` не считает `inset` от «ближайшего предка с transform/filter»,
 *    потому что вообще не ищет containing block для позиционирования, —
 *    той ловушки, которая дважды за спринт увела панель листа (Task 15.6),
 *    здесь нет по построению;
 * 3. на конце страницы панель садится на своё место в потоке, а не висит
 *    поверх подвала.
 *
 * ФОН СПЛОШНОЙ (`--bg`), БЕЗ `backdrop-filter`. Размытие «идёт» такой
 * панели ровно так же, как оно идёт шапке, — но `backdrop-filter` сделал бы
 * из неё того самого предка из пункта 2 для всего, что внутри, и следующий
 * автор, поставивший в панель лист или поповер, платил бы за косметику
 * отладкой позиционирования. Шапка эту цену платит через псевдоэлемент
 * (`.rf-topbar::before`, Task 15.6); здесь незачем: панель узкая, за ней
 * чипы, и «стекло» ничего не добавляет к пониманию страницы.
 *
 * `z-index` не задан: панель — последний элемент формы в потоке, и
 * перекрывать ей нечего, кроме чипов выше, которые её и так не
 * позиционированы.
 */
.rf-sticky-actions {
    position: sticky;
    inset-block-end: 0;
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-3);
    margin-block-start: var(--space-6);
    padding-block: var(--space-4);
    background: var(--bg);
    border-block-start: 1px solid var(--border);
}

/*
 * Кольцо фокуса — ОДНО правило на все интерактивные примитивы.
 * Через `:focus-visible`, а не `:focus`: второй рисует кольцо и при
 * клике мышью, из-за чего его исторически отключали целиком — и
 * вместе с ним теряли клавиатуру. `.rf-segmented button`/`.rf-sheet-host >
 * summary` были заведены в Task 3 заранее, в предвидении этой задачи;
 * `.rf-sheet__close` и `.rf-filter-chip` дописывает уже она сама — оба
 * интерактивны и появились только сейчас. Правило одно на всех
 * интерактивных примитивах плана, и будущему автору нечего забыть.
 */
.rf-btn:focus-visible,
.rf-chip:focus-visible,
.rf-tab:focus-visible,
.rf-field__input:focus-visible,
.rf-field__textarea:focus-visible,
.rf-segmented button:focus-visible,
.rf-sheet-host > summary:focus-visible,
.rf-sheet__close:focus-visible,
.rf-filter-chip:focus-visible,
/* Чип-чекбокс (Task 17): фокус лежит на чекбоксе ВНУТРИ чипа, и родное
 * кольцо браузера обводит квадратик 13px внутри пилюли 44px. Обводим саму
 * пилюлю — тем же приёмом, что подпись форменного сегмента строкой ниже:
 * кольцо обязано показывать таргет, по которому человек нажимает, а не
 * деталь внутри него. `:has(… :focus-visible)`, а не `:focus-within` —
 * второй рисует кольцо и от клика мышью (докблок выше). */
.rf-chip:has(input:focus-visible),
/* Форменный сегмент (Task 15): фокус лежит на `.rf-vh`-радио, которого не
 * видно, поэтому кольцо рисуется на его подписи — иначе клавиатурный
 * читатель терял бы место на странице ровно в переключателе. */
.rf-segmented input:focus-visible + label {
    outline: var(--focus-ring);
    outline-offset: 2px;
}

/*
 * Родное кольцо браузера НА САМОМ ЧЕКБОКСЕ — гасим (Дизайн-спринт, Task 17,
 * фикс-раунд 1, M-5). Без этой строки чип рисовал ДВА кольца разом: 3px на
 * пилюле (правило выше) и авторский `outline: auto` UA-таблицы на
 * квадратике 13px внутри неё — замерено с клавиатуры (Tab): `input`
 * получал `outline-style: auto` `rgb(0, 95, 204)`, хотя докблок
 * `.rf-chip:has(input:focus-visible)` уже объясняет, что кольцо обязано
 * стоять на таргете, по которому нажимают, а не на детали внутри него.
 * Снимать безопасно: кольцо на пилюле уже есть строкой выше, клавиатурный
 * фокус ничего не теряет.
 */
.rf-chip input:focus-visible { outline: none; }

/* ── prefers-reduced-motion (FE-2) ─────────────────────────────────── */

@media (prefers-reduced-motion: reduce) {
    .rf-btn,
    .rf-chip,
    .rf-tab,
    .rf-field__input,
    .rf-segmented button,
    .rf-segmented label,
    .rf-sheet-host > summary,
    .rf-filter-chip {
        transition: none;
    }

    /* Треугольник фасета доворачивается мгновенно — поворот на 180° под
     * рукой у читателя, попросившего меньше движения, самый заметный. */
    .rf-facet > summary::after { transition: none; }

    .rf-toast { animation: none; }

    /* Скелетон не «замирает наполовину»: у бегущего блика без движения нет
     * смысла, поэтому остаётся ровная плашка, а не кадр градиента. */
    .rf-skeleton {
        animation: none;
        background: color-mix(in srgb, var(--text-muted) 14%, var(--surface));
    }
}

}
