From 1604245f9403cd7852f4df05b69b350b1fac00c6 Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 13:50:40 -0400 Subject: [PATCH 1/4] Recherche partout (#68, chantier 2+3) : onglets, Tout voir sur place, focus, mise en page --- frontend/recherche.html | 181 ++++++++++++++++++++++++++++++---------- 1 file changed, 136 insertions(+), 45 deletions(-) diff --git a/frontend/recherche.html b/frontend/recherche.html index 1a7a6fc..98c780e 100644 --- a/frontend/recherche.html +++ b/frontend/recherche.html @@ -1,55 +1,146 @@ - + Rechercher partout — ObsiGate - - - - - - -
-

Rechercher partout

-

Notes ObsiGate, pages Flowdeck, tâches TikTak, cartes Kanbante — dans l'environnement courant (dev reste dev). « / » ou Ctrl+K pour rechercher, ↑/↓ puis Entrée pour ouvrir un résultat.

-
- + 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. + Sidebar statique (navigation de l'app) : les vaults, tags et + récents dynamiques vivent dans la SPA, pas sur cette page. --> + + + + + +
+ -
-

ObsiGate — notes

Tout voir
-

Tapez une recherche ci-dessus.

-
-
-

Flowdeck — pages

Tout voir
-

Tapez une recherche ci-dessus.

-
-
-

TikTak — tâches

Tout voir
-

Tapez une recherche ci-dessus.

-
-
-

Kanbante — cartes

Tout voir
-

Tapez une recherche ci-dessus.

-
-
+
+ +
+
+
+ Domaine + + + +
+ + + +
+ +
+
+
+ + Retour à l'application +
+
+
+ +
+
+
+

Rechercher partout

+

Notes ObsiGate, pages Flowdeck, tâches TikTak, cartes Kanbante — dans l'environnement courant (dev reste dev). « / » ou Ctrl+K pour rechercher, ↑/↓ puis Entrée pour ouvrir un résultat. « Tout voir » bascule vers l'onglet de l'application, sans quitter la page.

+
+ +
+ +
+

ObsiGate — notes

Tout voir
+

Tapez une recherche ci-dessus.

+
+
+

Flowdeck — pages

Tout voir
+

Tapez une recherche ci-dessus.

+
+
+

TikTak — tâches

Tout voir
+

Tapez une recherche ci-dessus.

+
+
+

Kanbante — cartes

Tout voir
+

Tapez une recherche ci-dessus.

+
+
+

Console Spark — discussions

Tout voir
+

Tapez une recherche ci-dessus.

+
+
+
+
+
+
-- 2.54.0 From 6eb679f234742e5453b211226d2996404183187a Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 13:50:42 -0400 Subject: [PATCH 2/4] Recherche partout (#68, chantier 2+3) : onglets, Tout voir sur place, focus, mise en page --- frontend/js/recherche.js | 608 ++++++++++++++++++++++++++++++++++----- 1 file changed, 543 insertions(+), 65 deletions(-) diff --git a/frontend/js/recherche.js b/frontend/js/recherche.js index 3f396bd..6a6f442 100644 --- a/frontend/js/recherche.js +++ b/frontend/js/recherche.js @@ -1,11 +1,39 @@ -/* Recherche partout — page de résultats unifiée (fusion-trois-apps #68). +/* Recherche partout — page de résultats unifiée (fusion-trois-apps #68, + * chantier 2+3 : onglets, « Tout voir », focus). * * Vanilla, sans dépendance. Un seul fichier vendangé tel quel par chaque * hôte (ObsiGate, Flowdeck, TikTak, Kanbante) : la configuration vient * des attributs data-* de #recherche, rendus côté serveur par l'hôte. * + * Présentation en onglets : Tout, ObsiGate, Flowdeck, TikTak, Kanbante, + * Console Spark. « Tout » = les 4 sections groupées, chaque liste + * tronquée à PER_APP_LIMIT ; un onglet application = tous les résultats + * de cette application (aucune troncature côté client — la requête + * ObsiGate part avec limit=FULL_LIMIT, les autres API renvoient leur + * jeu complet). « Tout voir » d'une section ne quitte plus la page : + * il bascule vers l'onglet de l'application (son href reste une URL + * partageable ?q=&tab= au cas où le JS ne tournerait pas). + * L'onglet actif vit dans l'URL (?tab=, absent = Tout) : partageable, + * et le retour arrière navigateur le restaure (popstate). + * + * Compteurs : chaque onglet affiche le nombre TOTAL de résultats de + * son application (pas le nombre tronqué affiché dans « Tout »), + * « … » pendant le chargement, « 0 » pour un vide honnête, rien pour + * une section en erreur/indisponible (pas de faux 0). L'onglet Tout + * affiche « … » tant qu'une section charge, sinon la somme des totaux + * connus (une section en erreur compte pour 0) ; il est vide si les + * 4 sections sont en erreur ou si aucune recherche n'a encore eu lieu. + * Une section injoignable ne casse jamais les autres. + * + * Focus : l'attribut autofocus seul ne survit pas au chargement des + * surcouches (barre, palette, app) — la page appelle donc focus() + * explicitement en fin d'init (plus un rappel rAF/timeout et un + * rappel pageshow). Cliquer la barre « Rechercher partout » (bouton + * data-act="palette") sur cette même page replace aussi le curseur + * dans la boîte ; « / » et Ctrl+K / ⌘K font de même, comme avant. + * * Contrat d'API (audité, rien d'inventé) : - * - ObsiGate : GET {base}/api/search?q=&vault=all&limit=8 + * - ObsiGate : GET {base}/api/search?q=&vault=all&limit= * -> { results: [{ vault, path, title, snippet }] } * Lien profond vérifié : {base}#file={vault}:{path} (Viewer.openFile). * - Flowdeck : GET {base}/api/search?q= @@ -14,10 +42,27 @@ * - TikTak : GET {base}/api/search?q= * -> { tasks: [{ id, title, description, status, due_at }], ... } * Lien profond vérifié : {base}search?q=&task= (page /search). - * - Kanbante : GET {base}/api/search?q= (NOUVEAU, cette livraison) + * - Kanbante : GET {base}/api/search?q= * -> { groups: [{ full_name, rows: [{ issue, column }] }] } * Lien profond : {base}board/ (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é. * * 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 > @@ -31,11 +76,12 @@ * 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 cette - * livraison). Chaque sœur n'autorise en CORS que les origines sœurs + * (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 "*"). * 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"; @@ -56,12 +102,17 @@ tiktak: "https://tiktak-dev.dracodev.net/", kanbante: "https://kanbante-dev.dracodev.net/", }; + var PROD_AGENT = "https://agent.dracodev.net/"; + var DEV_AGENT = "https://agent-dev.dracodev.net/"; + var CONSOLE_SEARCH_PATH = "api/search"; var APPS = [ { key: "obsigate", label: "ObsiGate" }, { key: "flowdeck", label: "Flowdeck" }, { key: "tiktak", label: "TikTak" }, { key: "kanbante", label: "Kanbante" }, ]; + var CONSOLE_APP = { key: "console", label: "Console Spark" }; + var TABS = ["all", "obsigate", "flowdeck", "tiktak", "kanbante", "console"]; var COLUMN_LABELS = { backlog: "Backlog", in_progress: "En cours", @@ -73,6 +124,7 @@ var TIMEOUT_MS = 8000; var DEBOUNCE_MS = 250; var PER_APP_LIMIT = 8; + var FULL_LIMIT = 100; function esc(s) { return String(s == null ? "" : s) @@ -120,9 +172,91 @@ return out; } - function searchPath(app, q) { + /* Base console : explicite > URL agent injectée par l'hôte > + * dérivation -dev > href d'onglet de barre console/spark > prod. + * opts : { host, explicit, agentUrl, tabHrefs:{} } */ + function resolveConsoleBase(opts) { + opts = opts || {}; + var u = cleanUrl(opts.explicit); + if (!u) u = cleanUrl(opts.agentUrl); + if (!u && isDevHost(opts.host)) u = DEV_AGENT; + if (!u) + u = cleanUrl( + opts.tabHrefs && (opts.tabHrefs.console || opts.tabHrefs.spark) + ); + if (!u) u = PROD_AGENT; + return withSlash(u); + } + + /* Onglet valide dans l'URL, sinon "all". */ + function parseTab(v) { + return TABS.indexOf(v) !== -1 ? v : "all"; + } + + /* Sections visibles pour un onglet. « Tout » = les 4 sections + * groupées (vue #68) ; la console n'y figure pas (voir en-tête). */ + function visibleKeys(tab) { + tab = parseTab(tab); + if (tab === "all") return APPS.map(function (a) { return a.key; }); + return [tab]; + } + + /* Troncature d'affichage : liste complète dans l'onglet propre à + * l'application, PER_APP_LIMIT partout ailleurs (vue « Tout »). */ + function pageItems(items, full) { + items = items || []; + return full ? items : items.slice(0, PER_APP_LIMIT); + } + + /* ?q=&tab= partageable. tab "all" omis (c'est le défaut). */ + function buildSearchUrl(q, tab) { + var parts = []; + if (q) parts.push("q=" + encodeURIComponent(q)); + tab = parseTab(tab); + if (tab !== "all") parts.push("tab=" + encodeURIComponent(tab)); + return parts.length ? "?" + parts.join("&") : ""; + } + + /* Libellés des compteurs d'onglets à partir des états : + * states : { key: { status, total } }. + * Chargement -> "…", ok/vide -> total ("0" honnête), + * erreur/indisponible/inactif -> "" (pas de faux 0). + * Tout : "…" tant qu'une section charge, sinon somme des totaux + * connus, "" si les 4 sections sont en erreur/inactives. */ + function tabCounts(states) { + states = states || {}; + var out = {}; + function labelFor(key) { + var st = states[key] || {}; + if (st.status === "loading") return "…"; + if (st.status === "ok" || st.status === "empty") return String(st.total || 0); + return ""; + } + var allLoading = false; + var allTotal = 0; + var allKnown = false; + APPS.forEach(function (app) { + var st = states[app.key] || {}; + out[app.key] = labelFor(app.key); + if (st.status === "loading") allLoading = true; + if (st.status === "ok" || st.status === "empty") { + allKnown = true; + allTotal += st.total || 0; + } + }); + out.console = labelFor("console"); + out.all = allLoading ? "…" : allKnown ? String(allTotal) : ""; + return out; + } + + function searchPath(app, q, full) { if (app === "obsigate") { - return "api/search?q=" + encodeURIComponent(q) + "&vault=all&limit=" + PER_APP_LIMIT; + return ( + "api/search?q=" + + encodeURIComponent(q) + + "&vault=all&limit=" + + (full ? FULL_LIMIT : PER_APP_LIMIT) + ); } return "api/search?q=" + encodeURIComponent(q); } @@ -133,11 +267,17 @@ } /* Normalise la réponse d'une app en [{ title, snippet, context, url }]. - * base : base résolue de l'app (avec / final). q : requête (liens TikTak). */ - function normalize(app, data, base, q) { + * base : base résolue de l'app (avec / final). q : requête (liens TikTak). + * full=true : aucune troncature (stockage complet) ; sinon PER_APP_LIMIT + * (comportement historique, conservé par défaut pour les tests). */ + function normalize(app, data, base, q, full) { data = data || {}; + var limit = full ? null : PER_APP_LIMIT; + function cut(arr) { + return limit == null ? arr : arr.slice(0, limit); + } if (app === "obsigate") { - return (data.results || []).slice(0, PER_APP_LIMIT).map(function (r) { + return cut(data.results || []).map(function (r) { return { title: r.title || r.path || "Sans titre", snippet: snippetOf(r.snippet || ""), @@ -153,7 +293,7 @@ } if (app === "flowdeck") { var items = (data.pages || []).concat(data.collections || []); - return items.slice(0, PER_APP_LIMIT).map(function (r) { + return cut(items).map(function (r) { var rel = String(r.url || "").replace(/^\//, ""); return { title: r.title || "Sans titre", @@ -164,7 +304,7 @@ }); } if (app === "tiktak") { - return (data.tasks || []).slice(0, PER_APP_LIMIT).map(function (t) { + return cut(data.tasks || []).map(function (t) { var ctx = t.status === "done" ? "Terminée" : "Active"; if (t.due_at) ctx += " · échéance " + String(t.due_at).slice(0, 10); return { @@ -182,7 +322,7 @@ rows.push({ group: g.full_name, row: row }); }); }); - return rows.slice(0, PER_APP_LIMIT).map(function (x) { + return cut(rows).map(function (x) { var issue = x.row.issue || {}; var col = COLUMN_LABELS[x.row.column] || x.row.column || ""; return { @@ -196,6 +336,34 @@ 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 , pas en faux lien profond. + * Retourne { recognized, items } : recognized=false -> l'appelant + * affiche « indisponible », jamais un vide mensonger. */ + function normalizeConsole(data, base) { + data = data || {}; + 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) { + r = r || {}; + var raw = r.url || r.href || r.link || ""; + var url = null; + if (/^https?:\/\//.test(raw)) url = raw; + else if (raw.charAt(0) === "/" || raw.charAt(0) === "#") url = base + String(raw).replace(/^\//, ""); + return { + title: r.title || r.name || r.subject || "Sans titre", + snippet: snippetOf(r.snippet || r.excerpt || r.preview || r.body || ""), + context: r.subtitle || r.source || r.type || "", + url: url, + }; + }); + return { recognized: true, items: items }; + } + /* Recherche native de l'app (repli quand la section est en erreur). */ function nativeSearchUrl(app, base, q) { if (app === "tiktak" || app === "kanbante") { @@ -214,6 +382,7 @@ } function appLabel(key) { + if (key === "console") return CONSOLE_APP.label; for (var i = 0; i < APPS.length; i++) { if (APPS[i].key === key) return APPS[i].label; } @@ -224,11 +393,23 @@ esc: esc, isDevHost: isDevHost, resolveBases: resolveBases, + resolveConsoleBase: resolveConsoleBase, + parseTab: parseTab, + visibleKeys: visibleKeys, + pageItems: pageItems, + buildSearchUrl: buildSearchUrl, + tabCounts: tabCounts, searchPath: searchPath, normalize: normalize, + normalizeConsole: normalizeConsole, nativeSearchUrl: nativeSearchUrl, PROD: PROD, DEV: DEV, + PROD_AGENT: PROD_AGENT, + DEV_AGENT: DEV_AGENT, + CONSOLE_SEARCH_PATH: CONSOLE_SEARCH_PATH, + PER_APP_LIMIT: PER_APP_LIMIT, + FULL_LIMIT: FULL_LIMIT, }; if (typeof module !== "undefined" && module.exports) module.exports = api; global.RecherchePartout = api; @@ -268,16 +449,24 @@ }); } - function renderSection(section, state) { - /* state : { status: "loading"|"empty"|"error"|"ok", items, message, q, base, app } */ + function renderSection(section, state, full) { + /* state : { status: "idle"|"loading"|"empty"|"error"|"ok"|"unavailable", + * items (complets), message, q, base, app }. + * full=true (onglet propre à l'app) : tout afficher ; sinon + * PER_APP_LIMIT (vue « Tout »). Le compteur affiche le TOTAL. */ var body = section.querySelector(".rp-body"); var count = section.querySelector(".rp-count"); var toutVoir = section.querySelector(".rp-all"); - toutVoir.href = nativeSearchUrl(state.app, state.base, state.q); - toutVoir.textContent = "Tout voir"; - toutVoir.title = nativeSearchLabel(state.app); + if (toutVoir) { + try { + toutVoir.href = + global.location.pathname + buildSearchUrl(state.q, state.app); + } catch (e) {} + toutVoir.textContent = "Tout voir"; + toutVoir.title = "Afficher tous les résultats de " + appLabel(state.app); + } if (state.status === "loading") { - count.textContent = ""; + count.textContent = "…"; body.innerHTML = '

Recherche…

'; return; } @@ -293,19 +482,31 @@ "

"; return; } - if (state.status === "empty") { + if (state.status === "unavailable") { count.textContent = ""; + body.innerHTML = + '

Console Spark : recherche indisponible pour l\'instant' + + (state.message ? " (" + esc(state.message) + ")" : "") + + ".

"; + return; + } + if (state.status === "idle") { + count.textContent = ""; + body.innerHTML = + '

Tapez une recherche ci-dessus.

'; + return; + } + if (state.status === "empty") { + count.textContent = "0"; body.innerHTML = '

Aucun résultat pour « ' + esc(state.q) + " ».

"; return; } + var shown = pageItems(state.items, full); count.textContent = String(state.items.length); - body.innerHTML = state.items + body.innerHTML = shown .map(function (item) { - return ( - '' + + var inner = '' + esc(item.title) + "" + @@ -314,9 +515,13 @@ : "") + (item.snippet ? '' + esc(item.snippet) + "" - : "") + - "" - ); + : ""); + if (item.url) { + return ( + '' + inner + "" + ); + } + return '' + inner + ""; }) .join(""); } @@ -349,40 +554,205 @@ barUrls: barUrls, tabHrefs: tabHrefs, }); + var consoleBase = resolveConsoleBase({ + host: global.location.hostname, + explicit: root.getAttribute("data-base-console"), + agentUrl: + (global.DRACO_URLS && global.DRACO_URLS.agent) || global.DRACO_AGENT_URL, + tabHrefs: tabHrefs, + }); + var allKeys = APPS.map(function (a) { return a.key; }).concat(["console"]); var sections = {}; - APPS.forEach(function (app) { - var el = document.querySelector('[data-rp-section="' + app.key + '"]'); - if (el) sections[app.key] = el; + allKeys.forEach(function (key) { + var el = document.querySelector('[data-rp-section="' + key + '"]'); + if (el) sections[key] = el; + }); + var tabButtons = {}; + document.querySelectorAll("[data-rp-tab]").forEach(function (btn) { + tabButtons[btn.getAttribute("data-rp-tab")] = btn; + }); + var tablist = document.querySelector(".rp-tabs"); + + /* États par application (+ console). total = items.length. */ + var states = {}; + allKeys.forEach(function (key) { + states[key] = { + status: "idle", + items: [], + message: "", + q: "", + base: key === "console" ? consoleBase : bases[key], + app: key, + }; }); + var activeTab = "all"; var currentQ = ""; var runId = 0; + var consoleFetchedQ = null; var debounceTimer = null; + function statesForCounts() { + var out = {}; + allKeys.forEach(function (key) { + out[key] = { status: states[key].status, total: states[key].items.length }; + }); + return out; + } + + function renderTabs() { + var counts = tabCounts(statesForCounts()); + Object.keys(tabButtons).forEach(function (key) { + var btn = tabButtons[key]; + var selected = key === activeTab; + btn.classList.toggle("active", selected); + btn.setAttribute("aria-selected", selected ? "true" : "false"); + btn.tabIndex = selected ? 0 : -1; + var span = btn.querySelector(".rp-tab-count"); + if (span) span.textContent = counts[key] || ""; + }); + } + + /* Bascule d'onglet : visibilité des sections, re-rendu (plein dans + * l'onglet propre à l'app, tronqué dans « Tout »), compteurs. + * opts : { push (historique), focusTab }. */ + function applyTab(tab, opts) { + opts = opts || {}; + activeTab = parseTab(tab); + try { + root.setAttribute("data-rp-tab", activeTab); + } catch (e) {} + var visible = visibleKeys(activeTab); + allKeys.forEach(function (key) { + var sec = sections[key]; + if (!sec) return; + var show = visible.indexOf(key) !== -1; + sec.hidden = !show; + if (show) renderSection(sec, states[key], activeTab === key); + var tv = sec.querySelector(".rp-all"); + if (tv) tv.hidden = activeTab === key; + }); + renderTabs(); + if (opts.push) { + try { + global.history.pushState( + null, + "", + global.location.pathname + buildSearchUrl(currentQ, activeTab) + ); + } catch (e) {} + } + if (opts.focusTab && tabButtons[activeTab]) { + try { + tabButtons[activeTab].focus(); + } catch (e) {} + } + } + + function setTab(tab, opts) { + applyTab(tab, opts); + /* La console n'est interrogée qu'une fois affichée. */ + if (parseTab(tab) === "console" && currentQ && consoleFetchedQ !== currentQ) { + fetchConsole(currentQ, runId); + } + } + + function fetchConsole(q, myRun) { + var sec = sections.console; + if (!sec) return; + consoleFetchedQ = q; + states.console = { + status: "loading", + items: [], + message: "", + q: q, + base: consoleBase, + app: "console", + }; + renderSection(sec, states.console, true); + renderTabs(); + var url = consoleBase + CONSOLE_SEARCH_PATH + "?q=" + encodeURIComponent(q); + 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) msg = "HTTP " + err.status; + else msg = "injoignable (CORS ou réseau)"; + states.console = { + status: "unavailable", + items: [], + message: msg, + q: q, + base: consoleBase, + app: "console", + }; + } else { + var norm = normalizeConsole(data, consoleBase); + if (!norm.recognized) { + states.console = { + status: "unavailable", + items: [], + message: "format inattendu", + q: q, + base: consoleBase, + app: "console", + }; + } else { + states.console = { + status: norm.items.length ? "ok" : "empty", + items: norm.items, + message: "", + q: q, + base: consoleBase, + app: "console", + }; + } + } + renderSection(sec, states.console, true); + renderTabs(); + }); + } + function runSearch(q) { currentQ = q; var myRun = ++runId; if (!q) { - APPS.forEach(function (app) { - var sec = sections[app.key]; + allKeys.forEach(function (key) { + var sec = sections[key]; if (!sec) return; - sec.querySelector(".rp-body").innerHTML = - '

Tapez une recherche ci-dessus.

'; - sec.querySelector(".rp-count").textContent = ""; - sec.querySelector(".rp-all").href = nativeSearchUrl(app.key, bases[app.key], ""); + states[key] = { + status: "idle", + items: [], + message: "", + q: "", + base: key === "console" ? consoleBase : bases[key], + app: key, + }; + renderSection(sec, states[key], activeTab === key); }); + consoleFetchedQ = null; + renderTabs(); return; } APPS.forEach(function (app) { var sec = sections[app.key]; if (!sec) return; - renderSection(sec, { status: "loading", app: app.key, base: bases[app.key], q: q }); + states[app.key] = { + status: "loading", + items: [], + message: "", + q: q, + base: bases[app.key], + app: app.key, + }; + renderSection(sec, states[app.key], activeTab === app.key); var url; if (app.key === hostApp) { /* Même origine : pas de CORS, pas de CSP cross-origin. */ - url = "/" + searchPath(app.key, q); + url = "/" + searchPath(app.key, q, true); } else { - url = bases[app.key] + searchPath(app.key, q); + url = bases[app.key] + searchPath(app.key, q, true); } fetchSection(app.key, url, function (err, data) { if (myRun !== runId) return; @@ -393,25 +763,45 @@ msg = "Connexion requise sur " + app.label; else if (err.status) msg = "Indisponible (HTTP " + err.status + ")"; else msg = "Injoignable depuis cet hôte (CORS ou réseau)"; - renderSection(sec, { + states[app.key] = { status: "error", + items: [], message: msg, - app: app.key, - base: bases[app.key], q: q, - }); - return; + base: bases[app.key], + app: app.key, + }; + } else { + var items = normalize(app.key, data, bases[app.key], q, true); + states[app.key] = { + status: items.length ? "ok" : "empty", + items: items, + message: "", + q: q, + base: bases[app.key], + app: app.key, + }; } - var items = normalize(app.key, data, bases[app.key], q); - renderSection(sec, { - status: items.length ? "ok" : "empty", - items: items, - app: app.key, - base: bases[app.key], - q: q, - }); + renderSection(sec, states[app.key], activeTab === app.key); + renderTabs(); }); }); + /* Console : seulement si son onglet est affiché ; sinon son + * compteur reste vide (pas de faux 0) jusqu'à son activation. */ + if (activeTab === "console") { + fetchConsole(q, myRun); + } else { + states.console = { + status: "idle", + items: [], + message: "", + q: q, + base: consoleBase, + app: "console", + }; + consoleFetchedQ = null; + renderTabs(); + } } function scheduleSearch(q, immediate) { @@ -427,13 +817,33 @@ function syncUrl(q) { try { - var url = new URL(global.location.href); - if (q) url.searchParams.set("q", q); - else url.searchParams.delete("q"); - global.history.replaceState(null, "", url.toString()); + global.history.replaceState( + null, + "", + global.location.pathname + buildSearchUrl(q, activeTab) + ); } catch (e) {} } + function focusSearch() { + try { + input.focus({ preventScroll: true }); + } catch (e) { + try { + input.focus(); + } catch (e2) {} + } + } + + function visibleLinks() { + return Array.prototype.slice + .call(document.querySelectorAll("[data-rp-link]")) + .filter(function (a) { + var s = a.closest ? a.closest("[data-rp-section]") : null; + return !s || !s.hidden; + }); + } + input.addEventListener("input", function () { var q = input.value.trim(); syncUrl(q); @@ -441,7 +851,7 @@ }); input.addEventListener("keydown", function (e) { if (e.key === "Enter") { - var first = document.querySelector("[data-rp-link]"); + var first = visibleLinks()[0]; if (first) { e.preventDefault(); first.click(); @@ -471,6 +881,9 @@ input.select(); return; } + /* Sur un onglet, ↑/↓ ne déplacent pas les résultats : les + * flèches ←/→ du tablist s'en chargent (voir ci-dessous). */ + if (target && target.closest && target.closest(".rp-tab")) return; if (!typing && (e.key === "ArrowDown" || e.key === "ArrowUp")) { moveFocus(e.key === "ArrowDown" ? 1 : -1, e); } @@ -478,8 +891,62 @@ true ); + /* « Tout voir » reste sur la page : bascule vers l'onglet. */ + root.addEventListener("click", function (e) { + var all = e.target && e.target.closest ? e.target.closest(".rp-all") : null; + if (!all) return; + var sec = all.closest ? all.closest("[data-rp-section]") : null; + if (!sec) return; + e.preventDefault(); + setTab(sec.getAttribute("data-rp-section"), { push: true, focusTab: true }); + }); + + /* Clic sur la barre « Rechercher partout » (bouton + * data-act="palette") pendant qu'on est déjà sur la page : + * replacer le curseur dans la boîte au lieu de recharger. */ + document.addEventListener("click", function (e) { + var p = e.target && e.target.closest ? e.target.closest('[data-act="palette"]') : null; + if (!p) return; + e.preventDefault(); + focusSearch(); + try { + input.select(); + } catch (err) {} + }); + + /* Onglets : clic + clavier (←/→, Début/Fin). */ + Object.keys(tabButtons).forEach(function (key) { + tabButtons[key].addEventListener("click", function () { + setTab(key, { push: true }); + }); + }); + if (tablist) { + tablist.addEventListener("keydown", function (e) { + var order = TABS; + var idx = order.indexOf(activeTab); + var next = null; + if (e.key === "ArrowRight") next = order[(idx + 1) % order.length]; + else if (e.key === "ArrowLeft") + next = order[(idx - 1 + order.length) % order.length]; + else if (e.key === "Home") next = order[0]; + else if (e.key === "End") next = order[order.length - 1]; + if (!next) return; + e.preventDefault(); + setTab(next, { push: true, focusTab: true }); + }); + } + + /* Retour arrière navigateur : restaurer q + onglet. */ + global.addEventListener("popstate", function () { + var params = new URLSearchParams(global.location.search); + var q = (params.get("q") || "").trim(); + input.value = q; + applyTab(params.get("tab")); + runSearch(q); + }); + function moveFocus(delta, e) { - var links = Array.prototype.slice.call(document.querySelectorAll("[data-rp-link]")); + var links = visibleLinks(); if (!links.length) return; var active = document.activeElement; var idx = links.indexOf(active); @@ -492,12 +959,23 @@ var params = new URLSearchParams(global.location.search); var initialQ = (params.get("q") || "").trim(); - if (initialQ) { - input.value = initialQ; - runSearch(initialQ); - } else { - runSearch(""); - } + var initialTab = parseTab(params.get("tab")); + if (initialQ) input.value = initialQ; + applyTab(initialTab); + runSearch(initialQ); + + /* Focus explicite (voir en-tête) : l'attribut autofocus seul ne + * survit pas au chargement des surcouches. */ + focusSearch(); + try { + if (global.requestAnimationFrame) global.requestAnimationFrame(focusSearch); + } catch (e) {} + try { + setTimeout(focusSearch, 0); + } catch (e) {} + global.addEventListener("pageshow", function (e) { + if (e.persisted) focusSearch(); + }); } if (document.readyState === "loading") { -- 2.54.0 From 8f5e1f1b0c9978e5d7c4c15741b30038168c9966 Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 13:50:46 -0400 Subject: [PATCH 3/4] Recherche partout (#68, chantier 2+3) : onglets, Tout voir sur place, focus, mise en page --- frontend/recherche.css | 55 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/frontend/recherche.css b/frontend/recherche.css index f8ab9d9..1168700 100644 --- a/frontend/recherche.css +++ b/frontend/recherche.css @@ -33,3 +33,58 @@ .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; } + +/* ---- 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; +} +.rp-tab { + background: none; border: none; border-bottom: 2px solid transparent; + padding: 8px 10px; margin-bottom: -1px; font: inherit; cursor: pointer; + 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-count { + font-size: .8rem; font-weight: 700; background: rgba(127, 127, 127, .25); + border-radius: 999px; padding: 1px 7px; margin-left: 2px; +} +.rp-tab-count:empty { display: none; } +.rp-section[hidden] { display: none; } +.rp-all[hidden] { display: none; } + +/* ---- TikTak : la page sort de la colonne sidebar (chantier 2+3). + * Cause : recherche.html étend base.html sans layout_class, donc + * .rp-page (enfant direct de .layout) tombait en auto-placement dans + * la 2e colonne de la grille (largeur sidebar). La règle ci-dessous, + * gardée par l'ancêtre .layout (TikTak seul), l'étend de la colonne 2 + * à la fin de la grille — juste à droite du rail d'icônes — sur toute + * la largeur disponible, sans toucher aux autres pages (elles ne + * portent pas .rp-page). En mobile (≤760 px, point de bascule TikTak), + * la grille n'a qu'une colonne : la page la prend entièrement. */ +.layout > .rp-page { + grid-column: 2 / -1; max-width: none; min-width: 0; overflow-y: auto; +} +@media (max-width: 760px) { + .layout > .rp-page { grid-column: 1 / -1; } +} + +/* ---- ObsiGate : navigation statique de la page autonome (chantier + * 2+3). La page /recherche est servie hors SPA (route avant le + * catch-all) : elle reprend les classes de l'app (style.css : + * .app-container, .header, .main-body, .sidebar, .content-wrapper, + * .content-area) et cette navigation, calée sur les variables de + * l'app, en guise de sidebar. Les vaults dynamiques restent dans + * la SPA (voir le commentaire dans recherche.html). */ +.rp-og-nav { display: flex; flex-direction: column; gap: 4px; padding: 12px; } +.rp-og-nav a { + display: block; padding: 8px 10px; border-radius: 8px; + text-decoration: none; color: inherit; +} +.rp-og-nav a:hover { background: rgba(127, 127, 127, .15); } +.rp-og-nav a[aria-current="page"] { + background: rgba(127, 127, 127, .18); font-weight: 700; +} +.rp-og-note { opacity: .65; font-size: .85rem; padding: 8px 10px; margin: 8px 0 0; } -- 2.54.0 From 53b6f27fd28cb0e9d1d28b2e905f2717a11ce9d8 Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 13:50:50 -0400 Subject: [PATCH 4/4] Recherche partout (#68, chantier 2+3) : onglets, Tout voir sur place, focus, mise en page --- tests/frontend/recherche.test.mjs | 82 +++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/tests/frontend/recherche.test.mjs b/tests/frontend/recherche.test.mjs index 2204084..c9961df 100644 --- a/tests/frontend/recherche.test.mjs +++ b/tests/frontend/recherche.test.mjs @@ -59,4 +59,86 @@ ok( "repli TikTak", ); + +/* ---- Chantier 2+3 : onglets, compteurs, URL, console (logique pure) ---- */ + +/* parseTab */ +ok(rp.parseTab("obsigate") === "obsigate", "onglet valide conservé"); +ok(rp.parseTab("console") === "console", "onglet console valide"); +ok(rp.parseTab("bidon") === "all", "onglet inconnu -> Tout"); +ok(rp.parseTab(undefined) === "all", "onglet absent -> Tout"); + +/* visibleKeys : Tout = les 4 sections groupées, sans la console */ +ok(JSON.stringify(rp.visibleKeys("all")) === JSON.stringify(["obsigate", "flowdeck", "tiktak", "kanbante"]), "Tout = 4 sections, pas la console"); +ok(JSON.stringify(rp.visibleKeys("tiktak")) === JSON.stringify(["tiktak"]), "onglet app = sa seule section"); +ok(JSON.stringify(rp.visibleKeys("console")) === JSON.stringify(["console"]), "onglet console = sa seule section"); +ok(JSON.stringify(rp.visibleKeys("bidon")) === JSON.stringify(["obsigate", "flowdeck", "tiktak", "kanbante"]), "onglet inconnu -> Tout"); + +/* pageItems : troncature Tout (8), complet dans l'onglet de l'app */ +let douze = Array.from({ length: 12 }, (_, i) => ({ title: "T" + i })); +ok(rp.pageItems(douze, false).length === 8, "vue Tout tronquée à 8"); +ok(rp.pageItems(douze, true).length === 12, "onglet app sans troncature"); + +/* tabCounts : "…" en chargement, total sinon, "" en erreur (pas de faux 0) */ +let c = rp.tabCounts({ + obsigate: { status: "loading", total: 0 }, + flowdeck: { status: "ok", total: 3 }, + tiktak: { status: "empty", total: 0 }, + kanbante: { status: "error", total: 0 }, + console: { status: "idle", total: 0 }, +}); +ok(c.obsigate === "…", "compteur … pendant le chargement"); +ok(c.flowdeck === "3" && c.tiktak === "0", "compteur total, vide honnête à 0"); +ok(c.kanbante === "" && c.console === "", "erreur/inactif : pas de faux 0"); +ok(c.all === "…", "Tout = … tant qu'une section charge"); +c = rp.tabCounts({ + obsigate: { status: "ok", total: 5 }, + flowdeck: { status: "ok", total: 2 }, + tiktak: { status: "empty", total: 0 }, + kanbante: { status: "error", total: 0 }, + console: { status: "ok", total: 4 }, +}); +ok(c.all === "7", "Tout = somme des totaux connus"); +ok(c.console === "4", "compteur console"); +c = rp.tabCounts({ + obsigate: { status: "error", total: 0 }, + flowdeck: { status: "error", total: 0 }, + tiktak: { status: "error", total: 0 }, + kanbante: { status: "error", total: 0 }, + console: { status: "idle", total: 0 }, +}); +ok(c.all === "", "Tout vide si les 4 sections sont en erreur"); + +/* buildSearchUrl : état onglet partageable */ +ok(rp.buildSearchUrl("café", "tiktak") === "?q=caf%C3%A9&tab=tiktak", "URL q+onglet"); +ok(rp.buildSearchUrl("x", "all") === "?q=x", "URL Tout sans param tab"); +ok(rp.buildSearchUrl("", "all") === "", "URL vide sans recherche ni onglet"); +ok(rp.buildSearchUrl("", "console") === "?tab=console", "URL onglet seul"); + +/* searchPath complet (onglet) vs tronqué (Tout) */ +ok(rp.searchPath("obsigate", "café", true) === "api/search?q=caf%C3%A9&vault=all&limit=100", "path ObsiGate complet (100)"); +ok(rp.searchPath("obsigate", "café") === "api/search?q=caf%C3%A9&vault=all&limit=8", "path ObsiGate Tout (8)"); + +/* normalize complet vs tronqué */ +let taches12 = Array.from({ length: 12 }, (_, i) => ({ id: i + 1, title: "T" + i, status: "active" })); +ok(rp.normalize("tiktak", { tasks: taches12 }, "https://tiktak-dev.dracodev.net/", "t", true).length === 12, "normalize complet"); +ok(rp.normalize("tiktak", { tasks: taches12 }, "https://tiktak-dev.dracodev.net/", "t").length === 8, "normalize tronqué par défaut"); + +/* normalizeConsole : formes tolérées, pas de faux lien, format inconnu signalé */ +let nc = rp.normalizeConsole({ results: [{ title: "D", url: "https://agent.dracodev.net/console/1", snippet: "s" }] }, "https://agent.dracodev.net/"); +ok(nc.recognized === true && nc.items[0].url === "https://agent.dracodev.net/console/1", "console : URL absolue conservée"); +nc = rp.normalizeConsole({ discussions: [{ name: "Conv", href: "/discussions/9" }] }, "https://agent-dev.dracodev.net/"); +ok(nc.recognized === true && nc.items[0].url === "https://agent-dev.dracodev.net/discussions/9", "console : URL relative préfixée"); +nc = rp.normalizeConsole({ items: [{ title: "Sans lien", id: 7 }] }, "https://agent.dracodev.net/"); +ok(nc.recognized === true && nc.items[0].url === null, "console : sans URL -> pas de faux lien"); +nc = rp.normalizeConsole({ autre: [] }, "https://agent.dracodev.net/"); +ok(nc.recognized === false, "console : format inconnu -> non reconnu (indisponible, pas vide)"); + +/* resolveConsoleBase : explicite > agent injecté > dev > href barre > prod */ +ok(rp.resolveConsoleBase({ explicit: "https://agent-perso.example/", agentUrl: "https://agent-autre.example/" }) === "https://agent-perso.example/", "console : explicite prioritaire"); +ok(rp.resolveConsoleBase({ agentUrl: "https://agent-injecte.example/" }) === "https://agent-injecte.example/", "console : agent injecté par l'hôte"); +ok(rp.resolveConsoleBase({ host: "tiktak-dev.dracodev.net" }) === "https://agent-dev.dracodev.net/", "console : dérivation -dev"); +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"); + console.log(`recherche.test.mjs : ${n} assertions OK`); -- 2.54.0