diff --git a/hypn0/hypn0_site/models.py b/hypn0/hypn0_site/models.py index c74fc81..af791de 100644 --- a/hypn0/hypn0_site/models.py +++ b/hypn0/hypn0_site/models.py @@ -152,6 +152,54 @@ class TbHypn0Item(models.Model): def __str__(self) -> str: return f"{self.s_title} ({self.s_hash_id})" + @property + def card_bg_style(self) -> str: + """ + Вычисляет утонченную палитру подложки карточки на основе цвета гипноточки. + + Использует CSS-переменные для бесшовной адаптации под текущую тему зрителя (светлая/темная): + - Экстремально светлые точки (luminance >= 215, напр. чисто белый): всегда темный фон. + - Экстремально темные точки (luminance <= 40, напр. чисто черный): всегда светлый фон. + - Цветные/промежуточные оттенки (40 < luminance < 215): изящный тинт цвета точки, + который гармонично подстраивается под светлую и темную тему зрителя. + """ + color_hex = "#a855ff" + if isinstance(self.j_metadata, dict): + color_hex = self.j_metadata.get("color", "#a855ff") or "#a855ff" + + # Нормализация HEX + hex_clean = color_hex.lstrip("#") + if len(hex_clean) == 3: + hex_clean = "".join([c * 2 for c in hex_clean]) + elif len(hex_clean) != 6: + hex_clean = "a855ff" + + try: + r = int(hex_clean[0:2], 16) + g = int(hex_clean[2:4], 16) + b = int(hex_clean[4:6], 16) + except ValueError: + r, g, b = 168, 85, 255 + + # Перцептивная яркость (ITU-R BT.601) + luminance = 0.299 * r + 0.587 * g + 0.114 * b + + if luminance >= 215: + # Экстремально светлая точка (белая/пастельно-белая) -> принудительно темный графит в обеих темах + dark_bg = f"rgb({max(8, int(r * 0.05))}, {max(8, int(g * 0.05))}, {max(12, int(b * 0.06))})" + return f"--card-bg-light: {dark_bg}; --card-bg-dark: {dark_bg};" + elif luminance <= 40: + # Экстремально темная точка (черная/глубокая смола) -> принудительно шелково-светлый в обеих темах + light_bg = f"rgb({min(248, int(244 + r * 0.03))}, {min(248, int(244 + g * 0.03))}, {min(250, int(246 + b * 0.03))})" + return f"--card-bg-light: {light_bg}; --card-bg-dark: {light_bg};" + else: + # Цветная точка (фиолетовый, изумрудный, бирюзовый, оранжевый и т.д.) + # Светлая тема: мягкий шелковый фон с 3.5% тинтом цвета точки + light_bg = f"rgb({min(250, int(245 + r * 0.035))}, {min(250, int(245 + g * 0.035))}, {min(252, int(247 + b * 0.035))})" + # Темная тема: глубокий графитово-ночной фон с 7% тинтом цвета точки + dark_bg = f"rgb({max(9, int(9 + r * 0.07))}, {max(9, int(9 + g * 0.07))}, {max(13, int(13 + b * 0.08))})" + return f"--card-bg-light: {light_bg}; --card-bg-dark: {dark_bg};" + def increment_views(self): """Безопасный инкремент просмотров""" TbHypn0Item.objects.filter(id=self.id).update(i_views_count=F('i_views_count') + 1) diff --git a/hypn0/hypn0_site/services/halftone.py b/hypn0/hypn0_site/services/halftone.py index bf6b0f3..93e7269 100644 --- a/hypn0/hypn0_site/services/halftone.py +++ b/hypn0/hypn0_site/services/halftone.py @@ -273,9 +273,10 @@ def prepare_gallery_svg(svg_content: str) -> str: if not svg_content: return svg_content + # Надежная глобальная пауза в контексте :root и пробуждение по ховеру hover_css = ( - "svg:not(:hover) .shape,svg:not(:hover) circle,svg:not(:hover) rect," - "svg:not(:hover) polygon,svg:not(:hover) path{animation-play-state:paused!important}" + "@media(hover:hover){:root{animation-play-state:paused!important}:root:hover *{animation-play-state:running!important}}" + "svg:not(:hover) *{animation-play-state:paused!important}" ) if "" in svg_content: @@ -292,11 +293,16 @@ def prepare_active_svg(svg_content: str) -> str: if not svg_content: return svg_content - hover_css = ( + # Удаляем любые внедренные правила пауз и медиа-запросов ховера + cleaned = re.sub(r"@media\s*\(\s*hover\s*:\s*hover\s*\)\s*\{[^}]*:[^}]*\}", "", svg_content) + cleaned = re.sub(r"svg:not\(:hover\)[^{]*\{[^}]*\}", "", cleaned) + # Старый формат для обратной совместимости + old_hover_css = ( "svg:not(:hover) .shape,svg:not(:hover) circle,svg:not(:hover) rect," "svg:not(:hover) polygon,svg:not(:hover) path{animation-play-state:paused!important}" ) - return svg_content.replace(hover_css, "") + cleaned = cleaned.replace(old_hover_css, "") + return cleaned def analyze_svg_structure(svg_content: str) -> dict: diff --git a/hypn0/hypn0_site/tests.py b/hypn0/hypn0_site/tests.py index 18caf4b..aaefe42 100644 --- a/hypn0/hypn0_site/tests.py +++ b/hypn0/hypn0_site/tests.py @@ -238,8 +238,9 @@ class GalleryPreparationTests(TestCase): def test_prepare_gallery_svg(self): raw_svg = '' prepared = prepare_gallery_svg(raw_svg) + self.assertIn("@media(hover:hover)", prepared) + self.assertIn(":root{animation-play-state:paused!important}", prepared) self.assertIn("svg:not(:hover)", prepared) - self.assertIn("animation-play-state:paused!important", prepared) class PublishViewTests(TestCase): @@ -697,3 +698,34 @@ class GalleryFreshFloorTests(TestCase): url = reverse("hypn0_site:gallery_floor", kwargs={"floor_slug": "non_existent"}) response = self.client.get(url) self.assertEqual(response.status_code, 404) + + +class CardBgStyleTests(TestCase): + """Тестирование вычисления адаптивных стилей подложки карточки.""" + + def test_extreme_white_color_forces_dark_background(self): + item = TbHypn0Item(j_metadata={"color": "#ffffff"}) + style = item.card_bg_style + self.assertIn("--card-bg-light: rgb(12, 12, 15);", style) + self.assertIn("--card-bg-dark: rgb(12, 12, 15);", style) + + def test_extreme_black_color_forces_light_background(self): + item = TbHypn0Item(j_metadata={"color": "#000000"}) + style = item.card_bg_style + self.assertIn("--card-bg-light: rgb(244, 244, 246);", style) + self.assertIn("--card-bg-dark: rgb(244, 244, 246);", style) + + def test_colorful_intermediate_color_creates_theme_adaptive_background(self): + item = TbHypn0Item(j_metadata={"color": "#a855ff"}) + style = item.card_bg_style + self.assertIn("--card-bg-light: rgb(250, 247, 252);", style) + self.assertIn("--card-bg-dark: rgb(20, 14, 33);", style) + + def test_fallback_on_empty_or_invalid_color(self): + item_none = TbHypn0Item(j_metadata=None) + self.assertIn("--card-bg-light:", item_none.card_bg_style) + self.assertIn("--card-bg-dark:", item_none.card_bg_style) + + item_invalid = TbHypn0Item(j_metadata={"color": "invalid-hex"}) + self.assertIn("--card-bg-light:", item_invalid.card_bg_style) + self.assertIn("--card-bg-dark:", item_invalid.card_bg_style) diff --git a/hypn0/hypn0_site/views.py b/hypn0/hypn0_site/views.py index 777833e..f230948 100644 --- a/hypn0/hypn0_site/views.py +++ b/hypn0/hypn0_site/views.py @@ -20,7 +20,7 @@ from .services.halftone import ( from .services.naming import generate_hypno_title -def get_floor_fresh(limit: int | None = 6, offset: int = 0): +def get_floor_fresh(limit: int | None = 8, offset: int = 0): """ 1-й ЭТАЖ: «Плеск бессознательного» (Инкубатор открытий). Выборка: свежие кандидаты и первичный поток (Level.CANDIDATE, Level.LEVEL_1). @@ -73,7 +73,7 @@ def gallery_floor(request: HttpRequest, floor_slug: str) -> HttpResponse: @ensure_csrf_cookie def index(request: HttpRequest | None) -> HttpResponse: - fresh_items = get_floor_fresh(limit=6) + fresh_items = get_floor_fresh(limit=8) return render(request, "index.html", { "fresh_items": fresh_items, }) diff --git a/hypn0/templates/block/gallery_card.html b/hypn0/templates/block/gallery_card.html index f732e26..2fa6b38 100644 --- a/hypn0/templates/block/gallery_card.html +++ b/hypn0/templates/block/gallery_card.html @@ -1,9 +1,11 @@ {% load static %} -