chore(quantik-game): полировка по финальному ревью + security-review

Финальное ревью: READY TO MERGE (0 блокеров). Security: SECURE (0 critical).
Применены дешёвые фиксы из ревью:
- validateSpec: блок game{} санитизируется ПОИМЁННО (chapter/subject →
  sanitizeText, order/par_ms/unlockStars → проверка типа, неизвестные ключи
  отбрасываются) — закрыт латентный хранимый XSS (раньше clean.game=spec.game).
- quantik.html: @media (prefers-reduced-motion) делает анимации мгновенными
  (не выключает — иначе forwards-появление узлов оставило бы их скрытыми).
- progress-logic.js: фикс комментария isUnlocked (сумма звёзд по ВСЕМ уровням
  с меньшим глобальным order, а не «той же главы»).
План: Ф6 (лидерборд/гонка) удалена (Amendment 1, решение пользователя);
финальные гейты отмечены; deferred-бэклог зафиксирован.
Затронутые тесты 45/45; lint:routes 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
This commit is contained in:
Maxim Dolgolyov
2026-06-14 17:00:13 +03:00
parent c780b6fd96
commit 69df2f8190
5 changed files with 54 additions and 16 deletions
+4 -5
View File
@@ -46,11 +46,10 @@
}
/* ── Разблокировка ────────────────────────────────────────────────────────
Уровень открыт, если СУММА звёзд во ВСЕХ предыдущих уровнях той же главы
(по полю order) ≥ level.unlockStars. Первый уровень главы (минимальный order
или unlockStars==0) открыт всегда. Глава открывается, если открыт её первый
уровень — он гейтится суммой звёзд предыдущих глав через unlockStars==0
первого уровня (по умолчанию) ИЛИ явным порогом.
Уровень открыт, если СУММА звёзд во ВСЕХ уровнях с меньшим ГЛОБАЛЬНЫМ order
(по всем главам, не только текущей) ≥ level.unlockStars. Уровень с
unlockStars==0 (или без него) открыт всегда. Так первый уровень главы
гейтится суммой звёзд всех предыдущих глав через свой порог unlockStars.
Чистая функция: вход — уровень + карта прогресса + ВЕСЬ список (для подсчёта
«предыдущих» по order). Возвращает bool. */
+12
View File
@@ -220,6 +220,18 @@
.qg-star-svg { filter: drop-shadow(0 2px 6px rgba(251,191,36,0.4)); }
.qg-star-on { animation: qgStarPop .45s cubic-bezier(.22,1.3,.4,1) backwards; animation-delay: calc(.12s * var(--si, 0) + .15s); }
@keyframes qgStarPop { 0% { transform: scale(0) rotate(-30deg); opacity: 0; } 70% { transform: scale(1.25) rotate(6deg); } 100% { transform: scale(1) rotate(0); opacity: 1; } }
/* Доступность: уважаем prefers-reduced-motion. Делаем анимации мгновенными
(а не выключаем) — иначе forwards-анимация появления узлов (.qm-in/qmNodeIn)
не применит конечное состояние и узлы останутся скрытыми. Циклы (пульс/мерцание/
покачивание) при этом фактически останавливаются (1 итерация мгновенно). */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .01ms !important;
animation-iteration-count: 1 !important;
transition-duration: .01ms !important;
scroll-behavior: auto !important;
}
}
.qg-stats { display: flex; justify-content: center; gap: 22px; margin-bottom: 20px; }
.qg-stat { display: flex; flex-direction: column; gap: 3px; }
.qg-stat-lbl { font-size: .68rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: #8B9AAE; }