diff --git a/tests/test_recherche_theme.py b/tests/test_recherche_theme.py
new file mode 100644
index 0000000..4b3e8c1
--- /dev/null
+++ b/tests/test_recherche_theme.py
@@ -0,0 +1,167 @@
+"""Tests — thème de la page /recherche ObsiGate (défaut 4).
+
+Mécanisme audité (ne pas réinventer) : le thème ObsiGate n'est PAS une
+préférence serveur. La SPA le lit dans localStorage au démarrage —
+``frontend/js/ui.js`` ``initTheme`` (mode seul, pour la première
+peinture) puis ``frontend/js/sync.js`` ``init`` -> ``themes.js``
+``initThemes`` (clé + mode, palette complète en variables inline sur
+``:root``). Clés : ``obsigate-theme`` (clé, ex. ``'spark'``,
+``'dracula'``, ou historiquement un mode) et ``obsigate-theme-mode``
+(``'dark'`` | ``'light'`` | ``'high-contrast'`` | ``'sepia'``).
+
+La page /recherche, servie hors SPA avec un ``data-theme="dark"`` en
+dur et aucun script de thème, restait donc sombre quel que soit le
+réglage. Correctif verrouillé ici, en deux étapes dans
+``frontend/recherche.html`` (aucun fichier partagé touché :
+``js/recherche.js`` reste identique sur les 4 hôtes, ``draco-barre.css``
+reste le fichier commun vendangé, ``backend/main.py`` et sa CSP
+strictes sont inchangés) :
+
+- étape 1 : script synchrone en tête, avant les feuilles — pose
+ ``data-theme`` = mode persisté avant la première peinture ;
+- étape 2 : module différé — ``initThemes()`` de ``themes.js`` pose la
+ palette complète (16 thèmes, modes générés, thèmes personnalisés).
+
+``frontend/recherche.css`` décline la barre pour les modes que
+``draco-barre.css`` ne connaît pas et branche bordures/accents sur
+``var(--border)`` / ``var(--accent)`` (anciennes valeurs en repli).
+"""
+
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+FRONTEND = ROOT / "frontend"
+MODES = ["dark", "light", "high-contrast", "sepia"]
+NONCE = "TESTNONCE1234567890"
+
+
+def _html() -> str:
+ return (FRONTEND / "recherche.html").read_text(encoding="utf-8")
+
+
+def _css() -> str:
+ return (FRONTEND / "recherche.css").read_text(encoding="utf-8")
+
+
+def _inline_scripts(html: str) -> list[str]:
+ """Contenu des scripts inline exécutables (sans src)."""
+ return re.findall(
+ r"", html, re.DOTALL
+ )
+
+
+def test_etape1_avant_les_feuilles_et_mode_valide():
+ html = _html()
+ scripts = _inline_scripts(html)
+ assert scripts, "aucun script inline dans recherche.html"
+ etape1 = scripts[0]
+ assert "obsigate-theme-mode" in etape1
+ assert "obsigate-theme" in etape1
+ assert "setAttribute('data-theme'" in etape1 or 'setAttribute("data-theme"' in etape1
+ for mode in MODES:
+ assert f"'{mode}'" in etape1, f"mode {mode} absent de l'étape 1"
+ # Le script étape 1 précède la première feuille de style.
+ assert html.index("', html, re.DOTALL
+ )
+ assert len(modules) == 1, "exactement un module inline attendu"
+ etape2 = modules[0]
+ assert "import('/static/js/themes.js')" in etape2
+ assert "initThemes" in etape2
+ # Garde historique : 'obsigate-theme' valant encore un mode sans
+ # 'obsigate-theme-mode' -> on garde le mode de l'étape 1.
+ assert "savedMode === null" in etape2
+ assert "saved === 'dark'" in etape2 and "saved === 'light'" in etape2
+ # Échec de chargement silencieux : la page garde le mode étape 1.
+ assert ".catch(function () {})" in etape2
+ # Le module est après recherche.js (différé : il tourne avant
+ # l'init DOMContentLoaded de recherche.js, sans le modifier).
+ assert html.index("/static/js/recherche.js") < html.index(
+ '',
+ 1,
+ )
+ out = inject_csp_nonce(html, NONCE)
+ assert not re.findall(r"' in html
+ # Aucune police externe ajoutée : le repli système imposé par la
+ # CSP stricte de /recherche (font-src 'self' data:) est inchangé.
+ assert "fonts.googleapis.com" not in html
+ assert "fonts.gstatic.com" not in html
+ # Ordre des feuilles inchangé (recherche.css après la barre).
+ assert html.index("draco-barre.css") < html.index("recherche.css")