Compare commits
63
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cf2a3fc25 | ||
|
|
6602a070be | ||
|
|
9e1ff33698 | ||
|
|
47dd9c06a1 | ||
|
|
90b9189169 | ||
|
|
91ddfcd3b3 | ||
|
|
2c10040046 | ||
|
|
32fbe1d137 | ||
|
|
4d1757e3d2 | ||
|
|
9dc62885b0 | ||
|
|
cc007dfa04 | ||
|
|
f0ca8b9ec6 | ||
|
|
6a938cf778 | ||
|
|
c60694bf23 | ||
|
|
a08fa53bab | ||
|
|
1c81259e0d | ||
|
|
5749b1c718 | ||
|
|
c35aafd2e2 | ||
|
|
219c614216 | ||
|
|
0d6499ed1a | ||
|
|
a09ae26d78 | ||
|
|
56f39c0959 | ||
|
|
99a7ebd504 | ||
|
|
e8f48d3009 | ||
|
|
dd6a4dd82c | ||
|
|
a2f3564cc8 | ||
|
|
ea39916db5 | ||
|
|
724d7a7799 | ||
|
|
5ff51d8e2d | ||
|
|
436084aebd | ||
|
|
8ab30d25ef | ||
|
|
6cac4e71f5 | ||
|
|
210f4f8983 | ||
|
|
f150d4e136 | ||
|
|
5787bcf168 | ||
|
|
80006bdf35 | ||
|
|
0219fafa34 | ||
|
|
c97dfb0ef4 | ||
|
|
1b1c793919 | ||
|
|
d11ca0cbff | ||
|
|
0e2df86be5 | ||
|
|
fd71204b5e | ||
|
|
5c8d9f1a4d | ||
|
|
7a4fb2fb7d | ||
|
|
3888e4ad6a | ||
|
|
96898bc5e6 | ||
|
|
fb61169c23 | ||
|
|
16403821de | ||
|
|
49bf9b32bf | ||
|
|
53d1d2c29d | ||
|
|
e723366755 | ||
|
|
fc70d125d4 | ||
|
|
0336f19f0f | ||
|
|
8f437e3a3e | ||
|
|
00676834e8 | ||
|
|
0e78ae3c60 | ||
|
|
b72d2607a8 | ||
|
|
2235d0be02 | ||
|
|
699600eda3 | ||
|
|
3c4bbbf2f1 | ||
|
|
7556cd0bee | ||
|
|
aa98eb6866 | ||
|
|
409e67d57b |
@@ -1,3 +1,44 @@
|
||||
## 0.41.0 — 2026-10-28 — Identité Kanbante : sidebar charbon, accent vert, icônes ligne, bouton thème en Administration (#54, #55)
|
||||
|
||||
- **Sidebar (#54)** : fond charbon draco (`rgba(26, 28, 26, 0.96)`) à la place du bleu-noir `rgba(13, 16, 24)` oublié par le re-thème 0.39.0 — la barre latérale a désormais la même teinte que le reste du site, en sombre comme en clair (clair déjà blanc).
|
||||
- **Accent vert (#54)** : le rose Spark cède la place au vert Kanbante — `--accent: #0d9488`, second ton `#14b8a6` (clair : `#0f766e`), anneaux, focus, dégradés et replis associés. La barre Fusion partagée n'est pas touchée (Spark rose, marques des autres apps).
|
||||
- **Icônes (#54)** : les emoji de la barre latérale sont remplacés par des SVG fins en trait (stroke currentColor), style rail Spark, pour chaque entrée de navigation ; le logo Kanbante devient des colonnes kanban blanches sur fond vert sarcelle.
|
||||
- **Bouton Dark/Light (#55)** : déplacé de l'en-tête applicatif vers la section Administration de la barre latérale (icône seule en rail replié, libellé explicite sinon). Comportement inchangé (bascule + choix mémorisé). Conséquence : seuls les administrateurs le voient, la section Administration leur étant réservée.
|
||||
|
||||
## 0.40.0 — 2026-10-28 — Navigation : section Administration + bouton Dark/Light (#51, #52)
|
||||
|
||||
- **Accordéon (#51)** : la section Administration ne fermait jamais — le JS posait `nav-closed-admin`, mais `style.css` ne définissait les règles de fermeture que pour les 4 autres sections. Les règles admin sont ajoutées (fermeture et rotation du chevron) : l'accordéon redevient exclusif.
|
||||
- **Bouton Dark/Light (#52)** : bouton « ☀️ Mode clair / 🌙 Mode sombre » dans l'en-tête applicatif, qui bascule `data-theme` sur `<html>` et mémorise le choix (localStorage, appliqué avant le rendu par l'amorce de `_header.html`). Thème clair sur les jetons draco (fond #f2f2f0, surfaces blanches/grises, accent rose conservé, second ton assombri pour les liens), barre Fusion et panneau Spark qui suivent. Défaut inchangé : sombre. `nav.js` est désormais versionné (`?v=`), comme dashboard.js depuis la 0.38.1.
|
||||
|
||||
## 0.39.0 — 2026-10-28 — Thème du site : look agent-dev / barre Fusion (#49)
|
||||
|
||||
- **Re-thème complet** : `style.css` passe sur les jetons draco-barre (Console v7.2) — fond #101211, surfaces #1a1c1a / #242624 / #20221f, bordure #2e302c, textes #eceee9 / #a6aba2 / #8b9089, accent rose Spark #c2437e (second ton #e078a8). Fond plat, plus de halos ni de dégradés violet→bleu, y compris les valeurs de repli et l'écran de connexion.
|
||||
- **Inchangé** : couleurs sémantiques (vert/jaune/rouge) et couleurs de colonnes choisies par l'utilisateur. Pas de mode clair : l'app force le sombre, comme la console de référence.
|
||||
|
||||
## 0.38.1 — 2026-10-28 — Page Projets : le correctif 0.38.0 atteint enfin le navigateur (#47)
|
||||
|
||||
- **Cache-busting (#47)** : `dashboard.html` charge désormais `style.css?v={{ version }}` et `dashboard.js?v={{ version }}`, comme les autres pages (admin, board, project) et la barre Fusion. Sans ça, le navigateur gardait l'ancien CSS (menu transparent, bouton de copie non stylé) et l'ancien JS (aucun gestionnaire de copie) alors que le HTML était déjà neuf — c'est pourquoi la 0.38.0 semblait ne rien changer, bouton présent mais inerte. Le code 0.38.0 lui-même (jetons `--menu-bg` opaques, gestionnaire `data-copy-url`) était correct dans le dépôt.
|
||||
|
||||
## 0.38.0 — 2026-10-28 — Page Projets : copie d'URL et refonte du menu ⋯
|
||||
|
||||
- **Copie d'URL (#44)** : chaque tuile projet (grille, y compris Épinglés) propose désormais « 🔗 Copier l'URL du projet » dans son menu ⋯ — l'URL Gitea du dépôt est copiée dans le presse-papiers, avec confirmation « URL du projet copiée » et repli (textarea + execCommand) quand l'API Clipboard n'est pas disponible.
|
||||
- **Menu ⋯ (#45)** : refonte sur les jetons de la barre d'apps commune (draco-barre.css, prototype Console v7.2 / console Spark) — fond opaque, bordure, rayon 14 px, ombre portée, survol/focus visibles, séparateur avant l'action de copie. Corrige la cause du menu transparent : il utilisait `var(--surface-1)`, variable jamais définie.
|
||||
- **Thèmes sombre et clair** : jetons clairs par défaut, variante sombre via `[data-theme="dark"]` sur `<html>` et repli `prefers-color-scheme`, comme la barre Fusion.
|
||||
|
||||
## 0.37.6 — 2026-10-28 — Corrections console/recherche : CORS console Spark
|
||||
|
||||
- **CORS recherche (PR #43)** : la recherche Kanbante autorise désormais la console Spark (agent prod+dev) en inter-origines — les pages console peuvent appeler la recherche de l'hôte sans erreur CORS/réseau.
|
||||
|
||||
## 0.37.5 — 2026-10-28 — Alignement recherche console : onglet Console Spark sur /api/v1/recherche
|
||||
|
||||
- **Recherche (#68)** : l'onglet Console Spark de `/recherche` appelle désormais `GET /api/v1/recherche` (au lieu de `/api/search`) et reconstruit les liens profonds console (`?section=…&objet=…`, `disc=`, `activite=`) — les résultats console réels s'affichent enfin dans la page hôte.
|
||||
|
||||
## 0.37.4 — 2026-10-28 — Correctifs recherche/alertes/console : CORS sœurs prod+dev, recherche en onglets, alertes en onglets
|
||||
|
||||
- **CORS recherche/alertes (#68/#69)** : allowlist explicite des 8 origines sœurs prod+dev (`*.dracodev.net`, avec et sans `-dev`), jamais `*`, avec credentials — pour `app/recherche.py` et le middleware CORS de `GET /api/events`.
|
||||
- **Recherche partout (#68)** : onglets Tout/ObsiGate/Flowdeck/TikTak/Kanbante/Console Spark, « Tout voir » restant sur place, focus automatique, mise en page corrigée.
|
||||
- **Alertes (#69/#71)** : vue Alertes en onglets (Tout, par application, Console Spark, Historique = fermées seules), compteurs, filtre texte, onglet dans l'URL ; onglet « Console Spark » en dernier dans la barre d'apps, après Kanbante.
|
||||
|
||||
## 0.37.3 — 2026-10-28 — Programme Fusion : menu barre, alertes unifiées, recherche partout
|
||||
|
||||
- **Barre (#70)** : bouton « ••• » retiré (menu par défaut supprimé), « Console Spark » ajoutée au menu de la marque, fermeture/focus corrigés.
|
||||
|
||||
+1
-1
@@ -177,7 +177,7 @@ from urllib.parse import urlparse
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
APP_VERSION = "0.37.3"
|
||||
APP_VERSION = "0.41.0"
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
|
||||
+8
-2
@@ -438,10 +438,16 @@ class DracoAlertesCorsMiddleware(BaseHTTPMiddleware):
|
||||
if request.url.path != "/api/events":
|
||||
return await call_next(request)
|
||||
from app.config import resoudre_urls_draco
|
||||
from app.recherche import DEV_ORIGINS, PROD_ORIGINS, origin_of
|
||||
|
||||
urls = resoudre_urls_draco(request.headers.get("host"))
|
||||
soeurs = {urls.sso_url, urls.flowdeck_url, urls.tiktak_url,
|
||||
urls.public_url}
|
||||
# Correctif CORS alertes (fusion #69, 2026-10-28) : comme
|
||||
# pour /api/search, les origines sœurs prod ET dev sont
|
||||
# listées explicitement — l'ancienne allowlist (environnement
|
||||
# résolu seul) refusait les pages de l'autre environnement.
|
||||
soeurs = ({origin_of(u) for u in (urls.sso_url, urls.flowdeck_url,
|
||||
urls.tiktak_url, urls.public_url)}
|
||||
| set(PROD_ORIGINS) | set(DEV_ORIGINS)) - {None}
|
||||
origine = request.headers.get("origin", "")
|
||||
if origine not in soeurs:
|
||||
return await call_next(request)
|
||||
|
||||
+21
-4
@@ -9,9 +9,17 @@ testables sans base ni serveur.
|
||||
recherche Gitea de la page ``/search`` (titres + corps, tous états,
|
||||
tous projets du workspace) et la regroupe par dépôt.
|
||||
- La CORS bornée n'autorise que ``GET /api/search``, et seulement
|
||||
pour les origines sœurs résolues par ``app.config`` (explicite >
|
||||
hôte de la requête > dérivation > défaut officiel — donc ``-dev``
|
||||
reste ``-dev``), jamais ``*``. Le middleware est un ASGI pur :
|
||||
pour les origines sœurs officielles **prod ET dev, listées
|
||||
explicitement** (``PROD_ORIGINS | DEV_ORIGINS``), **plus les
|
||||
origines de la console Spark** (``AGENT_ORIGINS`` : agent prod ET
|
||||
dev — fédération de la recherche depuis la console, 2026-10-28),
|
||||
**plus** les
|
||||
origines résolues par ``app.config`` — jamais ``*``. Correctif
|
||||
2026-10-28 (fusion #68/#69, matrice constatée par Bruno) :
|
||||
l'ancienne allowlist ne contenait que l'environnement résolu
|
||||
(prod OU dev), donc une page de l'autre environnement voyait sa
|
||||
section afficher « Injoignable depuis cet hôte (CORS ou réseau) ».
|
||||
Le middleware est un ASGI pur :
|
||||
il répond lui-même aux preflights OPTIONS (qui ne portent jamais
|
||||
de cookies, donc qu'``AuthMiddleware`` refuserait sinon) et ajoute
|
||||
les en-têtes aux réponses de recherche, sans toucher aux autres
|
||||
@@ -39,6 +47,12 @@ DEV_ORIGINS = frozenset({
|
||||
"https://kanbante-dev.dracodev.net",
|
||||
})
|
||||
|
||||
AGENT_ORIGINS = frozenset({
|
||||
"https://agent.dracodev.net",
|
||||
"https://agent-dev.dracodev.net",
|
||||
})
|
||||
|
||||
|
||||
# Clés du dictionnaire rendu par render() (app/main.py).
|
||||
_SOEUR_KEYS = ("obsigate", "flowdeck", "tiktak", "kanbante")
|
||||
|
||||
@@ -97,7 +111,10 @@ def sister_origins(
|
||||
origin = origin_of((resolved or {}).get(key))
|
||||
if origin:
|
||||
origins.add(origin)
|
||||
return frozenset(origins)
|
||||
# Correctif 2026-10-28 : prod ET dev toujours, quelle que soit
|
||||
# la résolution — ``resolved`` n'ajoute que des origines de
|
||||
# configuration sans jamais retirer une origine officielle.
|
||||
return frozenset(origins) | PROD_ORIGINS | DEV_ORIGINS | AGENT_ORIGINS
|
||||
|
||||
|
||||
def cors_headers_for(
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
d'agents sont INJECTÉES par l'hôte depuis app.config
|
||||
(draco_urls / agent_url, défauts = noms officiels) — aucun nom
|
||||
public codé en dur dans ce gabarit ni dans draco-panneau.js.
|
||||
Chantier 5 (fusion #69/#71, sans bump) : 5e onglet Console Spark
|
||||
(data-app="agent", agent_url), après Kanbante ; l'entrée
|
||||
« Console Spark » du menu de la marque est conservée.
|
||||
CSS/JS : /static/css/draco-barre.css, /static/js/draco-barre.js.
|
||||
Inclus par _header.html (toutes les pages applicatives ; ni le
|
||||
wallboard ni l'écran de connexion, volontaires). #}
|
||||
@@ -37,6 +40,10 @@
|
||||
<span class="draco-apptab-txt"><strong>Kanbante</strong><small>Kanban</small></span>
|
||||
<span class="draco-badge" data-badge="kanbante" hidden>0</span>
|
||||
</a>
|
||||
<a class="draco-apptab" data-app="agent" href="{{ agent_url }}/">
|
||||
<span class="draco-apptab-ico" style="--app:#c2437e">✦</span>
|
||||
<span class="draco-apptab-txt"><strong>Console Spark</strong><small>Agent commun</small></span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="draco-bar-zone draco-bar-centre">
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
<script>
|
||||
/* Thème (#52) : appliqué avant le rendu de la barre, pour
|
||||
éviter le flash sombre quand l'utilisateur a choisi le
|
||||
clair. Défaut = sombre (les gabarits portent
|
||||
data-theme="dark") ; seul un choix mémorisé bascule. */
|
||||
try {
|
||||
var _t = localStorage.getItem('kanbante-theme');
|
||||
if (_t === 'light' || _t === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', _t);
|
||||
}
|
||||
} catch (e) { /* mode privé : le thème ne survit qu'à la page */ }
|
||||
</script>
|
||||
{% include "_draco_bar.html" %}
|
||||
{# ── Cadre applicatif partagé (issue #16, refonte 0.16.0) ─────────
|
||||
Barre latérale unique pour TOUTES les pages applicatives :
|
||||
@@ -78,10 +90,10 @@
|
||||
<aside class="sidebar" id="sidebar" aria-label="Navigation principale">
|
||||
<div class="side-head">
|
||||
<a href="/" class="side-logo" aria-label="Kanbante — accueil">
|
||||
<span class="logo-mark">📋</span>
|
||||
<span class="logo-mark"><svg viewBox="0 0 24 24" fill="#fff" aria-hidden="true"><rect x="3" y="4" width="5" height="16" rx="1"/><rect x="9.5" y="4" width="5" height="10" rx="1"/><rect x="16" y="4" width="5" height="13" rx="1"/></svg></span>
|
||||
<span class="side-label gradient-text">Kanbante</span>
|
||||
</a>
|
||||
<button type="button" class="side-search palette-trigger" title="Recherche & commandes (Ctrl+K)" aria-label="Recherche et commandes (Ctrl+K)">🔍</button>
|
||||
<button type="button" class="side-search palette-trigger" title="Recherche & commandes (Ctrl+K)" aria-label="Recherche et commandes (Ctrl+K)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="M20 20l-4-4"/></svg></button>
|
||||
<button type="button" class="side-collapse" id="side-collapse" title="Replier ou déplier le menu" aria-label="Replier ou déplier le menu"><span class="side-collapse-ico">«</span></button>
|
||||
</div>
|
||||
{% if current_workspace and user_workspaces and user_workspaces|length > 1 %}
|
||||
@@ -95,6 +107,9 @@
|
||||
</select>
|
||||
</form>
|
||||
{% endif %}
|
||||
{# Icônes de navigation (#54) : SVG fins en trait, style rail
|
||||
Spark — monochromes (currentColor), pas d'emoji. #}
|
||||
{% macro nav_icon(key) %}<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">{% if key == 'home' %}<path d="M3 11l9-8 9 8"/><path d="M5 10v11h14V10"/><path d="M10 21v-6h4v6"/>{% elif key == 'focus' %}<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1" fill="currentColor"/>{% elif key == 'today' %}<path d="M7 18a5 5 0 0 1 10 0"/><path d="M3 18h18"/><path d="M12 9V6"/><path d="M5.5 11l1.5 1.5"/><path d="M18.5 11l-1.5 1.5"/>{% elif key == 'my' %}<circle cx="12" cy="8" r="3.5"/><path d="M4 20c0-3.5 3.5-5.5 8-5.5s8 2 8 5.5"/>{% elif key == 'briefing' %}<path d="M4 20V11"/><path d="M10 20V4"/><path d="M16 20v-8"/><path d="M3 20h18"/>{% elif key == 'standup' %}<path d="M4 5h16v11h-7l-4 4v-4H4z"/>{% elif key == 'digest' %}<path d="M6 3h12v18H6z"/><path d="M9 8h6"/><path d="M9 12h6"/><path d="M9 16h4"/>{% elif key == 'inflight' %}<path d="M3 12l19-9-7 18-4-6z"/><path d="M11 15L22 3"/>{% elif key == 'forecast' %}<path d="M3 18l5-6 4 3 9-9"/><path d="M16 6h5v5"/>{% elif key == 'health' %}<path d="M12 20C7 16 3 13 3 9a4.5 4.5 0 0 1 8-2.5A4.5 4.5 0 0 1 21 9c0 4-4 7-9 11z"/>{% elif key == 'rules' %}<path d="M6 3h12v18H6z"/><path d="M9 12l2 2 4-4"/>{% elif key == 'activity' %}<path d="M4 4h16v16H4z"/><path d="M7 8h10"/><path d="M7 12h10"/><path d="M7 16h6"/>{% elif key == 'search' %}<circle cx="11" cy="11" r="7"/><path d="M20 20l-4-4"/>{% elif key == 'capture' %}<path d="M13 2L4 14h6l-1 8 10-13h-6z"/>{% elif key == 'archives' %}<path d="M3 8h18"/><path d="M5 8v12h14V8"/><path d="M9 8v5h6V8"/>{% elif key == 'wall' %}<path d="M3 5h18v11H3z"/><path d="M9 21h6"/><path d="M12 16v5"/>{% elif key == 'admin' %}<path d="M4 4h7v7H4z"/><path d="M13 4h7v7h-7z"/><path d="M4 13h7v7H4z"/><path d="M13 13h7v7h-7z"/>{% elif key == 'admin_connections' %}<path d="M9 3v5"/><path d="M15 3v5"/><path d="M7 8h10v5a5 5 0 0 1-10 0z"/><path d="M12 18v3"/>{% elif key == 'admin_workspaces' %}<circle cx="12" cy="12" r="9"/><path d="M15 9l-2 4-4 2 2-4z"/>{% elif key == 'admin_columns' %}<path d="M4 4h4v16H4z"/><path d="M10 4h4v16h-4z"/><path d="M16 4h4v16h-4z"/>{% elif key == 'admin_settings' %}<circle cx="12" cy="12" r="3"/><path d="M12 2v3"/><path d="M12 19v3"/><path d="M2 12h3"/><path d="M19 12h3"/><path d="M4.5 4.5l2 2"/><path d="M17.5 17.5l2 2"/><path d="M19.5 4.5l-2 2"/><path d="M6.5 17.5l-2 2"/>{% else %}<circle cx="12" cy="12" r="9"/>{% endif %}</svg>{% endmacro %}
|
||||
<nav class="side-nav">
|
||||
{% for gkey, group, links in nav_groups %}
|
||||
<div class="side-section" data-group="{{ gkey }}">
|
||||
@@ -106,10 +121,16 @@
|
||||
<div class="side-acc-inner">
|
||||
{% for key, href, icon, label in links %}
|
||||
<a href="{{ href }}" class="side-link{% if active_nav == key %} active{% endif %}" title="{{ label }}">
|
||||
<span class="side-ico">{{ icon }}</span>
|
||||
<span class="side-ico">{{ nav_icon(key) }}</span>
|
||||
<span class="side-label">{{ label }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% if gkey == 'admin' %}
|
||||
<button type="button" class="side-link theme-toggle-link" id="theme-toggle" title="Basculer entre thème sombre et clair" aria-label="Basculer entre thème sombre et clair">
|
||||
<span class="side-ico" data-theme-ico>☀️</span>
|
||||
<span class="side-label" data-theme-label>Mode clair</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -123,6 +144,9 @@
|
||||
<a href="/profile" class="side-logout" title="Mon profil">👤<span class="side-label"> {{ current_user.display_name or current_user.username }}</span></a>
|
||||
{% endif %}
|
||||
<span class="side-version side-label">v{{ version }}</span>
|
||||
{% if not is_admin %}
|
||||
<button type="button" class="theme-toggle-foot" id="theme-toggle" title="Basculer entre thème sombre et clair" aria-label="Basculer entre thème sombre et clair"><span data-theme-ico>☀️</span></button>
|
||||
{% endif %}
|
||||
{% if auth_enabled %}
|
||||
<a href="/logout" class="side-logout" title="Se déconnecter">⏻<span class="side-label"> Sortir</span></a>
|
||||
{% endif %}
|
||||
@@ -142,5 +166,5 @@
|
||||
{% endif %}
|
||||
<button type="button" class="nav-pill palette-trigger topbar-search" title="Recherche et commandes (Ctrl+K)">🔍</button>
|
||||
</div>
|
||||
<script src="/static/js/nav.js" defer></script>
|
||||
<script src="/static/js/nav.js?v={{ version }}" defer></script>
|
||||
</header>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Kanbante — Dashboard</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📋</text></svg>">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
|
||||
<script defer src="/static/vendor/alpine.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -109,6 +109,8 @@
|
||||
<a href="/sprint/{{ full }}">📖 Récit de sprint</a>
|
||||
{% if repo.html_url %}
|
||||
<a href="{{ repo.html_url }}" target="_blank" rel="noopener">🔗 Gitea ↗</a>
|
||||
<div class="card-menu-sep" role="separator"></div>
|
||||
<button type="button" class="card-menu-item" data-copy-url="{{ repo.html_url }}" title="Copier l'URL Gitea de {{ repo.name }}" aria-label="Copier l'URL du projet {{ repo.name }}">🔗 Copier l'URL du projet</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -259,7 +261,7 @@
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
<script>window.KANBANTE_CSRF = "{{ csrf_token }}";</script>
|
||||
<script src="/static/js/stars.js" defer></script>
|
||||
<script src="/static/js/dashboard.js" defer></script>
|
||||
<script src="/static/js/dashboard.js?v={{ version }}" defer></script>
|
||||
<script src="/static/js/tablesort.js" defer></script>
|
||||
<script>
|
||||
document.addEventListener('keydown', function (e) {
|
||||
|
||||
@@ -15,13 +15,22 @@
|
||||
data-base-obsigate="{{ draco_urls.obsigate }}"
|
||||
data-base-flowdeck="{{ draco_urls.flowdeck }}"
|
||||
data-base-tiktak="{{ draco_urls.tiktak }}"
|
||||
data-base-kanbante="{{ draco_urls.kanbante }}">
|
||||
data-base-kanbante="{{ draco_urls.kanbante }}"
|
||||
data-base-console="{{ agent_url }}">
|
||||
<h1>Rechercher partout</h1>
|
||||
<p class="rp-hint">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.</p>
|
||||
<p class="rp-hint">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.</p>
|
||||
<div class="rp-form">
|
||||
<input id="rp-q" class="rp-input" type="search" name="q" value="{{ q }}"
|
||||
placeholder="Rechercher partout…" autocomplete="off" autofocus>
|
||||
</div>
|
||||
<nav class="rp-tabs" role="tablist" aria-label="Résultats par application">
|
||||
<button type="button" class="rp-tab active" role="tab" aria-selected="true" data-rp-tab="all">Tout <span class="rp-tab-count"></span></button>
|
||||
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="obsigate">ObsiGate <span class="rp-tab-count"></span></button>
|
||||
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="flowdeck">Flowdeck <span class="rp-tab-count"></span></button>
|
||||
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="tiktak">TikTak <span class="rp-tab-count"></span></button>
|
||||
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="kanbante">Kanbante <span class="rp-tab-count"></span></button>
|
||||
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="console">Console Spark <span class="rp-tab-count"></span></button>
|
||||
</nav>
|
||||
<section class="rp-section" data-rp-section="obsigate">
|
||||
<div class="rp-head"><h2>ObsiGate — notes</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
|
||||
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
|
||||
@@ -38,6 +47,10 @@
|
||||
<div class="rp-head"><h2>Kanbante — cartes</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
|
||||
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
|
||||
</section>
|
||||
<section class="rp-section" data-rp-section="console">
|
||||
<div class="rp-head"><h2>Console Spark — discussions</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
|
||||
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
|
||||
@@ -37,3 +37,22 @@
|
||||
.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; }
|
||||
/* Chantier 5 — onglets + filtre (mêmes variables d'hôte, mêmes replis). */
|
||||
.draco-alertes-tabs { display: flex; flex-wrap: wrap; gap: 6px; margin: 2px 0 12px; }
|
||||
.draco-alertes-tab {
|
||||
border: 1px solid var(--border, #333); background: transparent;
|
||||
color: inherit; border-radius: 999px; padding: 5px 11px;
|
||||
cursor: pointer; font-size: 13px; line-height: 1.3;
|
||||
}
|
||||
.draco-alertes-tab[aria-selected="true"] { background: #e8762c; border-color: #e8762c; color: #fff; }
|
||||
.draco-alertes-tab:focus-visible { outline: 2px solid #e8762c; outline-offset: 2px; }
|
||||
.draco-alertes-filtre { display: flex; gap: 8px; align-items: center; margin: 0 0 4px; }
|
||||
.draco-alertes-filtre label { font-size: 13px; color: var(--text-dim, #999); }
|
||||
.draco-alertes-filtre input {
|
||||
flex: 1; min-width: 0; background: transparent; color: inherit;
|
||||
border: 1px solid var(--border, #333); border-radius: 8px; padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.draco-alertes-compte { font-size: 12.5px; color: var(--text-dim, #999); margin: 4px 0 10px; min-height: 1em; }
|
||||
.draco-alertes-compte:empty { display: none; }
|
||||
.draco-alertes-item.is-error { border-color: #c2437e; }
|
||||
|
||||
@@ -192,5 +192,9 @@
|
||||
.draco-bar[data-current="obsigate"] .draco-apptab[data-app="obsigate"],
|
||||
.draco-bar[data-current="flowdeck"] .draco-apptab[data-app="flowdeck"],
|
||||
.draco-bar[data-current="tiktak"] .draco-apptab[data-app="tiktak"],
|
||||
.draco-bar[data-current="kanbante"] .draco-apptab[data-app="kanbante"] { display: flex; }
|
||||
.draco-bar[data-current="kanbante"] .draco-apptab[data-app="kanbante"],
|
||||
/* Chantier 5 : sur la console elle-même (data-current="agent",
|
||||
programme console #71), c'est l'onglet Console Spark qui reste
|
||||
visible en mobile — les 4 hôtes gardent leur propre courant. */
|
||||
.draco-bar[data-current="agent"] .draco-apptab[data-app="agent"] { display: flex; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
+226
-138
@@ -1,23 +1,24 @@
|
||||
/* ═══════════════════════════════════════════════
|
||||
Kanbante — design system 0.4
|
||||
Dark, calm, modern. One accent gradient, generous
|
||||
radii, soft depth, visible focus, quiet motion.
|
||||
Thème agent (draco-barre, Console v7.2) : charbon neutre,
|
||||
surfaces grises chaudes, accent rose Spark — plus de
|
||||
dominante bleu/mauve (issue #49).
|
||||
═══════════════════════════════════════════════ */
|
||||
|
||||
:root {
|
||||
--bg: #0b0e14;
|
||||
--bg-glow-1: rgba(124, 92, 252, 0.13);
|
||||
--bg-glow-2: rgba(91, 138, 247, 0.10);
|
||||
--surface: #141822;
|
||||
--surface-2: #1b202e;
|
||||
--surface-3: #232939;
|
||||
--border: rgba(148, 152, 164, 0.16);
|
||||
--border-strong: rgba(148, 152, 164, 0.30);
|
||||
--text: #eceef3;
|
||||
--text-2: #9aa0ae;
|
||||
--text-3: #6d7280;
|
||||
--accent: #7c5cfc;
|
||||
--accent-2: #5b8af7;
|
||||
--bg: #101211;
|
||||
--bg-glow-1: transparent;
|
||||
--bg-glow-2: transparent;
|
||||
--surface: #1a1c1a;
|
||||
--surface-2: #242624;
|
||||
--surface-3: #20221f;
|
||||
--border: #2e302c;
|
||||
--border-strong: #3f4340;
|
||||
--text: #eceee9;
|
||||
--text-2: #a6aba2;
|
||||
--text-3: #8b9089;
|
||||
--accent: #0d9488;
|
||||
--accent-2: #14b8a6;
|
||||
--green: #10b981;
|
||||
--yellow: #f59e0b;
|
||||
--red: #ef4444;
|
||||
@@ -25,7 +26,7 @@
|
||||
--radius-sm: 10px;
|
||||
--shadow: 0 8px 28px rgba(0, 0, 0, 0.38);
|
||||
--shadow-sm: 0 2px 10px rgba(0, 0, 0, 0.25);
|
||||
--ring: 0 0 0 3px rgba(124, 92, 252, 0.35);
|
||||
--ring: 0 0 0 3px rgba(13, 148, 136, 0.35);
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
@@ -47,7 +48,7 @@ body {
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
::selection { background: rgba(124, 92, 252, 0.45); }
|
||||
::selection { background: rgba(13, 148, 136, 0.45); }
|
||||
|
||||
a { color: var(--accent-2); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
@@ -78,7 +79,7 @@ button, input, select, textarea { font: inherit; }
|
||||
padding: 8px 10px;
|
||||
}
|
||||
:where(input, textarea, select):focus-visible {
|
||||
outline: 2px solid rgba(124, 92, 252, 0.55);
|
||||
outline: 2px solid rgba(13, 148, 136, 0.55);
|
||||
outline-offset: 0;
|
||||
}
|
||||
|
||||
@@ -92,7 +93,7 @@ button, input, select, textarea { font: inherit; }
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 24px;
|
||||
background: rgba(16, 19, 28, 0.82);
|
||||
background: rgba(26, 28, 26, 0.82);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
@@ -120,7 +121,7 @@ button, input, select, textarea { font: inherit; }
|
||||
font-size: 1rem;
|
||||
}
|
||||
.gradient-text {
|
||||
background: linear-gradient(120deg, #a78bfa, #7dd3fc);
|
||||
background: linear-gradient(120deg, #0d9488, #14b8a6);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
@@ -144,7 +145,7 @@ header h1 { font-size: 1.2rem; font-weight: 600; }
|
||||
.nav-pill:hover { color: var(--text); background: var(--surface-2); text-decoration: none; }
|
||||
.nav-pill.active {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, rgba(124,92,252,.85), rgba(91,138,247,.85));
|
||||
background: linear-gradient(135deg, rgba(13,148,136,.85), rgba(20,184,166,.85));
|
||||
border-color: rgba(255,255,255,0.12);
|
||||
}
|
||||
|
||||
@@ -154,7 +155,7 @@ header h1 { font-size: 1.2rem; font-weight: 600; }
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
color: #c4b5fd;
|
||||
color: #7dd3c8;
|
||||
}
|
||||
.badge.secondary { background: var(--surface-2); color: var(--text-2); }
|
||||
|
||||
@@ -280,13 +281,13 @@ a.kpi:hover { background: var(--surface-2); text-decoration: none; }
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: #c4b5fd;
|
||||
color: #7dd3c8;
|
||||
font-weight: 700;
|
||||
}
|
||||
.date { font-size: 0.75rem; color: var(--text-3); }
|
||||
.project-card h3 { font-size: 1.08rem; margin-bottom: 6px; letter-spacing: -0.01em; }
|
||||
.project-card h3 a { color: var(--text); }
|
||||
.project-card h3 a:hover { color: #a5c4fd; text-decoration: none; }
|
||||
.project-card h3 a:hover { color: #14b8a6; text-decoration: none; }
|
||||
.desc {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-2);
|
||||
@@ -362,7 +363,7 @@ a.kpi:hover { background: var(--surface-2); text-decoration: none; }
|
||||
.milestone-chip:hover { color: var(--text); text-decoration: none; border-color: var(--border-strong); }
|
||||
.milestone-chip.active {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, rgba(124,92,252,.9), rgba(91,138,247,.9));
|
||||
background: linear-gradient(135deg, rgba(13,148,136,.9), rgba(20,184,166,.9));
|
||||
border-color: transparent;
|
||||
}
|
||||
.milestone-banner {
|
||||
@@ -371,8 +372,8 @@ a.kpi:hover { background: var(--surface-2); text-decoration: none; }
|
||||
gap: 16px;
|
||||
margin: 14px 24px 0;
|
||||
padding: 14px 18px;
|
||||
background: linear-gradient(120deg, rgba(124,92,252,.14), rgba(91,138,247,.10));
|
||||
border: 1px solid rgba(124,92,252,.35);
|
||||
background: linear-gradient(120deg, rgba(13,148,136,.14), rgba(20,184,166,.10));
|
||||
border: 1px solid rgba(13,148,136,.35);
|
||||
border-radius: var(--radius);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@@ -489,7 +490,7 @@ a.kpi:hover { background: var(--surface-2); text-decoration: none; }
|
||||
.kanban-card.dragging { opacity: 0.55; transform: rotate(2deg) scale(1.02); box-shadow: var(--shadow); }
|
||||
.card-id {
|
||||
font-size: 0.7rem;
|
||||
color: #c4b5fd;
|
||||
color: #7dd3c8;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
display: flex;
|
||||
@@ -640,7 +641,7 @@ footer {
|
||||
.focus-group { margin-bottom: 30px; }
|
||||
.focus-group h2 { font-size: 1.06rem; margin-bottom: 12px; display: flex; gap: 10px; align-items: center; }
|
||||
.focus-group h2 a { color: var(--text); }
|
||||
.focus-group h2 a:hover { color: #a5c4fd; text-decoration: none; }
|
||||
.focus-group h2 a:hover { color: #14b8a6; text-decoration: none; }
|
||||
.focus-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
|
||||
.focus-card { margin-bottom: 0; cursor: default; }
|
||||
|
||||
@@ -771,7 +772,7 @@ footer {
|
||||
}
|
||||
.issue-row:hover { border-color: var(--border-strong); }
|
||||
.issue-main { color: var(--text); flex: 1; min-width: 220px; }
|
||||
.issue-main:hover { color: #a5c4fd; text-decoration: none; }
|
||||
.issue-main:hover { color: #14b8a6; text-decoration: none; }
|
||||
.col-chip {
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
@@ -817,7 +818,7 @@ footer {
|
||||
cursor: col-resize;
|
||||
z-index: 5;
|
||||
}
|
||||
.notes-resize:hover { background: rgba(124, 92, 252, 0.35); }
|
||||
.notes-resize:hover { background: rgba(13, 148, 136, 0.35); }
|
||||
.notes-foot { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-top: 10px; }
|
||||
.notes-foot .btn-save { margin-top: 0; }
|
||||
.notes-status { font-size: 0.76rem; color: var(--text-3); }
|
||||
@@ -925,8 +926,8 @@ footer {
|
||||
|
||||
/* ── Wallboard (0.6.0) : affichage plein écran, lisible à 3 mètres ── */
|
||||
.wall-body {
|
||||
background: #0b1220;
|
||||
color: #e5ecf5;
|
||||
background: #101211;
|
||||
color: #eceee9;
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
height: 100vh;
|
||||
@@ -950,12 +951,12 @@ footer {
|
||||
.wall-head {
|
||||
display: flex; justify-content: space-between; align-items: flex-start; gap: 24px;
|
||||
}
|
||||
.wall-kicker { font-size: 1rem; letter-spacing: 0.22em; text-transform: uppercase; color: #8fa3bf; }
|
||||
.wall-kicker { font-size: 1rem; letter-spacing: 0.22em; text-transform: uppercase; color: #a6aba2; }
|
||||
.wall-project { font-size: clamp(2.2rem, 5.2vw, 4.2rem); margin: 0.4rem 0 0; line-height: 1.05; }
|
||||
.wall-clock { text-align: right; }
|
||||
.wall-time { font-size: clamp(2rem, 4.5vw, 3.6rem); font-weight: 800; line-height: 1; }
|
||||
.wall-date { color: #8fa3bf; font-size: 1.15rem; margin-top: 0.35rem; text-transform: capitalize; }
|
||||
.wall-rotation { color: #5b6b84; margin-top: 0.4rem; font-size: 1rem; }
|
||||
.wall-date { color: #a6aba2; font-size: 1.15rem; margin-top: 0.35rem; text-transform: capitalize; }
|
||||
.wall-rotation { color: #8b9089; margin-top: 0.4rem; font-size: 1rem; }
|
||||
.wall-counts {
|
||||
display: grid; grid-template-columns: repeat(6, 1fr); gap: 14px;
|
||||
}
|
||||
@@ -968,7 +969,7 @@ footer {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
.wall-count-n { display: block; font-size: clamp(1.9rem, 3.6vw, 3rem); font-weight: 800; }
|
||||
.wall-count-l { display: block; color: #aebdd2; font-size: clamp(0.78rem, 1.25vw, 1.02rem); margin-top: 2px; }
|
||||
.wall-count-l { display: block; color: #a6aba2; font-size: clamp(0.78rem, 1.25vw, 1.02rem); margin-top: 2px; }
|
||||
.wall-cards {
|
||||
list-style: none; margin: 0; padding: 0;
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
@@ -976,7 +977,7 @@ footer {
|
||||
-webkit-mask-image: linear-gradient(to bottom, #000 88%, transparent);
|
||||
mask-image: linear-gradient(to bottom, #000 88%, transparent);
|
||||
}
|
||||
.wall-more { justify-content: center; color: #8fa3bf; font-size: 1.1rem; background: transparent; }
|
||||
.wall-more { justify-content: center; color: #a6aba2; font-size: 1.1rem; background: transparent; }
|
||||
.wall-card {
|
||||
display: flex; align-items: center; gap: 18px;
|
||||
background: rgba(255, 255, 255, 0.045);
|
||||
@@ -992,13 +993,13 @@ footer {
|
||||
.wall-card-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.wall-card-age { color: #fbbf24; font-weight: 700; white-space: nowrap; }
|
||||
.wall-card-age.hot { color: #f87171; }
|
||||
.wall-idle { font-size: 1.5rem; color: #8fa3bf; }
|
||||
.wall-idle { font-size: 1.5rem; color: #a6aba2; }
|
||||
.wall-foot {
|
||||
margin-top: auto;
|
||||
display: flex; gap: 36px; flex-wrap: wrap;
|
||||
justify-content: space-between; align-items: center;
|
||||
width: 100%; box-sizing: border-box;
|
||||
font-size: clamp(1rem, 1.7vw, 1.35rem); color: #aebdd2;
|
||||
font-size: clamp(1rem, 1.7vw, 1.35rem); color: #a6aba2;
|
||||
}
|
||||
@media (max-height: 760px) {
|
||||
.wall-slide { gap: 1.6vh; padding-top: 2.5vh; }
|
||||
@@ -1011,9 +1012,9 @@ footer {
|
||||
.wall-empty {
|
||||
min-height: 100vh; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center; gap: 10px; text-align: center;
|
||||
color: #aebdd2; padding: 24px;
|
||||
color: #a6aba2; padding: 24px;
|
||||
}
|
||||
.wall-empty h1 { color: #e5ecf5; font-size: 2.6rem; margin: 0; }
|
||||
.wall-empty h1 { color: #eceee9; font-size: 2.6rem; margin: 0; }
|
||||
.wall-empty code { background: rgba(255,255,255,0.08); padding: 2px 8px; border-radius: 6px; }
|
||||
@media (max-width: 900px) {
|
||||
.wall-counts { grid-template-columns: repeat(3, 1fr); }
|
||||
@@ -1043,8 +1044,8 @@ footer {
|
||||
.palette-overlay[hidden] { display: none; }
|
||||
.palette {
|
||||
width: min(640px, 100%);
|
||||
background: var(--surface-1, #131a29);
|
||||
border: 1px solid var(--border-strong, #2c3a52);
|
||||
background: var(--surface-1, #1a1c1a);
|
||||
border: 1px solid var(--border-strong, #3f4340);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55);
|
||||
overflow: hidden;
|
||||
@@ -1052,14 +1053,14 @@ footer {
|
||||
}
|
||||
.palette-input-row {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 14px 16px; border-bottom: 1px solid var(--border, #22304a);
|
||||
padding: 14px 16px; border-bottom: 1px solid var(--border, #2e302c);
|
||||
}
|
||||
.palette-input-row input {
|
||||
flex: 1; background: transparent; border: none; outline: none;
|
||||
color: var(--text-1); font: inherit; font-size: 1.05rem;
|
||||
}
|
||||
.palette-input-row kbd {
|
||||
background: var(--surface-3, #1d2942); border: 1px solid var(--border, #22304a);
|
||||
background: var(--surface-3, #20221f); border: 1px solid var(--border, #2e302c);
|
||||
border-radius: 6px; padding: 2px 8px; font-size: 0.75rem; color: var(--text-3);
|
||||
}
|
||||
.palette-results { list-style: none; margin: 0; padding: 8px; max-height: 46vh; overflow-y: auto; }
|
||||
@@ -1067,13 +1068,13 @@ footer {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 10px 12px; border-radius: 10px; cursor: pointer;
|
||||
}
|
||||
.palette-item.active { background: var(--surface-3, #1d2942); }
|
||||
.palette-item.active { background: var(--surface-3, #20221f); }
|
||||
.palette-item-icon { font-size: 1.05rem; width: 24px; text-align: center; }
|
||||
.palette-item-main { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.palette-item-sub { color: var(--text-3); font-size: 0.8rem; white-space: nowrap; }
|
||||
.palette-empty { padding: 18px 14px; color: var(--text-3); text-align: center; }
|
||||
.palette-hint {
|
||||
padding: 9px 16px; border-top: 1px solid var(--border, #22304a);
|
||||
padding: 9px 16px; border-top: 1px solid var(--border, #2e302c);
|
||||
color: var(--text-3); font-size: 0.78rem; text-align: right;
|
||||
}
|
||||
.palette-trigger { cursor: pointer; font: inherit; }
|
||||
@@ -1082,54 +1083,54 @@ footer {
|
||||
.triage-main { max-width: 860px; margin: 0 auto; padding: 24px 16px 40px; }
|
||||
.triage-head h1 { margin: 0 0 12px; font-size: 1.4rem; }
|
||||
.triage-progress-wrap { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; }
|
||||
.triage-progress { flex: 1; height: 10px; border-radius: 99px; background: var(--surface-3, #1d2942); overflow: hidden; }
|
||||
.triage-progress { flex: 1; height: 10px; border-radius: 99px; background: var(--surface-3, #20221f); overflow: hidden; }
|
||||
.triage-progress-bar { height: 100%; width: 0; background: var(--good, #3fb950); border-radius: 99px; transition: width 0.25s ease; }
|
||||
.triage-progress-label { color: var(--text-3); font-size: 0.85rem; white-space: nowrap; }
|
||||
.triage-card {
|
||||
background: var(--surface-1, #131a29);
|
||||
border: 1px solid var(--border-strong, #2c3a52);
|
||||
background: var(--surface-1, #1a1c1a);
|
||||
border: 1px solid var(--border-strong, #3f4340);
|
||||
border-radius: 16px; padding: 26px 28px; min-height: 220px;
|
||||
}
|
||||
.triage-card-top { display: flex; gap: 14px; align-items: baseline; color: var(--text-3); font-size: 0.88rem; }
|
||||
.triage-card-num { font-weight: 700; color: var(--accent, #58a6ff); }
|
||||
.triage-card-num { font-weight: 700; color: var(--accent, #0d9488); }
|
||||
.triage-card-link { margin-left: auto; color: var(--text-3); text-decoration: none; }
|
||||
.triage-card-title { margin: 10px 0 8px; font-size: 1.5rem; line-height: 1.3; }
|
||||
.triage-card-labels { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.triage-card-excerpt { color: var(--text-2, #b6c2d2); line-height: 1.55; white-space: pre-wrap; }
|
||||
.triage-card-excerpt { color: var(--text-2, #a6aba2); line-height: 1.55; white-space: pre-wrap; }
|
||||
.triage-card-assign { color: var(--text-3); }
|
||||
.triage-keys { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 18px; }
|
||||
.triage-keys button {
|
||||
background: var(--surface-2, #182236); color: var(--text-1);
|
||||
border: 1px solid var(--border, #22304a); border-radius: 10px;
|
||||
border: 1px solid var(--border, #2e302c); border-radius: 10px;
|
||||
padding: 9px 13px; font: inherit; font-size: 0.9rem; cursor: pointer;
|
||||
}
|
||||
.triage-keys button:hover { border-color: var(--accent, #58a6ff); }
|
||||
.triage-keys button:hover { border-color: var(--accent, #0d9488); }
|
||||
.triage-keys kbd {
|
||||
background: var(--surface-3, #1d2942); border: 1px solid var(--border-strong, #2c3a52);
|
||||
background: var(--surface-3, #20221f); border: 1px solid var(--border-strong, #3f4340);
|
||||
border-radius: 5px; padding: 1px 6px; font-size: 0.78rem; margin-right: 4px;
|
||||
}
|
||||
.triage-status { min-height: 22px; margin-top: 12px; color: var(--good, #3fb950); }
|
||||
.triage-labels {
|
||||
margin-top: 14px; background: var(--surface-1, #131a29);
|
||||
border: 1px solid var(--border-strong, #2c3a52); border-radius: 14px; padding: 16px 18px;
|
||||
margin-top: 14px; background: var(--surface-1, #1a1c1a);
|
||||
border: 1px solid var(--border-strong, #3f4340); border-radius: 14px; padding: 16px 18px;
|
||||
}
|
||||
.triage-labels-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||
.triage-labels-list { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.triage-label-chip {
|
||||
background: var(--surface-3, #1d2942); color: var(--text-1);
|
||||
border: 1px solid var(--border, #22304a); border-radius: 99px;
|
||||
background: var(--surface-3, #20221f); color: var(--text-1);
|
||||
border: 1px solid var(--border, #2e302c); border-radius: 99px;
|
||||
padding: 5px 12px; font: inherit; font-size: 0.85rem; cursor: pointer;
|
||||
}
|
||||
.triage-label-chip.on { border-color: var(--good, #3fb950); color: var(--good, #3fb950); }
|
||||
.triage-done { background: var(--surface-1, #131a29); border: 1px solid var(--border-strong, #2c3a52); border-radius: 16px; padding: 26px 28px; }
|
||||
.triage-done { background: var(--surface-1, #1a1c1a); border: 1px solid var(--border-strong, #3f4340); border-radius: 16px; padding: 26px 28px; }
|
||||
.triage-summary { line-height: 1.9; }
|
||||
.triage-projects { display: grid; gap: 10px; }
|
||||
.triage-project {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
background: var(--surface-1, #131a29); border: 1px solid var(--border, #22304a);
|
||||
background: var(--surface-1, #1a1c1a); border: 1px solid var(--border, #2e302c);
|
||||
border-radius: 12px; padding: 16px 20px; color: var(--text-1); text-decoration: none;
|
||||
}
|
||||
.triage-project:hover { border-color: var(--accent, #58a6ff); }
|
||||
.triage-project:hover { border-color: var(--accent, #0d9488); }
|
||||
.triage-project-count { color: var(--text-3); }
|
||||
|
||||
/* ── Wallboard : navigation manuelle (0.11.0) ── */
|
||||
@@ -1143,14 +1144,14 @@ footer {
|
||||
border: 1px solid rgba(143, 163, 191, 0.45);
|
||||
font-size: 1.15rem; cursor: pointer; backdrop-filter: blur(4px);
|
||||
}
|
||||
.wall-nav-btn:hover { border-color: #58a6ff; color: #fff; }
|
||||
.wall-nav-btn:hover { border-color: #0d9488; color: #fff; }
|
||||
.wall-dots { display: flex; gap: 8px; }
|
||||
.wall-dot {
|
||||
width: 13px; height: 13px; border-radius: 50%; padding: 0;
|
||||
background: rgba(143, 163, 191, 0.35); border: 1px solid rgba(143, 163, 191, 0.5);
|
||||
cursor: pointer;
|
||||
}
|
||||
.wall-dot.on { background: #58a6ff; border-color: #58a6ff; }
|
||||
.wall-dot.on { background: #0d9488; border-color: #0d9488; }
|
||||
.wall-paused {
|
||||
position: fixed; top: 18px; left: 50%; transform: translateX(-50%);
|
||||
background: rgba(19, 26, 41, 0.85); color: #f0b429;
|
||||
@@ -1169,14 +1170,14 @@ footer {
|
||||
.today-sub { color: var(--text-3); margin: 0 0 22px; }
|
||||
.today-picks { display: grid; gap: 14px; margin-bottom: 20px; }
|
||||
.today-card {
|
||||
background: var(--surface-1, #131a29); border: 1px solid var(--border-strong, #2c3a52);
|
||||
border-left: 5px solid var(--accent, #58a6ff); border-radius: 14px; padding: 20px 24px;
|
||||
background: var(--surface-1, #1a1c1a); border: 1px solid var(--border-strong, #3f4340);
|
||||
border-left: 5px solid var(--accent, #0d9488); border-radius: 14px; padding: 20px 24px;
|
||||
}
|
||||
.today-card.done { border-left-color: var(--good, #3fb950); opacity: 0.85; }
|
||||
.today-card-top { display: flex; justify-content: space-between; color: var(--text-3); font-size: 0.85rem; }
|
||||
.today-card h2 { margin: 8px 0 12px; font-size: 1.3rem; line-height: 1.35; }
|
||||
.today-card-actions { display: flex; gap: 16px; align-items: center; }
|
||||
.today-card-actions a { color: var(--accent, #58a6ff); text-decoration: none; font-size: 0.9rem; }
|
||||
.today-card-actions a { color: var(--accent, #0d9488); text-decoration: none; font-size: 0.9rem; }
|
||||
.today-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 10px; }
|
||||
.today-list li { display: flex; justify-content: space-between; gap: 12px; align-items: center; }
|
||||
.today-list em { color: var(--text-3); font-style: normal; font-size: 0.85rem; }
|
||||
@@ -1184,35 +1185,35 @@ footer {
|
||||
/* ── Prévisions + Récit (0.12.0, #7 #4) ── */
|
||||
.forecast-flex { display: flex; gap: 22px; align-items: center; flex-wrap: wrap; }
|
||||
.sparkline { width: 168px; height: 48px; }
|
||||
.sparkline rect { fill: var(--accent, #58a6ff); opacity: 0.85; }
|
||||
.sparkline rect { fill: var(--accent, #0d9488); opacity: 0.85; }
|
||||
.forecast-nums p { margin: 4px 0; }
|
||||
.forecast-milestones { margin: 12px 0 0; padding-left: 18px; line-height: 1.8; }
|
||||
.forecast-line { color: var(--accent, #58a6ff); }
|
||||
.milestone-forecast { color: var(--accent, #58a6ff); font-weight: 600; }
|
||||
.milestone-sprint { color: var(--text-1); text-decoration: none; border: 1px solid var(--border, #22304a); border-radius: 8px; padding: 2px 10px; }
|
||||
.milestone-sprint:hover { border-color: var(--accent, #58a6ff); }
|
||||
.forecast-line { color: var(--accent, #0d9488); }
|
||||
.milestone-forecast { color: var(--accent, #0d9488); font-weight: 600; }
|
||||
.milestone-sprint { color: var(--text-1); text-decoration: none; border: 1px solid var(--border, #2e302c); border-radius: 8px; padding: 2px 10px; }
|
||||
.milestone-sprint:hover { border-color: var(--accent, #0d9488); }
|
||||
.sprint-render { line-height: 1.65; }
|
||||
.sprint-render h1 { font-size: 1.5rem; }
|
||||
.sprint-render h2 { font-size: 1.15rem; margin-top: 22px; }
|
||||
.sprint-render a { color: var(--accent, #58a6ff); }
|
||||
.sprint-render a { color: var(--accent, #0d9488); }
|
||||
|
||||
/* ── Code lié (0.13.0, #1) ── */
|
||||
.card-code { font-size: 0.78rem; color: var(--accent, #58a6ff); margin-top: 5px; }
|
||||
.card-code { font-size: 0.78rem; color: var(--accent, #0d9488); margin-top: 5px; }
|
||||
.detail-code { margin: 10px 0; }
|
||||
.code-line { padding: 3px 0; font-size: 0.9rem; color: var(--text-2, #b6c2d2); }
|
||||
.code-line a { color: var(--accent, #58a6ff); text-decoration: none; }
|
||||
.code-line code { background: var(--surface-3, #1d2942); border-radius: 4px; padding: 0 5px; }
|
||||
.code-line { padding: 3px 0; font-size: 0.9rem; color: var(--text-2, #a6aba2); }
|
||||
.code-line a { color: var(--accent, #0d9488); text-decoration: none; }
|
||||
.code-line code { background: var(--surface-3, #20221f); border-radius: 4px; padding: 0 5px; }
|
||||
|
||||
/* ── Branche depuis la carte (0.14.0, #3) ── */
|
||||
.branch-input {
|
||||
background: var(--surface-3, #1d2942); color: var(--text-1);
|
||||
border: 1px solid var(--border, #22304a); border-radius: 6px;
|
||||
background: var(--surface-3, #20221f); color: var(--text-1);
|
||||
border: 1px solid var(--border, #2e302c); border-radius: 6px;
|
||||
padding: 3px 8px; font-family: monospace; font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ── Santé du flux (0.14.0, #9) ── */
|
||||
.health-table { width: 100%; border-collapse: collapse; margin: 10px 0; }
|
||||
.health-table th, .health-table td { text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--border, #22304a); }
|
||||
.health-table th, .health-table td { text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--border, #2e302c); }
|
||||
.health-slowest td { color: var(--warn, #e8a33d); font-weight: 600; }
|
||||
.zombie-actions { display: flex; gap: 6px; flex-shrink: 0; }
|
||||
|
||||
@@ -1245,7 +1246,7 @@ body.with-sidebar.sidebar-collapsed { padding-left: 68px; }
|
||||
width: 238px; box-sizing: border-box;
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
padding: 16px 12px 12px; overflow-y: auto; overflow-x: hidden;
|
||||
background: rgba(13, 16, 24, 0.96);
|
||||
background: rgba(26, 28, 26, 0.96);
|
||||
border-right: 1px solid var(--border);
|
||||
transition: width 0.26s cubic-bezier(0.4, 0, 0.2, 1), transform 0.26s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
@@ -1266,7 +1267,7 @@ body.sidebar-collapsed .sidebar { width: 68px; }
|
||||
transition: border-color 0.15s, color 0.15s, transform 0.15s;
|
||||
}
|
||||
.side-search:hover {
|
||||
color: var(--text); border-color: var(--accent, #58a6ff);
|
||||
color: var(--text); border-color: var(--accent, #0d9488);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
body.sidebar-collapsed .side-head { flex-direction: column; padding-left: 0; padding-right: 0; }
|
||||
@@ -1297,11 +1298,13 @@ body.sidebar-collapsed .side-search { margin-left: 0; }
|
||||
body.nav-closed-principal [data-group="principal"] .side-acc,
|
||||
body.nav-closed-rituels [data-group="rituels"] .side-acc,
|
||||
body.nav-closed-pilotage [data-group="pilotage"] .side-acc,
|
||||
body.nav-closed-outils [data-group="outils"] .side-acc { grid-template-rows: 0fr; }
|
||||
body.nav-closed-outils [data-group="outils"] .side-acc,
|
||||
body.nav-closed-admin [data-group="admin"] .side-acc { grid-template-rows: 0fr; }
|
||||
body.nav-closed-principal [data-group="principal"] .side-chevron,
|
||||
body.nav-closed-rituels [data-group="rituels"] .side-chevron,
|
||||
body.nav-closed-pilotage [data-group="pilotage"] .side-chevron,
|
||||
body.nav-closed-outils [data-group="outils"] .side-chevron { transform: rotate(-90deg); }
|
||||
body.nav-closed-outils [data-group="outils"] .side-chevron,
|
||||
body.nav-closed-admin [data-group="admin"] .side-chevron { transform: rotate(-90deg); }
|
||||
@media (min-width: 901px) {
|
||||
/* Rail replié : pas d'accordéon, toutes les icônes visibles. */
|
||||
body.sidebar-collapsed .side-acc { grid-template-rows: 1fr !important; }
|
||||
@@ -1316,10 +1319,22 @@ body.nav-closed-outils [data-group="outils"] .side-chevron { transform: rotate(-
|
||||
}
|
||||
.side-link:hover { background: var(--surface-2); color: var(--text); text-decoration: none; }
|
||||
.side-link.active {
|
||||
color: #fff; border-left-color: #a78bfa;
|
||||
background: linear-gradient(90deg, rgba(124,92,252,.32), rgba(91,138,247,.14));
|
||||
color: #fff; border-left-color: #0d9488;
|
||||
background: linear-gradient(90deg, rgba(13,148,136,.32), rgba(20,184,166,.14));
|
||||
}
|
||||
.side-ico { font-size: 1.05rem; width: 22px; text-align: center; flex-shrink: 0; }
|
||||
.side-ico { font-size: 1.05rem; width: 22px; text-align: center; flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; }
|
||||
.side-ico svg { width: 20px; height: 20px; display: block; }
|
||||
.logo-mark svg { width: 20px; height: 20px; display: block; }
|
||||
.side-search svg { width: 19px; height: 19px; display: block; }
|
||||
/* Bouton thème dans la section Administration (#55) : même
|
||||
gabarit qu'un lien de navigation, mais c'est un <button>. */
|
||||
.theme-toggle-link { background: transparent; border: none; cursor: pointer; font: inherit; width: 100%; text-align: left; }
|
||||
.theme-toggle-link:hover { background: var(--surface-2); color: var(--text); text-decoration: none; }
|
||||
/* Repli non-admin (#55) : bouton thème dans le pied de la barre,
|
||||
icône seule — la section Administration n'existe que pour les
|
||||
administrateurs, les autres utilisateurs gardent un accès. */
|
||||
.theme-toggle-foot { background: transparent; border: none; cursor: pointer; font-size: 1rem; padding: 2px; color: var(--text-3); }
|
||||
.theme-toggle-foot:hover { color: var(--text); }
|
||||
.side-foot {
|
||||
margin-top: auto; padding-top: 12px;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
@@ -1335,7 +1350,7 @@ body.nav-closed-outils [data-group="outils"] .side-chevron { transform: rotate(-
|
||||
transition: border-color 0.15s, color 0.15s, transform 0.15s;
|
||||
}
|
||||
.side-collapse:hover {
|
||||
color: var(--text); border-color: var(--accent, #58a6ff);
|
||||
color: var(--text); border-color: var(--accent, #0d9488);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.side-fresh { color: var(--text-3); font-size: 0.76rem; white-space: nowrap; }
|
||||
@@ -1401,10 +1416,10 @@ body.sidebar-collapsed .side-foot { justify-content: center; }
|
||||
padding: 7px 14px; color: var(--text); background: var(--surface-1);
|
||||
font-size: 0.88rem; font-weight: 600;
|
||||
}
|
||||
.health-chip:hover { text-decoration: none; border-color: var(--accent, #58a6ff); }
|
||||
.health-chip:hover { text-decoration: none; border-color: var(--accent, #0d9488); }
|
||||
.proj-actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 18px 0 22px; }
|
||||
.proj-actions .nav-pill { border: 1px solid var(--border); }
|
||||
.proj-primary { background: linear-gradient(135deg, rgba(124,92,252,.85), rgba(91,138,247,.85)); color: #fff !important; }
|
||||
.proj-primary { background: linear-gradient(135deg, rgba(13,148,136,.85), rgba(20,184,166,.85)); color: #fff !important; }
|
||||
.proj-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; }
|
||||
.proj-panel {
|
||||
background: var(--surface-1); border: 1px solid var(--border);
|
||||
@@ -1451,7 +1466,7 @@ body.sidebar-collapsed .side-foot { justify-content: center; }
|
||||
background: rgba(255, 255, 255, 0.08); color: var(--text-2);
|
||||
border-radius: 999px; padding: 1px 7px;
|
||||
}
|
||||
.seg-tabs button.active .seg-count { background: rgba(124, 92, 252, 0.22); color: var(--text); }
|
||||
.seg-tabs button.active .seg-count { background: rgba(13, 148, 136, 0.22); color: var(--text); }
|
||||
.dash-toolbar .toolbar-search { padding: 0; flex: 1 1 200px; }
|
||||
.dash-toolbar .toolbar-search input { max-width: none; background: var(--surface-2); }
|
||||
.dash-tool { display: inline-flex; align-items: center; gap: 8px; font-size: 0.88rem; color: var(--text-2); white-space: nowrap; }
|
||||
@@ -1460,13 +1475,13 @@ body.sidebar-collapsed .side-foot { justify-content: center; }
|
||||
border: 1px solid var(--border); border-radius: 8px; padding: 6px 8px;
|
||||
min-height: 36px;
|
||||
}
|
||||
.dash-tool-check input { width: 17px; height: 17px; accent-color: #7c5cfc; }
|
||||
.dash-tool-check input { width: 17px; height: 17px; accent-color: #0d9488; }
|
||||
.dash-view-toggle { margin-left: auto; display: inline-flex; border: 1px solid var(--border); border-radius: 9px; overflow: hidden; }
|
||||
.dash-view-toggle button {
|
||||
background: var(--surface-1); color: var(--text-2); border: none;
|
||||
padding: 7px 14px; cursor: pointer; font-size: 0.88rem; min-height: 36px;
|
||||
}
|
||||
.dash-view-toggle button.active { background: var(--surface-3, #1d2942); color: var(--text); font-weight: 700; }
|
||||
.dash-view-toggle button.active { background: var(--surface-3, #20221f); color: var(--text); font-weight: 700; }
|
||||
.owner-group { grid-column: 1 / -1; }
|
||||
.owner-head {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
@@ -1475,7 +1490,7 @@ body.sidebar-collapsed .side-foot { justify-content: center; }
|
||||
border: 1px solid var(--border); border-radius: 10px;
|
||||
font-size: 0.95rem; font-weight: 700;
|
||||
}
|
||||
.owner-head:hover { border-color: var(--accent, #58a6ff); }
|
||||
.owner-head:hover { border-color: var(--accent, #0d9488); }
|
||||
.owner-head .owner-chevron { transition: transform 0.2s ease; font-size: 0.7rem; }
|
||||
.owner-head[aria-expanded="false"] .owner-chevron { transform: rotate(-90deg); }
|
||||
.owner-grid { padding: 0 !important; }
|
||||
@@ -1486,20 +1501,60 @@ body.sidebar-collapsed .side-foot { justify-content: center; }
|
||||
padding: 2px 7px; border-radius: 8px; line-height: 1;
|
||||
}
|
||||
.card-menu-btn:hover { background: var(--surface-2); color: var(--text); }
|
||||
.card-menu-btn[aria-expanded="true"] { background: var(--surface-2); color: var(--text); }
|
||||
/* Menu ⋯ des tuiles projets — refonte issues #44/#45.
|
||||
Jetons repris de la barre d'apps commune (draco-barre.css,
|
||||
prototype validé Console v7.2, aussi utilisé par la console
|
||||
Spark) : clair par défaut, sombre via [data-theme="dark"]
|
||||
ou prefers-color-scheme. Correctif du bug d'origine : le
|
||||
menu utilisait var(--surface-1), variable jamais définie,
|
||||
donc aucun fond — le menu était transparent et illisible. */
|
||||
.card-menu {
|
||||
--menu-bg: #ffffff; --menu-bg-2: #e9eae6; --menu-ink: #1b1d1a;
|
||||
--menu-ink-2: #5c615a; --menu-line: #dedfd9; --menu-agent: #0d9488;
|
||||
position: absolute; right: 0; top: calc(100% + 6px); z-index: 60;
|
||||
min-width: 210px; display: flex; flex-direction: column; gap: 2px;
|
||||
background: var(--surface-1); border: 1px solid var(--border);
|
||||
border-radius: 12px; padding: 6px;
|
||||
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.45);
|
||||
min-width: 248px; display: flex; flex-direction: column; gap: 2px;
|
||||
background: var(--menu-bg); border: 1px solid var(--menu-line);
|
||||
border-radius: 14px; padding: 6px; color: var(--menu-ink);
|
||||
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.card-menu {
|
||||
--menu-bg: #1a1c1a; --menu-bg-2: #242624; --menu-ink: #eceee9;
|
||||
--menu-ink-2: #a6aba2; --menu-line: #2e302c;
|
||||
}
|
||||
}
|
||||
[data-theme="dark"] .card-menu {
|
||||
--menu-bg: #1a1c1a; --menu-bg-2: #242624; --menu-ink: #eceee9;
|
||||
--menu-ink-2: #a6aba2; --menu-line: #2e302c;
|
||||
}
|
||||
.card-menu[hidden] { display: none !important; }
|
||||
.card-menu a {
|
||||
.card-menu a, .card-menu-item {
|
||||
display: flex; align-items: center; min-height: 32px;
|
||||
padding: 8px 10px; border-radius: 8px;
|
||||
color: var(--text-2); font-size: 0.92rem;
|
||||
padding: 8px 10px; border-radius: 10px;
|
||||
color: var(--menu-ink-2); font-size: 0.92rem;
|
||||
}
|
||||
.card-menu-item {
|
||||
width: 100%; box-sizing: border-box; border: 0;
|
||||
background: transparent; font: inherit; text-align: left; cursor: pointer;
|
||||
}
|
||||
.card-menu a:hover, .card-menu a:focus-visible,
|
||||
.card-menu-item:hover, .card-menu-item:focus-visible {
|
||||
background: var(--menu-bg-2); color: var(--menu-ink); text-decoration: none;
|
||||
}
|
||||
.card-menu a:focus-visible, .card-menu-item:focus-visible {
|
||||
outline: 2px solid var(--menu-agent); outline-offset: 1px;
|
||||
}
|
||||
.card-menu-sep { height: 1px; margin: 6px 10px; background: var(--menu-line); }
|
||||
/* Confirmation de copie — pastille fixe, lisible sur fond
|
||||
clair comme sombre, même patron que la barre (draco). */
|
||||
.kanbante-toast {
|
||||
position: fixed; left: 50%; bottom: 22px; transform: translateX(-50%);
|
||||
z-index: 3000; background: #1f1f1f; color: #eee;
|
||||
padding: 10px 16px; border-radius: 10px;
|
||||
font: 500 13.5px system-ui, "Segoe UI", sans-serif;
|
||||
box-shadow: 0 6px 24px rgb(0 0 0 / .35);
|
||||
}
|
||||
.card-menu a:hover { background: var(--surface-2); color: var(--text); text-decoration: none; }
|
||||
.proj-table {
|
||||
width: calc(100% - 48px); margin: 0 24px 18px;
|
||||
border-collapse: collapse; font-size: 0.93rem;
|
||||
@@ -1528,7 +1583,7 @@ body.sidebar-collapsed .side-foot { justify-content: center; }
|
||||
.forecast-summary { display: flex; gap: 8px; flex-wrap: wrap; margin: 10px 0 4px; }
|
||||
.forecast-summary .chip {
|
||||
font-size: 12.5px; padding: 3px 10px; border-radius: 999px;
|
||||
background: rgba(255, 255, 255, .07); border: 1px solid var(--border, #333);
|
||||
background: rgba(255, 255, 255, .07); border: 1px solid var(--border, #2e302c);
|
||||
}
|
||||
.forecast-summary .chip-late { background: rgba(248, 81, 73, .15); }
|
||||
.forecast-summary .chip-ok { background: rgba(63, 185, 80, .15); }
|
||||
@@ -1548,8 +1603,8 @@ a.ms-title:hover { text-decoration: underline; }
|
||||
}
|
||||
.health-switch-bar select {
|
||||
padding: 8px 10px; border-radius: 8px; font-size: 14px; min-height: 38px;
|
||||
background: var(--panel-2, #1c2330); color: inherit;
|
||||
border: 1px solid var(--border, #333);
|
||||
background: var(--panel-2, #242624); color: inherit;
|
||||
border: 1px solid var(--border, #2e302c);
|
||||
}
|
||||
.health-back { font-size: 13.5px; }
|
||||
|
||||
@@ -1570,11 +1625,11 @@ a.ms-title:hover { text-decoration: underline; }
|
||||
padding: 8px 10px; border-radius: 8px; cursor: pointer;
|
||||
font-weight: 400; font-size: 0.94rem;
|
||||
}
|
||||
.repo-item.active { background: var(--surface-2, #1c2330); }
|
||||
.repo-item.active { background: var(--surface-2, #242624); }
|
||||
.repo-item-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.repo-item-meta { color: var(--text-3, #8b94a5); font-size: 0.78rem; white-space: nowrap; }
|
||||
.repo-item-meta { color: var(--text-3, #8b9089); font-size: 0.78rem; white-space: nowrap; }
|
||||
.repo-hint { font-weight: 400; font-size: 0.85rem; color: #f0a35e; }
|
||||
.repo-tip { font-weight: 400; font-size: 0.82rem; color: var(--text-3, #8b94a5); }
|
||||
.repo-tip { font-weight: 400; font-size: 0.82rem; color: var(--text-3, #8b9089); }
|
||||
.repo-tip kbd {
|
||||
font-size: 0.72rem; border: 1px solid var(--border); border-bottom-width: 2px;
|
||||
border-radius: 5px; padding: 0 5px; background: var(--surface);
|
||||
@@ -1586,14 +1641,14 @@ a.ms-title:hover { text-decoration: underline; }
|
||||
min-height: 100dvh; margin: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background:
|
||||
radial-gradient(640px 420px at 18% 12%, rgba(124, 92, 252, 0.20), transparent 62%),
|
||||
radial-gradient(560px 380px at 84% 88%, rgba(91, 138, 247, 0.16), transparent 60%),
|
||||
var(--bg, #0a0f16);
|
||||
radial-gradient(640px 420px at 18% 12%, rgba(13, 148, 136, 0.20), transparent 62%),
|
||||
radial-gradient(560px 380px at 84% 88%, rgba(20, 184, 166, 0.16), transparent 60%),
|
||||
var(--bg, #101211);
|
||||
padding: 24px 16px;
|
||||
}
|
||||
.login-wrap { width: 100%; max-width: 400px; }
|
||||
.login-card {
|
||||
background: var(--surface, #10151f);
|
||||
background: var(--surface, #1a1c1a);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
padding: 34px 32px 26px;
|
||||
@@ -1603,16 +1658,16 @@ a.ms-title:hover { text-decoration: underline; }
|
||||
.login-mark {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 56px; height: 56px; border-radius: 16px; font-size: 1.7rem;
|
||||
background: linear-gradient(135deg, rgba(124,92,252,.35), rgba(91,138,247,.25));
|
||||
background: linear-gradient(135deg, rgba(13,148,136,.35), rgba(20,184,166,.25));
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.login-brand h1 {
|
||||
margin: 12px 0 4px; font-size: 1.55rem; font-weight: 700;
|
||||
background: linear-gradient(120deg, #a78bfa, #7dd3fc);
|
||||
background: linear-gradient(120deg, #0d9488, #14b8a6);
|
||||
-webkit-background-clip: text; background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
.login-sub { margin: 0; color: var(--text-3, #8b94a5); font-size: 0.9rem; }
|
||||
.login-sub { margin: 0; color: var(--text-3, #8b9089); font-size: 0.9rem; }
|
||||
.login-error {
|
||||
margin: 0 0 14px; padding: 10px 12px; border-radius: 10px;
|
||||
background: rgba(240, 120, 90, 0.12); border: 1px solid rgba(240, 120, 90, 0.45);
|
||||
@@ -1622,13 +1677,13 @@ a.ms-title:hover { text-decoration: underline; }
|
||||
.login-form label { display: flex; flex-direction: column; gap: 6px; font-weight: 600; font-size: 0.92rem; }
|
||||
.login-form input {
|
||||
padding: 11px 13px; border: 1px solid var(--border); border-radius: 10px;
|
||||
background: var(--bg-2, #0d1220); color: var(--text-1, #e6ebf2);
|
||||
background: var(--bg-2, #101211); color: var(--text-1, #eceee9);
|
||||
font: inherit; font-weight: 400;
|
||||
}
|
||||
.login-form input:focus { outline: 2px solid rgba(124, 92, 252, 0.55); outline-offset: 0; border-color: transparent; }
|
||||
.login-form input:focus { outline: 2px solid rgba(13, 148, 136, 0.55); outline-offset: 0; border-color: transparent; }
|
||||
.login-submit {
|
||||
margin-top: 4px; padding: 12px 18px; cursor: pointer;
|
||||
background: linear-gradient(135deg, var(--accent, #7c5cfc), var(--accent-2, #5b8af7));
|
||||
background: linear-gradient(135deg, var(--accent, #0d9488), var(--accent-2, #14b8a6));
|
||||
color: #fff; border: none; border-radius: 10px;
|
||||
font: inherit; font-weight: 600; font-size: 1rem;
|
||||
transition: filter 0.15s, transform 0.1s;
|
||||
@@ -1637,14 +1692,14 @@ a.ms-title:hover { text-decoration: underline; }
|
||||
.login-submit:active { transform: scale(0.98); }
|
||||
.login-sso {
|
||||
display: block; margin-top: 12px; padding: 11px 18px; text-align: center;
|
||||
color: var(--text-1, #e6ebf2); text-decoration: none;
|
||||
color: var(--text-1, #eceee9); text-decoration: none;
|
||||
border: 1px solid var(--border); border-radius: 10px;
|
||||
font: inherit; font-weight: 500; font-size: 0.95rem;
|
||||
transition: filter 0.15s, transform 0.1s;
|
||||
}
|
||||
.login-sso:hover { filter: brightness(1.35); }
|
||||
.login-sso:active { transform: scale(0.98); }
|
||||
.login-foot { margin: 18px 0 0; text-align: center; color: var(--text-3, #8b94a5); font-size: 0.8rem; }
|
||||
.login-foot { margin: 18px 0 0; text-align: center; color: var(--text-3, #8b9089); font-size: 0.8rem; }
|
||||
|
||||
/* ── Fusion ACC-1 (kanbante#28) : cohabitation avec la barre commune ──
|
||||
La barre d'apps est FIXE et pleine largeur (0.23.1 — en flux, le
|
||||
@@ -1662,26 +1717,26 @@ body.has-draco-bar .sidebar { top: 60px; }
|
||||
/* ── Espaces, admin, profils (0.24.0) ─────────────────── */
|
||||
.ws-switch { display: flex; align-items: center; gap: 8px; padding: 10px 14px 2px; }
|
||||
.ws-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; flex: none; }
|
||||
.ws-select { flex: 1; min-width: 0; background: var(--surface-2, #1c1c22); color: inherit;
|
||||
border: 1px solid var(--border, #333); border-radius: 8px; padding: 6px 8px; font: inherit; }
|
||||
.ws-select { flex: 1; min-width: 0; background: var(--surface-2, #242624); color: inherit;
|
||||
border: 1px solid var(--border, #2e302c); border-radius: 8px; padding: 6px 8px; font: inherit; }
|
||||
body.sidebar-collapsed .ws-switch { display: none; }
|
||||
.admin-form { display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: end; margin: 10px 0; }
|
||||
.admin-form label { display: flex; flex-direction: column; gap: 4px; font-size: 0.85em; }
|
||||
.admin-form input[type="text"], .admin-form input[type="password"], .admin-form select {
|
||||
background: var(--surface-2, #1c1c22); color: inherit;
|
||||
border: 1px solid var(--border, #333); border-radius: 8px; padding: 7px 9px; font: inherit; }
|
||||
background: var(--surface-2, #242624); color: inherit;
|
||||
border: 1px solid var(--border, #2e302c); border-radius: 8px; padding: 7px 9px; font: inherit; }
|
||||
.inline-form { display: inline-flex; gap: 6px; align-items: center; }
|
||||
.btn-mini { padding: 4px 10px; font-size: 0.85em; border-radius: 8px;
|
||||
border: 1px solid var(--border, #333); background: var(--surface-2, #1c1c22);
|
||||
border: 1px solid var(--border, #2e302c); background: var(--surface-2, #242624);
|
||||
color: inherit; cursor: pointer; }
|
||||
.btn-mini:hover { border-color: var(--accent, #7c5cfc); }
|
||||
.btn-mini:hover { border-color: var(--accent, #0d9488); }
|
||||
.btn-mini:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
.btn-danger:hover { border-color: #e5534b; color: #e5534b; }
|
||||
.chip-ok { display: inline-block; padding: 4px 10px; border-radius: 999px;
|
||||
background: rgba(16, 185, 129, 0.15); border: 1px solid rgba(16, 185, 129, 0.5); }
|
||||
.admin-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||
.conn-badge { font-size: 0.78em; padding: 1px 7px; border-radius: 999px;
|
||||
border: 1px solid var(--border, #333); white-space: nowrap; }
|
||||
border: 1px solid var(--border, #2e302c); white-space: nowrap; }
|
||||
.seg-link { align-self: center; margin-left: 8px; font-size: 0.85em; text-decoration: none; }
|
||||
|
||||
/* ── Tiroir de détail : édition et pièces jointes (0.31.0) ── */
|
||||
@@ -1689,7 +1744,7 @@ body.sidebar-collapsed .ws-switch { display: none; }
|
||||
.detail-actions { margin: 10px 0; }
|
||||
#detail-edit-form input[type="text"] { width: 100%; margin-bottom: 8px; }
|
||||
#detail-edit-form textarea { width: 100%; }
|
||||
.asset-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--border, #333); }
|
||||
.asset-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--border, #2e302c); }
|
||||
.asset-row a { text-decoration: none; font-weight: 600; }
|
||||
.asset-meta { color: var(--muted, #999); font-size: 0.85em; margin-right: auto; }
|
||||
.asset-form { display: flex; gap: 8px; align-items: center; margin: 8px 0 4px; flex-wrap: wrap; }
|
||||
@@ -1701,10 +1756,10 @@ body.sidebar-collapsed .ws-switch { display: none; }
|
||||
.composer { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 12px 14px; margin: 6px 0 10px; }
|
||||
.composer-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 10px; }
|
||||
.composer-title { font-weight: 700; font-size: 0.95rem; }
|
||||
.composer-target { color: var(--muted, #9aa3b5); font-size: 0.82rem; }
|
||||
.composer-target { color: var(--muted, #a6aba2); font-size: 0.82rem; }
|
||||
.composer-main { display: flex; gap: 8px; }
|
||||
.composer-main input[type="text"] { flex: 1; background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; padding: 9px 12px; color: inherit; font-size: 0.95rem; }
|
||||
.composer-main input[type="text"]:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(124, 92, 252, 0.25); }
|
||||
.composer-main input[type="text"]:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(13, 148, 136, 0.25); }
|
||||
.composer-desc { margin-top: 10px; }
|
||||
.composer-desc summary { list-style: none; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; font-size: 0.88rem; color: var(--accent-2); padding: 4px 2px; user-select: none; }
|
||||
.composer-desc summary::-webkit-details-marker { display: none; }
|
||||
@@ -1712,8 +1767,8 @@ body.sidebar-collapsed .ws-switch { display: none; }
|
||||
.composer-chevron { transition: transform 0.15s ease; font-size: 0.75rem; }
|
||||
.composer-desc[open ] .composer-chevron { transform: rotate(180deg); }
|
||||
.composer-desc textarea { width: 100%; margin-top: 8px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; color: inherit; font-family: inherit; resize: vertical; }
|
||||
.composer-desc textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(124, 92, 252, 0.25); }
|
||||
.composer-hint { color: var(--muted, #9aa3b5); font-size: 0.78rem; margin: 6px 0 0; }
|
||||
.composer-desc textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(13, 148, 136, 0.25); }
|
||||
.composer-hint { color: var(--muted, #a6aba2); font-size: 0.78rem; margin: 6px 0 0; }
|
||||
|
||||
/* ── Mes issues : barre de filtres (0.33.0) ── */
|
||||
.my-filters { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 12px 14px; margin: 12px 0 18px; }
|
||||
@@ -1722,22 +1777,22 @@ body.sidebar-collapsed .ws-switch { display: none; }
|
||||
.my-search-wrap { position: relative; flex: 1 1 280px; }
|
||||
.my-search-icon { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); opacity: 0.65; pointer-events: none; }
|
||||
.my-search-wrap input[type="search"] { width: 100%; background: var(--surface-2); border: 1px solid var(--border); border-radius: 999px; padding: 9px 14px 9px 38px; color: inherit; font-size: 0.92rem; }
|
||||
.my-search-wrap input[type="search"]:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(124, 92, 252, 0.25); }
|
||||
.my-search-wrap input[type="search"]:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(13, 148, 136, 0.25); }
|
||||
.my-filters select { background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; padding: 9px 10px; color: inherit; font-size: 0.9rem; }
|
||||
.my-filters select:focus { outline: none; border-color: var(--accent); }
|
||||
.my-col-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-right: auto; }
|
||||
.filter-chip { display: inline-flex; align-items: center; gap: 6px; background: var(--surface-2); border: 1px solid var(--border); color: inherit; border-radius: 999px; padding: 5px 12px; font-size: 0.85rem; cursor: pointer; transition: border-color 0.15s, background 0.15s; }
|
||||
.filter-chip:hover { border-color: var(--border-strong); }
|
||||
.filter-chip.on { background: rgba(124, 92, 252, 0.18); border-color: var(--accent); font-weight: 600; }
|
||||
.filter-chip.on { background: rgba(13, 148, 136, 0.18); border-color: var(--accent); font-weight: 600; }
|
||||
.filter-chip-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
||||
.filter-chip-count { opacity: 0.7; font-size: 0.78rem; }
|
||||
.my-count { color: var(--muted, #9aa3b5); font-size: 0.88rem; white-space: nowrap; }
|
||||
.my-count { color: var(--muted, #a6aba2); font-size: 0.88rem; white-space: nowrap; }
|
||||
|
||||
/* ── En vol partout (0.34.0, issue #24) ── */
|
||||
.inflight-summary { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin: 10px 0 18px; }
|
||||
.inflight-total { font-weight: 700; margin-right: 4px; }
|
||||
.inflight-repo { color: var(--accent-2); font-weight: 600; font-size: 0.85rem; }
|
||||
.inflight-wip { color: var(--muted, #9aa3b5); font-size: 0.8rem; font-weight: 400; margin-left: 8px; }
|
||||
.inflight-wip { color: var(--muted, #a6aba2); font-size: 0.8rem; font-weight: 400; margin-left: 8px; }
|
||||
|
||||
/* ── Page Projet : gestion des jalons (0.35.0, #26) ── */
|
||||
.ms-edit { margin-top: 8px; }
|
||||
@@ -1748,5 +1803,38 @@ body.sidebar-collapsed .ws-switch { display: none; }
|
||||
.ms-form label { display: flex; flex-direction: column; gap: 4px; font-size: 0.85rem; }
|
||||
.ms-form input[type="text"], .ms-form input[type="date"], .ms-form textarea { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; color: inherit; font-family: inherit; }
|
||||
.ms-form input:focus, .ms-form textarea:focus { outline: none; border-color: var(--accent); }
|
||||
.ms-hint { color: var(--muted, #9aa3b5); font-size: 0.78rem; }
|
||||
.ms-hint { color: var(--muted, #a6aba2); font-size: 0.78rem; }
|
||||
.ms-form-actions { display: flex; gap: 8px; }
|
||||
|
||||
/* ── Thème clair (#52) : jetons draco-barre (clair par défaut).
|
||||
html[data-theme="light"] prime sur le :root sombre et sur le
|
||||
prefers-color-scheme de la barre Fusion. Le sombre reste le
|
||||
défaut (les gabarits portent data-theme="dark", le JS du
|
||||
thème ne bascule que sur choix mémorisé). */
|
||||
html[data-theme="light"] {
|
||||
--bg: #f2f2f0; --surface: #ffffff; --surface-2: #e9eae6; --surface-3: #f6f6f3;
|
||||
--border: #dedfd9; --border-strong: #c9cbc2;
|
||||
--text: #1b1d1a; --text-2: #5c615a; --text-3: #787d75;
|
||||
--accent: #0d9488; --accent-2: #0f766e;
|
||||
--shadow: 0 8px 28px rgba(0, 0, 0, 0.12);
|
||||
--shadow-sm: 0 2px 10px rgba(0, 0, 0, 0.08);
|
||||
color-scheme: light;
|
||||
}
|
||||
html[data-theme="light"] .sidebar { background: rgba(255, 255, 255, 0.96); }
|
||||
html[data-theme="light"] .app-header { background: rgba(255, 255, 255, 0.85); }
|
||||
/* Lien actif de la barre latérale : fond vert translucide sur
|
||||
fond clair — le texte blanc du thème sombre y serait illisible. */
|
||||
html[data-theme="light"] .side-link.active { color: #1b1d1a; }
|
||||
/* Menu ⋯ des tuiles : jetons clairs explicites, pour primer sur
|
||||
le prefers-color-scheme sombre du système. */
|
||||
html[data-theme="light"] .card-menu {
|
||||
--menu-bg: #ffffff; --menu-bg-2: #e9eae6; --menu-ink: #1b1d1a;
|
||||
--menu-ink-2: #5c615a; --menu-line: #dedfd9;
|
||||
}
|
||||
/* Barre Fusion : jetons clairs explicites, pour primer sur son
|
||||
@media (prefers-color-scheme: dark). Le panneau Spark suit
|
||||
via son MutationObserver sur data-theme (draco-panneau.js). */
|
||||
html[data-theme="light"] .draco-bar {
|
||||
--bg: #f2f2f0; --surface: #ffffff; --surface-2: #e9eae6; --surface-3: #f6f6f3;
|
||||
--ink: #1b1d1a; --ink-2: #5c615a; --ink-3: #787d75; --line: #dedfd9;
|
||||
}
|
||||
|
||||
@@ -176,6 +176,47 @@
|
||||
if (e.key === 'Escape') closeMenus();
|
||||
});
|
||||
|
||||
/* ── Copier l'URL du projet (#44) ─────────────────────── */
|
||||
function showCopyToast(message) {
|
||||
var toast = document.createElement('div');
|
||||
toast.className = 'kanbante-toast';
|
||||
toast.setAttribute('role', 'status');
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(function () { toast.remove(); }, 2600);
|
||||
}
|
||||
function copyTextFallback(text) {
|
||||
var area = document.createElement('textarea');
|
||||
area.value = text;
|
||||
area.style.cssText = 'position:fixed;opacity:0;pointer-events:none;';
|
||||
document.body.appendChild(area);
|
||||
area.select();
|
||||
var ok = false;
|
||||
try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
|
||||
area.remove();
|
||||
return ok;
|
||||
}
|
||||
function copyText(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
return navigator.clipboard.writeText(text).then(
|
||||
function () { return true; },
|
||||
function () { return copyTextFallback(text); }
|
||||
);
|
||||
}
|
||||
return Promise.resolve(copyTextFallback(text));
|
||||
}
|
||||
document.querySelectorAll('[data-copy-url]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
var url = btn.getAttribute('data-copy-url') || '';
|
||||
if (!url) return;
|
||||
copyText(url).then(function (ok) {
|
||||
closeMenus();
|
||||
showCopyToast(ok ? 'URL du projet copiée' : 'La copie a échoué');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/* Le tri des tableaux denses (#22) vit dans tablesort.js,
|
||||
partagé avec /forecast et /health (0.19.0). */
|
||||
})();
|
||||
|
||||
+431
-109
@@ -1,14 +1,25 @@
|
||||
/* Alertes unifiées dracodev — Fusion #69 (chantier 2).
|
||||
/* Alertes unifiées dracodev — Fusion #69 (chantier 2) + chantier 5
|
||||
* (onglets + composant partagé + onglet Console Spark).
|
||||
*
|
||||
* SOURCE CANONIQUE : Projets/dracodev-commons, ui/alertes/. Les copies
|
||||
* dans les 4 hôtes (TikTak, Kanbante, Flowdeck, ObsiGate) sont des
|
||||
* vendoring byte-identiques de ce fichier — ne pas les modifier sur
|
||||
* place, toute évolution passe par commons puis re-vendoring.
|
||||
*
|
||||
* 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é.
|
||||
* ou non authentifiée affiche son état réel par section (une section
|
||||
* en échec n'empêche jamais de consulter les autres), jamais du vide
|
||||
* déguisé. En attendant le correctif CORS des allowlists (autre
|
||||
* chantier), une section inter-origines peut afficher cet état
|
||||
* d'erreur : c'est un état d'erreur transitoire, pas un comportement
|
||||
* normal — aucun état « Injoignable » systématique n'est présenté
|
||||
* comme normal.
|
||||
*
|
||||
* Sources réelles (audit #69, 2026-10-28) :
|
||||
* Sources réelles (audit #69, 2026-10-28, inchangé) :
|
||||
* - 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.
|
||||
@@ -23,20 +34,55 @@
|
||||
* 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
|
||||
* masqués), pas un journal lisible par l'utilisateur. L'onglet
|
||||
* ObsiGate le dit et renvoie vers l'accueil d'ObsiGate.
|
||||
* - Console Spark : AUCUN endpoint d'alertes console n'est publié
|
||||
* sur agent-commun main au moment de ce chantier (autre chantier
|
||||
* en cours). L'onglet Console Spark affiche donc un état vide
|
||||
* HONNÊTE (« source indisponible », lien vers la console) et ne
|
||||
* fabrique aucune alerte. Branchement prévu, sans deviner d'URL :
|
||||
* poser window.DRACO_ALERTES_CONSOLE_URL (ou
|
||||
* DRACO_BAR_CONFIG.consoleAlertsUrl) vers l'endpoint livré par
|
||||
* l'autre chantier ; forme attendue : tableau, ou
|
||||
* {alerts:[...]} / {items:[...]}, chaque alerte portant title,
|
||||
* detail|message|context, date|created_at, active (booléen) ou
|
||||
* status/state (pause budget, accès requis, en attente, fermée…),
|
||||
* url|link. Les missions en pause budget / accès requis et les
|
||||
* approbations en attente y apparaîtront avec leurs états réels.
|
||||
*
|
||||
* 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] ;
|
||||
* 3. href rendu de l'onglet .draco-apptab[data-app] (l'onglet
|
||||
* « Console Spark » ajouté par le chantier 5 porte data-app
|
||||
* "agent", donc la base agent suit le même ordre) ;
|
||||
* 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).
|
||||
* lit aussi window.DRACO_URLS (injecté par son backend, clé "agent"
|
||||
* incluse).
|
||||
* 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.
|
||||
*
|
||||
* Présentation (identique partout, imposée par ce seul fichier) :
|
||||
* champs par alerte = application, titre, date, état, lien profond
|
||||
* (environnement -dev respecté via la résolution ci-dessus) ;
|
||||
* ordre = date décroissante dans chaque onglet (sans date en
|
||||
* dernier) ; format de date = toLocaleString("fr-FR"), « sans date »
|
||||
* si absente ou illisible ; libellés d'état affichés = « Active »,
|
||||
* « Lue », ou le libellé d'événement Kanbante (ex. « Fermée »),
|
||||
* ou l'état réel console quand la source existe. Le type brut
|
||||
* Kanbante (« moved »…) n'est plus affiché : il dupliquait le
|
||||
* libellé d'état en anglais.
|
||||
* Onglets : Tout, ObsiGate, Flowdeck, TikTak, Kanbante,
|
||||
* Console Spark, Historique — dans cet ordre. Tout et chaque onglet
|
||||
* par application ne montrent QUE les alertes actives ; Historique
|
||||
* ne montre QUE les alertes fermées (lues / événements Kanbante /
|
||||
* console fermées). Le filtre texte (titre + contexte) s'applique
|
||||
* APRÈS le périmètre de l'onglet courant : il ne mélange jamais
|
||||
* actifs et historique. L'onglet courant est dans l'URL
|
||||
* (#alertes=<onglet>, partageable, retour arrière via pushState).
|
||||
*
|
||||
* 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).
|
||||
@@ -50,23 +96,39 @@
|
||||
obsigate: "https://og.dracodev.net/",
|
||||
flowdeck: "https://flowdeck.dracodev.net/",
|
||||
tiktak: "https://tiktak.dracodev.net/",
|
||||
kanbante: "https://kanbante.dracodev.net/"
|
||||
kanbante: "https://kanbante.dracodev.net/",
|
||||
agent: "https://agent.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/"
|
||||
kanbante: "https://kanbante-dev.dracodev.net/",
|
||||
agent: "https://agent-dev.dracodev.net/"
|
||||
};
|
||||
var APP_LABELS = {
|
||||
obsigate: "ObsiGate", flowdeck: "Flowdeck",
|
||||
tiktak: "TikTak", kanbante: "Kanbante"
|
||||
tiktak: "TikTak", kanbante: "Kanbante", console: "Console Spark"
|
||||
};
|
||||
var APP_ORDER = ["obsigate", "flowdeck", "tiktak", "kanbante"];
|
||||
var ALL_APPS = ["obsigate", "flowdeck", "tiktak", "kanbante", "console"];
|
||||
var TABS = ["tout", "obsigate", "flowdeck", "tiktak", "kanbante", "console", "historique"];
|
||||
var TAB_LABELS = {
|
||||
tout: "Tout", obsigate: "ObsiGate", flowdeck: "Flowdeck",
|
||||
tiktak: "TikTak", kanbante: "Kanbante",
|
||||
console: "Console Spark", historique: "Historique"
|
||||
};
|
||||
var EVENT_LABELS = {
|
||||
created: "créée", moved: "déplacée", closed: "fermée",
|
||||
reopened: "rouverte", commented: "commentée"
|
||||
};
|
||||
var CONSOLE_ACTIVE_STATUSES = [
|
||||
"active", "pending", "pause", "pause_budget", "acces_requis",
|
||||
"en_attente", "en attente", "requise", "awaiting"
|
||||
];
|
||||
var CONSOLE_CLOSED_STATUSES = [
|
||||
"closed", "resolved", "resolue", "lue", "terminee", "done", "fermee"
|
||||
];
|
||||
|
||||
function isDevHost(hostname) {
|
||||
if (!hostname) return false;
|
||||
@@ -98,12 +160,16 @@
|
||||
}
|
||||
function fmtDate(s) {
|
||||
var t = parseDate(s);
|
||||
if (!t) return s ? String(s).slice(0, 16) : "";
|
||||
if (!t) return "";
|
||||
try { return t.toLocaleString("fr-FR"); } catch (e) { return t.toISOString().slice(0, 16); }
|
||||
}
|
||||
function capitalize(s) {
|
||||
s = String(s || "");
|
||||
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
|
||||
}
|
||||
|
||||
/* Résolution des bases. `env` permet les tests sans navigateur :
|
||||
* { hostname, barUrls, configUrls, tabHrefs, dracoUrls }. */
|
||||
* { hostname, barUrls, configUrls, tabHrefs, dracoUrls, consoleUrl }. */
|
||||
function resolveUrls(env) {
|
||||
env = env || {};
|
||||
var hostname = env.hostname;
|
||||
@@ -112,28 +178,31 @@
|
||||
}
|
||||
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]);
|
||||
ALL_APPS.forEach(function (key) {
|
||||
var app = key === "console" ? "agent" : key;
|
||||
var out = key === "console" ? "agent" : key;
|
||||
var u = cleanUrl(env.barUrls && (env.barUrls[key] || env.barUrls[app]))
|
||||
|| cleanUrl(env.configUrls && (env.configUrls[key] || 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];
|
||||
if (env.tabHrefs && (env.tabHrefs[key] || env.tabHrefs[app])) href = env.tabHrefs[key] || 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 && env.dracoUrls && (env.dracoUrls[key] || env.dracoUrls[app])) u = cleanUrl(env.dracoUrls[key] || 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]);
|
||||
if (global.DRACO_URLS && (global.DRACO_URLS[key] || global.DRACO_URLS[app])) u = cleanUrl(global.DRACO_URLS[key] || global.DRACO_URLS[app]);
|
||||
} catch (e) { /* rien */ }
|
||||
}
|
||||
if (!u) u = DEFAULT_URLS[app];
|
||||
urls[app] = withSlash(u);
|
||||
urls[out] = withSlash(u);
|
||||
if (key === "console") urls.console = withSlash(u);
|
||||
});
|
||||
return urls;
|
||||
}
|
||||
@@ -151,9 +220,23 @@
|
||||
} catch (e) { /* rien */ }
|
||||
return {
|
||||
barUrls: bar.urls || null, configUrls: cfg.urls || null,
|
||||
tabHrefs: tabs, dracoUrls: global.DRACO_URLS || null
|
||||
tabHrefs: tabs, dracoUrls: global.DRACO_URLS || null,
|
||||
consoleUrl: null
|
||||
};
|
||||
}
|
||||
/* URL de la source console, si l'hôte l'a configurée. AUCUNE URL
|
||||
* n'est devinée : sans configuration, la source est indisponible
|
||||
* (état vide honnête, voir renderCurrent). */
|
||||
function consoleSourceUrl(env) {
|
||||
env = env || {};
|
||||
if (env.consoleUrl) return env.consoleUrl;
|
||||
try {
|
||||
if (global.DRACO_ALERTES_CONSOLE_URL) return global.DRACO_ALERTES_CONSOLE_URL;
|
||||
var cfg = global.DRACO_BAR_CONFIG || {};
|
||||
if (cfg.consoleAlertsUrl) return cfg.consoleAlertsUrl;
|
||||
} catch (e) { /* rien */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeFlowdeck(base, payload) {
|
||||
var list = (payload && payload.notifications) || [];
|
||||
@@ -192,7 +275,48 @@
|
||||
};
|
||||
});
|
||||
}
|
||||
/* Actives d'abord, puis date décroissante (sans date en dernier). */
|
||||
function normalizeConsole(base, payload) {
|
||||
var list = Array.isArray(payload) ? payload
|
||||
: (payload && (payload.alerts || payload.items)) || [];
|
||||
return list.map(function (n) {
|
||||
var status = String(n.status || "").toLowerCase().replace(/_/g, " ").trim();
|
||||
var stateRaw = String(n.state || status || "");
|
||||
var active;
|
||||
if (typeof n.active === "boolean") active = n.active;
|
||||
else if (status && CONSOLE_ACTIVE_STATUSES.indexOf(status.replace(/ /g, "_")) !== -1) active = true;
|
||||
else if (status && CONSOLE_CLOSED_STATUSES.indexOf(status.replace(/ /g, "_")) !== -1) active = false;
|
||||
else if (/pause|acc[eè]s|attente/.test(stateRaw.toLowerCase())) active = true;
|
||||
else if (/ferm|résol|resol|termin/.test(stateRaw.toLowerCase())) active = false;
|
||||
else active = true; /* source réelle, statut inconnu : on l'expose en active */
|
||||
return {
|
||||
app: "console", id: n.id, title: n.title || "(sans titre)",
|
||||
detail: n.detail || n.message || n.context || "", date: n.date || n.created_at || "",
|
||||
active: active, state: stateRaw || (active ? "en attente" : "fermée"),
|
||||
kind: n.kind || n.type || "",
|
||||
url: n.url || n.link ? joinUrl(base, n.url || n.link) : withSlash(base)
|
||||
};
|
||||
});
|
||||
}
|
||||
/* Libellé d'état AFFICHÉ, uniforme partout (les valeurs brutes de
|
||||
* normalize* sont inchangées, pour compatibilité des tests #69). */
|
||||
function displayState(a) {
|
||||
if (a.active) return "Active";
|
||||
if (a.app === "kanbante") return capitalize(a.state);
|
||||
if (a.app === "console") return capitalize(a.state);
|
||||
return "Lue";
|
||||
}
|
||||
function metaLine(a) {
|
||||
var date = fmtDate(a.date) || "sans date";
|
||||
/* Kanbante : le type brut (« moved ») dupliquait le libellé
|
||||
* d'état ; il n'est plus affiché. Les autres apps gardent leur
|
||||
* type quand il apporte une information. */
|
||||
if (a.app === "kanbante") return APP_LABELS[a.app] + " · " + date + " · " + displayState(a);
|
||||
var kind = a.kind ? " · " + a.kind : "";
|
||||
return APP_LABELS[a.app] + kind + " · " + date + " · " + displayState(a);
|
||||
}
|
||||
/* Actives d'abord, puis date décroissante (sans date en dernier).
|
||||
* Dans un onglet, le périmètre est déjà homogène (que des actives
|
||||
* ou que des fermées) : l'ordre effectif est donc la date décroissante. */
|
||||
function sortAlerts(list) {
|
||||
return list.slice().sort(function (a, b) {
|
||||
if (a.active !== b.active) return a.active ? -1 : 1;
|
||||
@@ -208,9 +332,74 @@
|
||||
list.forEach(function (a) { if (a.active) n++; });
|
||||
return n;
|
||||
}
|
||||
function allItems(results) {
|
||||
var all = [];
|
||||
ALL_APPS.forEach(function (app) {
|
||||
(results[app] && results[app].items || []).forEach(function (a) { all.push(a); });
|
||||
});
|
||||
return all;
|
||||
}
|
||||
/* Périmètre d'un onglet. Retour : { items, errors, consoleMissing }.
|
||||
* Tout et onglets par application = actives seulement ; Historique =
|
||||
* fermées seulement. Les erreurs de section sont remontées dans
|
||||
* chaque onglet concerné (jamais silencieusement avalées). */
|
||||
function scopeAlerts(all, tab, results) {
|
||||
results = results || {};
|
||||
var items = [], errors = [];
|
||||
function collect(app, pred) {
|
||||
if (results[app] && results[app].error) errors.push({ app: app, error: results[app].error });
|
||||
all.forEach(function (a) { if (a.app === app && pred(a)) items.push(a); });
|
||||
}
|
||||
if (tab === "historique") {
|
||||
ALL_APPS.forEach(function (app) { collect(app, function (a) { return !a.active; }); });
|
||||
} else if (tab === "tout") {
|
||||
ALL_APPS.forEach(function (app) { collect(app, function (a) { return a.active; }); });
|
||||
} else if (ALL_APPS.indexOf(tab) !== -1) {
|
||||
collect(tab, function (a) { return a.active; });
|
||||
} else {
|
||||
ALL_APPS.forEach(function (app) { collect(app, function (a) { return a.active; }); });
|
||||
}
|
||||
return {
|
||||
items: sortAlerts(items), errors: errors,
|
||||
consoleMissing: !!(results.console && results.console.sourceMissing)
|
||||
};
|
||||
}
|
||||
function countByTab(all, results) {
|
||||
var counts = {};
|
||||
TABS.forEach(function (tab) { counts[tab] = scopeAlerts(all, tab, results).items.length; });
|
||||
return counts;
|
||||
}
|
||||
/* Filtre texte, appliqué APRÈS le périmètre de l'onglet : titre ou
|
||||
* contexte, insensible à la casse, sans jamais mélanger actifs et
|
||||
* historique (le périmètre est déjà fixé par scopeAlerts). */
|
||||
function filterAlerts(list, q) {
|
||||
q = String(q || "").trim().toLowerCase();
|
||||
if (!q) return list.slice();
|
||||
return list.filter(function (a) {
|
||||
return (String(a.title || "").toLowerCase().indexOf(q) !== -1)
|
||||
|| (String(a.detail || "").toLowerCase().indexOf(q) !== -1);
|
||||
});
|
||||
}
|
||||
/* Onglet depuis le hash d'URL : "#alertes=historique" -> "historique",
|
||||
* "#alertes" -> "tout", autre -> null. */
|
||||
function parseAlertTab(hash) {
|
||||
if (!hash) return null;
|
||||
var h = String(hash).replace(/^#/, "");
|
||||
if (h === "alertes") return "tout";
|
||||
var m = /^alertes=([a-z]+)$/.exec(h);
|
||||
if (m && TABS.indexOf(m[1]) !== -1) return m[1];
|
||||
return null;
|
||||
}
|
||||
function hashForTab(tab) {
|
||||
if (TABS.indexOf(tab) === -1) tab = "tout";
|
||||
return "#alertes=" + tab;
|
||||
}
|
||||
|
||||
/* ---- Rendu (navigateur seulement) ---- */
|
||||
var panel = null, listEl = null, badgeTimer = null;
|
||||
var panel = null, listEl = null, tabsEl = null, filterEl = null,
|
||||
countEl = null, badgeTimer = null;
|
||||
var currentTab = "tout", currentData = null, currentFilter = "";
|
||||
var panelPushed = false, closingViaBack = false, suppressHash = false;
|
||||
|
||||
function el(tag, cls, text) {
|
||||
var d = document.createElement(tag);
|
||||
@@ -226,96 +415,204 @@
|
||||
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);
|
||||
close.addEventListener("click", function () { closePanel(); });
|
||||
head.appendChild(close);
|
||||
box.appendChild(head);
|
||||
box.appendChild(note);
|
||||
box.appendChild(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. " +
|
||||
"Une section en échec n'empêche pas de consulter les autres."));
|
||||
tabsEl = el("div", "draco-alertes-tabs");
|
||||
tabsEl.setAttribute("role", "tablist");
|
||||
tabsEl.setAttribute("aria-label", "Alertes par application");
|
||||
tabsEl.addEventListener("keydown", onTabsKeydown);
|
||||
box.appendChild(tabsEl);
|
||||
var filtre = el("div", "draco-alertes-filtre");
|
||||
var lbl = el("label", "", "Filtrer");
|
||||
lbl.setAttribute("for", "draco-alertes-filtre-input");
|
||||
filtre.appendChild(lbl);
|
||||
filterEl = el("input", "");
|
||||
filterEl.type = "search";
|
||||
filterEl.id = "draco-alertes-filtre-input";
|
||||
filterEl.setAttribute("placeholder", "Filtrer par titre ou contexte, dans cet onglet…");
|
||||
filterEl.setAttribute("aria-label", "Filtrer les alertes de l'onglet courant");
|
||||
filterEl.addEventListener("input", function () {
|
||||
currentFilter = filterEl.value;
|
||||
renderCurrent();
|
||||
});
|
||||
filtre.appendChild(filterEl);
|
||||
box.appendChild(filtre);
|
||||
countEl = el("p", "draco-alertes-compte", "");
|
||||
box.appendChild(countEl);
|
||||
listEl = el("div", "draco-alertes-list");
|
||||
listEl.setAttribute("role", "tabpanel");
|
||||
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();
|
||||
if (ev.key === "Escape" && panel && panel.style.display !== "none") 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;
|
||||
function closePanel() {
|
||||
if (!panel) return;
|
||||
panel.style.display = "none";
|
||||
if (panelPushed) {
|
||||
panelPushed = false;
|
||||
closingViaBack = true;
|
||||
try { history.back(); } catch (e) { closingViaBack = false; }
|
||||
return;
|
||||
}
|
||||
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;
|
||||
try {
|
||||
var h = parseAlertTab(window.location.hash);
|
||||
if (h) {
|
||||
suppressHash = true;
|
||||
history.replaceState(null, "", window.location.pathname + window.location.search);
|
||||
suppressHash = false;
|
||||
}
|
||||
} catch (e) { /* rien */ }
|
||||
}
|
||||
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."));
|
||||
function onHashChange() {
|
||||
if (suppressHash) return;
|
||||
if (closingViaBack) { closingViaBack = false; return; }
|
||||
var tab = parseAlertTab(window.location.hash);
|
||||
if (tab) {
|
||||
if (!panel || panel.style.display === "none") openPanel(tab);
|
||||
else setTab(tab, false);
|
||||
} else if (panel && panel.style.display !== "none") {
|
||||
panelPushed = false;
|
||||
panel.style.display = "none";
|
||||
}
|
||||
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 onTabsKeydown(ev) {
|
||||
var keys = ["ArrowLeft", "ArrowRight", "Home", "End"];
|
||||
if (keys.indexOf(ev.key) === -1) return;
|
||||
var btns = tabsEl.querySelectorAll(".draco-alertes-tab");
|
||||
var idx = TABS.indexOf(currentTab);
|
||||
if (ev.key === "ArrowLeft") idx = (idx + TABS.length - 1) % TABS.length;
|
||||
else if (ev.key === "ArrowRight") idx = (idx + 1) % TABS.length;
|
||||
else if (ev.key === "Home") idx = 0;
|
||||
else if (ev.key === "End") idx = TABS.length - 1;
|
||||
ev.preventDefault();
|
||||
setTab(TABS[idx], true);
|
||||
if (btns[idx] && btns[idx].focus) btns[idx].focus();
|
||||
}
|
||||
function renderTabs() {
|
||||
if (!tabsEl || !currentData) return;
|
||||
tabsEl.textContent = "";
|
||||
var all = allItems(currentData.results);
|
||||
var counts = countByTab(all, currentData.results);
|
||||
TABS.forEach(function (tab) {
|
||||
var b = el("button", "draco-alertes-tab",
|
||||
TAB_LABELS[tab] + " (" + counts[tab] + ")");
|
||||
b.type = "button";
|
||||
b.setAttribute("role", "tab");
|
||||
b.setAttribute("aria-selected", tab === currentTab ? "true" : "false");
|
||||
b.tabIndex = tab === currentTab ? 0 : -1;
|
||||
b.addEventListener("click", function () { setTab(tab, true); });
|
||||
tabsEl.appendChild(b);
|
||||
});
|
||||
}
|
||||
function renderRow(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));
|
||||
main.appendChild(el("div", "draco-alertes-meta", metaLine(a)));
|
||||
row.appendChild(main);
|
||||
return row;
|
||||
}
|
||||
function renderErrorRow(app, msg) {
|
||||
var row = el("div", "draco-alertes-item is-error");
|
||||
var main = el("div", "draco-alertes-main");
|
||||
main.appendChild(el("div", "draco-alertes-title", APP_LABELS[app] + " — section en échec"));
|
||||
main.appendChild(el("div", "draco-alertes-detail", msg));
|
||||
main.appendChild(el("div", "draco-alertes-meta",
|
||||
APP_LABELS[app] + " · section en échec — les autres sections restent consultables"));
|
||||
row.appendChild(main);
|
||||
return row;
|
||||
}
|
||||
function renderCurrent() {
|
||||
if (!listEl || !currentData) return;
|
||||
renderTabs();
|
||||
var scope = scopeAlerts(allItems(currentData.results), currentTab, currentData.results);
|
||||
var filtered = filterAlerts(scope.items, currentFilter);
|
||||
var q = String(currentFilter || "").trim();
|
||||
if (countEl) {
|
||||
countEl.textContent = q
|
||||
? filtered.length + " résultat(s) sur " + scope.items.length + " dans cet onglet — filtre « " + q + " »"
|
||||
: "";
|
||||
}
|
||||
listEl.textContent = "";
|
||||
/* Onglet Console sans source : état vide honnête, jamais d'alertes
|
||||
* inventées, avec le lien profond vers la console. */
|
||||
if (currentTab === "console" && scope.consoleMissing) {
|
||||
var sec = el("section", "draco-alertes-section");
|
||||
sec.appendChild(el("h3", "", "Console Spark (0)"));
|
||||
sec.appendChild(el("p", "draco-alertes-empty",
|
||||
"Aucune alerte console — source indisponible : l'endpoint " +
|
||||
"d'alertes console (agent-commun, autre chantier) n'est pas " +
|
||||
"encore publié. Les missions en pause budget / accès requis " +
|
||||
"et les approbations en attente apparaîtront ici, avec leurs " +
|
||||
"états réels, dès sa livraison."));
|
||||
var link = el("div", "draco-alertes-empty");
|
||||
var a = el("a", "", "Ouvrir la Console Spark");
|
||||
a.href = currentData.urls.agent || currentData.urls.console || "#";
|
||||
link.appendChild(a);
|
||||
sec.appendChild(link);
|
||||
listEl.appendChild(sec);
|
||||
scope.errors.forEach(function (e) { listEl.appendChild(renderErrorRow(e.app, e.error)); });
|
||||
return;
|
||||
}
|
||||
if (currentTab === "obsigate" && !scope.items.length && !scope.errors.length) {
|
||||
var secOg = el("section", "draco-alertes-section");
|
||||
secOg.appendChild(el("h3", "", "ObsiGate (0)"));
|
||||
secOg.appendChild(el("p", "draco-alertes-empty",
|
||||
"ObsiGate n'expose aucune notification entrante consultable " +
|
||||
"(ses canaux /api/notify sont des envois sortants, réservés aux administrateurs)."));
|
||||
var linkOg = el("div", "draco-alertes-empty");
|
||||
var aOg = el("a", "", "Ouvrir ObsiGate");
|
||||
aOg.href = currentData.urls.obsigate;
|
||||
linkOg.appendChild(aOg);
|
||||
secOg.appendChild(linkOg);
|
||||
listEl.appendChild(secOg);
|
||||
return;
|
||||
}
|
||||
scope.errors.forEach(function (e) { listEl.appendChild(renderErrorRow(e.app, e.error)); });
|
||||
if (!scope.items.length && !scope.errors.length) {
|
||||
var empty = currentTab === "historique"
|
||||
? "Aucune alerte fermée dans les applications joignables."
|
||||
: currentTab === "tout"
|
||||
? "Aucune alerte active dans les applications joignables."
|
||||
: "Aucune alerte active pour " + TAB_LABELS[currentTab] + ".";
|
||||
if (currentTab === "console") empty = "Aucune alerte console.";
|
||||
listEl.appendChild(el("p", "draco-alertes-empty", empty));
|
||||
return;
|
||||
}
|
||||
if (q && !filtered.length) {
|
||||
listEl.appendChild(el("p", "draco-alertes-empty",
|
||||
"Aucune alerte dans cet onglet pour ce filtre."));
|
||||
return;
|
||||
}
|
||||
filtered.forEach(function (a) { listEl.appendChild(renderRow(a)); });
|
||||
}
|
||||
function setTab(tab, push) {
|
||||
if (TABS.indexOf(tab) === -1) tab = "tout";
|
||||
currentTab = tab;
|
||||
renderCurrent();
|
||||
if (push) {
|
||||
try {
|
||||
history.pushState(null, "", hashForTab(tab));
|
||||
panelPushed = true;
|
||||
} catch (e) { /* URL partageable : repli silencieux */ }
|
||||
}
|
||||
}
|
||||
|
||||
function fetchJson(url, timeoutMs) {
|
||||
var ctrl = null, timer = null;
|
||||
@@ -342,6 +639,7 @@
|
||||
}
|
||||
function loadAll() {
|
||||
var urls = resolveUrls(browserEnv());
|
||||
var consoleUrl = consoleSourceUrl(browserEnv());
|
||||
var jobs = {
|
||||
flowdeck: fetchJson(urls.flowdeck + "api/notifications?limit=50")
|
||||
.then(function (p) { return { items: normalizeFlowdeck(urls.flowdeck, p) }; }),
|
||||
@@ -350,6 +648,11 @@
|
||||
kanbante: fetchJson(urls.kanbante + "api/events?limit=100")
|
||||
.then(function (p) { return { items: normalizeKanbante(urls.kanbante, p) }; })
|
||||
};
|
||||
if (consoleUrl) {
|
||||
var agentBase = urls.agent || urls.console;
|
||||
jobs.console = fetchJson(consoleUrl)
|
||||
.then(function (p) { return { items: normalizeConsole(agentBase, 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) {
|
||||
@@ -361,6 +664,7 @@
|
||||
})).then(function (pairs) {
|
||||
var results = { obsigate: { items: [] } };
|
||||
pairs.forEach(function (kv) { results[kv[0]] = kv[1]; });
|
||||
if (!consoleUrl) results.console = { items: [], sourceMissing: true };
|
||||
return { urls: urls, results: results };
|
||||
});
|
||||
}
|
||||
@@ -380,28 +684,35 @@
|
||||
}
|
||||
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);
|
||||
var n = countActive(allItems(r.results));
|
||||
setBadge(n);
|
||||
return n;
|
||||
}).catch(function () { /* pastille optionnelle : la barre vit sans */ });
|
||||
}
|
||||
function openPanel() {
|
||||
function openPanel(initialTab) {
|
||||
ensurePanel();
|
||||
panel.style.display = "flex";
|
||||
var tab = initialTab || parseAlertTab(window.location.hash) || "tout";
|
||||
currentTab = tab;
|
||||
currentFilter = "";
|
||||
if (filterEl) filterEl.value = "";
|
||||
listEl.textContent = "";
|
||||
listEl.appendChild(el("p", "draco-alertes-empty", "Chargement des alertes…"));
|
||||
if (tabsEl) tabsEl.textContent = "";
|
||||
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));
|
||||
currentData = r;
|
||||
/* Ouverture par le bouton (pas de hash) : l'onglet entre dans
|
||||
* l'URL pour être partageable ; ouverture par lien partagé : le
|
||||
* hash est déjà le bon, on ne pousse pas d'entrée en double. */
|
||||
var fromHash = parseAlertTab(window.location.hash);
|
||||
if (!fromHash) {
|
||||
try { history.pushState(null, "", hashForTab(tab)); panelPushed = true; } catch (e) { /* rien */ }
|
||||
}
|
||||
renderCurrent();
|
||||
setBadge(countActive(allItems(r.results)));
|
||||
}).catch(function () {
|
||||
render({ obsigate: { items: [] } }, resolveUrls(browserEnv()));
|
||||
currentData = { urls: resolveUrls(browserEnv()), results: { obsigate: { items: [] }, console: { items: [], sourceMissing: true } } };
|
||||
renderCurrent();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -410,18 +721,29 @@
|
||||
_test: {
|
||||
resolveUrls: resolveUrls, normalizeFlowdeck: normalizeFlowdeck,
|
||||
normalizeTiktak: normalizeTiktak, normalizeKanbante: normalizeKanbante,
|
||||
normalizeConsole: normalizeConsole, consoleSourceUrl: consoleSourceUrl,
|
||||
sortAlerts: sortAlerts, countActive: countActive,
|
||||
isDevHost: isDevHost, DEFAULT_URLS: DEFAULT_URLS, DEV_URLS: DEV_URLS
|
||||
filterAlerts: filterAlerts, scopeAlerts: scopeAlerts,
|
||||
countByTab: countByTab, parseAlertTab: parseAlertTab,
|
||||
hashForTab: hashForTab, displayState: displayState,
|
||||
metaLine: metaLine, fmtDate: fmtDate,
|
||||
isDevHost: isDevHost, DEFAULT_URLS: DEFAULT_URLS, DEV_URLS: DEV_URLS,
|
||||
TABS: TABS, TAB_LABELS: TAB_LABELS, APP_LABELS: APP_LABELS
|
||||
}
|
||||
};
|
||||
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). */
|
||||
/* Pastille au chargement (après les scripts defer de la barre) ;
|
||||
* un lien partagé #alertes=<onglet> ouvre directement le panneau
|
||||
* sur cet onglet. */
|
||||
if (typeof document !== "undefined" && document.addEventListener) {
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
if (badgeTimer) clearTimeout(badgeTimer);
|
||||
badgeTimer = setTimeout(refreshBadge, 300);
|
||||
var tab = parseAlertTab(window.location.hash);
|
||||
if (tab) setTimeout(function () { openPanel(tab); }, 350);
|
||||
});
|
||||
window.addEventListener("hashchange", onHashChange);
|
||||
}
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
||||
@@ -51,9 +51,13 @@
|
||||
* 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.
|
||||
* La clé spark ne correspond à aucun onglet de la barre : elle est
|
||||
* résolue et exposée via DRACO_BAR.urls pour le panneau Spark
|
||||
* (panneau-agent), qui suit le même contrat d'injection. La clé agent,
|
||||
* elle, a un onglet depuis le chantier 5 (ordre : ObsiGate, Flowdeck,
|
||||
* TikTak, Kanbante, Console Spark) : elle suit donc l'étape « href
|
||||
* rendu » comme les autres onglets, en plus d'alimenter le menu de
|
||||
* la marque et DRACO_BAR.urls.
|
||||
* Actions émises (data-act) : rail, palette, domaine (data-dom),
|
||||
* alertes, spark, rappel-entete. L'app intégratrice décide de chacune
|
||||
* (ouvrir la palette, le panneau Spark, changer de domaine…).
|
||||
@@ -79,9 +83,10 @@
|
||||
* 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é :
|
||||
* 2) dérivation -dev agent-dev sur hôte -dev, 3) href rendu de
|
||||
* l'onglet « Console Spark » (chantier 5, data-app="agent"),
|
||||
* 4) défaut officiel prod https://agent.dracodev.net/.
|
||||
* 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
|
||||
@@ -329,6 +334,11 @@
|
||||
appsMenu.setAttribute("role", "menu");
|
||||
appsMenu.setAttribute("aria-label", "Applications");
|
||||
document.querySelectorAll(".draco-apptab").forEach(function (t) {
|
||||
/* Chantier 5 : l'onglet agent (« Console Spark ») n'est PAS
|
||||
* repris ici — l'entrée dédiée ci-dessous le représente déjà
|
||||
* (après Kanbante, avec séparateur) ; sans ce saut, le menu
|
||||
* listerait « Console Spark » deux fois. */
|
||||
if (t.dataset.app === "agent") return;
|
||||
var a = document.createElement("a");
|
||||
a.className = "draco-apps-item";
|
||||
a.href = resolvedUrls[t.dataset.app] || t.getAttribute("href");
|
||||
|
||||
@@ -34,6 +34,35 @@
|
||||
if (window.innerWidth > 900) closeDrawer();
|
||||
});
|
||||
|
||||
/* Bascule Dark/Light (#52) : le choix est mémorisé et
|
||||
l'amorce de _header.html l'a déjà appliqué avant le rendu.
|
||||
Ici, on ne fait que synchroniser le libellé et le clic. */
|
||||
var themeBtn = document.getElementById('theme-toggle');
|
||||
function syncThemeBtn(theme) {
|
||||
if (!themeBtn) return;
|
||||
var light = theme === 'light';
|
||||
/* Bouton dans la section Administration (#55) : icône et
|
||||
libellé sont des <span> séparés ; l'ancien bouton de
|
||||
l'en-tête, lui, n'avait qu'un texte. */
|
||||
var ico = themeBtn.querySelector('[data-theme-ico]');
|
||||
var lbl = themeBtn.querySelector('[data-theme-label]');
|
||||
if (ico) ico.textContent = light ? '🌙' : '☀️';
|
||||
if (lbl) lbl.textContent = light ? 'Mode sombre' : 'Mode clair';
|
||||
if (!ico && !lbl) {
|
||||
themeBtn.textContent = light ? '🌙 Mode sombre' : '☀️ Mode clair';
|
||||
}
|
||||
themeBtn.setAttribute('aria-pressed', light ? 'true' : 'false');
|
||||
}
|
||||
if (themeBtn) {
|
||||
syncThemeBtn(document.documentElement.getAttribute('data-theme') || 'dark');
|
||||
themeBtn.addEventListener('click', function () {
|
||||
var next = document.documentElement.getAttribute('data-theme') === 'light' ? 'dark' : 'light';
|
||||
document.documentElement.setAttribute('data-theme', next);
|
||||
try { localStorage.setItem('kanbante-theme', next); } catch (e) { /* non-fatal */ }
|
||||
syncThemeBtn(next);
|
||||
});
|
||||
}
|
||||
|
||||
if (collapseBtn) {
|
||||
collapseBtn.addEventListener('click', function () {
|
||||
var collapsed = document.body.classList.toggle('sidebar-collapsed');
|
||||
|
||||
+641
-66
@@ -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,39 @@
|
||||
* - TikTak : GET {base}/api/search?q=
|
||||
* -> { tasks: [{ id, title, description, status, due_at }], ... }
|
||||
* Lien profond vérifié : {base}search?q=<q>&task=<id> (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/<full_name> (la carte n'a pas d'URL propre
|
||||
* adressable — même limite que la page /search native de Kanbante).
|
||||
* - Console Spark : GET {base-console}/api/v1/recherche?q=&limite=
|
||||
* (agent-commun, authentifié par le cookie SSO draco_session) ->
|
||||
* { q, domain, limite_par_type, resultats[], par_type,
|
||||
* non_cherchables[] } ; chaque résultat porte type, id, titre,
|
||||
* extrait, statut, domaine, section, objet et lien (relatif à
|
||||
* l'origine console). q < 2 caractères -> 422. Types cherchables :
|
||||
* discussion, mission, approbation, livrable, activite,
|
||||
* planification, objectif, identite (aucun secret). Liens profonds
|
||||
* (préfixés par la base console) : discussion
|
||||
* /?section=conversation&disc=<id>, mission
|
||||
* /?section=missions&objet=<id>, approbation
|
||||
* /?section=approbations&objet=<id>, objectif
|
||||
* /?section=objectifs&objet=<id>, activite /?activite=<id> ;
|
||||
* livrable /?section=livrables, planification /?section=planifie
|
||||
* et identite /?section=identites ouvrent la vue sans sélection
|
||||
* d'objet. L'onglet appelle avec limite=20 (plafond par type) car
|
||||
* il affiche la liste complète, comme les onglets d'applications.
|
||||
* En cas d'échec, de HTTP non-OK ou de format non reconnu, la
|
||||
* section affiche honnêtement « Console Spark : recherche
|
||||
* indisponible pour l'instant » — jamais de faux résultats,
|
||||
* jamais de faux 0. La base console vient, dans l'ordre, de
|
||||
* data-base-console rendu par l'hôte, de window.DRACO_URLS.agent
|
||||
* (ObsiGate) / window.DRACO_AGENT_URL (TikTak), de la dérivation
|
||||
* -dev si l'hôte courant est -dev, du href d'un onglet de barre
|
||||
* data-app="console"/"spark"/"agent" s'il existe, sinon du défaut
|
||||
* prod agent.dracodev.net. L'onglet console n'est interrogé que
|
||||
* lorsqu'il est affiché (ou quand une recherche a lieu pendant
|
||||
* qu'il est affiché) : « Tout » ne le contient pas, pour ne pas
|
||||
* polluer la vue groupée d'un message d'indisponibilité.
|
||||
*
|
||||
* Résolution des bases sœurs (même ordre que la barre v0.1.6) :
|
||||
* 1. data-base-* rendus par l'hôte (son propre résolveur : explicite >
|
||||
@@ -31,9 +88,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
|
||||
* (GET sur l'endpoint de recherche uniquement, pas de "*").
|
||||
* (ObsiGate et TikTak nativement, Flowdeck et Kanbante via #68)
|
||||
* et par GET /api/v1/recherche de la console (identity() accepte
|
||||
* draco_session). Chaque sœur n'autorise en CORS que les origines
|
||||
* sœurs (GET sur l'endpoint de recherche uniquement, pas de "*") ;
|
||||
* côté console, un middleware borné à /api/v1/recherche autorise
|
||||
* de même les 8 origines hôtes prod ET dev, avec credentials.
|
||||
* Une sœur injoignable / non autorisée ne casse pas les autres :
|
||||
* sa section affiche l'erreur + un lien vers sa recherche native.
|
||||
*/
|
||||
@@ -56,12 +116,36 @@
|
||||
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/v1/recherche";
|
||||
var CONSOLE_SEARCH_LIMIT = 20;
|
||||
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 CONSOLE_TYPE_LABELS = {
|
||||
discussion: "Discussion",
|
||||
mission: "Mission",
|
||||
approbation: "Approbation",
|
||||
livrable: "Livrable",
|
||||
activite: "Activité",
|
||||
planification: "Planification",
|
||||
objectif: "Objectif",
|
||||
identite: "Identité",
|
||||
};
|
||||
/* Lien profond console (relatif à l'origine console) quand le
|
||||
* résultat n'en porte pas : même règle que _lien_recherche
|
||||
* d'agent-commun (section + objet, activite en section=null). */
|
||||
var CONSOLE_VIEW_LINKS = {
|
||||
livrable: "/?section=livrables",
|
||||
planification: "/?section=planifie",
|
||||
identite: "/?section=identites",
|
||||
};
|
||||
var TABS = ["all", "obsigate", "flowdeck", "tiktak", "kanbante", "console"];
|
||||
var COLUMN_LABELS = {
|
||||
backlog: "Backlog",
|
||||
in_progress: "En cours",
|
||||
@@ -73,6 +157,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 +205,92 @@
|
||||
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 || opts.tabHrefs.agent)
|
||||
);
|
||||
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 +301,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 +327,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 +338,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 +356,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 +370,86 @@
|
||||
return [];
|
||||
}
|
||||
|
||||
/* Normalisation console : format réel GET /api/v1/recherche,
|
||||
* { resultats: [{ type, id, titre, extrait, statut, domaine,
|
||||
* section, objet, lien }] }. Le lien relatif est préfixé par la
|
||||
* base console ; sans lien, il est reconstruit depuis type/id
|
||||
* (même règle que la console) ; sans lien reconstructible,
|
||||
* url=null et le résultat est rendu en <span>, pas en faux lien.
|
||||
* Les anciennes formes tolérées (results/discussions/items, avec
|
||||
* url/href/link) restent acceptées en repli, pour ne pas casser
|
||||
* un déploiement console antérieur. Retourne { recognized,
|
||||
* items } : recognized=false -> l'appelant affiche
|
||||
* « indisponible », jamais un vide mensonger. */
|
||||
function consoleLinkFor(r) {
|
||||
var lien = r.lien || "";
|
||||
if (lien) return lien;
|
||||
var id = r.id != null ? String(r.id) : "";
|
||||
if (r.type === "discussion")
|
||||
return "/?section=conversation&disc=" + encodeURIComponent(id);
|
||||
if (r.type === "mission")
|
||||
return "/?section=missions&objet=" + encodeURIComponent(id);
|
||||
if (r.type === "approbation")
|
||||
return "/?section=approbations&objet=" + encodeURIComponent(id);
|
||||
if (r.type === "objectif")
|
||||
return "/?section=objectifs&objet=" + encodeURIComponent(id);
|
||||
if (r.type === "activite") return "/?activite=" + encodeURIComponent(id);
|
||||
if (CONSOLE_VIEW_LINKS[r.type]) return CONSOLE_VIEW_LINKS[r.type];
|
||||
if (r.section)
|
||||
return (
|
||||
"/?section=" +
|
||||
encodeURIComponent(r.section) +
|
||||
(r.objet != null
|
||||
? "&objet=" + encodeURIComponent(String(r.objet))
|
||||
: "")
|
||||
);
|
||||
return "";
|
||||
}
|
||||
|
||||
function normalizeConsole(data, base) {
|
||||
data = data || {};
|
||||
if (Array.isArray(data.resultats)) {
|
||||
var items = data.resultats.map(function (r) {
|
||||
r = r || {};
|
||||
var rel = consoleLinkFor(r);
|
||||
var url = null;
|
||||
if (/^https?:\/\//.test(rel)) url = rel;
|
||||
else if (rel.charAt(0) === "/" || rel.charAt(0) === "#")
|
||||
url = base + String(rel).replace(/^\//, "");
|
||||
var label = CONSOLE_TYPE_LABELS[r.type] || r.type || "Console";
|
||||
var context = label;
|
||||
if (r.statut) context += " · " + r.statut;
|
||||
if (r.domaine) context += " · " + r.domaine;
|
||||
return {
|
||||
title: r.titre || r.title || "Sans titre",
|
||||
snippet: snippetOf(r.extrait || r.snippet || ""),
|
||||
context: context,
|
||||
url: url,
|
||||
};
|
||||
});
|
||||
return { recognized: true, items: items };
|
||||
}
|
||||
var list = null;
|
||||
if (Array.isArray(data.results)) list = data.results;
|
||||
else if (Array.isArray(data.discussions)) list = data.discussions;
|
||||
else if (Array.isArray(data.items)) list = data.items;
|
||||
if (!list) return { recognized: false, items: [] };
|
||||
var legacy = 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: legacy };
|
||||
}
|
||||
|
||||
/* Recherche native de l'app (repli quand la section est en erreur). */
|
||||
function nativeSearchUrl(app, base, q) {
|
||||
if (app === "tiktak" || app === "kanbante") {
|
||||
@@ -214,6 +468,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 +479,26 @@
|
||||
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,
|
||||
CONSOLE_SEARCH_LIMIT: CONSOLE_SEARCH_LIMIT,
|
||||
CONSOLE_TYPE_LABELS: CONSOLE_TYPE_LABELS,
|
||||
consoleLinkFor: consoleLinkFor,
|
||||
PER_APP_LIMIT: PER_APP_LIMIT,
|
||||
FULL_LIMIT: FULL_LIMIT,
|
||||
};
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
global.RecherchePartout = api;
|
||||
@@ -268,16 +538,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 = '<p class="rp-state">Recherche…</p>';
|
||||
return;
|
||||
}
|
||||
@@ -293,19 +571,31 @@
|
||||
"</a></p>";
|
||||
return;
|
||||
}
|
||||
if (state.status === "empty") {
|
||||
if (state.status === "unavailable") {
|
||||
count.textContent = "";
|
||||
body.innerHTML =
|
||||
'<p class="rp-state">Console Spark : recherche indisponible pour l\'instant' +
|
||||
(state.message ? " (" + esc(state.message) + ")" : "") +
|
||||
".</p>";
|
||||
return;
|
||||
}
|
||||
if (state.status === "idle") {
|
||||
count.textContent = "";
|
||||
body.innerHTML =
|
||||
'<p class="rp-state">Tapez une recherche ci-dessus.</p>';
|
||||
return;
|
||||
}
|
||||
if (state.status === "empty") {
|
||||
count.textContent = "0";
|
||||
body.innerHTML =
|
||||
'<p class="rp-state">Aucun résultat pour « ' + esc(state.q) + " ».</p>";
|
||||
return;
|
||||
}
|
||||
var shown = pageItems(state.items, full);
|
||||
count.textContent = String(state.items.length);
|
||||
body.innerHTML = state.items
|
||||
body.innerHTML = shown
|
||||
.map(function (item) {
|
||||
return (
|
||||
'<a class="rp-item" data-rp-link href="' +
|
||||
esc(item.url) +
|
||||
'">' +
|
||||
var inner =
|
||||
'<span class="rp-title">' +
|
||||
esc(item.title) +
|
||||
"</span>" +
|
||||
@@ -314,9 +604,13 @@
|
||||
: "") +
|
||||
(item.snippet
|
||||
? '<span class="rp-snippet">' + esc(item.snippet) + "</span>"
|
||||
: "") +
|
||||
"</a>"
|
||||
);
|
||||
: "");
|
||||
if (item.url) {
|
||||
return (
|
||||
'<a class="rp-item" data-rp-link href="' + esc(item.url) + '">' + inner + "</a>"
|
||||
);
|
||||
}
|
||||
return '<span class="rp-item">' + inner + "</span>";
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
@@ -349,40 +643,213 @@
|
||||
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) +
|
||||
"&limite=" +
|
||||
CONSOLE_SEARCH_LIMIT;
|
||||
fetchSection("console", url, function (err, data) {
|
||||
if (myRun !== runId) return;
|
||||
if (err) {
|
||||
var msg;
|
||||
if (err.name === "AbortError") msg = "délai dépassé (8 s)";
|
||||
else if (err.status === 422)
|
||||
msg = "au moins 2 caractères requis";
|
||||
else if (err.status) msg = "HTTP " + err.status;
|
||||
else msg = "injoignable (CORS ou réseau)";
|
||||
states.console = {
|
||||
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 =
|
||||
'<p class="rp-state">Tapez une recherche ci-dessus.</p>';
|
||||
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 +860,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 +914,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 +948,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 +978,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 +988,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 +1056,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") {
|
||||
|
||||
+157
-5
@@ -1,18 +1,61 @@
|
||||
// Tests unitaires (node:test) du module partagé draco-alertes.js.
|
||||
// Exécutés dans la sandbox : node --test draco-alertes.test.js
|
||||
// Copie vendangée byte-identique de Projets/dracodev-commons
|
||||
// ui/alertes/ (chantier 5) — seul le require change selon l'hôte.
|
||||
// Hôte : Kanbante
|
||||
// Aucune donnée inventée : les payloads reprennent la forme exacte
|
||||
// des API réelles (Flowdeck /api/notifications, TikTak
|
||||
// /api/notifications, Kanbante /api/events).
|
||||
// /api/notifications, Kanbante /api/events). Le payload console
|
||||
// n'est qu'un EXEMPLE DE FORMAT (voir README commons) : aucun
|
||||
// endpoint console n'est publié sur agent-commun main au moment
|
||||
// du chantier.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const t = require("../static/js/draco-alertes.js");
|
||||
|
||||
test("resolveUrls: config explicite > hote -dev > href > defaut prod", () => {
|
||||
function sampleResults() {
|
||||
return {
|
||||
flowdeck: { items: t.normalizeFlowdeck("https://flowdeck.dracodev.net/", {
|
||||
notifications: [
|
||||
{ id: 1, ntype: "mention", title: "Mention budget", message: "voir page", url: "/collections/3", is_read: 0, created_at: "2026-10-28T10:00:00" },
|
||||
{ id: 2, ntype: "reminder", title: "Rappel lu", message: "", url: "", is_read: 1, created_at: "2026-10-27T09:00:00" },
|
||||
], unread: 1,
|
||||
}) },
|
||||
tiktak: { items: t.normalizeTiktak("https://tiktak.dracodev.net/", [
|
||||
{ id: 7, kind: "reminder", title: "Rappel tâche", body: "tache", task_id: 42, is_read: 0, created_at: "2026-10-28T08:00:00" },
|
||||
{ id: 8, kind: "share", title: "Partage lu", body: "", task_id: null, is_read: 1, created_at: "2026-10-26T08:00:00" },
|
||||
]) },
|
||||
kanbante: { items: 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" },
|
||||
{ id: 10, repo_full_name: "Projets/fusion-trois-apps", issue_number: 69, event_type: "moved", from_column: "todo", to_column: "review", created_at: "2026-10-28T12:00:00" },
|
||||
],
|
||||
}) },
|
||||
obsigate: { items: [] },
|
||||
console: { items: [], sourceMissing: true },
|
||||
};
|
||||
}
|
||||
function allOf(results) {
|
||||
const all = [];
|
||||
for (const app of ["obsigate", "flowdeck", "tiktak", "kanbante", "console"]) {
|
||||
(results[app] && results[app].items || []).forEach((a) => all.push(a));
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
test("ordre des onglets : Tout, apps, Console Spark, Historique", () => {
|
||||
assert.deepEqual(t.TABS, ["tout", "obsigate", "flowdeck", "tiktak", "kanbante", "console", "historique"]);
|
||||
assert.equal(t.TAB_LABELS.console, "Console Spark");
|
||||
});
|
||||
|
||||
test("resolveUrls: config explicite > hote -dev > href > defaut prod, agent inclus", () => {
|
||||
const prod = t.resolveUrls({ hostname: "tiktak.dracodev.net", tabHrefs: {} });
|
||||
assert.equal(prod.tiktak, "https://tiktak.dracodev.net/");
|
||||
assert.equal(prod.agent, "https://agent.dracodev.net/");
|
||||
const dev = t.resolveUrls({ hostname: "tiktak-dev.dracodev.net", tabHrefs: {} });
|
||||
assert.equal(dev.flowdeck, "https://flowdeck-dev.dracodev.net/");
|
||||
assert.equal(dev.obsigate, "https://og-dev.dracodev.net/");
|
||||
assert.equal(dev.agent, "https://agent-dev.dracodev.net/");
|
||||
const explicit = t.resolveUrls({
|
||||
hostname: "tiktak-dev.dracodev.net",
|
||||
configUrls: { flowdeck: "https://flowdeck.dracodev.net/" },
|
||||
@@ -20,9 +63,10 @@ test("resolveUrls: config explicite > hote -dev > href > defaut prod", () => {
|
||||
assert.equal(explicit.flowdeck, "https://flowdeck.dracodev.net/");
|
||||
const href = t.resolveUrls({
|
||||
hostname: "example.org",
|
||||
tabHrefs: { kanbante: "https://kanbante-dev.dracodev.net/" },
|
||||
tabHrefs: { kanbante: "https://kanbante-dev.dracodev.net/", agent: "https://agent.dracodev.net/" },
|
||||
});
|
||||
assert.equal(href.kanbante, "https://kanbante-dev.dracodev.net/");
|
||||
assert.equal(href.agent, "https://agent.dracodev.net/");
|
||||
const token = t.resolveUrls({ hostname: "x", tabHrefs: { tiktak: "{{URL_TIKTAK}}" } });
|
||||
assert.equal(token.tiktak, "https://tiktak.dracodev.net/");
|
||||
assert.equal(t.isDevHost("tiktak-dev.dracodev.net.evil.com"), false);
|
||||
@@ -34,8 +78,7 @@ test("normalizeFlowdeck: non lues actives, lien = base + url", () => {
|
||||
notifications: [
|
||||
{ id: 1, ntype: "mention", title: "Mention", message: "voir page", url: "/collections/3", is_read: 0, created_at: "2026-10-28T10:00:00" },
|
||||
{ id: 2, ntype: "reminder", title: "Rappel", message: "", url: "", is_read: 1, created_at: "2026-10-27T09:00:00" },
|
||||
],
|
||||
unread: 1,
|
||||
], unread: 1,
|
||||
});
|
||||
assert.equal(items.length, 2);
|
||||
assert.equal(items[0].active, true);
|
||||
@@ -82,3 +125,112 @@ test("sortAlerts + countActive: actives d'abord, date decroissante", () => {
|
||||
assert.equal(sorted[3].date, "");
|
||||
assert.equal(t.countActive(list), 2);
|
||||
});
|
||||
|
||||
test("scopeAlerts: Tout et onglets apps = actives seulement ; Historique = fermees seulement", () => {
|
||||
const r = sampleResults();
|
||||
const all = allOf(r);
|
||||
const tout = t.scopeAlerts(all, "tout", r);
|
||||
assert.equal(tout.items.length, 2);
|
||||
assert.ok(tout.items.every((a) => a.active));
|
||||
assert.equal(tout.items[0].date, "2026-10-28T10:00:00"); // date decroissante
|
||||
const fd = t.scopeAlerts(all, "flowdeck", r);
|
||||
assert.equal(fd.items.length, 1);
|
||||
assert.equal(fd.items[0].app, "flowdeck");
|
||||
const hist = t.scopeAlerts(all, "historique", r);
|
||||
assert.equal(hist.items.length, 4); // 1 FD lue + 1 TK lue + 2 KB
|
||||
assert.ok(hist.items.every((a) => !a.active));
|
||||
const og = t.scopeAlerts(all, "obsigate", r);
|
||||
assert.equal(og.items.length, 0);
|
||||
const cs = t.scopeAlerts(all, "console", r);
|
||||
assert.equal(cs.items.length, 0);
|
||||
assert.equal(cs.consoleMissing, true);
|
||||
});
|
||||
|
||||
test("scopeAlerts: une section en echec remonte aussi dans Historique (pas avalee)", () => {
|
||||
const r = sampleResults();
|
||||
r.flowdeck = { items: [], error: "Application injoignable depuis cet hôte (TypeError)." };
|
||||
const hist = t.scopeAlerts(allOf(r), "historique", r);
|
||||
assert.equal(hist.errors.length, 1);
|
||||
assert.equal(hist.errors[0].app, "flowdeck");
|
||||
const tout = t.scopeAlerts(allOf(r), "tout", r);
|
||||
assert.equal(tout.errors.length, 1);
|
||||
});
|
||||
|
||||
test("countByTab: compteurs par onglet sur l'echantillon", () => {
|
||||
const r = sampleResults();
|
||||
const c = t.countByTab(allOf(r), r);
|
||||
assert.deepEqual(c, {
|
||||
tout: 2, obsigate: 0, flowdeck: 1, tiktak: 1,
|
||||
kanbante: 0, console: 0, historique: 4,
|
||||
});
|
||||
});
|
||||
|
||||
test("filterAlerts: titre + contexte, insensible a la casse, sans melanger actifs/historique", () => {
|
||||
const r = sampleResults();
|
||||
const all = allOf(r);
|
||||
const tout = t.scopeAlerts(all, "tout", r).items;
|
||||
assert.equal(t.filterAlerts(tout, "budget").length, 1);
|
||||
assert.equal(t.filterAlerts(tout, "BUDGET").length, 1);
|
||||
assert.equal(t.filterAlerts(tout, "voir page").length, 1); // contexte (detail)
|
||||
assert.equal(t.filterAlerts(tout, "tache").length, 1);
|
||||
// « Rappel lu » est une alerte fermee : absente de Tout meme filtree.
|
||||
assert.equal(t.filterAlerts(tout, "Rappel lu").length, 0);
|
||||
const hist = t.scopeAlerts(all, "historique", r).items;
|
||||
assert.equal(t.filterAlerts(hist, "Rappel lu").length, 1);
|
||||
assert.equal(t.filterAlerts(tout, "").length, tout.length);
|
||||
});
|
||||
|
||||
test("presentation identique: meta Kanbante sans type brut anglais ; date illisible = sans date", () => {
|
||||
const kb = t.normalizeKanbante("https://kanbante.dracodev.net/", {
|
||||
events: [{ id: 1, repo_full_name: "R", issue_number: 1, event_type: "moved", from_column: "todo", to_column: "review", created_at: "2026-10-28T12:00:00" }],
|
||||
})[0];
|
||||
assert.ok(!t.metaLine(kb).includes("moved"));
|
||||
assert.match(t.metaLine(kb), /Kanbante · .* · Déplacée/);
|
||||
assert.equal(t.displayState({ active: true }), "Active");
|
||||
assert.equal(t.displayState({ active: false, app: "flowdeck" }), "Lue");
|
||||
const sansDate = { app: "tiktak", active: true, title: "x", detail: "", date: "nimporte-quoi", kind: "", url: "https://x/" };
|
||||
assert.match(t.metaLine(sansDate), /sans date/);
|
||||
});
|
||||
|
||||
test("hash URL: parse + round-trip, onglet inconnu rejete", () => {
|
||||
assert.equal(t.parseAlertTab("#alertes=historique"), "historique");
|
||||
assert.equal(t.parseAlertTab("#alertes=console"), "console");
|
||||
assert.equal(t.parseAlertTab("#alertes"), "tout");
|
||||
assert.equal(t.parseAlertTab("#autre"), null);
|
||||
assert.equal(t.parseAlertTab("#alertes=nimporte"), null);
|
||||
assert.equal(t.parseAlertTab(""), null);
|
||||
for (const tab of t.TABS) assert.equal(t.parseAlertTab(t.hashForTab(tab)), tab);
|
||||
assert.equal(t.hashForTab("nimporte"), "#alertes=tout");
|
||||
});
|
||||
|
||||
test("normalizeConsole (FORMAT EXEMPLE): statuts reels -> actif/ferme, lien console par defaut", () => {
|
||||
const items = t.normalizeConsole("https://agent.dracodev.net/", {
|
||||
alerts: [
|
||||
{ id: "m1", title: "Mission en pause budget", detail: "budget épuisé", status: "pause_budget", created_at: "2026-10-28T09:00:00" },
|
||||
{ id: "m2", title: "Accès requis", detail: "jeton manquant", status: "acces_requis", created_at: "2026-10-28T09:30:00" },
|
||||
{ id: "a1", title: "Approbation en attente", status: "en_attente", url: "/approbations", created_at: "2026-10-28T10:00:00" },
|
||||
{ id: "m3", title: "Mission terminée", status: "terminee", created_at: "2026-10-27T10:00:00" },
|
||||
{ id: "m4", title: "Sans statut", created_at: "2026-10-27T11:00:00" },
|
||||
],
|
||||
});
|
||||
assert.equal(items.length, 5);
|
||||
assert.equal(items[0].active, true);
|
||||
assert.equal(items[1].active, true);
|
||||
assert.equal(items[2].active, true);
|
||||
assert.equal(items[2].url, "https://agent.dracodev.net/approbations");
|
||||
assert.equal(items[3].active, false);
|
||||
assert.equal(items[4].active, true); // statut inconnu : expose en active, jamais masque
|
||||
assert.equal(items[0].url, "https://agent.dracodev.net/");
|
||||
const r = sampleResults();
|
||||
r.console = { items };
|
||||
const c = t.countByTab(allOf(r), r);
|
||||
assert.equal(c.console, 4);
|
||||
assert.equal(c.tout, 6);
|
||||
assert.equal(c.historique, 5);
|
||||
});
|
||||
|
||||
test("consoleSourceUrl: null sans configuration (aucune URL devineee)", () => {
|
||||
assert.equal(t.consoleSourceUrl({}), null);
|
||||
assert.equal(t.consoleSourceUrl({ consoleUrl: "https://agent-dev.dracodev.net/api/alertes" }),
|
||||
"https://agent-dev.dracodev.net/api/alertes");
|
||||
});
|
||||
|
||||
@@ -65,4 +65,120 @@ ok(items[0].context === "Projets/site · En cours", "contexte Kanbante");
|
||||
ok(rp.nativeSearchUrl("tiktak", "https://tiktak-dev.dracodev.net/", "q") === "https://tiktak-dev.dracodev.net/search?q=q", "repli TikTak");
|
||||
ok(rp.nativeSearchUrl("obsigate", "https://og-dev.dracodev.net/", "q") === "https://og-dev.dracodev.net/", "repli ObsiGate = accueil (palette)");
|
||||
|
||||
|
||||
/* ---- 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");
|
||||
|
||||
|
||||
/* ---- Alignement console (agent-commun /api/v1/recherche) ---- */
|
||||
ok(rp.CONSOLE_SEARCH_PATH === "api/v1/recherche", "console : chemin reel /api/v1/recherche");
|
||||
ok(rp.CONSOLE_SEARCH_LIMIT === 20, "console : limite 20 (plafond par type)");
|
||||
let rc = rp.normalizeConsole({ resultats: [
|
||||
{ type: "discussion", id: "c42", titre: "Conv", extrait: "ex", statut: null, domaine: "pro", section: "conversation", objet: "c42", lien: "/?section=conversation&disc=c42" },
|
||||
{ type: "mission", id: "t1", titre: "Mission", extrait: "ex", statut: "paused_budget", domaine: "pro", section: "missions", objet: "t1", lien: "/?section=missions&objet=t1" },
|
||||
{ type: "approbation", id: "a9", titre: "publier", extrait: "ex", statut: "pending", domaine: null, section: "approbations", objet: "a9", lien: "/?section=approbations&objet=a9" },
|
||||
{ type: "objectif", id: "o3", titre: "Obj", extrait: "ex", statut: "actif", domaine: "pro", section: "objectifs", objet: "o3", lien: "/?section=objectifs&objet=o3" },
|
||||
{ type: "activite", id: "a7", titre: "Act", extrait: "ex", statut: "terminee", domaine: "pro", section: null, objet: "a7", lien: "/?activite=a7" },
|
||||
{ type: "livrable", id: "l1", titre: "Liv", extrait: "ex", statut: "terminee", domaine: "pro", section: "livrables", objet: null, lien: "/?section=livrables" },
|
||||
{ type: "planification", id: "p1", titre: "Plan", extrait: "ex", statut: "active", domaine: "pro", section: "planifie", objet: null, lien: "/?section=planifie" },
|
||||
{ type: "identite", id: "i1", titre: "Id", extrait: "ex", statut: "active", domaine: null, section: "identites", objet: null, lien: "/?section=identites" },
|
||||
] }, "https://agent-dev.dracodev.net/");
|
||||
ok(rc.recognized === true && rc.items.length === 8, "console : 8 resultats reconnus");
|
||||
ok(rc.items[0].url === "https://agent-dev.dracodev.net/?section=conversation&disc=c42", "console : lien profond discussion");
|
||||
ok(rc.items[1].url === "https://agent-dev.dracodev.net/?section=missions&objet=t1" && rc.items[1].context === "Mission \u00b7 paused_budget \u00b7 pro", "console : lien mission + contexte statut/domaine");
|
||||
ok(rc.items[2].url === "https://agent-dev.dracodev.net/?section=approbations&objet=a9", "console : lien profond approbation");
|
||||
ok(rc.items[3].url === "https://agent-dev.dracodev.net/?section=objectifs&objet=o3", "console : lien profond objectif");
|
||||
ok(rc.items[4].url === "https://agent-dev.dracodev.net/?activite=a7", "console : lien activite sans section");
|
||||
ok(rc.items[5].url === "https://agent-dev.dracodev.net/?section=livrables", "console : livrable = vue seulement");
|
||||
ok(rc.items[6].url === "https://agent-dev.dracodev.net/?section=planifie", "console : planification = vue seulement");
|
||||
ok(rc.items[7].url === "https://agent-dev.dracodev.net/?section=identites", "console : identite = vue seulement");
|
||||
rc = rp.normalizeConsole({ resultats: [
|
||||
{ type: "mission", id: "t9", titre: "Sans lien", extrait: "", statut: null, domaine: null, section: "missions", objet: "t9" },
|
||||
{ type: "activite", id: "a9", titre: "Act sans lien", extrait: "", statut: null, domaine: null, section: null, objet: "a9" },
|
||||
] }, "https://agent.dracodev.net/");
|
||||
ok(rc.items[0].url === "https://agent.dracodev.net/?section=missions&objet=t9", "console : lien reconstruit sans lien servi");
|
||||
ok(rc.items[1].url === "https://agent.dracodev.net/?activite=a9", "console : lien activite reconstruit");
|
||||
rc = rp.normalizeConsole({ resultats: [] }, "https://agent.dracodev.net/");
|
||||
ok(rc.recognized === true && rc.items.length === 0, "console : resultats vide reconnu (vide honnete, pas indisponible)");
|
||||
ok(rp.consoleLinkFor({ type: "discussion", id: "c1" }) === "/?section=conversation&disc=c1", "consoleLinkFor discussion");
|
||||
ok(rp.resolveConsoleBase({ host: "x.example", tabHrefs: { agent: "https://agent-perso.example/" } }) === "https://agent-perso.example/", "console : href onglet barre agent accepte");
|
||||
|
||||
console.log("recherche.test.js : " + n + " assertions OK");
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Menu ⋯ des tuiles projets : copie d'URL (#44) et thème
|
||||
opaque barre Fusion / Spark, sombre et clair (#45).
|
||||
|
||||
Le menu était transparent parce que son fond utilisait
|
||||
var(--surface-1), variable jamais définie dans :root.
|
||||
"""
|
||||
|
||||
|
||||
def test_card_menu_offers_copy_url(client, fake_gitea):
|
||||
page = client.get("/")
|
||||
assert "Copier l'URL du projet" in page.text
|
||||
assert 'data-copy-url="http://gitea.test/Projets/demo"' in page.text
|
||||
|
||||
|
||||
def test_card_menu_theme_is_opaque_and_dual(client, fake_gitea):
|
||||
css = client.get("/static/css/style.css").text
|
||||
# Le menu ne repose plus sur la variable indéfinie.
|
||||
menu = css.split(".card-menu {", 1)[1].split("}", 1)[0]
|
||||
assert "var(--surface-1)" not in menu
|
||||
assert "var(--menu-bg)" in menu
|
||||
# Jetons barre Fusion / Console v7.2, clair par défaut…
|
||||
assert "--menu-bg: #ffffff" in css
|
||||
# …et variante sombre explicite, comme draco-barre.css.
|
||||
assert '[data-theme="dark"] .card-menu' in css
|
||||
assert "--menu-bg: #1a1c1a" in css
|
||||
assert "prefers-color-scheme: dark" in css
|
||||
|
||||
|
||||
def test_dashboard_js_copy_contract(client, fake_gitea):
|
||||
js = client.get("/static/js/dashboard.js").text
|
||||
assert "data-copy-url" in js
|
||||
assert "navigator.clipboard" in js
|
||||
assert "execCommand('copy')" in js
|
||||
assert "URL du projet copiée" in js
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Page Projets (#47) : les fichiers qui portent le menu ⋯ et la
|
||||
copie d'URL doivent être versionnés, sinon le navigateur garde
|
||||
l'ancien CSS/JS après une release alors que le HTML est neuf —
|
||||
c'est exactement ce qui a fait paraître la 0.38.0 inefficace
|
||||
(menu toujours transparent, bouton de copie présent mais inerte).
|
||||
"""
|
||||
|
||||
|
||||
def test_dashboard_static_assets_are_versioned(client, fake_gitea):
|
||||
page = client.get("/")
|
||||
assert "/static/css/style.css?v=" in page.text
|
||||
assert "/static/js/dashboard.js?v=" in page.text
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Fusion #69 — Alertes unifiées (chantier 2, Kanbante).
|
||||
"""Fusion #69 — Alertes unifiées (chantier 2 + chantier 5, Kanbante).
|
||||
|
||||
Kanbante n'a pas de table de notifications : sa source réelle est le
|
||||
journal d'événements, déjà exposé par GET /api/events (aucun nouvel
|
||||
@@ -44,3 +44,27 @@ def test_cors_events_soeur_preflight_get_et_tiers(client):
|
||||
headers={"Origin": "https://evil.example",
|
||||
"Access-Control-Request-Method": "GET"})
|
||||
assert "access-control-allow-origin" not in r3.headers
|
||||
# Correctif 2026-10-28 : une origine sœur dev passe aussi.
|
||||
r4 = client.options("/api/events",
|
||||
headers={"Origin": "https://tiktak-dev.dracodev.net",
|
||||
"Access-Control-Request-Method": "GET"})
|
||||
assert r4.status_code == 200
|
||||
assert r4.headers.get("access-control-allow-origin") == "https://tiktak-dev.dracodev.net"
|
||||
assert r4.headers.get("access-control-allow-credentials") == "true"
|
||||
|
||||
|
||||
def test_chantier5_onglet_console_spark_et_alertes_onglets(client):
|
||||
"""Chantier 5 : onglet Console Spark en dernier dans la barre, et
|
||||
vue Alertes en onglets (module + styles vendangés depuis commons)."""
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
nav = r.text.split('<nav class="draco-apptabs"')[1].split("</nav>")[0]
|
||||
assert nav.index('data-app="kanbante"') < nav.index('data-app="agent"')
|
||||
assert "Console Spark" in nav
|
||||
js = client.get("/static/js/draco-alertes.js").text
|
||||
assert "draco-alertes-tab" in js
|
||||
assert "historique" in js
|
||||
assert "source indisponible" in js
|
||||
css = client.get("/static/css/draco-alertes.css").text
|
||||
assert "draco-alertes-tab" in css
|
||||
assert "draco-alertes-filtre" in css
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Identité Kanbante (#54, #55) : sidebar charbon (pas bleutée),
|
||||
accent vert Kanbante (pas rose Spark), icônes de navigation en
|
||||
trait (pas d'emoji), bouton Dark/Light dans la section
|
||||
Administration (pas dans l'en-tête).
|
||||
"""
|
||||
|
||||
|
||||
def test_sidebar_charbon_et_accent_vert(client, fake_gitea):
|
||||
css = client.get("/static/css/style.css").text
|
||||
assert "rgba(26, 28, 26, 0.96)" in css
|
||||
assert "rgba(13, 16, 24" not in css
|
||||
assert "--accent: #0d9488;" in css
|
||||
assert "#c2437e" not in css
|
||||
|
||||
|
||||
def test_nav_icons_sont_des_svg(client, fake_gitea):
|
||||
page = client.get("/")
|
||||
assert 'class="side-ico"><svg' in page.text
|
||||
# Le logo Kanbante est aussi un SVG (colonnes blanches).
|
||||
assert 'class="logo-mark"><svg' in page.text
|
||||
|
||||
|
||||
def test_theme_toggle_dans_admin_pas_dans_en_tete(client, fake_gitea):
|
||||
page = client.get("/")
|
||||
assert 'id="theme-toggle"' in page.text
|
||||
head = page.text.split("header-actions", 1)[1].split("</header>", 1)[0]
|
||||
assert 'id="theme-toggle"' not in head
|
||||
# Placement administrateur : le bouton est dans le bloc de la
|
||||
# section Administration du gabarit (les non-admins, eux, le
|
||||
# voient dans le pied de la barre, la section n'existant pas
|
||||
# pour eux — sans quoi ils perdraient le basculeur).
|
||||
tpl = open("app/templates/_header.html", encoding="utf-8").read()
|
||||
admin_block = tpl.split("gkey == 'admin'", 1)[1]
|
||||
assert 'id="theme-toggle"' in admin_block
|
||||
assert "data-theme-label" in admin_block
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Navigation (#51) et bascule Dark/Light (#52).
|
||||
|
||||
L'accordéon de la barre latérale omettait la section admin :
|
||||
le JS posait nav-closed-admin, mais aucun style ne la suivait.
|
||||
Le thème clair, lui, doit être atteignable par un bouton et
|
||||
mémorisé, avec les jetons draco clairs.
|
||||
"""
|
||||
|
||||
|
||||
def test_admin_accordion_closes(client, fake_gitea):
|
||||
css = client.get("/static/css/style.css").text
|
||||
assert 'body.nav-closed-admin [data-group="admin"] .side-acc' in css
|
||||
assert 'body.nav-closed-admin [data-group="admin"] .side-chevron' in css
|
||||
|
||||
|
||||
def test_theme_toggle_present_and_versioned(client, fake_gitea):
|
||||
page = client.get("/")
|
||||
assert 'id="theme-toggle"' in page.text
|
||||
assert "/static/js/nav.js?v=" in page.text
|
||||
# #55 : le bouton n'est plus dans l'en-tête applicatif.
|
||||
head = page.text.split("header-actions", 1)[1].split("</header>", 1)[0]
|
||||
assert 'id="theme-toggle"' not in head
|
||||
|
||||
|
||||
def test_theme_js_contract(client, fake_gitea):
|
||||
js = client.get("/static/js/nav.js").text
|
||||
assert "kanbante-theme" in js
|
||||
assert "theme-toggle" in js
|
||||
assert "data-theme" in js
|
||||
|
||||
|
||||
def test_light_theme_tokens(client, fake_gitea):
|
||||
css = client.get("/static/css/style.css").text
|
||||
assert 'html[data-theme="light"]' in css
|
||||
assert "--bg: #f2f2f0;" in css
|
||||
assert "--surface: #ffffff;" in css
|
||||
assert "--accent-2: #0f766e;" in css
|
||||
assert 'html[data-theme="light"] .draco-bar' in css
|
||||
assert 'html[data-theme="light"] .card-menu' in css
|
||||
+34
-2
@@ -34,20 +34,45 @@ def test_origin_of():
|
||||
|
||||
|
||||
def test_sister_origins_dev():
|
||||
# Correctif 2026-10-28 : 10 origines officielles (8 sœurs + 2 agent), prod ET dev,
|
||||
# quelle que soit la résolution.
|
||||
assert sister_origins(RESOLVED_DEV) == {
|
||||
"https://og.dracodev.net",
|
||||
"https://flowdeck.dracodev.net",
|
||||
"https://tiktak.dracodev.net",
|
||||
"https://kanbante.dracodev.net",
|
||||
"https://og-dev.dracodev.net",
|
||||
"https://flowdeck-dev.dracodev.net",
|
||||
"https://tiktak-dev.dracodev.net",
|
||||
"https://kanbante-dev.dracodev.net",
|
||||
"https://agent.dracodev.net",
|
||||
"https://agent-dev.dracodev.net",
|
||||
}
|
||||
|
||||
|
||||
|
||||
def test_cors_headers_agent_console_autorise():
|
||||
# Fédération depuis la console (2026-10-28) : les origines agent
|
||||
# prod ET dev passent, avec credentials ; un tiers reste refusé.
|
||||
for origine in ("https://agent.dracodev.net",
|
||||
"https://agent-dev.dracodev.net"):
|
||||
headers = cors_headers_for(origine, RESOLVED_DEV)
|
||||
assert headers is not None
|
||||
assert headers["Access-Control-Allow-Origin"] == origine
|
||||
assert headers["Access-Control-Allow-Credentials"] == "true"
|
||||
assert cors_headers_for("https://agent.dracodev.net.evil.example",
|
||||
RESOLVED_DEV) is None
|
||||
|
||||
def test_cors_headers():
|
||||
headers = cors_headers_for("https://tiktak-dev.dracodev.net", RESOLVED_DEV)
|
||||
assert headers["Access-Control-Allow-Origin"] == "https://tiktak-dev.dracodev.net"
|
||||
assert headers["Access-Control-Allow-Credentials"] == "true"
|
||||
assert cors_headers_for("https://evil.example", RESOLVED_DEV) is None
|
||||
assert cors_headers_for("https://og.dracodev.net", RESOLVED_DEV) is None
|
||||
assert cors_headers_for("https://og-dev.dracodev.net.evil.example",
|
||||
RESOLVED_DEV) is None
|
||||
# Autre environnement autorisé (correctif 2026-10-28).
|
||||
assert cors_headers_for("https://og.dracodev.net",
|
||||
RESOLVED_DEV) is not None
|
||||
|
||||
|
||||
def test_preflight_headers():
|
||||
@@ -86,7 +111,14 @@ def test_groupes_vers_json_vide():
|
||||
|
||||
|
||||
def test_sister_origins_hote_requete():
|
||||
# Configuration muette + hôte dev : le repli inclut les -dev.
|
||||
# Correctif 2026-10-28 : configuration muette, quel que soit
|
||||
# l'hôte de la requête, prod ET dev passent.
|
||||
assert "https://og-dev.dracodev.net" in sister_origins(
|
||||
{}, request_host="kanbante-dev.dracodev.net"
|
||||
)
|
||||
assert "https://og.dracodev.net" in sister_origins(
|
||||
{}, request_host="kanbante-dev.dracodev.net"
|
||||
)
|
||||
assert "https://og-dev.dracodev.net" in sister_origins(
|
||||
{}, request_host="kanbante.dracodev.net"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Thème du site (#49, #54) : Kanbante doit ressembler à la console
|
||||
agent / barre Fusion (draco-barre, Console v7.2), pas au bleu/mauve
|
||||
historique — et l'accent est désormais le vert Kanbante, pas le
|
||||
rose Spark. Le test fige les jetons et interdit le retour des
|
||||
littéraux rose/violet/bleu de thème dans style.css.
|
||||
"""
|
||||
|
||||
|
||||
def test_theme_agent_tokens(client, fake_gitea):
|
||||
css = client.get("/static/css/style.css").text
|
||||
assert "--bg: #101211;" in css
|
||||
assert "--surface: #1a1c1a;" in css
|
||||
assert "--surface-2: #242624;" in css
|
||||
assert "--surface-3: #20221f;" in css
|
||||
assert "--border: #2e302c;" in css
|
||||
assert "--text: #eceee9;" in css
|
||||
assert "--accent: #0d9488;" in css
|
||||
assert "--accent-2: #14b8a6;" in css
|
||||
|
||||
|
||||
def test_no_blue_purple_theme_literals(client, fake_gitea):
|
||||
css = client.get("/static/css/style.css").text
|
||||
for banned in (
|
||||
"#7c5cfc", "#5b8af7", "#58a6ff", "#a78bfa", "#7dd3fc",
|
||||
"#c4b5fd", "#a5c4fd", "#0b0e14", "#141822", "#1b202e",
|
||||
"#232939", "124, 92, 252", "124,92,252",
|
||||
"91, 138, 247", "91,138,247",
|
||||
"#c2437e", "#e078a8", "194, 67, 126", "194,67,126",
|
||||
"224, 120, 168", "224,120,168",
|
||||
):
|
||||
assert banned not in css, banned
|
||||
Reference in New Issue
Block a user