diff --git a/frontend/recherche.css b/frontend/recherche.css index 1168700..b7e8940 100644 --- a/frontend/recherche.css +++ b/frontend/recherche.css @@ -7,17 +7,17 @@ .rp-form { display: flex; gap: 8px; margin-bottom: 24px; } .rp-input { flex: 1; font: inherit; font-size: 1.05rem; padding: 10px 12px; - border: 1px solid #888; border-radius: 8px; background: inherit; color: inherit; + border: 1px solid var(--border, #888); border-radius: 8px; background: inherit; color: inherit; } -.rp-input:focus { outline: 2px solid #3b82f6; outline-offset: 1px; } +.rp-input:focus { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; } .rp-section { margin-bottom: 28px; } .rp-head { display: flex; align-items: baseline; gap: 10px; - border-bottom: 1px solid #888; padding-bottom: 6px; margin-bottom: 8px; + border-bottom: 1px solid var(--border, #888); padding-bottom: 6px; margin-bottom: 8px; } .rp-head h2 { font-size: 1.05rem; margin: 0; } .rp-count { - font-size: .8rem; font-weight: 700; background: #3b82f6; color: #fff; + font-size: .8rem; font-weight: 700; background: var(--accent, #3b82f6); color: #fff; border-radius: 999px; padding: 1px 8px; min-width: 1.2em; text-align: center; } .rp-count:empty { display: none; } @@ -29,7 +29,7 @@ text-decoration: none; color: inherit; } .rp-item:hover, .rp-item:focus { background: rgba(127, 127, 127, .15); outline: none; } -.rp-item:focus-visible { outline: 2px solid #3b82f6; } +.rp-item:focus-visible { outline: 2px solid var(--accent, #3b82f6); } .rp-title { display: block; font-weight: 600; } .rp-context { display: block; font-size: .8rem; opacity: .65; } .rp-snippet { display: block; font-size: .88rem; opacity: .85; margin-top: 2px; } @@ -37,7 +37,7 @@ /* ---- Onglets (chantier 2+3) : mêmes onglets sur les 4 hôtes. ---- */ .rp-tabs { display: flex; gap: 4px; flex-wrap: wrap; - border-bottom: 1px solid #888; margin-bottom: 20px; + border-bottom: 1px solid var(--border, #888); margin-bottom: 20px; } .rp-tab { background: none; border: none; border-bottom: 2px solid transparent; @@ -45,8 +45,8 @@ color: inherit; opacity: .7; white-space: nowrap; } .rp-tab:hover { opacity: 1; } -.rp-tab.active { opacity: 1; border-bottom-color: #3b82f6; font-weight: 700; } -.rp-tab:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; } +.rp-tab.active { opacity: 1; border-bottom-color: var(--accent, #3b82f6); font-weight: 700; } +.rp-tab:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; } .rp-tab-count { font-size: .8rem; font-weight: 700; background: rgba(127, 127, 127, .25); border-radius: 999px; padding: 1px 7px; margin-left: 2px; @@ -88,3 +88,24 @@ background: rgba(127, 127, 127, .18); font-weight: 700; } .rp-og-note { opacity: .65; font-size: .85rem; padding: 8px 10px; margin: 8px 0 0; } + +/* ---- Thème (défaut 4) : la page suit le réglage ObsiGate. ---- + * L'étape 1 (script synchrone en tête de recherche.html) pose + * data-theme = mode persisté ; l'étape 2 (themes.js) pose la palette + * complète en variables inline sur :root. Les règles .rp-* ci-dessus + * utilisent donc var(--border) / var(--accent) avec les anciennes + * valeurs en repli, et le reste hérite déjà du corps (style.css). + * color-scheme : les contrôles natifs (barres de défilement, champ + * de recherche) suivent le mode au lieu de celui de l'OS. + * Barre : draco-barre.css (fichier commun vendangé, NON modifié ici) + * ne connaît que [data-theme="dark"] ; sans les règles ci-dessous, + * sa media query prefers-color-scheme:dark imposerait une barre + * sombre sur une page claire (OS sombre + mode ObsiGate clair), et + * les modes high-contrast/sepia n'auraient aucune déclinaison. + * Les valeurs reprennent les défauts clairs de draco-barre.css et + * les générateurs _genHighContrast/_genSepia de themes.js. */ +[data-theme="dark"], [data-theme="high-contrast"] { color-scheme: dark; } +[data-theme="light"], [data-theme="sepia"] { color-scheme: light; } +[data-theme="light"] .draco-bar { --bg: #f2f2f0; --surface: #ffffff; --surface-2: #e9eae6; --surface-3: #f6f6f3; --ink: #1b1d1a; --ink-2: #5c615a; --ink-3: #787d75; --line: #dedfd9; } +[data-theme="high-contrast"] .draco-bar { --bg: #000000; --surface: #0a0a0a; --surface-2: #141414; --surface-3: #000000; --ink: #ffffff; --ink-2: #cccccc; --ink-3: #888888; --line: #444444; } +[data-theme="sepia"] .draco-bar { --bg: #f5ecd5; --surface: #eee3c8; --surface-2: #e8dcc0; --surface-3: #f5ecd5; --ink: #3d2e1a; --ink-2: #6b5a3e; --ink-3: #9a8a6a; --line: #c8b890; } diff --git a/frontend/recherche.html b/frontend/recherche.html index 23d5b3a..9a6944b 100644 --- a/frontend/recherche.html +++ b/frontend/recherche.html @@ -25,6 +25,46 @@ comportements de la SPA et ne sont pas rejoués ici. Sidebar statique (navigation de l'app) : les vaults, tags et récents dynamiques vivent dans la SPA, pas sur cette page. --> + Thème (défaut 4) : le thème ObsiGate n'est PAS une préférence + serveur — il vit dans localStorage, lu par la SPA au démarrage + (frontend/js/ui.js initTheme : mode ; frontend/js/sync.js + init() -> themes.js initThemes : clé + mode). Étape 1 ci-dessous + (script synchrone en tête, avant les feuilles) : pose + data-theme = mode persisté (obsigate-theme-mode, repli + obsigate-theme historique quand il valait déjà un mode, sinon + 'dark', le défaut SPA) pour que la première peinture soit déjà + dans le bon mode clair/sombre. Étape 2 en fin de page (module + différé) : initThemes() de themes.js applique la palette + complète — 16 thèmes intégrés, modes high-contrast/sepia + générés, thèmes personnalisés — via variables inline sur :root, + exactement comme la SPA. data-theme="dark" sur n'est + plus que le repli sans JS. Aucun endpoint serveur, aucune + police externe ajoutée (la CSP stricte de /recherche et le + repli typographique système sont inchangés). + @@ -149,5 +189,31 @@ + + 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")