From 680df39089fea41af75e541e7194f031dcb2683e Mon Sep 17 00:00:00 2001 From: Allison Date: Tue, 28 Apr 2026 13:11:02 -0400 Subject: [PATCH] =?UTF-8?q?feat(marketing):=20round=204=20=E2=80=94=20Cadr?= =?UTF-8?q?e=20+=20Cycle=20cin=C3=A9matiques=20(radar,=20data=20packet=20f?= =?UTF-8?q?light,=20stamp=20impact,=20savings=20counter)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 4 transforme les 2 sections "Cadre réglementaire" en expériences cinématiques : CADRE (Moniteur d'Interception) - Radar sweep circulaire vert continu en background HUD (4s loop, SVG + @keyframes) - 6 paquets data "voice.wav" en flight QC→US via offset-path bezier (stagger 420ms, glow rouge) - Console typewriter char-by-char 3 lignes (28ms/char + caret blink, 3e ligne rouge glow) - 6 REGS reveal cascadé via revealRegsCascade (stagger 120ms) + hover red glow + border-left - Verdict NON CONFORME : pulse glow rouge + scan-line traversante 3s - Decorative grid 40×40 console-style + grid existant 20×20 - Eyebrow ⚠ remplacé par SVG warning-triangle inline CYCLE (Trois options) - Phase reveal 1→4 séquentiel (déjà existant) avec animations renforcées - Col 1 horloge accélérée 1 tour/3s (au lieu de 8s) - Col 1 prix counter Alpine 0→315 (easeOutCubic 1.4s) via priceHumain + countTo - Col 2 stamp NON CONFORME impact (rotate -22→-3deg + scale 2.4→1, cubic-bezier 1.6 ease) - Col 2 flash rouge background à l'impact (cycle-col-flash) + 10 particules de fuite (au lieu de 6) - Col 3 checkmark draw via stroke-dashoffset 24→0 - Col 3 glow border vert pulsant (cycle-conforme-glow, double-couche emerald + cyan) - Col 3 badge "Loi 25 conforme" top-right avec pulse subtil (cycle-conforme-badge) - Connecting lines avec dash flow continu (cycle-line-flow @keyframes) - Live red dot "Réunion en cours" avec pulse box-shadow - Section "Économies annuelles · 25 utilisateurs" : 3 cards avec counter Alpine (sav1=3924, sav2=6924, sav3=2004) + hover lift + emerald shadow - Eyebrow ⚠ remplacé par SVG warning-triangle Accessibilité & performance - prefers-reduced-motion désactive TOUT (radar, packets, typewriter, stamp, glow, counter) - Mobile (<768px) cache radar + packets + leak particles (CPU-intensive) - Counter helper countTo respecte reduced-motion via matchMedia - Tous les SVG ont aria-hidden, scènes ont role=img/listitem appropriés - HUD console role=log + aria-live=polite - OQLF NBSP préservé (315 $/réunion, Loi 25, 100 % Québec, 25 utilisateurs, 3 924 $) Tests : 4 tests round 4 ajoutés (cadre cinematic, cycle cinematic, no-emoji warning, reduced-motion guards). 65/68 landing tests passent (3 failures pré-existantes unrelated : nav /blog, footer /blog, trust-bar phrasing). Co-Authored-By: Claude Opus 4.7 (1M context) --- static/css/marketing.css | 124 +++++- templates/marketing/landing.html | 494 ++++++++++++++++++----- tests/test_marketing_landing_template.py | 98 +++++ 3 files changed, 596 insertions(+), 120 deletions(-) diff --git a/static/css/marketing.css b/static/css/marketing.css index 6eeb75a..eda24ce 100644 --- a/static/css/marketing.css +++ b/static/css/marketing.css @@ -49,7 +49,8 @@ --color-green-700: oklch(52.7% 0.154 150.069); --color-green-800: oklch(44.8% 0.119 151.328); --color-green-900: oklch(39.3% 0.095 152.535); - --color-emerald-300: oklch(84.5% 0.143 164.978); + --color-emerald-50: oklch(97.9% 0.021 166.113); + --color-emerald-100: oklch(95% 0.052 163.051); --color-emerald-500: oklch(69.6% 0.17 162.48); --color-emerald-600: oklch(59.6% 0.145 163.225); --color-emerald-700: oklch(50.8% 0.118 165.612); @@ -128,6 +129,7 @@ --font-weight-semibold: 600; --font-weight-bold: 700; --font-weight-black: 900; + --tracking-tighter: -0.05em; --tracking-tight: -0.025em; --tracking-wide: 0.025em; --tracking-wider: 0.05em; @@ -147,7 +149,6 @@ --blur-sm: 8px; --blur-md: 12px; --blur-xl: 24px; - --blur-3xl: 64px; --default-transition-duration: 150ms; --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); } @@ -391,6 +392,9 @@ .-top-3 { top: calc(var(--spacing) * -3); } + .-top-8 { + top: calc(var(--spacing) * -8); + } .-top-12 { top: calc(var(--spacing) * -12); } @@ -439,6 +443,9 @@ .-right-1\.5 { right: calc(var(--spacing) * -1.5); } + .-right-8 { + right: calc(var(--spacing) * -8); + } .-right-12 { right: calc(var(--spacing) * -12); } @@ -526,6 +533,9 @@ .left-\[3\%\] { left: 3%; } + .left-\[6\%\] { + left: 6%; + } .z-10 { z-index: 10; } @@ -829,6 +839,9 @@ .h-32 { height: calc(var(--spacing) * 32); } + .h-40 { + height: calc(var(--spacing) * 40); + } .h-48 { height: calc(var(--spacing) * 48); } @@ -958,6 +971,9 @@ .w-1\/2 { width: calc(1 / 2 * 100%); } + .w-1\/3 { + width: calc(1 / 3 * 100%); + } .w-2 { width: calc(var(--spacing) * 2); } @@ -1445,10 +1461,6 @@ -moz-column-gap: calc(var(--spacing) * 6); column-gap: calc(var(--spacing) * 6); } - .gap-x-8 { - -moz-column-gap: calc(var(--spacing) * 8); - column-gap: calc(var(--spacing) * 8); - } .space-x-1 { :where(& > :not(:last-child)) { --tw-space-x-reverse: 0; @@ -1490,9 +1502,6 @@ .gap-y-2 { row-gap: calc(var(--spacing) * 2); } - .gap-y-3 { - row-gap: calc(var(--spacing) * 3); - } .divide-y { :where(& > :not(:last-child)) { --tw-divide-y-reverse: 0; @@ -1590,6 +1599,10 @@ border-style: var(--tw-border-style); border-width: 2px; } + .border-\[3px\] { + border-style: var(--tw-border-style); + border-width: 3px; + } .border-y { border-block-style: var(--tw-border-style); border-block-width: 1px; @@ -1719,6 +1732,21 @@ .border-brand-border { border-color: #e6ebf2; } + .border-emerald-100 { + border-color: var(--color-emerald-100); + } + .border-emerald-500\/40 { + border-color: color-mix(in srgb, oklch(69.6% 0.17 162.48) 40%, transparent); + @supports (color: color-mix(in lab, red, red)) { + border-color: color-mix(in oklab, var(--color-emerald-500) 40%, transparent); + } + } + .border-emerald-500\/45 { + border-color: color-mix(in srgb, oklch(69.6% 0.17 162.48) 45%, transparent); + @supports (color: color-mix(in lab, red, red)) { + border-color: color-mix(in oklab, var(--color-emerald-500) 45%, transparent); + } + } .border-gray-300 { border-color: var(--color-gray-300); } @@ -1956,12 +1984,6 @@ background-color: color-mix(in oklab, var(--color-amber-500) 10%, transparent); } } - .bg-amber-500\/15 { - background-color: color-mix(in srgb, oklch(76.9% 0.188 70.08) 15%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-amber-500) 15%, transparent); - } - } .bg-amber-500\/20 { background-color: color-mix(in srgb, oklch(76.9% 0.188 70.08) 20%, transparent); @supports (color: color-mix(in lab, red, red)) { @@ -2064,6 +2086,21 @@ .bg-brand-navy2 { background-color: #0b1525; } + .bg-emerald-50 { + background-color: var(--color-emerald-50); + } + .bg-emerald-500\/12 { + background-color: color-mix(in srgb, oklch(69.6% 0.17 162.48) 12%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-emerald-500) 12%, transparent); + } + } + .bg-emerald-500\/15 { + background-color: color-mix(in srgb, oklch(69.6% 0.17 162.48) 15%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-emerald-500) 15%, transparent); + } + } .bg-emerald-600 { background-color: var(--color-emerald-600); } @@ -2166,6 +2203,12 @@ background-color: color-mix(in oklab, var(--color-red-50) 30%, transparent); } } + .bg-red-50\/40 { + background-color: color-mix(in srgb, oklch(97.1% 0.013 17.38) 40%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-red-50) 40%, transparent); + } + } .bg-red-100 { background-color: var(--color-red-100); } @@ -2178,6 +2221,9 @@ .bg-red-400 { background-color: var(--color-red-400); } + .bg-red-500 { + background-color: var(--color-red-500); + } .bg-red-500\/10 { background-color: color-mix(in srgb, oklch(63.7% 0.237 25.331) 10%, transparent); @supports (color: color-mix(in lab, red, red)) { @@ -2190,6 +2236,18 @@ background-color: color-mix(in oklab, var(--color-red-500) 20%, transparent); } } + .bg-red-500\/40 { + background-color: color-mix(in srgb, oklch(63.7% 0.237 25.331) 40%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-red-500) 40%, transparent); + } + } + .bg-red-500\/85 { + background-color: color-mix(in srgb, oklch(63.7% 0.237 25.331) 85%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-red-500) 85%, transparent); + } + } .bg-red-600 { background-color: var(--color-red-600); } @@ -2229,6 +2287,12 @@ background-color: color-mix(in oklab, var(--color-white) 30%, transparent); } } + .bg-white\/80 { + background-color: color-mix(in srgb, #fff 80%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-white) 80%, transparent); + } + } .bg-white\/95 { background-color: color-mix(in srgb, #fff 95%, transparent); @supports (color: color-mix(in lab, red, red)) { @@ -2812,6 +2876,10 @@ --tw-tracking: var(--tracking-tight); letter-spacing: var(--tracking-tight); } + .tracking-tighter { + --tw-tracking: var(--tracking-tighter); + letter-spacing: var(--tracking-tighter); + } .tracking-wide { --tw-tracking: var(--tracking-wide); letter-spacing: var(--tracking-wide); @@ -2995,6 +3063,9 @@ .text-brand-navy\/80 { color: color-mix(in oklab, #060d1a 80%, transparent); } + .text-brand-navy\/85 { + color: color-mix(in oklab, #060d1a 85%, transparent); + } .text-brand-navy\/90 { color: color-mix(in oklab, #060d1a 90%, transparent); } @@ -3007,6 +3078,15 @@ .text-emerald-600 { color: var(--color-emerald-600); } + .text-emerald-600\/70 { + color: color-mix(in srgb, oklch(59.6% 0.145 163.225) 70%, transparent); + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, var(--color-emerald-600) 70%, transparent); + } + } + .text-emerald-700 { + color: var(--color-emerald-700); + } .text-gray-200 { color: var(--color-gray-200); } @@ -3097,6 +3177,12 @@ color: color-mix(in oklab, var(--color-red-500) 80%, transparent); } } + .text-red-500\/85 { + color: color-mix(in srgb, oklch(63.7% 0.237 25.331) 85%, transparent); + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, var(--color-red-500) 85%, transparent); + } + } .text-red-600 { color: var(--color-red-600); } @@ -3288,10 +3374,18 @@ --tw-shadow: 0 0 6px var(--tw-shadow-color, #F59E0B); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } + .shadow-\[0_0_6px_rgba\(239\,68\,68\,0\.6\)\] { + --tw-shadow: 0 0 6px var(--tw-shadow-color, rgba(239,68,68,0.6)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } .shadow-\[0_0_28px_rgba\(0\,98\,255\,0\.35\)\] { --tw-shadow: 0 0 28px var(--tw-shadow-color, rgba(0,98,255,0.35)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } + .shadow-\[0_8px_30px_-6px_rgba\(239\,68\,68\,0\.55\)\] { + --tw-shadow: 0 8px 30px -6px var(--tw-shadow-color, rgba(239,68,68,0.55)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } .shadow-cta { --tw-shadow: 0 4px 20px var(--tw-shadow-color, rgba(0, 98, 255, 0.28)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); diff --git a/templates/marketing/landing.html b/templates/marketing/landing.html index 1650f89..e7ce8e4 100644 --- a/templates/marketing/landing.html +++ b/templates/marketing/landing.html @@ -411,10 +411,10 @@ {# ===== CYCLE — "Trois options. Une seule est conforme." ===== #} {# Source canonique : InnovA-AI/Website-Sanity/components/sections/dictai-cycle.tsx - Animation : phase reveal phasé (1→4) déclenché par IntersectionObserver natif + Alpine. - 3 colonnes comparatives : 01 Humaine · 02 Cloud US (overlay non-conforme) · 03 DictIA (featured + pulse rings) #} + Round 4 cinématique : phase reveal séquentiel + horloge accélérée + prix counter + stamp impact NON CONFORME + + checkmark draw + glow vert + connecting line dash flow + section "Économies annuelles" avec 3 counters animés #}
-

⚠ CADRE RÉGLEMENTAIRE

+

+ + CADRE RÉGLEMENTAIRE +

Trois options. Une seule est conforme.

@@ -519,23 +604,25 @@ Réunion en cours — données confidentielles + + Live
- {# Lignes de connexion SVG — de la source vers les 3 colonnes #} + {# Lignes de connexion SVG — de la source vers les 3 colonnes (round 4 : dash flow continu) #}
@@ -576,8 +663,8 @@
- 315 - $ / réunion + 315 + $ / réunion
@@ -587,18 +674,20 @@
{# COL 2 — IA cloud américaine #} -
- {# Overlay légal NON CONFORME (phase 3) #} -
-
+ {# Overlay légal NON CONFORME (phase 3) — round 4 : STAMP huissier qui claque #} +
+
NON CONFORME
-
Loi 25 · Cloud Act américain
+
Loi 25 · Cloud Act américain
@@ -612,10 +701,10 @@
@@ -660,11 +749,18 @@ style="background: radial-gradient(ellipse 80% 35% at 50% 0%, rgba(0,189,216,0.12) 0%, transparent 65%);">
-
-
-
-
+ {# Numéro 03 → checkmark vert (round 4) #} + Solution + {# Badge top-right : Loi 25 conforme (round 4) #} + + + Loi 25 conforme +
@@ -725,20 +821,46 @@
- {# Barre d'économies — apparaît avec phase 4 #} -
-
- - Économies annuelles · 25 utilisateurs +
+ + + Économies annuelles · 25 utilisateurs + +
- {% for sav in [('3 924 $', 'vs Otter.ai'), ('6 924 $', 'vs MS Teams'), ('2 004 $', 'vs Sténographe')] %} -
- {{ sav[0] | safe }} - {{ sav[1] }} + +
+ {% for sav in [ + {'icon': 'otter', 'val_prop': 'sav1', 'val_static': '3 924', 'label': 'vs Otter.ai', 'sub': 'IA cloud US'}, + {'icon': 'teams', 'val_prop': 'sav2', 'val_static': '6 924', 'label': 'vs MS Teams', 'sub': 'Copilot premium'}, + {'icon': 'scribe','val_prop': 'sav3', 'val_static': '2 004', 'label': 'vs Sténographe','sub': 'Service humain'} + ] %} +
+ +
+
+ {# OQLF NBSP entre nombre et $ — préservé en placeholder statique pour SEO/no-JS, JS écrase via x-html #} + {{ sav.val_static | safe }} $ +
+
{{ sav.label }}
+
{{ sav.sub }}
+
+
+ {% endfor %}
- {% endfor %}
@@ -1768,11 +1890,13 @@ {# ===== CADRE RÉGLEMENTAIRE — Moniteur d'Interception ===== #} {# Source canonique : InnovA-AI/Website-Sanity/components/sections/dictai-contraste.tsx (REGS + MoniteurInterception) - Animation : cycle automatique en 4 étapes (folder QC→US, alerte, HUD log, flash REGS séquentiel) + Alpine - Cartographie 6 textes : Loi 25, Loi 96, Cloud Act, Guide IA Barreau, Cadre IA MCN, CAI #} + Round 4 cinématique : radar sweep continu, 6 paquets data en flight QC→US (offset-path bezier), + typewriter 3 lignes char-par-char, REGS reveal cascadé + glow rouge hover, verdict pulse + scan-line, + grid pattern bg console. #}
+ {# Round 4 : decorative grid pattern bg console-style 40×40 #} + style="background-image: linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px), radial-gradient(circle, rgba(11,15,26,0.045) 1px, transparent 1px); background-size: 40px 40px, 40px 40px, 28px 28px;">
-

⚠ CADRE RÉGLEMENTAIRE QUÉBEC

+

+ + CADRE RÉGLEMENTAIRE QUÉBEC +

Ce que vos outils actuels enfreignent en secret.

@@ -1872,20 +2085,45 @@ Moniteur d'Interception - ⚠ Alerte Active + + Alerte Active
{# Body — 2 colonnes #}
- {# LEFT — animation track (~42%) #} -
- {# Grille bg #} + {# LEFT — animation track (~42%) — round 4 : radar + data packets en flight #} + + + {# Round 4 : Radar sweep — cercle vert avec ligne rotative balayant 360° #} + {# Labels QC / US #}
@@ -1898,14 +2136,15 @@
- {# Track #} -
+ {# Track + folder + DATA PACKETS en flight (round 4) #} +
-
- + + {# Folder source (QC) #} +
+ + {# Folder destination (US) avec halo alerte #} +
+ + + {# Burst rouge à l'arrivée des paquets #} + +
+ + {# 6 PAQUETS DATA en flight QC→US (round 4 : offset-path bezier) #} +
- {# HUD panel #} + {# HUD panel — typewriter char-by-char (round 4) #}
- - +
+ + +
+
+ + +
+
+ + +
@@ -1945,7 +2219,9 @@ {'label': 'Cadre IA — MCN', 'detail': 'Gouvernance IA pour organismes publics (déc. 2025, conformité 19 juin 2026)', 'href': 'https://www.tresor.gouv.qc.ca/', 'risk': False}, {'label': 'CAI', 'detail': 'Commission d\'accès à l\'information — application active', 'href': 'https://www.cai.gouv.qc.ca/', 'risk': False} ] %} -
  • +
  • - {# Footer — verdict #} -
    - - - - + {# Footer — verdict (round 4 : pulse glow rouge + scan-line traversante) #} +
    +
    + + + + + {# Scan-line horizontale (round 4) #} + +
    diff --git a/tests/test_marketing_landing_template.py b/tests/test_marketing_landing_template.py index 8aab7c2..f63f3db 100644 --- a/tests/test_marketing_landing_template.py +++ b/tests/test_marketing_landing_template.py @@ -848,6 +848,104 @@ def test_round2_cadre_reglementaire_section_present(): assert 'runCycle' in body +def test_round4_cadre_cinematic_features(): + """Round 4 — Cadre Moniteur d'Interception cinematic upgrades. + + - Radar sweep circulaire continu en background HUD + - 6 paquets data 'voice.wav' en flight QC→US (offset-path bezier) + - Console typewriter char-by-char (3 lignes via hudTyped + caret blink) + - 6 REGS reveal cascadé (revealedRegs IntersectionObserver) + - Verdict 'NON CONFORME' pulse glow + scan-line traversante + - eyebrow ⚠ remplacé par SVG warning-triangle + """ + client = app.test_client() + body = client.get('/').data.decode('utf-8') + # Radar sweep + assert 'cadre-radar-sweep' in body, "Round 4 radar sweep keyframe missing" + assert 'cadre-radar' in body + # 6 data packets en flight (voice.wav répété 6×) + assert body.count('voice.wav') >= 6, "Round 4 must have 6 'voice.wav' packets in flight" + assert 'cadre-packet' in body + assert 'offset-path' in body, "Round 4 packets must use offset-path for bezier flight" + # Typewriter + assert 'hudTyped' in body, "Round 4 typewriter state missing" + assert 'typeLine' in body, "Round 4 typewriter function missing" + # REGS cascade reveal + assert 'revealRegsCascade' in body or 'revealedRegs' in body + assert 'cadre-reg-item' in body + # Verdict pulse glow + scan line + assert 'cadre-verdict-active' in body + assert 'cadre-scan-line' in body + # ⚠ remplacé par SVG (le mot WARNING ne doit plus apparaître entouré de ⚠ dans l'eyebrow Cadre) + assert '⚠ CADRE RÉGLEMENTAIRE QUÉBEC' not in body, "⚠ literal must be replaced by SVG" + + +def test_round4_cycle_cinematic_features(): + """Round 4 — Cycle (Trois options) cinematic upgrades. + + - Phase reveal séquentiel + price counter Col 1 (priceHumain 0→315) + - Stamp 'NON CONFORME' impact (cycle-stamp keyframes) + - Col 3 checkmark draw (cycle-check-svg stroke-dashoffset) + - Col 3 glow vert (cycle-conforme-glow) + - Badge 'Loi 25 conforme' pulse (cycle-conforme-badge) + - Section 'Économies annuelles · 25 utilisateurs' avec 3 counters (sav1/sav2/sav3) + - Connecting line dash flow (cycle-line-flow) + - eyebrow ⚠ remplacé par SVG + """ + client = app.test_client() + body = client.get('/').data.decode('utf-8') + # Price counter + assert 'priceHumain' in body, "Round 4 price counter state missing" + assert 'countTo' in body, "Round 4 counter helper missing" + # Stamp impact + flash + assert 'cycle-stamp' in body + assert 'cycle-stamp-impact' in body or '@keyframes cycle-stamp-impact' in body + assert 'cycle-col-flash' in body + # Checkmark draw + assert 'cycle-check-svg' in body + # Conforme badge + glow + assert 'cycle-conforme-badge' in body + assert 'cycle-conforme-glow' in body + assert 'Loi 25 conforme' in body or 'Loi 25 conforme' in body + # Économies annuelles avec 3 counters + assert 'Économies annuelles' in body + assert 'sav1' in body and 'sav2' in body and 'sav3' in body + assert 'cycle-savings-card' in body + # Live dot "Réunion en cours" + assert 'cycle-live-dot' in body + # Dash flow + assert 'cycle-line-flow' in body + # ⚠ remplacé + assert '⚠ CADRE RÉGLEMENTAIRE

    ' not in body, "Cycle eyebrow ⚠ literal must be replaced by SVG" + + +def test_round4_no_emoji_warning_triangle(): + """Round 4 — aucun ⚠ littéral ne doit subsister dans le HTML rendu.""" + client = app.test_client() + body = client.get('/').data.decode('utf-8') + # Le caractère ⚠ U+26A0 ne doit plus apparaître nulle part dans landing.html + # (sauf dans les keyframes/CSS comments qui sont absents) + assert '⚠' not in body, "Round 4 must not contain literal ⚠ character anywhere on landing" + + +def test_round4_reduced_motion_disables_all_cinematics(): + """Round 4 — prefers-reduced-motion media query must disable ALL new cinematics.""" + client = app.test_client() + body = client.get('/').data.decode('utf-8') + # Le bloc @media (prefers-reduced-motion: reduce) doit explicitement neutraliser : + # - radar (cadre-radar) + # - packets (cadre-packet) + # - typewriter (typeLine has reduced-motion shortcut) + # - stamp (cycle-stamp) + # - conforme glow + badge + assert 'prefers-reduced-motion: reduce' in body + assert 'cadre-radar' in body and 'cadre-packet' in body + # Round 4 reduced-motion must disable cycle-stamp + cycle-conforme-badge animations + assert 'cycle-stamp' in body and 'cycle-conforme-badge' in body + # Counter helper has explicit reduced-motion guard + assert "matchMedia('(prefers-reduced-motion: reduce)')" in body + + def test_round2_no_external_js_libs_added(): """Round 2 must NOT add Framer Motion / GSAP / canvas-confetti / etc.""" client = app.test_client()