Compare commits

..
26 Commits
Author SHA1 Message Date
bruno fc44efabbb Changelog 2.82.3 : thème /recherche, CORS console (PR #49/#51)
CI / lint (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 1s
2026-10-28 16:22:51 -04:00
bruno 3ed09cbc0f Bump 2.82.3
CI / lint (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / security (push) Failing after 0s
CI / e2e (push) Skipped
2026-10-28 16:22:48 -04:00
bruno a4f27bdbe7 Bump 2.82.3
CI / lint (push) Failing after 1s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 16:22:44 -04:00
bruno 05d1903045 Bump 2.82.3
CI / lint (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 1s
2026-10-28 16:22:39 -04:00
bruno b5b4a00b75 Bump 2.82.3
CI / lint (push) Failing after 0s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 16:22:35 -04:00
bruno 97f997840c Bump 2.82.3
CI / lint (push) Failing after 2s
CI / test (push) Skipped
CI / build (push) Skipped
CI / security (push) Failing after 0s
CI / e2e (push) Skipped
2026-10-28 16:22:31 -04:00
bruno 474851b3bb Bump 2.82.3
CI / lint (push) Failing after 2s
CI / security (push) Failing after 2s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 16:22:26 -04:00
bruno 4dfa8fd74c Merge pull request 'CORS recherche : autoriser la console Spark (agent prod+dev)' (#51) from federation-recherche-console into main
CI / lint (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 1s
2026-10-28 16:17:07 -04:00
bruno e709310e70 Merge pull request 'recherche: appliquer le thème ObsiGate à la page /recherche (défaut 4)' (#49) from obsigate-recherche-theme into main
CI / lint (push) Failing after 0s
CI / security (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 16:17:05 -04:00
bruno 4d0c8ad934 Tests CORS recherche: origines agent autorisees, tiers refuse
CI / lint (pull_request) Failing after 2s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / security (pull_request) Failing after 1s
CI / e2e (pull_request) Skipped
2026-10-28 16:12:15 -04:00
bruno b609e8913c CORS recherche: autoriser les origines agent (console Spark) prod+dev 2026-10-28 16:12:10 -04:00
bruno f10e3d12e6 recherche: tests du theme de la page (defaut 4)
CI / lint (pull_request) Failing after 1s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
CI / security (pull_request) Successful in 1m57s
2026-10-28 15:59:18 -04:00
bruno 78c514c78b recherche: decliner la barre et les accents selon le theme (defaut 4) 2026-10-28 15:59:13 -04:00
bruno d20ae93b40 recherche: appliquer le theme ObsiGate (defaut 4) 2026-10-28 15:59:08 -04:00
bruno a885153a68 Bump ObsiGate 2.82.2 : changelog (alignement recherche console #68)
CI / lint (push) Failing after 1s
CI / security (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 15:07:30 -04:00
bruno 3edeb88c92 Bump ObsiGate 2.82.2 (alignement recherche console #68)
CI / lint (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / security (push) Failing after 0s
CI / e2e (push) Skipped
2026-10-28 15:07:26 -04:00
bruno 677b174f09 Bump ObsiGate 2.82.2 (alignement recherche console #68)
CI / lint (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 0s
2026-10-28 15:07:23 -04:00
bruno ab3d0798c7 Bump ObsiGate 2.82.2 (alignement recherche console #68)
CI / lint (push) Failing after 1s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 15:07:20 -04:00
bruno 332983c4d6 Bump ObsiGate 2.82.2 (alignement recherche console #68)
CI / lint (push) Failing after 1s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 15:07:17 -04:00
bruno 2c2f23dc57 Bump ObsiGate 2.82.2 (alignement recherche console #68)
CI / lint (push) Failing after 1s
CI / security (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 15:07:14 -04:00
bruno 097accc44c Bump ObsiGate 2.82.2 (alignement recherche console #68)
CI / lint (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 1s
2026-10-28 15:07:10 -04:00
bruno 7ea5614093 Merge pull request 'recherche : onglet Console Spark sur GET /api/v1/recherche + barre /recherche re-vendangée (alignement)' (#48) from align-recherche-console into main
CI / lint (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 0s
2026-10-28 15:04:32 -04:00
bruno e4da00cf17 recherche: jeton URL_AGENT + connect-src agent pour la page /recherche
CI / lint (pull_request) Failing after 3s
CI / security (pull_request) Failing after 2s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-28 14:45:13 -04:00
bruno 03e8abf8c8 recherche: re-vendoring barre /recherche (5e onglet Console Spark) 2026-10-28 14:45:09 -04:00
bruno 6750cdf81a recherche: tests alignement console /api/v1/recherche 2026-10-28 14:45:06 -04:00
bruno ffc70e10b9 recherche: onglet Console Spark sur GET /api/v1/recherche (alignement) 2026-10-28 14:45:01 -04:00
15 changed files with 494 additions and 63 deletions
+10 -1
View File
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> **En cours de développement** : les changements à venir sont listés dans la section
> [Unreleased](#unreleased). La dernière version livrée est **2.82.1**.
> [Unreleased](#unreleased). La dernière version livrée est **2.82.3**.
---
@@ -14,6 +14,15 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.82.3] — 2026-10-28
- **Recherche (PR #49)** : la page `/recherche` applique désormais le thème ObsiGate.
- **CORS recherche (PR #51)** : la recherche ObsiGate autorise désormais la console Spark (agent prod+dev) en inter-origines.
## [2.82.2] — 2026-10-28
- **Recherche (#68)** : l'onglet Console Spark de `/recherche` appelle désormais `GET /api/v1/recherche` (au lieu de `/api/search`) et reconstruit les liens profonds console (`?section=…&objet=…`, `disc=`, `activite=`) — les résultats console réels s'affichent enfin dans la page hôte ; barre de `/recherche` re-vendangée depuis `index.html` (5e onglet Console Spark) et `connect-src` CSP étendu à l'origine agent.
## [2.82.1] — 2026-10-28
### Modifié
+1 -1
View File
@@ -1 +1 @@
2.82.1
2.82.3
+12 -7
View File
@@ -966,12 +966,15 @@ if FRONTEND_DIR.exists():
frontend/recherche.html est servi avec les jetons {{URL_*}}
remplacés par urls_publiques() (même contrat que le fragment
de barre) et le nonce CSP frais ; la réponse pose sa propre
CSP (préservée par SecurityHeadersMiddleware, qui ne la pose
que si elle est absente) dont le connect-src autorise les
origines sœurs — sinon le navigateur bloquerait les appels
sœurs avant même CORS. La CSP par défaut (same-origin) reste
inchangée pour toutes les autres pages.
de barre, jeton {{URL_AGENT}} inclus pour l'onglet Console
Spark de la barre re-vendangée) et le nonce CSP frais ; la
réponse pose sa propre CSP (préservée par
SecurityHeadersMiddleware, qui ne la pose que si elle est
absente) dont le connect-src autorise les origines sœurs ET
l'origine de l'agent — sinon le navigateur bloquerait les
appels sœurs et l'appel console (onglet Console Spark,
GET /api/v1/recherche) avant même CORS. La CSP par défaut
(same-origin) reste inchangée pour toutes les autres pages.
"""
import json as _json
@@ -989,6 +992,7 @@ if FRONTEND_DIR.exists():
("{{URL_FLOWDECK}}", "flowdeck"),
("{{URL_TIKTAK}}", "tiktak"),
("{{URL_KANBANTE}}", "kanbante"),
("{{URL_AGENT}}", "agent"),
):
html = html.replace(jeton, urls.get(cle, ""))
script = (
@@ -1003,7 +1007,8 @@ if FRONTEND_DIR.exists():
html = inject_csp_nonce(html, request.state.csp_nonce)
soeurs = " ".join(sorted({
origin_of(urls.get(cle, "")) or ""
for cle in ("obsigate", "flowdeck", "tiktak", "kanbante")
for cle in ("obsigate", "flowdeck", "tiktak", "kanbante",
"agent")
} - {""}))
csp = (
"default-src 'self'; "
+11 -2
View File
@@ -9,7 +9,10 @@ Ce module n'autorise donc que :
- le chemin ``/api/search`` (jamais les autres routes, jamais les
écritures) ;
- les origines sœurs : les origines officielles **prod ET dev,
listées explicitement** (``PROD_ORIGINS | DEV_ORIGINS``), **plus**
listées explicitement** (``PROD_ORIGINS | DEV_ORIGINS``), **plus
les origines de la console Spark** (``AGENT_ORIGINS`` : agent prod
ET dev — fédération de la recherche depuis la console,
2026-10-28), **plus**
celles de ``backend.draco_config`` (``urls_publiques()`` :
explicite > dérivation par l'hôte de l'agent > défaut officiel)
— jamais ``*``. Correctif 2026-10-28 (fusion #68/#69, matrice
@@ -49,6 +52,12 @@ DEV_ORIGINS = frozenset({
"https://kanbante-dev.dracodev.net",
})
AGENT_ORIGINS = frozenset({
"https://agent.dracodev.net",
"https://agent-dev.dracodev.net",
})
# Clés de draco_config.urls_publiques() qui sont des applications sœurs.
_SOEUR_KEYS = ("obsigate", "flowdeck", "tiktak", "kanbante")
@@ -113,7 +122,7 @@ def sister_origins(resolved: dict | None = None, request_host: str | None = None
# l'environnement de la requête, désormais inclus d'office).
# ``resolved`` n'ajoute que des origines de configuration sans
# jamais retirer une origine officielle.
origins |= PROD_ORIGINS | DEV_ORIGINS
origins |= PROD_ORIGINS | DEV_ORIGINS | AGENT_ORIGINS
return frozenset(origins)
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.82.1"
version = "2.82.3"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.82.1"
version = "2.82.3"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.82.1",
"version": "2.82.3",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+1 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.82.1 | **Dernière mise à jour :** 2026-10-28
> **Version :** 2.82.3 | **Dernière mise à jour :** 2026-10-28
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées.
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
+128 -31
View File
@@ -46,23 +46,35 @@
* -> { groups: [{ full_name, rows: [{ issue, column }] }] }
* Lien profond : {base}board/<full_name> (la carte n'a pas d'URL propre
* adressable — même limite que la page /search native de Kanbante).
* - Console Spark : AUCUN endpoint de recherche mergé au moment de
* cette livraison (agent-commun : seule la PR #12 est ouverte, autre
* programme — ce dépôt n'est pas touché ici). Le code tente quand
* même l'appel conventionnel GET {base-console}/api/search?q= (même
* convention que les autres hôtes ; chemin À CONFIRMER avec le
* travailleur console dès que son endpoint atterrit) et, en cas
* d'échec, de HTTP non-OK ou de format non reconnu, affiche
* honnêtement « Console Spark : recherche indisponible pour
* l'instant » — jamais de faux résultats, jamais de faux 0. La base
* console vient, dans l'ordre, de data-base-console rendu par l'hôte,
* de window.DRACO_URLS.agent (ObsiGate) / window.DRACO_AGENT_URL
* (TikTak), de la dérivation -dev si l'hôte courant est -dev, du
* href d'un onglet de barre data-app="console"/"spark" s'il existe,
* sinon du défaut prod agent.dracodev.net. L'onglet console n'est
* interrogé que lorsqu'il est affiché (ou quand une recherche a lieu
* pendant qu'il est affiché) : « Tout » ne le contient pas, pour ne
* pas polluer la vue groupée d'un message d'indisponibilité.
* - Console Spark : GET {base-console}/api/v1/recherche?q=&limite=
* (agent-commun, authentifié par le cookie SSO draco_session) ->
* { q, domain, limite_par_type, resultats[], par_type,
* non_cherchables[] } ; chaque résultat porte type, id, titre,
* extrait, statut, domaine, section, objet et lien (relatif à
* l'origine console). q < 2 caractères -> 422. Types cherchables :
* discussion, mission, approbation, livrable, activite,
* planification, objectif, identite (aucun secret). Liens profonds
* (préfixés par la base console) : discussion
* /?section=conversation&disc=<id>, mission
* /?section=missions&objet=<id>, approbation
* /?section=approbations&objet=<id>, objectif
* /?section=objectifs&objet=<id>, activite /?activite=<id> ;
* livrable /?section=livrables, planification /?section=planifie
* et identite /?section=identites ouvrent la vue sans sélection
* d'objet. L'onglet appelle avec limite=20 (plafond par type) car
* il affiche la liste complète, comme les onglets d'applications.
* En cas d'échec, de HTTP non-OK ou de format non reconnu, la
* section affiche honnêtement « Console Spark : recherche
* indisponible pour l'instant » — jamais de faux résultats,
* jamais de faux 0. La base console vient, dans l'ordre, de
* data-base-console rendu par l'hôte, de window.DRACO_URLS.agent
* (ObsiGate) / window.DRACO_AGENT_URL (TikTak), de la dérivation
* -dev si l'hôte courant est -dev, du href d'un onglet de barre
* data-app="console"/"spark"/"agent" s'il existe, sinon du défaut
* prod agent.dracodev.net. L'onglet console n'est interrogé que
* lorsqu'il est affiché (ou quand une recherche a lieu pendant
* qu'il est affiché) : « Tout » ne le contient pas, pour ne pas
* polluer la vue groupée d'un message d'indisponibilité.
*
* Résolution des bases sœurs (même ordre que la barre v0.1.6) :
* 1. data-base-* rendus par l'hôte (son propre résolveur : explicite >
@@ -76,12 +88,14 @@
* credentials:"include". Le navigateur envoie alors les cookies de
* chaque origine, dont le cookie SSO partagé draco_session
* (Domain=.dracodev.net), accepté par les 4 API de recherche
* (ObsiGate et TikTak nativement, Flowdeck et Kanbante via #68).
* Chaque sœur n'autorise en CORS que les origines sœurs
* (GET sur l'endpoint de recherche uniquement, pas de "*").
* (ObsiGate et TikTak nativement, Flowdeck et Kanbante via #68)
* et par GET /api/v1/recherche de la console (identity() accepte
* draco_session). Chaque sœur n'autorise en CORS que les origines
* sœurs (GET sur l'endpoint de recherche uniquement, pas de "*") ;
* côté console, un middleware borné à /api/v1/recherche autorise
* de même les 8 origines hôtes prod ET dev, avec credentials.
* Une sœur injoignable / non autorisée ne casse pas les autres :
* sa section affiche l'erreur + un lien vers sa recherche native.
* Le correctif CORS lui-même est chez un autre travailleur.
*/
(function (global) {
"use strict";
@@ -104,7 +118,8 @@
};
var PROD_AGENT = "https://agent.dracodev.net/";
var DEV_AGENT = "https://agent-dev.dracodev.net/";
var CONSOLE_SEARCH_PATH = "api/search";
var CONSOLE_SEARCH_PATH = "api/v1/recherche";
var CONSOLE_SEARCH_LIMIT = 20;
var APPS = [
{ key: "obsigate", label: "ObsiGate" },
{ key: "flowdeck", label: "Flowdeck" },
@@ -112,6 +127,24 @@
{ key: "kanbante", label: "Kanbante" },
];
var CONSOLE_APP = { key: "console", label: "Console Spark" };
var CONSOLE_TYPE_LABELS = {
discussion: "Discussion",
mission: "Mission",
approbation: "Approbation",
livrable: "Livrable",
activite: "Activité",
planification: "Planification",
objectif: "Objectif",
identite: "Identité",
};
/* Lien profond console (relatif à l'origine console) quand le
* résultat n'en porte pas : même règle que _lien_recherche
* d'agent-commun (section + objet, activite en section=null). */
var CONSOLE_VIEW_LINKS = {
livrable: "/?section=livrables",
planification: "/?section=planifie",
identite: "/?section=identites",
};
var TABS = ["all", "obsigate", "flowdeck", "tiktak", "kanbante", "console"];
var COLUMN_LABELS = {
backlog: "Backlog",
@@ -182,7 +215,8 @@
if (!u && isDevHost(opts.host)) u = DEV_AGENT;
if (!u)
u = cleanUrl(
opts.tabHrefs && (opts.tabHrefs.console || opts.tabHrefs.spark)
opts.tabHrefs &&
(opts.tabHrefs.console || opts.tabHrefs.spark || opts.tabHrefs.agent)
);
if (!u) u = PROD_AGENT;
return withSlash(u);
@@ -336,19 +370,71 @@
return [];
}
/* Normalisation console, défensive : accepte results/discussions/
* items. Un élément sans URL exploitable (http(s), /relatif, #ancre)
* garde url=null et sera rendu en <span>, pas en faux lien profond.
* Retourne { recognized, items } : recognized=false -> l'appelant
* affiche « indisponible », jamais un vide mensonger. */
/* Normalisation console : format réel GET /api/v1/recherche,
* { resultats: [{ type, id, titre, extrait, statut, domaine,
* section, objet, lien }] }. Le lien relatif est préfixé par la
* base console ; sans lien, il est reconstruit depuis type/id
* (même règle que la console) ; sans lien reconstructible,
* url=null et le résultat est rendu en <span>, pas en faux lien.
* Les anciennes formes tolérées (results/discussions/items, avec
* url/href/link) restent acceptées en repli, pour ne pas casser
* un déploiement console antérieur. Retourne { recognized,
* items } : recognized=false -> l'appelant affiche
* « indisponible », jamais un vide mensonger. */
function consoleLinkFor(r) {
var lien = r.lien || "";
if (lien) return lien;
var id = r.id != null ? String(r.id) : "";
if (r.type === "discussion")
return "/?section=conversation&disc=" + encodeURIComponent(id);
if (r.type === "mission")
return "/?section=missions&objet=" + encodeURIComponent(id);
if (r.type === "approbation")
return "/?section=approbations&objet=" + encodeURIComponent(id);
if (r.type === "objectif")
return "/?section=objectifs&objet=" + encodeURIComponent(id);
if (r.type === "activite") return "/?activite=" + encodeURIComponent(id);
if (CONSOLE_VIEW_LINKS[r.type]) return CONSOLE_VIEW_LINKS[r.type];
if (r.section)
return (
"/?section=" +
encodeURIComponent(r.section) +
(r.objet != null
? "&objet=" + encodeURIComponent(String(r.objet))
: "")
);
return "";
}
function normalizeConsole(data, base) {
data = data || {};
if (Array.isArray(data.resultats)) {
var items = data.resultats.map(function (r) {
r = r || {};
var rel = consoleLinkFor(r);
var url = null;
if (/^https?:\/\//.test(rel)) url = rel;
else if (rel.charAt(0) === "/" || rel.charAt(0) === "#")
url = base + String(rel).replace(/^\//, "");
var label = CONSOLE_TYPE_LABELS[r.type] || r.type || "Console";
var context = label;
if (r.statut) context += " · " + r.statut;
if (r.domaine) context += " · " + r.domaine;
return {
title: r.titre || r.title || "Sans titre",
snippet: snippetOf(r.extrait || r.snippet || ""),
context: context,
url: url,
};
});
return { recognized: true, items: items };
}
var list = null;
if (Array.isArray(data.results)) list = data.results;
else if (Array.isArray(data.discussions)) list = data.discussions;
else if (Array.isArray(data.items)) list = data.items;
if (!list) return { recognized: false, items: [] };
var items = list.map(function (r) {
var legacy = list.map(function (r) {
r = r || {};
var raw = r.url || r.href || r.link || "";
var url = null;
@@ -361,7 +447,7 @@
url: url,
};
});
return { recognized: true, items: items };
return { recognized: true, items: legacy };
}
/* Recherche native de l'app (repli quand la section est en erreur). */
@@ -408,6 +494,9 @@
PROD_AGENT: PROD_AGENT,
DEV_AGENT: DEV_AGENT,
CONSOLE_SEARCH_PATH: CONSOLE_SEARCH_PATH,
CONSOLE_SEARCH_LIMIT: CONSOLE_SEARCH_LIMIT,
CONSOLE_TYPE_LABELS: CONSOLE_TYPE_LABELS,
consoleLinkFor: consoleLinkFor,
PER_APP_LIMIT: PER_APP_LIMIT,
FULL_LIMIT: FULL_LIMIT,
};
@@ -671,12 +760,20 @@
};
renderSection(sec, states.console, true);
renderTabs();
var url = consoleBase + CONSOLE_SEARCH_PATH + "?q=" + encodeURIComponent(q);
var url =
consoleBase +
CONSOLE_SEARCH_PATH +
"?q=" +
encodeURIComponent(q) +
"&limite=" +
CONSOLE_SEARCH_LIMIT;
fetchSection("console", url, function (err, data) {
if (myRun !== runId) return;
if (err) {
var msg;
if (err.name === "AbortError") msg = "délai dépassé (8 s)";
else if (err.status === 422)
msg = "au moins 2 caractères requis";
else if (err.status) msg = "HTTP " + err.status;
else msg = "injoignable (CORS ou réseau)";
states.console = {
+29 -8
View File
@@ -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; }
+80 -7
View File
@@ -14,15 +14,57 @@
retomberait sur les défauts prod (les jetons sont ignorés par
le JS) — jamais d'URL inventée.
La barre ci-dessous est une copie statique du snippet figé de
index.html (commons v0.1.6) : les onglets sont des liens réels,
le bouton central replace le curseur dans la boîte (recherche.js,
clic data-act="palette"). Les menus, le panneau Spark, les
alertes et le domaine restent des comportements de la SPA et ne
sont pas rejoués ici — l'autre travailleur (onglet Console Spark
de la barre) devra reporter son ajout dans cette copie au
re-vendoring, comme pour tout hôte.
index.html (commons v0.1.6 + chantier 5 fusion #69/#71, sans
bump) : les onglets sont des liens réels, le bouton central
replace le curseur dans la boîte (recherche.js,
clic data-act="palette"). Re-vendoring alignement recherche :
5e onglet Console Spark (data-app="agent", jeton {{URL_AGENT}}
remplacé par la route /recherche comme les 4 autres) et
icône SVG du bouton Alertes, repris d'index.html. Les menus,
le panneau Spark, les alertes et le domaine restent des
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 <html> 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).
<script>
/* Thème ObsiGate, étape 1 (synchrone, avant les feuilles) : poser
data-theme = mode persisté AVANT la première peinture, sinon la
page flasherait en sombre puis basculerait. Sources lues :
'obsigate-theme-mode' (SPA modulaire, js/ui.js initTheme) puis,
à défaut, 'obsigate-theme' quand il vaut déjà un mode (valeur
historique de l'app monolithique) ; toute autre valeur — clé de
thème comme 'dracula', mode inconnu, stockage indisponible —
retombe sur 'dark', le défaut SPA. La palette complète (clé)
est appliquée à l'étape 2 en fin de page. */
(function () {
var MODES = ['dark', 'light', 'high-contrast', 'sepia'];
var mode = null;
try { mode = window.localStorage.getItem('obsigate-theme-mode'); } catch (e) { mode = null; }
if (MODES.indexOf(mode) === -1) {
try {
var legacy = window.localStorage.getItem('obsigate-theme');
if (MODES.indexOf(legacy) !== -1) { mode = legacy; }
} catch (e) { /* stockage indisponible : repli sombre */ }
}
if (MODES.indexOf(mode) === -1) { mode = 'dark'; }
document.documentElement.setAttribute('data-theme', mode);
})();
</script>
<link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="/static/draco-barre.css">
<link rel="stylesheet" href="/static/recherche.css">
@@ -53,6 +95,10 @@
<span class="draco-apptab-txt"><strong>Kanbante</strong><small>Kanban</small></span>
<span class="draco-badge" data-badge="kanbante" hidden>0</span>
</a>
<a class="draco-apptab" data-app="agent" href="{{URL_AGENT}}">
<span class="draco-apptab-ico" style="--app:#c2437e">✦</span>
<span class="draco-apptab-txt"><strong>Console Spark</strong><small>Agent commun</small></span>
</a>
</nav>
</div>
<div class="draco-bar-zone draco-bar-centre">
@@ -70,6 +116,7 @@
<button type="button" data-act="domaine" data-dom="perso">Perso</button>
</div>
<button type="button" class="draco-alertes" data-act="alertes" aria-label="Alertes unifiées">
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
<span class="draco-alertes-txt">Alertes</span>
<span class="draco-badge" data-badge="alertes" hidden>0</span>
</button>
@@ -142,5 +189,31 @@
</div>
</div>
<script src="/static/js/recherche.js"></script>
<script type="module">
/* Thème ObsiGate, étape 2 (différée) : appliquer la palette
complète via le moteur de thèmes de la SPA (themes.js), seule
source de vérité des 16 thèmes, des modes high-contrast/sepia
et des thèmes personnalisés — aucune table recopiée ici.
initThemes() relit localStorage et pose les variables inline
sur :root + data-theme = mode, comme au démarrage de la SPA
(js/sync.js init). Garde historique : si 'obsigate-theme'
vaut encore un mode ('dark'/'light', app monolithique) sans
'obsigate-theme-mode', on garde le mode de l'étape 1 —
initThemes() y verrait une clé inconnue et retomberait sur
'spark'/'dark'. Échec de chargement silencieux : la page
garde alors le mode de l'étape 1, recherche.js n'est pas
touché (fichier partagé avec les 3 autres hôtes). */
(function () {
var saved = null;
var savedMode = null;
try { saved = window.localStorage.getItem('obsigate-theme'); } catch (e) {}
try { savedMode = window.localStorage.getItem('obsigate-theme-mode'); } catch (e) {}
if (savedMode === null && (saved === 'dark' || saved === 'light')) { return; }
import('/static/js/themes.js').then(function (mod) {
if (mod && typeof mod.initThemes === 'function') { mod.initThemes(); }
}).catch(function () {});
})();
</script>
</body>
</html>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.82.1",
"version": "2.82.3",
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
"main": "patch.js",
"directories": {
+35
View File
@@ -141,4 +141,39 @@ ok(rp.resolveConsoleBase({ host: "tiktak-dev.dracodev.net" }) === "https://agent
ok(rp.resolveConsoleBase({ host: "x.example", tabHrefs: { console: "https://console-perso.example/" } }) === "https://console-perso.example/", "console : href onglet barre");
ok(rp.resolveConsoleBase({}) === "https://agent.dracodev.net/", "console : défaut prod");
/* ---- Alignement console (agent-commun /api/v1/recherche) ---- */
let rc;
ok(rp.CONSOLE_SEARCH_PATH === "api/v1/recherche", "console : chemin reel /api/v1/recherche");
ok(rp.CONSOLE_SEARCH_LIMIT === 20, "console : limite 20 (plafond par type)");
rc = rp.normalizeConsole({ resultats: [
{ type: "discussion", id: "c42", titre: "Conv", extrait: "ex", statut: null, domaine: "pro", section: "conversation", objet: "c42", lien: "/?section=conversation&disc=c42" },
{ type: "mission", id: "t1", titre: "Mission", extrait: "ex", statut: "paused_budget", domaine: "pro", section: "missions", objet: "t1", lien: "/?section=missions&objet=t1" },
{ type: "approbation", id: "a9", titre: "publier", extrait: "ex", statut: "pending", domaine: null, section: "approbations", objet: "a9", lien: "/?section=approbations&objet=a9" },
{ type: "objectif", id: "o3", titre: "Obj", extrait: "ex", statut: "actif", domaine: "pro", section: "objectifs", objet: "o3", lien: "/?section=objectifs&objet=o3" },
{ type: "activite", id: "a7", titre: "Act", extrait: "ex", statut: "terminee", domaine: "pro", section: null, objet: "a7", lien: "/?activite=a7" },
{ type: "livrable", id: "l1", titre: "Liv", extrait: "ex", statut: "terminee", domaine: "pro", section: "livrables", objet: null, lien: "/?section=livrables" },
{ type: "planification", id: "p1", titre: "Plan", extrait: "ex", statut: "active", domaine: "pro", section: "planifie", objet: null, lien: "/?section=planifie" },
{ type: "identite", id: "i1", titre: "Id", extrait: "ex", statut: "active", domaine: null, section: "identites", objet: null, lien: "/?section=identites" },
] }, "https://agent-dev.dracodev.net/");
ok(rc.recognized === true && rc.items.length === 8, "console : 8 resultats reconnus");
ok(rc.items[0].url === "https://agent-dev.dracodev.net/?section=conversation&disc=c42", "console : lien profond discussion");
ok(rc.items[1].url === "https://agent-dev.dracodev.net/?section=missions&objet=t1" && rc.items[1].context === "Mission \u00b7 paused_budget \u00b7 pro", "console : lien mission + contexte statut/domaine");
ok(rc.items[2].url === "https://agent-dev.dracodev.net/?section=approbations&objet=a9", "console : lien profond approbation");
ok(rc.items[3].url === "https://agent-dev.dracodev.net/?section=objectifs&objet=o3", "console : lien profond objectif");
ok(rc.items[4].url === "https://agent-dev.dracodev.net/?activite=a7", "console : lien activite sans section");
ok(rc.items[5].url === "https://agent-dev.dracodev.net/?section=livrables", "console : livrable = vue seulement");
ok(rc.items[6].url === "https://agent-dev.dracodev.net/?section=planifie", "console : planification = vue seulement");
ok(rc.items[7].url === "https://agent-dev.dracodev.net/?section=identites", "console : identite = vue seulement");
rc = rp.normalizeConsole({ resultats: [
{ type: "mission", id: "t9", titre: "Sans lien", extrait: "", statut: null, domaine: null, section: "missions", objet: "t9" },
{ type: "activite", id: "a9", titre: "Act sans lien", extrait: "", statut: null, domaine: null, section: null, objet: "a9" },
] }, "https://agent.dracodev.net/");
ok(rc.items[0].url === "https://agent.dracodev.net/?section=missions&objet=t9", "console : lien reconstruit sans lien servi");
ok(rc.items[1].url === "https://agent.dracodev.net/?activite=a9", "console : lien activite reconstruit");
rc = rp.normalizeConsole({ resultats: [] }, "https://agent.dracodev.net/");
ok(rc.recognized === true && rc.items.length === 0, "console : resultats vide reconnu (vide honnete, pas indisponible)");
ok(rp.consoleLinkFor({ type: "discussion", id: "c1" }) === "/?section=conversation&disc=c1", "consoleLinkFor discussion");
ok(rp.resolveConsoleBase({ host: "x.example", tabHrefs: { agent: "https://agent-perso.example/" } }) === "https://agent-perso.example/", "console : href onglet barre agent accepte");
console.log(`recherche.test.mjs : ${n} assertions OK`);
+16 -1
View File
@@ -38,7 +38,7 @@ def test_origin_of():
def test_sister_origins_dev():
# Correctif 2026-10-28 : 8 origines officielles, prod ET dev,
# Correctif 2026-10-28 : 10 origines officielles (8 sœurs + 2 agent), prod ET dev,
# quelle que soit la résolution.
assert sister_origins(RESOLVED_DEV) == {
"https://og.dracodev.net",
@@ -49,9 +49,24 @@ def test_sister_origins_dev():
"https://flowdeck-dev.dracodev.net",
"https://tiktak-dev.dracodev.net",
"https://kanbante-dev.dracodev.net",
"https://agent.dracodev.net",
"https://agent-dev.dracodev.net",
}
def test_cors_headers_agent_console_autorise():
# Fédération depuis la console (2026-10-28) : les origines agent
# prod ET dev passent, avec credentials ; un tiers reste refusé.
for origine in ("https://agent.dracodev.net",
"https://agent-dev.dracodev.net"):
headers = cors_headers_for(origine, RESOLVED_DEV)
assert headers is not None
assert headers["Access-Control-Allow-Origin"] == origine
assert headers["Access-Control-Allow-Credentials"] == "true"
assert cors_headers_for("https://agent.dracodev.net.evil.example",
RESOLVED_DEV) is None
def test_sister_origins_ajoute_env_de_la_requete():
# Correctif 2026-10-28 : configuration muette, quel que soit
# l'hôte de la requête, prod ET dev passent — l'hôte ne
+167
View File
@@ -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"<script(?: type=\"module\")?>(.*?)</script>", 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("<script>") < html.index('<link rel="stylesheet"')
# Repli sans JS : sombre, le défaut SPA.
assert '<html lang="fr" data-theme="dark">' in html
def test_etape1_ignore_les_cles_de_theme():
"""Une clé ('dracula', 'spark', …) ne doit jamais devenir un mode."""
etape1 = _inline_scripts(_html())[0]
# La validation passe par une liste fermée de modes, jamais par la
# valeur brute de 'obsigate-theme'.
assert "MODES.indexOf(mode)" in etape1
assert "MODES.indexOf(legacy)" in etape1
assert "'dark'" in etape1 # repli final
def test_etape2_importe_themes_js_avec_garde_historique():
html = _html()
modules = re.findall(
r'<script type="module">(.*?)</script>', 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(
'<script type="module">'
)
# themes.js existe bien à l'URL importée et exporte initThemes.
themes = (FRONTEND / "js" / "themes.js").read_text(encoding="utf-8")
assert "initThemes" in themes
assert "AVAILABLE_MODES = ['dark', 'light', 'high-contrast', 'sepia']" in themes
def test_nonce_csp_pour_les_deux_etapes():
"""Comme /recherche injecte window.DRACO_URLS avant </head>, les
trois scripts inline doivent tous recevoir le nonce frais."""
from backend.csp import inject_csp_nonce
html = _html().replace(
"</head>",
'<script>window.DRACO_URLS = {};</script></head>',
1,
)
out = inject_csp_nonce(html, NONCE)
assert not re.findall(r"<script>", out), "script classique sans nonce"
assert not re.findall(
r'<script type="module">', out
), "module inline sans nonce"
assert out.count(f'nonce="{NONCE}"') == 3
def test_barre_decline_tous_les_modes():
"""draco-barre.css ne connaît que [data-theme="dark"] : les autres
modes sont déclinés dans recherche.css (fichier de la page), sans
toucher au fichier commun vendangé."""
css = _css()
barre = (FRONTEND / "draco-barre.css").read_text(encoding="utf-8")
assert '[data-theme="light"] .draco-bar' not in barre
for mode in ("light", "high-contrast", "sepia"):
assert f'[data-theme="{mode}"] .draco-bar' in css, mode
# Valeurs reprises des générateurs themes.js (_genHighContrast /
# _genSepia) : fond noir / sépia, encre blanche / brune.
assert "--surface: #0a0a0a" in css and "--ink: #ffffff" in css
assert "--surface: #eee3c8" in css and "--ink: #3d2e1a" in css
assert "color-scheme: dark" in css and "color-scheme: light" in css
def test_champs_onglets_resultats_suivent_accent_et_bordure():
css = _css()
assert "border: 1px solid var(--border, #888)" in css
assert "background: var(--accent, #3b82f6)" in css
assert "border-bottom-color: var(--accent, #3b82f6)" in css
assert "outline: 2px solid var(--accent, #3b82f6)" in css
def test_invariants_page_intacts():
"""Barre (5e onglet Console Spark), onglets, focus, 'Tout voir',
repli typographique : rien ne régresse."""
html = _html()
for app in ("obsigate", "flowdeck", "tiktak", "kanbante", "agent"):
assert f'data-app="{app}"' in html, app
assert "{{URL_AGENT}}" in html
for tab in ("all", "obsigate", "flowdeck", "tiktak", "kanbante", "console"):
assert f'data-rp-tab="{tab}"' in html, tab
assert "Tout voir" in html
assert 'id="rp-q"' in html and "autofocus" in html
assert '<script src="/static/js/recherche.js"></script>' 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")