Compare commits

..
Author SHA1 Message Date
bruno 2c69eb71d7 Fusion #69 : alertes unifiées (rebase sur main post-#70)
CI / lint (pull_request) Failing after 1s
CI / security (pull_request) Failing after 1s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-28 12:50:28 -04:00
bruno c50e0e5d0e Fusion #69 : alertes unifiées (rebase sur main post-#70) 2026-10-28 12:50:25 -04:00
bruno f2dcf8970e Fusion #69 : alertes unifiées (rebase sur main post-#70) 2026-10-28 12:50:21 -04:00
bruno e882612249 Fusion #69 : alertes unifiées (rebase sur main post-#70) 2026-10-28 12:50:16 -04:00
bruno 9c9a007419 Fusion #69 : alertes unifiées (rebase sur main post-#70) 2026-10-28 12:50:12 -04:00
bruno 56b70481e8 Merge pull request 'fusion #70 : retirer le bouton trois points, Console Spark au menu marque' (#38) from fusion-70-barre-console-spark into main
CI / lint (push) Failing after 2s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 12:39:59 -04:00
bruno 55351bc997 fusion #70 : vendanger CSS sans menu trois points
CI / lint (pull_request) Failing after 2s
CI / security (pull_request) Failing after 1s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-28 12:02:41 -04:00
bruno 33e4d14b81 fusion #70 : vendanger barre sans trois points, Console Spark, fermeture/focus corriges 2026-10-28 12:02:37 -04:00
bruno 7901c7919d fusion #70 : retirer le bouton trois points et le relais menu de la barre 2026-10-28 12:02:32 -04:00
bruno 3b8402374a Bump 2.81.3 (CHANGELOG.md)
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 10:39:13 -04:00
bruno 6635defb2e Bump 2.81.3 (docs/ROADMAP.md)
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 10:39:09 -04:00
bruno 7fb073d462 Bump 2.81.3 (desktop/Cargo.lock)
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 10:39:06 -04:00
bruno 2e59f8786b Bump 2.81.3 (desktop/Cargo.toml)
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 10:39:03 -04:00
bruno ba6069962a Bump 2.81.3 (desktop/tauri.conf.json)
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 10:39:00 -04:00
bruno 375de59e28 Bump 2.81.3 (package.json)
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 10:38:57 -04:00
bruno 3b84a86b8f Bump 2.81.3 (VERSION)
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 10:38:54 -04:00
bruno a5dac2349d Merge pull request 'barre-apps v0.1.6 : vendange (dérivation -dev, avatar compte) — menu natif préservé' (#37) from barre-apps-v0.1.6-obsigate into main
CI / lint (push) Failing after 3s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 1m59s
2026-10-28 10:34:21 -04:00
bruno da6de52198 barre-apps v0.1.6 : CSS vendange (menus, avatar bouton)
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) Failing after 0s
2026-10-28 10:32:06 -04:00
bruno 87016e5581 barre-apps v0.1.6 : JS vendange (derivation -dev, menus) 2026-10-28 10:32:01 -04:00
bruno e0e45ac048 barre-apps v0.1.6 : fragment inline (avatar bouton compte, aria menu) - menu natif et DRACO_URLS inchanges 2026-10-28 10:31:55 -04:00
bruno 1b3ba04b5b chore(release): v2.81.2 — CHANGELOG
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 09:30:42 -04:00
bruno 5c5180336b chore(release): v2.81.2 — synchroniser docs/ROADMAP.md
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 09:30:40 -04:00
bruno 73bf863e40 chore(release): v2.81.2 — synchroniser desktop/Cargo.lock
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 09:30:37 -04:00
bruno 06d0e354fe chore(release): v2.81.2 — synchroniser desktop/Cargo.toml
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 09:30:32 -04:00
bruno a01fba6602 chore(release): v2.81.2 — synchroniser desktop/tauri.conf.json
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 09:30:29 -04:00
bruno 54ebf2365c chore(release): v2.81.2 — synchroniser package.json
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 09:30:26 -04:00
bruno 24eaa7f9df chore(release): v2.81.2 (panneau Spark / console, PR #35)
CI / security (push) Successful in 2m0s
CI / lint (push) Successful in 3m12s
CI / test (push) Failing after 0s
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 09:30:24 -04:00
bruno 8e3eb3a9d7 Merge pull request 'fix(sidebar): style console, statut dans le titre, composer console, synchro domaine' (#35) from fix/sidebar-console-style-domaine into main
CI / security (push) Failing after 0s
CI / lint (push) Successful in 3m11s
CI / test (push) Failing after 0s
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 09:28:50 -04:00
bruno 01ce8b8ec3 Merge pull request 'chore(release): version 2.81.1 (correctifs CI + icone Secrets)' (#36) from fix/version-2.81.1 into main
CI / lint (push) Failing after 2s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 2m7s
2026-10-28 09:19:57 -04:00
bruno 5dd6809ef0 chore(release): bump version 2.81.1 (correctifs CI + icone Secrets)
CI / security (pull_request) Failing after 1s
CI / lint (pull_request) Successful in 3m17s
CI / test (pull_request) Successful in 5m26s
CI / build (pull_request) Failing after 1s
CI / e2e (pull_request) Skipped
2026-10-28 09:19:32 -04:00
bruno 7b8bc52650 chore(release): bump version 2.81.1 (correctifs CI + icone Secrets) 2026-10-28 09:19:28 -04:00
bruno 1758c3b6d2 chore(release): bump version 2.81.1 (correctifs CI + icone Secrets) 2026-10-28 09:19:24 -04:00
bruno 6669633902 chore(release): bump version 2.81.1 (correctifs CI + icone Secrets) 2026-10-28 09:19:20 -04:00
bruno a65d282c0b chore(release): bump version 2.81.1 (correctifs CI + icone Secrets) 2026-10-28 09:19:17 -04:00
bruno 524b43cc40 chore(release): bump version 2.81.1 (correctifs CI + icone Secrets) 2026-10-28 09:19:13 -04:00
bruno 4e0b27c4d7 chore(release): bump version 2.81.1 (correctifs CI + icone Secrets) 2026-10-28 09:19:10 -04:00
bruno c6d7acfd99 chore(release): bump version 2.81.1 (correctifs CI + icone Secrets) 2026-10-28 09:19:07 -04:00
bruno bf941c71ec chore(release): bump version 2.81.1 (correctifs CI + icone Secrets) 2026-10-28 09:19:05 -04:00
16 changed files with 1087 additions and 69 deletions
+21 -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.81.0**.
> [Unreleased](#unreleased). La dernière version livrée est **2.81.3**.
---
@@ -14,6 +14,26 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.81.3] — 2026-10-28
### Modifié
- **Barre d'apps** — vendange de `dracodev-commons` v0.1.6 : dérivation `-dev` par l'hôte courant, menu « ••• » par défaut, menu compte fonctionnel, spark sans dérivation inventée.
---
## [2.81.2] — 2026-10-28
### Modifié
- **Panneau Spark / console (PR #35)** — style console du panneau, statut « Connecté » intégré au sous-titre de l'en-tête (barre séparée supprimée), texte sous la zone de saisie supprimé, composer identique à la console (pilule, pièces jointes 4 max, textarea auto-extensible, micro dictée, envoi rond bleu), resynchronisation du domaine à chaque ouverture / changement de stockage / retour console, URL console avec `?domain=` ; la console honore `?domain=` valide (persisté avant chargement des discussions, PR agent-commun #7).
---
## [2.81.1] — 2026-10-28
---
## [2.81.0] — 2026-10-28
---
+3 -3
View File
@@ -4,7 +4,7 @@
**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.
[![Version](https://img.shields.io/badge/Version-2.81.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.81.1-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -977,8 +977,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
## 📝 Changelog
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.81.0).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.81.1).
---
*Projet : ObsiGate | Version : 2.81.0 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.81.1 | Dernière mise à jour : Septembre 2026*
+3 -3
View File
@@ -2,7 +2,7 @@
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
[![Version](https://img.shields.io/badge/Version-2.81.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.81.1-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -1152,8 +1152,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
## 📝 Changelog
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.81.0).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.81.1).
---
*Project: ObsiGate | Version: 2.81.0 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.81.1 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.81.0
2.81.3
+12 -2
View File
@@ -189,15 +189,25 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
# #223 : l'origine du service d'agents n'est pas figée — elle
# est dérivée de la configuration (DRACO_AGENT_URL, sinon
# l'origine d'AGENT_EVENEMENTS_URL, sinon le défaut officiel).
from backend.draco_config import agent_origine
from backend.draco_config import agent_origine, origine, urls_publiques
agent = agent_origine()
# Fusion #69 : le panneau Alertes unifiées lit les API
# notifications des applications soeurs depuis cet hôte ;
# leurs origines rejoignent connect-src (même résolution
# draco_config que les liens de la barre : explicite >
# dérivation depuis l'hôte de l'agent > défaut officiel).
_soeurs69 = " ".join(sorted({
origine(v)
for k, v in urls_publiques().items()
if k in ("obsigate", "flowdeck", "tiktak", "kanbante")
} - {""}))
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
f"script-src 'self' 'nonce-{nonce}' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.jsdelivr.net https://esm.sh; "
"img-src 'self' data: blob:; "
f"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net {agent}; "
f"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net {agent} {_soeurs69}; "
"font-src 'self' data: https://fonts.gstatic.com https://esm.sh; "
"worker-src 'self' blob:; "
f"frame-src 'self' blob: {agent}; "
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.81.0"
version = "2.81.3"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.81.0"
version = "2.81.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.81.0",
"version": "2.81.3",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+1 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.81.0 | **Dernière mise à jour :** 2026-10-28
> **Version :** 2.81.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)**
+39
View File
@@ -0,0 +1,39 @@
/* Alertes unifiées dracodev — Fusion #69. Styles neutres, variables
* d'hôte avec replis sombres (les 4 hôtes sont à dominante sombre). */
.draco-alertes-overlay {
position: fixed; inset: 0; z-index: 30000;
display: none; align-items: flex-start; justify-content: center;
padding: 8vh 16px 16px;
background: rgb(0 0 0 / .55); overflow-y: auto;
}
.draco-alertes-box {
width: 720px; max-width: 100%;
background: var(--bg-primary, #1f1f1f);
color: var(--text, #eceee9);
border: 1px solid var(--border, #333);
border-radius: 14px; box-shadow: 0 16px 48px rgb(0 0 0 / .5);
padding: 16px 18px 18px;
}
.draco-alertes-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.draco-alertes-head h2 { margin: 0; font-size: 18px; }
.draco-alertes-close {
border: 1px solid var(--border, #333); background: transparent;
color: inherit; border-radius: 8px; padding: 4px 10px; cursor: pointer;
}
.draco-alertes-note { font-size: 12.5px; color: var(--text-dim, #999); margin: 8px 0 14px; }
.draco-alertes-group { font-size: 15px; margin: 18px 0 6px; }
.draco-alertes-section h3 { font-size: 14px; margin: 12px 0 6px; }
.draco-alertes-item {
border: 1px solid var(--border, #2a2a2a); border-radius: 10px;
padding: 9px 12px; margin: 6px 0;
}
.draco-alertes-item.is-active { border-color: #e8762c; }
.draco-alertes-title { font-weight: 600; color: inherit; text-decoration: none; }
.draco-alertes-title:hover { text-decoration: underline; }
.draco-alertes-detail {
font-size: 13px; color: var(--text-dim, #bbb); margin-top: 2px;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.draco-alertes-meta { font-size: 11.5px; color: var(--text-tertiary, #777); margin-top: 4px; }
.draco-alertes-empty { font-size: 13px; color: var(--text-dim, #999); padding: 6px 0; }
.draco-alertes-empty a { color: inherit; }
+427
View File
@@ -0,0 +1,427 @@
/* Alertes unifiées dracodev — Fusion #69 (chantier 2).
*
* Agrégation À LA DEMANDE, pas une fédération temps réel : à chaque
* ouverture du panneau (et au chargement, pour la pastille), le module
* interroge les API de notifications réellement exposées par chaque
* application, avec les identifiants du navigateur (credentials:
* "include"). Aucune alerte n'est inventée : une source injoignable
* ou non authentifiée affiche son état réel (« Connexion requise »)
* avec un lien profond vers sa page native, jamais du vide déguisé.
*
* Sources réelles (audit #69, 2026-10-28) :
* - Flowdeck : GET {base}/api/notifications?limit=50
* -> { notifications: [{id, ntype, title, message, url, is_read,
* created_at}], unread } ; active = is_read = 0 ; lien = base + url.
* - TikTak : GET {base}/api/notifications
* -> [{id, kind, title, body, task_id, is_read, created_at}] ;
* active = is_read = 0 ; lien = base + "/?task=" + task_id, sinon
* base + "/notifications".
* - Kanbante : GET {base}/api/events?limit=100
* -> { events: [{repo_full_name, issue_number, event_type,
* from_column, to_column, created_at}] } ; AUCUN état lu/non lu
* n'existe dans Kanbante, donc tout y est historique et rien n'y
* est compté comme actif ; lien = base + "/board/" + repo.
* - ObsiGate : AUCUNE notification entrante consultable. Ses
* /api/notify/* sont des canaux d'envoi sortants (admin, secrets
* masqués), pas un journal lisible par l'utilisateur. La section
* ObsiGate le dit et renvoie vers l'accueil d'ObsiGate.
*
* Résolution des bases (même ordre que barre-apps v0.1.6) :
* 1. window.DRACO_BAR.urls déjà résolue par la barre, sinon
* window.DRACO_BAR_CONFIG.urls (config explicite de l'hôte) ;
* 2. dérivation -dev par l'hôte courant (hôte -dev dracodev.net) ;
* 3. href rendu de l'onglet .draco-apptab[data-app] ;
* 4. défaut officiel (production).
* ObsiGate n'a pas de .draco-apptab dans tous les cas : l'étape 3 y
* lit aussi window.DRACO_URLS (injecté par son backend).
* Aucun nom en -dev n'est un défaut : la dérivée -dev ne s'applique
* que sur un hôte -dev, exactement comme la barre.
*
* Le fichier ne touche à AUCUN fichier ui/barre-apps.* : il consomme
* seulement l'action "alertes" relayée par l'hôte et DRACO_BAR.setCounts
* pour la pastille (repli : mise à jour directe du badge).
* Insertion des données : textContent uniquement, jamais innerHTML
* avec une valeur venue du serveur.
*/
(function (global) {
"use strict";
var DEFAULT_URLS = {
obsigate: "https://og.dracodev.net/",
flowdeck: "https://flowdeck.dracodev.net/",
tiktak: "https://tiktak.dracodev.net/",
kanbante: "https://kanbante.dracodev.net/"
};
var DEV_URLS = {
obsigate: "https://og-dev.dracodev.net/",
flowdeck: "https://flowdeck-dev.dracodev.net/",
tiktak: "https://tiktak-dev.dracodev.net/",
kanbante: "https://kanbante-dev.dracodev.net/"
};
var APP_LABELS = {
obsigate: "ObsiGate", flowdeck: "Flowdeck",
tiktak: "TikTak", kanbante: "Kanbante"
};
var APP_ORDER = ["obsigate", "flowdeck", "tiktak", "kanbante"];
var EVENT_LABELS = {
created: "créée", moved: "déplacée", closed: "fermée",
reopened: "rouverte", commented: "commentée"
};
function isDevHost(hostname) {
if (!hostname) return false;
var h = String(hostname).toLowerCase();
if (h.slice(-13) !== ".dracodev.net") return false;
var first = h.split(".")[0];
return first.length > 4 && first.slice(-4) === "-dev";
}
function cleanUrl(u) {
if (!u || typeof u !== "string") return null;
if (u.indexOf("{{") !== -1) return null;
if (!u.replace(/\s/g, "")) return null;
return u;
}
function withSlash(u) {
return u.charAt(u.length - 1) !== "/" ? u + "/" : u;
}
function joinUrl(base, path) {
var b = withSlash(base);
if (!path) return b;
if (/^https?:\/\//.test(path)) return path;
return b + String(path).replace(/^\//, "");
}
function parseDate(s) {
if (!s) return null;
var t = new Date(String(s).indexOf("T") === -1 && String(s).indexOf("Z") === -1
? String(s).replace(" ", "T") + "Z" : s);
return isNaN(t.getTime()) ? null : t;
}
function fmtDate(s) {
var t = parseDate(s);
if (!t) return s ? String(s).slice(0, 16) : "";
try { return t.toLocaleString("fr-FR"); } catch (e) { return t.toISOString().slice(0, 16); }
}
/* Résolution des bases. `env` permet les tests sans navigateur :
* { hostname, barUrls, configUrls, tabHrefs, dracoUrls }. */
function resolveUrls(env) {
env = env || {};
var hostname = env.hostname;
if (hostname === undefined) {
try { hostname = (global.location && global.location.hostname) || ""; } catch (e) { hostname = ""; }
}
var dev = isDevHost(hostname);
var urls = {};
APP_ORDER.forEach(function (app) {
var u = cleanUrl(env.barUrls && env.barUrls[app])
|| cleanUrl(env.configUrls && env.configUrls[app]);
if (!u && dev) u = DEV_URLS[app] || null;
if (!u) {
var href = null;
if (env.tabHrefs && env.tabHrefs[app]) href = env.tabHrefs[app];
else if (typeof document !== "undefined" && document.querySelector) {
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
href = tab ? tab.getAttribute("href") : null;
}
u = cleanUrl(href);
}
if (!u && env.dracoUrls && env.dracoUrls[app]) u = cleanUrl(env.dracoUrls[app]);
if (!u) {
/* ObsiGate : window.DRACO_URLS injecté par le backend. */
try {
if (global.DRACO_URLS && global.DRACO_URLS[app]) u = cleanUrl(global.DRACO_URLS[app]);
} catch (e) { /* rien */ }
}
if (!u) u = DEFAULT_URLS[app];
urls[app] = withSlash(u);
});
return urls;
}
function browserEnv() {
var cfg = global.DRACO_BAR_CONFIG || {};
var bar = global.DRACO_BAR || {};
var tabs = {};
try {
if (typeof document !== "undefined" && document.querySelectorAll) {
var nodes = document.querySelectorAll(".draco-apptab[data-app]");
for (var i = 0; i < nodes.length; i++) {
tabs[nodes[i].getAttribute("data-app")] = nodes[i].getAttribute("href");
}
}
} catch (e) { /* rien */ }
return {
barUrls: bar.urls || null, configUrls: cfg.urls || null,
tabHrefs: tabs, dracoUrls: global.DRACO_URLS || null
};
}
function normalizeFlowdeck(base, payload) {
var list = (payload && payload.notifications) || [];
return list.map(function (n) {
return {
app: "flowdeck", id: n.id, title: n.title || "(sans titre)",
detail: n.message || "", date: n.created_at || "",
active: !n.is_read, state: n.is_read ? "lue" : "active",
kind: n.ntype || "", url: joinUrl(base, n.url || "")
};
});
}
function normalizeTiktak(base, payload) {
var list = Array.isArray(payload) ? payload : (payload && payload.notifications) || [];
return list.map(function (n) {
return {
app: "tiktak", id: n.id, title: n.title || "(sans titre)",
detail: n.body || "", date: n.created_at || "",
active: !n.is_read, state: n.is_read ? "lue" : "active",
kind: n.kind || "",
url: n.task_id ? withSlash(base) + "?task=" + n.task_id : joinUrl(base, "notifications")
};
});
}
function normalizeKanbante(base, payload) {
var list = (payload && payload.events) || [];
return list.map(function (e) {
var label = EVENT_LABELS[e.event_type] || e.event_type || "événement";
var detail = (e.from_column ? e.from_column + " → " : "") + (e.to_column || "");
return {
app: "kanbante", id: e.id,
title: (e.repo_full_name || "?") + " #" + e.issue_number + " — " + label,
detail: detail, date: e.created_at || "",
active: false, state: label, kind: e.event_type || "",
url: joinUrl(base, "board/" + (e.repo_full_name || ""))
};
});
}
/* Actives d'abord, puis date décroissante (sans date en dernier). */
function sortAlerts(list) {
return list.slice().sort(function (a, b) {
if (a.active !== b.active) return a.active ? -1 : 1;
var da = parseDate(a.date), db = parseDate(b.date);
if (da && db) return db - da;
if (da) return -1;
if (db) return 1;
return 0;
});
}
function countActive(list) {
var n = 0;
list.forEach(function (a) { if (a.active) n++; });
return n;
}
/* ---- Rendu (navigateur seulement) ---- */
var panel = null, listEl = null, badgeTimer = null;
function el(tag, cls, text) {
var d = document.createElement(tag);
if (cls) d.className = cls;
if (text !== undefined && text !== null) d.textContent = text;
return d;
}
function ensurePanel() {
if (panel) return panel;
var ov = el("div", "draco-alertes-overlay");
ov.setAttribute("role", "dialog");
ov.setAttribute("aria-label", "Alertes unifiées");
var box = el("div", "draco-alertes-box");
var head = el("div", "draco-alertes-head");
head.appendChild(el("h2", "", "Alertes unifiées"));
var note = el("p", "draco-alertes-note",
"Agrégation à la demande, pas une fédération temps réel : " +
"les alertes sont relues dans chaque application à l'ouverture.");
var close = el("button", "draco-alertes-close", "✕");
close.setAttribute("aria-label", "Fermer");
close.addEventListener("click", closePanel);
head.appendChild(close);
box.appendChild(head);
box.appendChild(note);
listEl = el("div", "draco-alertes-list");
box.appendChild(listEl);
ov.appendChild(box);
ov.addEventListener("click", function (ev) { if (ev.target === ov) closePanel(); });
document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape") closePanel();
});
document.body.appendChild(ov);
panel = ov;
return ov;
}
function closePanel() { if (panel) panel.style.display = "none"; }
function renderSection(title, items, fallback) {
var sec = el("section", "draco-alertes-section");
sec.appendChild(el("h3", "", title + " (" + items.length + ")"));
if (!items.length) {
sec.appendChild(el("p", "draco-alertes-empty", fallback));
return sec;
}
items.forEach(function (a) {
var row = el("div", "draco-alertes-item" + (a.active ? " is-active" : ""));
var main = el("div", "draco-alertes-main");
var link = el("a", "draco-alertes-title", a.title);
link.href = a.url;
main.appendChild(link);
if (a.detail) main.appendChild(el("div", "draco-alertes-detail", a.detail));
var meta = el("div", "draco-alertes-meta",
APP_LABELS[a.app] + (a.kind ? " · " + a.kind : "") +
" · " + (a.date ? fmtDate(a.date) : "sans date") + " · " + a.state);
main.appendChild(meta);
row.appendChild(main);
sec.appendChild(row);
});
return sec;
}
function render(results, urls) {
ensurePanel();
listEl.textContent = "";
var all = [];
APP_ORDER.forEach(function (app) {
(results[app] && results[app].items || []).forEach(function (a) { all.push(a); });
});
var actives = sortAlerts(all.filter(function (a) { return a.active; }));
var history = sortAlerts(all.filter(function (a) { return !a.active; }));
listEl.appendChild(el("h3", "draco-alertes-group", "Alertes actives"));
APP_ORDER.forEach(function (app) {
var items = actives.filter(function (a) { return a.app === app; });
if (results[app] && results[app].error) {
var sec = renderSection(APP_LABELS[app], [], results[app].error);
listEl.appendChild(sec);
} else if (app === "obsigate") {
listEl.appendChild(renderSection(APP_LABELS[app], [],
"ObsiGate n'expose aucune notification entrante consultable " +
"(ses canaux /api/notify sont des envois sortants, réservés aux administrateurs)."));
} else if (items.length) {
listEl.appendChild(renderSection(APP_LABELS[app], items, ""));
}
});
if (!actives.length) {
listEl.appendChild(el("p", "draco-alertes-empty", "Aucune alerte active dans les applications joignables."));
}
listEl.appendChild(el("h3", "draco-alertes-group", "Historique"));
APP_ORDER.forEach(function (app) {
if (results[app] && results[app].error) return; /* déjà signalé ci-dessus */
if (app === "obsigate") {
var link = el("div", "draco-alertes-empty");
var a = el("a", "", "Ouvrir ObsiGate");
a.href = urls.obsigate;
link.appendChild(a);
var sec = el("section", "draco-alertes-section");
sec.appendChild(el("h3", "", "ObsiGate (0)"));
sec.appendChild(link);
listEl.appendChild(sec);
return;
}
var items = history.filter(function (x) { return x.app === app; });
listEl.appendChild(renderSection(APP_LABELS[app], items,
"Aucun historique disponible pour cette application."));
});
}
function fetchJson(url, timeoutMs) {
var ctrl = null, timer = null;
try {
if (typeof AbortController !== "undefined") {
ctrl = new AbortController();
timer = setTimeout(function () { ctrl.abort(); }, timeoutMs || 8000);
}
} catch (e) { ctrl = null; }
var opts = { credentials: "include" };
if (ctrl) opts.signal = ctrl.signal;
return fetch(url, opts).then(function (r) {
if (timer) clearTimeout(timer);
if (!r.ok) {
var err = new Error("HTTP " + r.status);
err.status = r.status;
throw err;
}
return r.json();
}).catch(function (e) {
if (timer) clearTimeout(timer);
throw e;
});
}
function loadAll() {
var urls = resolveUrls(browserEnv());
var jobs = {
flowdeck: fetchJson(urls.flowdeck + "api/notifications?limit=50")
.then(function (p) { return { items: normalizeFlowdeck(urls.flowdeck, p) }; }),
tiktak: fetchJson(urls.tiktak + "api/notifications")
.then(function (p) { return { items: normalizeTiktak(urls.tiktak, p) }; }),
kanbante: fetchJson(urls.kanbante + "api/events?limit=100")
.then(function (p) { return { items: normalizeKanbante(urls.kanbante, p) }; })
};
var keys = Object.keys(jobs);
return Promise.all(keys.map(function (k) {
return jobs[k].then(function (v) { return [k, v]; }).catch(function (e) {
var msg = (e && e.status === 401)
? "Connexion requise sur " + APP_LABELS[k] + " : ouvrez sa page native pour vous y connecter, puis rouvrez ce panneau."
: "Application injoignable depuis cet hôte (" + (e && e.message ? e.message : "erreur réseau") + ").";
return [k, { items: [], error: msg }];
});
})).then(function (pairs) {
var results = { obsigate: { items: [] } };
pairs.forEach(function (kv) { results[kv[0]] = kv[1]; });
return { urls: urls, results: results };
});
}
function setBadge(n) {
try {
if (global.DRACO_BAR && typeof global.DRACO_BAR.setCounts === "function") {
global.DRACO_BAR.setCounts({ alertes: n });
return;
}
} catch (e) { /* repli ci-dessous */ }
try {
document.querySelectorAll('.draco-badge[data-badge="alertes"]').forEach(function (b) {
b.textContent = n;
b.hidden = !(n > 0);
});
} catch (e) { /* rien */ }
}
function refreshBadge() {
return loadAll().then(function (r) {
var all = [];
APP_ORDER.forEach(function (app) {
(r.results[app] && r.results[app].items || []).forEach(function (a) { all.push(a); });
});
setBadge(countActive(all));
return countActive(all);
}).catch(function () { /* pastille optionnelle : la barre vit sans */ });
}
function openPanel() {
ensurePanel();
panel.style.display = "flex";
listEl.textContent = "";
listEl.appendChild(el("p", "draco-alertes-empty", "Chargement des alertes…"));
loadAll().then(function (r) {
render(r.results, r.urls);
var all = [];
APP_ORDER.forEach(function (app) {
(r.results[app] && r.results[app].items || []).forEach(function (a) { all.push(a); });
});
setBadge(countActive(all));
}).catch(function () {
render({ obsigate: { items: [] } }, resolveUrls(browserEnv()));
});
}
var api = {
open: openPanel, close: closePanel, refreshBadge: refreshBadge,
_test: {
resolveUrls: resolveUrls, normalizeFlowdeck: normalizeFlowdeck,
normalizeTiktak: normalizeTiktak, normalizeKanbante: normalizeKanbante,
sortAlerts: sortAlerts, countActive: countActive,
isDevHost: isDevHost, DEFAULT_URLS: DEFAULT_URLS, DEV_URLS: DEV_URLS
}
};
global.DRACO_ALERTES = api;
if (typeof module !== "undefined" && module.exports) module.exports = api._test;
/* Pastille au chargement (après les scripts defer de la barre). */
if (typeof document !== "undefined" && document.addEventListener) {
document.addEventListener("DOMContentLoaded", function () {
if (badgeTimer) clearTimeout(badgeTimer);
badgeTimer = setTimeout(refreshBadge, 300);
});
}
})(typeof window !== "undefined" ? window : globalThis);
+35 -5
View File
@@ -2,7 +2,11 @@
v0.1.2 : palier mobile resserré selon la spec (voir @media 700px) ;
v0.1.3 : la marque ✦ (plus grande) ouvre le menu des applications —
c'est LE changement d'app en mobile (choix de Bruno, 2026-10-21) ;
v0.1.4 : palier mobile élargi de 700 à 900 px (zone morte comblée).
v0.1.4 : palier mobile élargi de 700 à 900 px (zone morte comblée) ;
v0.1.6 : menu compte (construit par le JS, mêmes jetons que le
menu des applications) ; l'avatar est un vrai <button>.
Correctif fusion #70 (SANS bump) : bouton trois points et menu par
défaut supprimés ; le menu de la marque gagne « Console Spark ».
Jetons repris du prototype validé Console v7.2.
Thème : clair par défaut, sombre via [data-theme="dark"] sur <html>
ou prefers-color-scheme si l'app intégratrice ne force rien. */
@@ -32,6 +36,7 @@
/* Marque — bouton du menu des applications (v0.1.3) */
.draco-brand { display: flex; align-items: center; gap: 7px; color: var(--ink); text-decoration: none; font-weight: 800; cursor: pointer; border-radius: 10px; padding: 4px 6px; margin: -4px -6px; }
.draco-brand:hover { background: var(--surface-2); }
.draco-brand:focus-visible { outline: 2px solid var(--agent); outline-offset: 2px; }
.draco-brand-mark { color: var(--agent); font-size: 24px; line-height: 1; }
/* Menu des applications — construit par le JS depuis les onglets */
@@ -42,6 +47,29 @@
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
}
.draco-apps-menu[hidden] { display: none; }
/* Menu compte (v0.1.6) — même boîte que le menu des apps, ancré à
* droite ; ses items réutilisent .draco-apps-item. */
.draco-compte-menu {
position: absolute; top: calc(100% + 6px); right: 10px; z-index: 400;
min-width: 248px; padding: 6px;
background: var(--surface); border: 1px solid var(--line); border-radius: 14px;
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
}
.draco-compte-menu[hidden] { display: none; }
.draco-menu-item {
display: flex; align-items: center; gap: 10px; width: 100%; box-sizing: border-box;
padding: 8px 10px; border: 0; border-radius: 10px;
background: transparent; color: var(--ink); font: inherit; text-align: left;
text-decoration: none; cursor: pointer;
}
.draco-menu-item:hover, .draco-menu-item:focus-visible { background: var(--surface-2); }
.draco-menu-sep { height: 1px; margin: 6px 10px; background: var(--line); }
.draco-compte-nom {
padding: 8px 10px 10px; font-weight: 700; font-size: 14px;
border-bottom: 1px solid var(--line); margin-bottom: 6px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.draco-apps-item {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border-radius: 10px;
@@ -122,8 +150,10 @@
}
.draco-icon-btn:hover { background: var(--surface-2); }
.draco-avatar {
width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center;
background: #2f6fed; color: #fff; font-weight: 700;
width: 34px; height: 34px; border: 0; padding: 0; border-radius: 50%;
display: grid; place-items: center; flex: none;
background: #2f6fed; color: #fff; font: inherit; font-weight: 700;
cursor: pointer;
}
/* Rappel de l'en-tête masqué — onglet ancré au bord supérieur (v6) */
@@ -147,8 +177,8 @@
@media (max-width: 900px) {
/* Mobile (spec des barres, § Mobile) : la barre se resserre sur
l'essentiel — marque, onglet de l'app courante, alertes, Spark,
avatar. Le rail, la recherche, les domaines et le menu « ••• »
disparaissent : chaque app garde ses contrôles natifs (barre
avatar. Le rail, la recherche et les domaines disparaissent :
chaque app garde ses contrôles natifs (barre
basse, en-tête) — un seul hamburger visible, celui de l'app.
Le changement d'app passe par le menu du ✦ (v0.1.3).
(v0.1.2 — avant, les onglets entiers et les pastilles domaines
+450 -42
View File
@@ -1,5 +1,14 @@
/* Barre d'apps commune dracodev — comportement (v0.1.0, ACC-0.2 ;
* v0.1.3 : menu des applications ouvert par la marque ✦).
* v0.1.3 : menu des applications ouvert par la marque ✦ ;
* v0.1.5 : noms publics par configuration — aucun nom public d'un
* environnement particulier n'est codé en dur, voir « URL » ci-dessous ;
* v0.1.6 (prochaine publication — VERSION non bumpée dans cette PR,
* la publication est séparée) : dérivation -dev par l'hôte courant,
* menu trois points par défaut et menu compte fonctionnels, voir ci-dessous ;
* correctif fusion #70 (SANS bump de version, publication séparée) :
* bouton trois points et son menu par défaut SUPPRIMÉS, « Console Spark »
* ajoutée au menu de la marque, fermeture/focus corrigés (voir
* « Menu des applications », « Focus » et « Écouteurs globaux »).
*
* Aucun gestionnaire inline : un seul écouteur délégué sur le document
* (patron validé par le prototype — les onclick du HTML généré sont
@@ -10,19 +19,200 @@
* current: "tiktak", // app courante
* domain: "pro", // domaine actif
* counts: { tiktak: 3, kanbante: 3, alertes: 3, spark: 0 },
* user: "Bruno", // nom affiché (v0.1.6) ;
* // ou { display_name: "Bruno", username: "bruno" }
* urls: { // bases des applications
* obsigate: "https://og.dracodev.net/",
* flowdeck: "https://flowdeck.dracodev.net/",
* tiktak: "https://tiktak.dracodev.net/",
* kanbante: "https://kanbante.dracodev.net/",
* agent: "https://agent.dracodev.net/",
* spark: "https://spark.dracodev.net/"
* },
* onAction: function (act, el) { … } // l'app relaie les actions
* } // qu'elle ne connaît pas
*
* URL des applications (contrat dev/prod, v0.1.5 puis v0.1.6) —
* résolution, dans l'ordre STRICT, pour chaque app :
* 1. DRACO_BAR_CONFIG.urls.<app>, injectée par l'app hôte (rendu
* serveur ou client) depuis SA configuration d'environnement —
* explicite, donc prioritaire, même sur un hôte -dev ;
* 2. (v0.1.6) SI l'hôte courant est un hôte -dev dracodev.net
* (première étiquette se terminant par -dev : tiktak-dev,
* kanbante-dev, og-dev, flowdeck-dev, agent-dev…), l'URL sœur
* dérivée avec -dev (voir DEV_URLS). C'est le correctif du bug
* constaté sur tiktak-dev / kanbante-dev, où les liens suivaient
* les noms SANS -dev faute de configuration explicite ;
* 3. le href déjà rendu dans le fragment HTML, c.-à-d. le jeton
* {{URL_<APP>}} remplacé côté serveur par l'app hôte ;
* 4. le défaut officiel ci-dessous (production, noms SANS suffixe).
* Un jeton {{URL_…}} non remplacé n'est jamais une URL : il est ignoré
* partout (config injectée comme href rendu) et ne peut pas être exposé.
* Cas spark (v0.1.6) : AUCUNE dérivation inventée — aucun hôte
* spark-dev connu ; sur un hôte -dev, spark retombe sur l'étape 3
* (href rendu) puis 4 (défaut prod), sauf config explicite (étape 1).
* Les clés agent et spark ne correspondent à aucun onglet de la barre :
* elles sont résolues et exposées via DRACO_BAR.urls pour le panneau
* Spark (panneau-agent), qui suit le même contrat d'injection.
* Actions émises (data-act) : rail, palette, domaine (data-dom),
* alertes, spark, menu, rappel-entete. L'app intégratrice décide de
* chacune (ouvrir la palette, le panneau Spark, changer de domaine…).
* Action INTERNE (jamais relayée) : apps — la marque ✦ ouvre le menu
* des applications, construit depuis les onglets ; c'est le moyen de
* changer d'app en mobile, où les onglets sont repliés (v0.1.2).
* alertes, spark, rappel-entete. L'app intégratrice décide de chacune
* (ouvrir la palette, le panneau Spark, changer de domaine…).
* Actions INTERNES (jamais relayées comme avant) : apps — la marque ✦
* ouvre le menu des applications, construit depuis les onglets, plus
* (depuis le correctif #70) une entrée « Console Spark » vers la
* console de l'agent commun (URL agent résolue, voir ci-dessous) ;
* c'est le moyen de changer d'app en mobile, où les onglets sont
* repliés (v0.1.2). Menu compte : voir ci-dessous.
* L'action « menu » n'est PLUS émise nulle part : le bouton trois points
* a été supprimé (#70) ; les branches onAction("menu") des hôtes
* (TikTak/Kanbante/Flowdeck : retour false pour le menu par défaut ;
* ObsiGate : clic #header-menu-btn) sont donc mortes et ont été
* retirées des gabarits hôtes — le menu natif d'ObsiGate
* (#header-menu-btn) lui-même n'est pas touché.
*
* Retrait du bouton trois points (correctif fusion #70) — le bouton et
* son menu par défaut (console agent + applications) sont supprimés
* du fragment, du JS et du CSS. La console agent reste joignable par
* l'entrée « Console Spark » du menu de la marque (ci-dessous) et
* par le bouton Spark (panneau), donc aucun accès n'est perdu.
* Menu des applications (v0.1.3, étendu #70) — construit depuis les
* onglets présents dans la barre, puis une entrée « Console Spark »
* (logo ✦, rôle « Agent commun ») APRÈS Kanbante, vers l'URL agent
* résolue : 1) config explicite DRACO_BAR_CONFIG.urls.agent,
* 2) dérivation -dev agent-dev sur hôte -dev, 3) défaut officiel
* prod https://agent.dracodev.net/ (il n'y a pas d'onglet agent, donc
* pas d'étape « href rendu »). AUCUN hôte spark-dev n'est inventé :
* la clé spark, elle, suit toujours son propre contrat (explicite >
* href rendu > défaut prod) et ne sert qu'au panneau Spark.
* Focus (correctif #70) — ouverture au clavier : Entrée sur la marque
* (clic natif du lien) ; Espace sur la marque (keydown dédié, car un
* lien ne s'active pas à l'Espace) : ouvre le menu et met le focus
* sur son premier item. Fermeture : Échap (focus rendu au déclencheur
* qui avait ouvert le menu : la marque pour le menu apps, l'avatar
* pour le menu compte), nouvel appui sur le déclencheur (le focus y
* est déjà), choix d'un item, ou clic extérieur — ce dernier cas ne
* déplace VOLONTAIREMENT pas le focus (l'utilisateur vient de cliquer
* ailleurs : recherche, tâche… ; lui voler le focus casserait son
* geste). aria-expanded est tenu à jour sur chaque déclencheur.
* Écouteurs globaux (correctif #70, bug tiktak-dev) — les écouteurs
* clic/Échap de la barre sont posés via EventTarget.prototype (voir
* addGlobalListener), PAS via document.addEventListener. Cause
* prouvée sur TikTak (app/static/js/app.js, navigation #89) : app.js
* remplace document/window.addEventListener par un registre
* (__tcDocWin) et chaque navigation douce appelle tcInit() puis
* __tcTeardown(), qui SUPPRIME tous les écouteurs du registre — y
* compris ceux de draco-barre.js (script defer, enregistré après le
* tcInit() initial). La barre survit à la navigation (hors .layout
* remplacé) mais ses gestionnaires sont morts : un menu ouvert avant
* la navigation reste ouvert (Échap et clic extérieur sans effet) et
* la marque, lien href="/", est interceptée par l'intercepteur SPA
* brut de TikTak (__tcDocAdd, hors registre, donc survivant) comme
* une navigation interne au lieu de basculer le menu. Les écouteurs
* posés sur le prototype ne figurent pas dans le registre et ne sont
* donc jamais supprimés par le teardown ; en complément, le gabarit
* TikTak exclut désormais .draco-bar de l'intercepteur SPA, pour que
* la marque ne déclenche plus de navigation douce. Les autres hôtes
* (SSR, rechargement complet) ne sont pas concernés par le teardown
* mais bénéficient du même code ; repli : si EventTarget.prototype
* est indisponible (mini-DOM de tests), document.addEventListener
* classique est utilisé.
* Menu compte (v0.1.6) — l'avatar, auparavant un <span> mort affichant
* un « B » codé en dur dans le fragment, est un vrai <button>
* (data-act="compte"). Son initiale vient de cfg.user ou, à défaut, de
* la première lettre du title {{UTILISATEUR}} remplacé ; sans nom connu,
* le neutre ✦ est conservé (jamais de lettre codée en dur). Le clic
* ouvre un petit menu affichant le nom de l'utilisateur ; les items
* Profil / Déconnexion sont relayés à l'hôte via onAction ou, à défaut,
* via l'événement draco:bar (détail { act: "profil" | "deconnexion" }),
* mais le menu affiche l'identité même sans gestionnaire hôte.
* Accessibilité : vrais <button>, aria-haspopup / aria-expanded,
* role="menu" / "menuitem", fermeture par Échap et clic extérieur.
*/
(function () {
"use strict";
var cfg = window.DRACO_BAR_CONFIG || {};
/* Défauts OFFICIELS (production) — les seuls noms publics présents
* dans ce fichier, documentés comme défauts (contrat dev/prod). */
var DEFAULT_URLS = {
obsigate: "https://og.dracodev.net/",
flowdeck: "https://flowdeck.dracodev.net/",
tiktak: "https://tiktak.dracodev.net/",
kanbante: "https://kanbante.dracodev.net/",
agent: "https://agent.dracodev.net/",
spark: "https://spark.dracodev.net/"
};
/* Dérivation -dev (v0.1.6) — appliquée UNIQUEMENT quand l'hôte courant
* est lui-même un hôte -dev dracodev.net (voir isDevHost). spark est
* volontairement absent : aucun hôte spark-dev connu, aucune URL
* inventée ; spark retombe sur href rendu puis défaut prod. */
var DEV_URLS = {
obsigate: "https://og-dev.dracodev.net/",
flowdeck: "https://flowdeck-dev.dracodev.net/",
tiktak: "https://tiktak-dev.dracodev.net/",
kanbante: "https://kanbante-dev.dracodev.net/",
agent: "https://agent-dev.dracodev.net/"
};
var APP_LABELS = {
obsigate: "ObsiGate", flowdeck: "Flowdeck",
tiktak: "TikTak", kanbante: "Kanbante"
};
/* Vrai si hostname est un hôte -dev dracodev.net : première étiquette
* se terminant par -dev ET domaine dracodev.net (un suffixe -dev sur
* un autre domaine, ou un hôte piège du type x-dev.dracodev.net.evil,
* ne déclenche rien). Insensible à la casse. */
function isDevHost(hostname) {
if (!hostname) return false;
var h = String(hostname).toLowerCase();
if (h.slice(-13) !== ".dracodev.net") return false;
var first = h.split(".")[0];
return first.length > 4 && first.slice(-4) === "-dev";
}
/* Un jeton {{…}} non remplacé n'est pas une URL : l'ignorer. */
function cleanUrl(u) {
if (!u || typeof u !== "string") return null;
if (u.indexOf("{{") !== -1) return null;
if (!u.replace(/\s/g, "")) return null;
return u;
}
function withSlash(u) {
return u.charAt(u.length - 1) !== "/" ? u + "/" : u;
}
function resolveUrls() {
var host = "";
try { host = (window.location && window.location.hostname) || ""; } catch (e) { host = ""; }
var dev = isDevHost(host);
var urls = {};
Object.keys(DEFAULT_URLS).forEach(function (app) {
/* 1. Config explicite de l'hôte — prioritaire, même sur -dev. */
var u = cleanUrl(cfg.urls && cfg.urls[app]);
/* 2. Dérivation -dev par l'hôte courant (spark : pas de dérivée). */
if (!u && dev) u = DEV_URLS[app] || null;
/* 3. href rendu dans le fragment (jeton remplacé côté serveur). */
if (!u) {
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
var href = tab ? tab.getAttribute("href") : null;
u = cleanUrl(href);
}
/* 4. Défaut officiel (production). */
if (!u) u = DEFAULT_URLS[app];
urls[app] = withSlash(u);
});
return urls;
}
function applyUrls(urls) {
document.querySelectorAll(".draco-apptab, .draco-apps-item").forEach(function (t) {
if (t.dataset.app && urls[t.dataset.app]) t.setAttribute("href", urls[t.dataset.app]);
});
}
var resolvedUrls = resolveUrls();
function setCounts(counts) {
Object.keys(counts || {}).forEach(function (k) {
/* Toutes les occurrences : l'onglet ET sa ligne dans le menu. */
@@ -43,26 +233,105 @@
});
}
/* ── Menu des applications (v0.1.3) ──────────────────────────────
* Construit depuis les onglets présents dans la barre : mêmes
* liens, mêmes icônes, mêmes badges. Ouvert par la marque ✦
* (data-act="apps"), fermé par un clic extérieur, Échap, ou le
* choix d'une application (la navigation suit son cours). */
/* ── Identité / avatar (v0.1.6) ───────────────────────────────────
* Nom : cfg.user (chaîne, ou objet display_name / name / username),
* sinon le title de l'avatar quand le jeton {{UTILISATEUR}} a bien été
* remplacé côté serveur. L'initiale en découle ; sans nom, le neutre
* ✦ du fragment est conservé — jamais de lettre codée en dur. */
function userName() {
var u = cfg.user;
if (typeof u === "string" && u.replace(/\s/g, "")) return u.trim();
if (u && typeof u === "object") {
var n = u.display_name || u.name || u.username;
if (n && String(n).replace(/\s/g, "")) return String(n).trim();
}
return null;
}
function titleName() {
var av = document.querySelector(".draco-avatar");
var t = av ? av.getAttribute("title") : null;
if (t && t.indexOf("{{") === -1 && t.replace(/\s/g, "")) return t.trim();
return null;
}
function displayName() { return userName() || titleName(); }
function syncAvatar() {
var av = document.querySelector(".draco-avatar");
if (!av) return;
/* Compatibilité avec un fragment vendangé antérieur (avatar <span>
* sans data-act) : le rendre déclencheur du menu compte. */
if (!av.getAttribute("data-act")) av.setAttribute("data-act", "compte");
if (!av.getAttribute("aria-haspopup")) av.setAttribute("aria-haspopup", "menu");
var name = displayName();
if (name) {
av.textContent = name.charAt(0).toUpperCase();
av.setAttribute("title", name);
av.setAttribute("aria-label", "Compte : " + name);
}
}
/* ── Menus déroulants (apps, compte) ───────────────────────────────
* Un seul menu ouvert à la fois ; fermeture par clic extérieur,
* Échap, ou choix d'un item. aria-expanded tenu à jour sur chaque
* déclencheur. Les href des menus sont (re)synchronisés avec
* resolvedUrls à chaque ouverture, pour que setUrls et la dérivation
* -dev s'y reflètent même si le menu a été construit avant. */
var bar = document.querySelector(".draco-bar");
var brand = bar ? bar.querySelector(".draco-brand") : null;
var menu = null;
var avatarBtn = document.querySelector(".draco-avatar");
var appsMenu = null, compteMenu = null;
/* Déclencheur du menu ouvert en dernier (marque ou avatar) : Échap
* lui rend le focus à la fermeture (correctif #70). */
var lastOpener = null;
function buildMenu() {
if (!bar || !brand || menu) return;
menu = document.createElement("div");
menu.className = "draco-apps-menu";
menu.hidden = true;
menu.setAttribute("role", "menu");
menu.setAttribute("aria-label", "Applications");
function setExpanded(btn, open) {
if (btn) btn.setAttribute("aria-expanded", open ? "true" : "false");
}
function refreshMenuHrefs(menu) {
if (!menu) return;
menu.querySelectorAll(".draco-apps-item").forEach(function (a) {
if (a.dataset.app && resolvedUrls[a.dataset.app]) a.setAttribute("href", resolvedUrls[a.dataset.app]);
});
}
function togglePopup(menu, btn, force) {
if (!menu) return;
var open = typeof force === "boolean" ? force : menu.hidden;
menu.hidden = !open;
setExpanded(btn, open);
}
function closeAllPopups(returnFocus) {
var anyOpen = (appsMenu && !appsMenu.hidden) || (compteMenu && !compteMenu.hidden);
if (appsMenu) togglePopup(appsMenu, brand, false);
if (compteMenu) togglePopup(compteMenu, avatarBtn, false);
/* Échap seulement : le clic extérieur ne déplace pas le focus
* (voir l'en-tête, « Focus »). */
if (returnFocus && anyOpen && lastOpener && typeof lastOpener.focus === "function") lastOpener.focus();
}
function tabLabel(app) {
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
var fort = tab ? tab.querySelector(".draco-apptab-txt strong") : null;
return fort ? fort.textContent : (APP_LABELS[app] || app);
}
function tabRole(app) {
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
var petit = tab ? tab.querySelector(".draco-apptab-txt small") : null;
return petit ? petit.textContent : "";
}
/* Menu des applications (v0.1.3) — construit depuis les onglets
* présents dans la barre : mêmes liens, mêmes icônes, mêmes badges.
* Ouvert par la marque ✦ (data-act="apps"), fermé par un clic
* extérieur, Échap, ou le choix d'une application (la navigation
* suit son cours). */
function buildAppsMenu() {
if (!bar || !brand || appsMenu) return;
appsMenu = document.createElement("div");
appsMenu.className = "draco-apps-menu";
appsMenu.hidden = true;
appsMenu.setAttribute("role", "menu");
appsMenu.setAttribute("aria-label", "Applications");
document.querySelectorAll(".draco-apptab").forEach(function (t) {
var a = document.createElement("a");
a.className = "draco-apps-item";
a.href = t.getAttribute("href");
a.href = resolvedUrls[t.dataset.app] || t.getAttribute("href");
a.dataset.app = t.dataset.app;
a.setAttribute("role", "menuitem");
var ico = t.querySelector(".draco-apptab-ico");
@@ -84,44 +353,183 @@
a.appendChild(txt);
var badge = t.querySelector(".draco-badge");
if (badge) a.appendChild(badge.cloneNode(true));
menu.appendChild(a);
appsMenu.appendChild(a);
});
bar.appendChild(menu);
/* Entrée « Console Spark » (#70) : après Kanbante, vers la console
* de l'agent commun (URL agent résolue — explicite > -dev >
* défaut prod ; aucun hôte spark-dev inventé). data-app="agent"
* l'intègre à refreshMenuHrefs/applyUrls/setCurrent comme les
* autres items ; l'icône ✦ reprend la couleur agent du CSS. */
var sep = document.createElement("div");
sep.className = "draco-menu-sep";
sep.setAttribute("aria-hidden", "true");
appsMenu.appendChild(sep);
var sparkItem = document.createElement("a");
sparkItem.className = "draco-apps-item";
sparkItem.href = resolvedUrls.agent || "#";
sparkItem.dataset.app = "agent";
sparkItem.setAttribute("role", "menuitem");
var sparkIco = document.createElement("span");
sparkIco.className = "draco-apptab-ico";
sparkIco.setAttribute("style", "--app:#c2437e");
sparkIco.textContent = "✦";
sparkItem.appendChild(sparkIco);
var sparkTxt = document.createElement("span");
sparkTxt.className = "draco-apps-item-txt";
var sparkNom = document.createElement("span");
sparkNom.className = "draco-apps-item-nom";
sparkNom.textContent = "Console Spark";
sparkTxt.appendChild(sparkNom);
var sparkRole = document.createElement("span");
sparkRole.className = "draco-apps-item-role";
sparkRole.textContent = "Agent commun";
sparkTxt.appendChild(sparkRole);
sparkItem.appendChild(sparkTxt);
appsMenu.appendChild(sparkItem);
bar.appendChild(appsMenu);
}
function toggleAppsMenu(force) {
buildAppsMenu();
if (!appsMenu) return;
if (force !== false) refreshMenuHrefs(appsMenu);
togglePopup(appsMenu, brand, force);
if (!appsMenu.hidden) lastOpener = brand;
}
function toggleMenu(force) {
buildMenu();
if (!menu) return;
var open = typeof force === "boolean" ? force : menu.hidden;
menu.hidden = !open;
if (brand) brand.setAttribute("aria-expanded", open ? "true" : "false");
/* Menu compte (v0.1.6) — en-tête d'identité + Profil / Déconnexion
* relayés à l'hôte. L'en-tête est rafraîchi à chaque ouverture, pour
* refléter un title posé après coup par l'hôte (ex. ObsiGate, qui
* remplit l'avatar depuis /api/auth/me après chargement). */
function buildCompteMenu() {
if (!bar || !avatarBtn || compteMenu) return;
compteMenu = document.createElement("div");
compteMenu.className = "draco-compte-menu";
compteMenu.hidden = true;
compteMenu.setAttribute("role", "menu");
compteMenu.setAttribute("aria-label", "Compte");
var head = document.createElement("div");
head.className = "draco-compte-nom";
compteMenu.appendChild(head);
[["profil", "Profil"], ["deconnexion", "Déconnexion"]].forEach(function (pair) {
var b = document.createElement("button");
b.type = "button";
b.className = "draco-menu-item";
b.setAttribute("data-compte-act", pair[0]);
b.setAttribute("role", "menuitem");
b.textContent = pair[1];
compteMenu.appendChild(b);
});
bar.appendChild(compteMenu);
}
function refreshCompteMenu() {
if (!compteMenu) return;
var head = compteMenu.querySelector(".draco-compte-nom");
var name = displayName();
if (head) head.textContent = name || "Compte";
}
function toggleCompteMenu(force) {
buildCompteMenu();
if (!compteMenu) return;
if (force !== false) { syncAvatar(); refreshCompteMenu(); }
togglePopup(compteMenu, avatarBtn, force);
if (!compteMenu.hidden) lastOpener = avatarBtn;
}
document.addEventListener("click", function (ev) {
var el = ev.target.closest("[data-act]");
if (el && el.dataset.act === "apps") {
ev.preventDefault();
toggleMenu();
/* Relais d'une action vers l'hôte : onAction si présent, sinon
* événement draco:bar (contrat historique des actions relayées). */
function relay(act, el, ev) {
if (typeof cfg.onAction === "function") cfg.onAction(act, el, ev);
else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } }));
}
/* Écouteurs globaux posés sur le prototype EventTarget quand il
* existe, pour survivre au registre/teardown d'un hôte SPA (TikTak,
* voir l'en-tête) ; sinon document.addEventListener classique. */
function addGlobalListener(type, fn) {
try {
if (typeof EventTarget !== "undefined" && EventTarget.prototype &&
EventTarget.prototype.addEventListener) {
EventTarget.prototype.addEventListener.call(document, type, fn, false);
return;
}
} catch (e) { /* repli ci-dessous */ }
document.addEventListener(type, fn);
}
addGlobalListener("click", function (ev) {
/* Items du menu compte : relais + fermeture, jamais de navigation. */
var compteItem = ev.target.closest ? ev.target.closest("[data-compte-act]") : null;
if (compteItem) {
relay(compteItem.getAttribute("data-compte-act"), compteItem, ev);
toggleCompteMenu(false);
return;
}
if (menu && !menu.hidden && !ev.target.closest(".draco-apps-menu") && !ev.target.closest(".draco-brand")) {
toggleMenu(false);
var el = ev.target.closest ? ev.target.closest("[data-act]") : null;
if (el && el.dataset.act === "apps") {
ev.preventDefault();
if (compteMenu) toggleCompteMenu(false);
toggleAppsMenu();
return;
}
if (el && el.dataset.act === "compte") {
if (appsMenu) toggleAppsMenu(false);
toggleCompteMenu();
return;
}
/* Clic extérieur : ferme tout menu ouvert (clic dans un menu ou
* sur son déclencheur déjà traité ci-dessus). */
var inPopup = ev.target.closest ? (ev.target.closest(".draco-apps-menu") ||
ev.target.closest(".draco-compte-menu")) : null;
if (!inPopup && ((appsMenu && !appsMenu.hidden) || (compteMenu && !compteMenu.hidden))) {
closeAllPopups(false);
}
if (!el) return;
var act = el.dataset.act;
if (act === "domaine") setDomain(el.dataset.dom);
if (typeof cfg.onAction === "function") cfg.onAction(act, el, ev);
else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } }));
relay(act, el, ev);
});
document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape") toggleMenu(false);
addGlobalListener("keydown", function (ev) {
if (ev.key === "Escape") { closeAllPopups(true); return; }
/* Espace sur la marque : un lien ne s'active pas à l'Espace, donc
* ouverture explicite + focus sur le premier item du menu (#70).
* Entrée : clic natif du lien, déjà géré par le gestionnaire clic
* ci-dessus (pas de double bascule ici). */
if ((ev.key === " " || ev.key === "Spacebar") && ev.target && ev.target.closest) {
var brandEl = ev.target.closest('[data-act="apps"]');
if (brandEl) {
if (ev.preventDefault) ev.preventDefault();
toggleAppsMenu();
if (appsMenu && !appsMenu.hidden) {
var first = appsMenu.querySelector(".draco-apps-item");
if (first && typeof first.focus === "function") first.focus();
}
}
}
});
/* API publique : l'app met les compteurs à jour sans recharger. */
window.DRACO_BAR = { setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain };
/* API publique : l'app met les compteurs à jour sans recharger.
* setUrls(urls) fusionne de nouvelles bases (même contrat que
* DRACO_BAR_CONFIG.urls — donc prioritaires sur la dérivation -dev)
* et les ré-applique aux onglets et aux menus ; DRACO_BAR.urls expose
* les bases résolues (défauts inclus), pour le panneau Spark et tout
* autre consommateur de l'hôte. */
window.DRACO_BAR = {
setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain,
urls: resolvedUrls,
setUrls: function (urls) {
cfg.urls = Object.assign({}, cfg.urls, urls || {});
resolvedUrls = resolveUrls();
window.DRACO_BAR.urls = resolvedUrls;
applyUrls(resolvedUrls);
}
};
buildMenu();
applyUrls(resolvedUrls);
buildAppsMenu();
buildCompteMenu();
syncAvatar();
refreshCompteMenu();
if (cfg.current) setCurrent(cfg.current);
if (cfg.domain) setDomain(cfg.domain);
if (cfg.counts) setCounts(cfg.counts);
+9 -7
View File
@@ -76,6 +76,7 @@
</style>
<link rel="stylesheet" href="/static/style.css" />
<link rel="stylesheet" href="/static/draco-barre.css" />
<link rel="stylesheet" href="/static/draco-alertes.css" />
<link rel="stylesheet" href="/static/draco-panneau.css" />
<link
rel="stylesheet"
@@ -332,7 +333,7 @@
<!-- Main Application (wrapped for show/hide) -->
<!-- Barre d'apps commune dracodev — Fusion #215 (ACC-1.1).
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.4
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.6
(ui/barre-apps/) : ne pas modifier la structure ici ; toute
évolution passe par une nouvelle version de commons.
CSS/JS : /static/draco-barre.css, /static/draco-barre.js. -->
@@ -386,8 +387,7 @@
<span class="draco-spark-ico">✦</span><span class="draco-spark-txt">Spark</span>
<span class="draco-badge" data-badge="spark" hidden>0</span>
</button>
<button type="button" class="draco-icon-btn" data-act="menu" aria-label="Plus d'actions" aria-haspopup="menu">•••</button>
<span class="draco-avatar" id="draco-avatar" title="">✦</span>
<button type="button" class="draco-avatar" id="draco-avatar" data-act="compte" aria-label="Compte" aria-haspopup="menu" aria-expanded="false" title="">✦</button>
</div>
</header>
<div class="draco-bar-rappel" data-act="rappel-entete" hidden>✦ dracodev — afficher l'en-tête</div>
@@ -395,8 +395,9 @@
/* Configuration ObsiGate de la barre commune (Fusion #215).
Phase 1 : navigation entre apps et domaine mémorisé
localement ; la recherche focus la recherche native
d'ObsiGate ; Alertes unifiées et Spark arrivent avec la
fédération / l'agent commun (phases suivantes). */
d'ObsiGate ; Alertes unifiées : panneau draco-alertes.js
(Fusion #69, agrégation à la demande, pas une fédération
temps réel) ; Spark via le panneau commun. */
/* #223 : les href des onglets portent les défauts officiels ;
l'hôte les réécrit depuis window.DRACO_URLS (injecté par le
backend depuis sa configuration) quand il est présent. */
@@ -432,8 +433,8 @@
return;
}
if (act === "palette") { var s = document.getElementById("search-input"); if (s) { s.focus(); s.scrollIntoView({ block: "center" }); } return; }
if (act === "menu") { var m = document.getElementById("header-menu-btn"); if (m) m.click(); return; }
if (act === "alertes") { dire("Alertes unifiées : arrive avec la fédération (programme Fusion)."); return; }
/* Correctif fusion #70 : relais "menu" supprimé avec le bouton trois points de la barre (la console agent est dans le menu de la marque, entrée « Console Spark »). Le bouton natif #header-menu-btn d'ObsiGate lui-même est inchangé et reste utilisable depuis l'en-tête ObsiGate. */
if (act === "alertes") { if (window.DRACO_ALERTES) { window.DRACO_ALERTES.open(); } else { dire("Alertes unifiées : le module n'a pas pu se charger."); } return; }
if (act === "spark") {
if (window.DRACO_PANNEAU) { window.DRACO_PANNEAU.toggle(); }
else { dire("Spark : le panneau n'a pas pu se charger."); }
@@ -6065,6 +6066,7 @@ curl -X POST https://votre-serveur.com/webhook \
</div>
</div>
</div>
<script src="/static/draco-alertes.js"></script>
<script defer src="/static/draco-barre.js"></script>
<script defer src="/static/draco-liens.js"></script>
<script defer src="/static/draco-panneau.js"></script>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.81.0",
"version": "2.81.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": {
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env node
/**
* ObsiGate — Alertes unifiées (Fusion #69, chantier 2).
*
* Tests du module partagé frontend/draco-alertes.js (logique pure,
* sans DOM) + vérification du gabarit livré frontend/index.html :
* le placeholder « Alertes unifiées : arrive avec la fédération »
* a disparu et le module est chargé.
*
* Usage: node tests/frontend/draco-alertes.test.mjs
*/
import test from "node:test";
import { strict as assert } from "node:assert";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, "..", "..");
const require = createRequire(import.meta.url);
const t = require(path.join(REPO_ROOT, "frontend", "draco-alertes.js"));
test("resolveUrls: config explicite > hote -dev > href > defaut prod", () => {
assert.equal(
t.resolveUrls({ hostname: "og.dracodev.net", tabHrefs: {} }).tiktak,
"https://tiktak.dracodev.net/"
);
assert.equal(
t.resolveUrls({ hostname: "og-dev.dracodev.net", tabHrefs: {} }).flowdeck,
"https://flowdeck-dev.dracodev.net/"
);
assert.equal(
t.resolveUrls({
hostname: "og-dev.dracodev.net",
configUrls: { kanbante: "https://kanbante.dracodev.net/" },
}).kanbante,
"https://kanbante.dracodev.net/"
);
assert.equal(t.isDevHost("og-dev.dracodev.net.evil.com"), false);
});
test("normalizeFlowdeck/TikTak/Kanbante: actifs = non lus, Kanbante jamais actif", () => {
const fd = t.normalizeFlowdeck("https://flowdeck.dracodev.net/", {
notifications: [
{ id: 1, ntype: "mention", title: "Mention", message: "m", url: "/collections/3", is_read: 0, created_at: "2026-10-28T10:00:00" },
],
unread: 1,
});
assert.equal(fd[0].active, true);
assert.equal(fd[0].url, "https://flowdeck.dracodev.net/collections/3");
const tt = t.normalizeTiktak("https://tiktak.dracodev.net/", [
{ id: 7, kind: "reminder", title: "Rappel", body: "", task_id: 42, is_read: 0, created_at: "2026-10-28T08:00:00" },
]);
assert.equal(tt[0].url, "https://tiktak.dracodev.net/?task=42");
const kb = t.normalizeKanbante("https://kanbante.dracodev.net/", {
events: [
{ id: 9, repo_full_name: "Projets/fusion-trois-apps", issue_number: 69, event_type: "commented", from_column: "", to_column: "", created_at: "2026-10-28T11:50:00" },
],
});
assert.equal(kb[0].active, false);
assert.equal(kb[0].url, "https://kanbante.dracodev.net/board/Projets/fusion-trois-apps");
assert.equal(t.countActive([...fd, ...tt, ...kb]), 2);
});
test("sortAlerts: actives d'abord, date decroissante", () => {
const sorted = t.sortAlerts([
{ app: "tiktak", active: false, date: "2026-10-28T12:00:00" },
{ app: "flowdeck", active: true, date: "2026-10-26T12:00:00" },
{ app: "flowdeck", active: true, date: "2026-10-28T12:00:00" },
]);
assert.equal(sorted[0].active, true);
assert.equal(sorted[0].date, "2026-10-28T12:00:00");
});
test("index.html: placeholder Alertes disparu, module charge", () => {
const html = readFileSync(path.join(REPO_ROOT, "frontend", "index.html"), "utf8");
assert.equal(html.includes("Alertes unifiées : arrive avec la fédération"), false);
assert.equal(html.includes("/static/draco-alertes.js"), true);
assert.equal(html.includes("/static/draco-alertes.css"), true);
assert.equal(html.includes("DRACO_ALERTES.open()"), true);
});