@imenam/mcp-messenger 1.0.23 → 1.0.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -391,7 +391,9 @@ correspondants et l'état de lecture :
391
391
  **replié par défaut**, et sélectionner un agent n'ouvre rien. Recliquer ne relâche rien
392
392
  non plus : on revient à la vue générale par « Tous les agents », en tête de colonne. La
393
393
  colonne se replie sur les seuls avatars d'agents quand la place manque, son bouton de
394
- repli restant en place : c'est lui qui la rouvre.
394
+ repli restant en place : c'est lui qui la rouvre. **Repliée, elle garde deux signes
395
+ distincts** : l'anneau désigne l'agent sélectionné, la pastille bleue dit qu'il reste du
396
+ non-lu — la même convention que le point de la liste des fils.
395
397
 
396
398
  **Le classement se fait en deux temps : d'abord ce qui a servi, ensuite ce qui est
397
399
  vide ; puis, dans chacun des deux, la dernière activité, la plus récente en tête.** Une
@@ -418,7 +420,15 @@ correspondants et l'état de lecture :
418
420
  - **trois puces** au-dessus de la liste : tous, non lus, priorité haute ;
419
421
  - **le lecteur du fil** : la conversation entière dans l'ordre, un bloc par message, avec
420
422
  qui écrit à qui — en clair, pas en adresses — et une pastille disant si le destinataire
421
- l'a lu. **Le corps d'un message est rendu comme du markdown** : titres, listes,
423
+ l'a lu. **Les messages s'ouvrent repliés** : chacun tient alors sur sa ligne d'identité —
424
+ pastille, qui écrit à qui, heure — et sur **l'objet du message**, qui est ce qui
425
+ l'identifie. Les deux forment le bandeau qui l'ouvre et le referme, cliquable d'un bout à
426
+ l'autre et grisé au survol pour se donner à voir comme une cible. Ouvrir un message ne
427
+ change rien à ce bandeau : le corps vient simplement se poser dessous. Le destinataire
428
+ n'y figure pas — un fil ne lie que deux boîtes, le répéter à chaque ligne n'apprend
429
+ rien ;
430
+ Un fil se parcourt avant de se lire, et le rafraîchissement automatique ne referme pas
431
+ ce qui est en train d'être lu. **Le corps d'un message est rendu comme du markdown** : titres, listes,
422
432
  citations, tableaux, blocs de code, emphase et liens. C'est la langue dans laquelle les
423
433
  agents s'écrivent, la console la lit donc ainsi plutôt que d'afficher sa syntaxe. Le
424
434
  rendu construit des nœuds un par un et n'insère jamais de HTML : rien de ce qu'un agent
@@ -426,9 +436,10 @@ correspondants et l'état de lecture :
426
436
  suivis. Une image n'est jamais chargée, elle est montrée par son intitulé et son lien.
427
437
  Les aperçus, eux, sont débarrassés de leur syntaxe par le serveur : ils tiennent sur une
428
438
  ligne, où un dièse ou une paire d'astérisques ne serait que du bruit ;
429
- - **le volet de détail** d'un message, ouvert à la demande : identifiants du message et du
430
- fil, les deux noms et l'adresse de chaque boîte, et le tableau de distribution (rôle,
431
- dossier, état). Tout ce qui se recopie a son bouton de copie ;
439
+ - **les détails d'un message**, dans une modale ouverte par l'icône d'information au bout
440
+ de sa ligne : l'objet, le nom et l'identifiant du fil, les identifiants, la date, la
441
+ priorité, les deux noms et l'adresse de chaque boîte, et le tableau de distribution
442
+ (rôle, dossier, état). Tout ce qui se recopie a son bouton de copie ;
432
443
  - une recherche unique, qui couvre le nom d'un fil, sa description et le texte de ses
433
444
  messages ;
434
445
  - **des panneaux redimensionnables** : chaque ligne de séparation se saisit à la souris,
@@ -439,8 +450,8 @@ correspondants et l'état de lecture :
439
450
 
440
451
  **Les adresses ne sont pas de l'information de lecture.** La console résout partout
441
452
  `box_9f2c4a01b3d7e5f8` en nom d'agent, et ne montre l'adresse qu'aux endroits où elle sert
442
- vraiment : le volet de détail, l'en-tête d'un fil et les boîtes sans nom, chaque fois avec
443
- un bouton de copie. Quand plusieurs sessions d'un même projet apparaissent, le nom de
453
+ vraiment : les détails d'un message, l'en-tête d'un fil et les boîtes sans nom, chaque fois
454
+ avec un bouton de copie. Quand plusieurs sessions d'un même projet apparaissent, le nom de
444
455
  l'agent est suivi de celui de la boîte entre parenthèses — sans quoi un fil se lirait
445
456
  « Documentor Dev → Documentor Dev » sans dire de quelles sessions il parle. Une boîte jamais connectée se distingue à son avatar en contour et à la mention
446
457
  « jamais connectée » — elle reste listée, puisqu'elle a du courrier en attente.
@@ -630,7 +641,7 @@ test/ # store, config, API HTTP, outils MCP, binaire réel
630
641
 
631
642
  ### Tests
632
643
 
633
- 274 tests couvrant : les suppressions de boîtes commandées depuis la console — une boîte
644
+ 287 tests couvrant : les suppressions de boîtes commandées depuis la console — une boîte
634
645
  désignée à la main, les vides, celles sans activité depuis une date — avec leur aperçu,
635
646
  leur double confirmation et le refus nominatif d'une boîte qui ne répond plus au critère, la reprise de la boîte d'une session interrompue (clé de session de
636
647
  l'hôte, boîte orpheline, boîte d'une session vivante jamais reprise), le rendu markdown des messages et l'aplatissement des aperçus (dont
@@ -26,6 +26,7 @@ const ICONS = {
26
26
  close: ["M18 6 6 18", "m6 6 12 12"],
27
27
  link: ["M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1", "M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"],
28
28
  chevron: ["m9 18 6-6-6-6"],
29
+ info: ["M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z", "M12 11v5", "M12 8h.01"],
29
30
  trash: ["M3 6h18", "M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2", "M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"],
30
31
  };
31
32
 
@@ -52,8 +53,11 @@ const state = {
52
53
  filter: "all",
53
54
  search: "",
54
55
  threadId: null,
55
- /** Message dont le volet de détail est ouvert, ou null si le volet est fermé. */
56
- messageId: null,
56
+ /**
57
+ * Messages dépliés dans le lecteur, par identifiant. Vide au départ : un fil s'ouvre
58
+ * replié, et se parcourt avant de se lire.
59
+ */
60
+ openMessages: new Set(),
57
61
  collapsed: false,
58
62
  /** Annuaire des boîtes, indexé par adresse : rempli par chaque `/admin/overview`. */
59
63
  directory: new Map(),
@@ -90,7 +94,8 @@ const el = {
90
94
  chips: document.getElementById("chips"),
91
95
  threads: document.getElementById("threads"),
92
96
  reader: document.getElementById("reader"),
93
- sheet: document.getElementById("sheet"),
97
+ details: document.getElementById("details"),
98
+ detailsBody: document.getElementById("details-body"),
94
99
  purge: document.getElementById("purge"),
95
100
  purgeOpen: document.getElementById("purge-open"),
96
101
  purgeClose: document.getElementById("purge-close"),
@@ -457,9 +462,6 @@ function select(kind, key) {
457
462
  if (isSelected(kind, key)) return;
458
463
  state.selection = { kind, key };
459
464
  state.threadId = null;
460
- state.messageId = null;
461
- el.sheet.hidden = true;
462
- el.console.classList.remove("has-sheet");
463
465
  refresh();
464
466
  }
465
467
 
@@ -869,29 +871,81 @@ function renderThread(data) {
869
871
  el.reader.replaceChildren(container);
870
872
  }
871
873
 
874
+ /**
875
+ * Un message du fil, replié ou déplié.
876
+ *
877
+ * Un fil se relit d'abord de haut en bas : ce qui doit sauter aux yeux est qui a écrit,
878
+ * quand, et sur quoi. Les corps sont donc repliés au départ, chaque message tenant sur sa
879
+ * ligne de titre, et c'est cette ligne qui l'ouvre. L'état est retenu par identifiant de
880
+ * message : le rafraîchissement automatique ne referme donc pas ce qu'on est en train de
881
+ * lire.
882
+ */
872
883
  function messageRow(message, isLast) {
873
884
  const item = node("li");
885
+ fillMessage(item, message, isLast);
886
+ return item;
887
+ }
888
+
889
+ /**
890
+ * Remplit la ligne d'un message, dépliée ou non.
891
+ *
892
+ * Le nœud est donné plutôt que rendu : ouvrir un message ne redessine que lui, et c'est
893
+ * bien cette ligne-là, celle qui est dans la page, qu'il faut réécrire — en reconstruire
894
+ * une autre reviendrait à modifier un élément que personne ne regarde.
895
+ */
896
+ function fillMessage(item, message, isLast) {
897
+ const open = state.openMessages.has(message.message_id);
874
898
  const classes = ["message"];
875
- if (state.messageId === message.message_id) classes.push("is-open");
876
- else if (isLast) classes.push("is-last");
899
+ if (open) classes.push("is-expanded");
900
+ if (isLast) classes.push("is-last");
877
901
  const row = node("div", classes.join(" "));
878
-
879
- row.appendChild(agentAvatar(message.from));
880
-
881
902
  const column = node("div", "message-body-col");
882
- const head = node("div", "message-head");
883
- head.appendChild(node("span", "message-from", party(message.from).name));
884
- head.appendChild(node("span", "message-to", `→ ${party(message.to).name}`));
885
- if (message.priority === "high") head.appendChild(tag("tag tag--high", "priorité haute"));
886
- if (message.priority === "low") head.appendChild(tag("tag", "priorité basse"));
887
- head.appendChild(node("span", "message-time", formatShort(message.created_at)));
903
+
904
+ /*
905
+ Tout ce qui est visible d'un message replié est cliquable, pastille comprise : le
906
+ bandeau est le bouton, et le survol le grise pour qu'on le voie comme tel. Pas de
907
+ chevron — sur une ligne qui s'annonce déjà comme une cible, il n'apprend rien.
908
+ */
909
+ const head = node("button", "message-head");
910
+ head.type = "button";
911
+ head.setAttribute("aria-expanded", open ? "true" : "false");
912
+ head.appendChild(agentAvatar(message.from));
913
+
914
+ const identity = node("span", "message-identity");
915
+ identity.appendChild(node("span", "message-from", party(message.from).name));
916
+ if (message.priority === "high") identity.appendChild(tag("tag tag--high", "priorité haute"));
917
+ if (message.priority === "low") identity.appendChild(tag("tag", "priorité basse"));
918
+ if (!message.read) {
919
+ const dot = node("span", "message-dot");
920
+ dot.title = `non lu par ${party(message.to).name}`;
921
+ identity.appendChild(dot);
922
+ }
923
+ identity.appendChild(node("span", "message-time", formatShort(message.created_at)));
924
+ identity.appendChild(detailsButton(message));
925
+ head.appendChild(identity);
926
+
927
+ // L'objet, toujours : c'est le titre du message, et c'est lui qu'on cherche du regard.
928
+ // Le bandeau ne bouge donc pas quand le message s'ouvre, seul le corps vient dessous.
929
+ head.appendChild(node("span", "message-subject", message.subject || "sans objet"));
930
+
931
+ head.addEventListener("click", () => {
932
+ if (open) state.openMessages.delete(message.message_id);
933
+ else state.openMessages.add(message.message_id);
934
+ fillMessage(item, message, isLast);
935
+ });
888
936
  column.appendChild(head);
889
937
 
890
- // Le sujet n'est répété que s'il diffère du nom du fil : sinon c'est du bruit.
891
- if (message.subject && message.subject !== message.thread_title) {
892
- column.appendChild(node("p", "message-subject", message.subject));
938
+ if (!open) {
939
+ row.appendChild(column);
940
+ item.replaceChildren(row);
941
+ return;
893
942
  }
894
- column.appendChild(messageText(message));
943
+
944
+ // Ce qui suit la ligne de titre s'aligne sous le texte, pas sous la pastille.
945
+ const content = node("div", "message-content");
946
+ column.appendChild(content);
947
+
948
+ content.appendChild(messageText(message));
895
949
 
896
950
  const foot = node("div", "message-foot");
897
951
  foot.appendChild(
@@ -899,18 +953,10 @@ function messageRow(message, isLast) {
899
953
  ? tag("tag tag--ok", `lu par ${party(message.to).name}`, "check")
900
954
  : tag("tag tag--warn", `non lu par ${party(message.to).name}`, "clock")
901
955
  );
902
- const open = state.messageId === message.message_id;
903
- foot.appendChild(
904
- button("btn btn--link", open ? "détails ouverts" : "détails", () => {
905
- if (open) closeSheet();
906
- else openMessage(message.message_id);
907
- })
908
- );
909
- column.appendChild(foot);
956
+ content.appendChild(foot);
910
957
 
911
958
  row.appendChild(column);
912
- item.appendChild(row);
913
- return item;
959
+ item.replaceChildren(row);
914
960
  }
915
961
 
916
962
  /**
@@ -925,44 +971,82 @@ function messageText(message) {
925
971
  return container;
926
972
  }
927
973
 
928
- // --- Volet de détail -------------------------------------------------------
974
+ // --- Détails d'un message --------------------------------------------------
929
975
 
930
- function renderSheet(message) {
976
+ /**
977
+ * Bouton d'information, au bout de la ligne.
978
+ *
979
+ * Un fil ne lie que deux boîtes : répéter à chaque message qui écrit à qui, sous quel nom
980
+ * de boîte, encombre la lecture pour une information déjà connue. Elle est donc rangée
981
+ * ici, à un clic, avec tout le reste — adresses, identifiants, distribution.
982
+ */
983
+ function detailsButton(message) {
984
+ const open = node("button", "message-info");
985
+ open.type = "button";
986
+ open.title = "Détails du message";
987
+ open.setAttribute("aria-label", "Détails du message");
988
+ open.appendChild(icon("info"));
989
+ // Le bandeau entier ouvre le message : ce clic-ci ne doit pas le replier au passage.
990
+ open.addEventListener("click", (event) => {
991
+ event.stopPropagation();
992
+ openDetails(message.message_id);
993
+ });
994
+ return open;
995
+ }
996
+
997
+ async function openDetails(messageId) {
998
+ el.detailsBody.replaceChildren(node("p", "muted", "Chargement…"));
999
+ el.details.showModal();
1000
+ try {
1001
+ renderDetails(await api(`/admin/messages/${encodeURIComponent(messageId)}`));
1002
+ } catch (error) {
1003
+ if (error.message === "unauthorized") el.details.close();
1004
+ else el.detailsBody.replaceChildren(node("p", "purge-error", `Erreur : ${error.message}`));
1005
+ }
1006
+ }
1007
+
1008
+ /** Tout ce que la conversation ne montre pas, et que la supervision peut vouloir lire. */
1009
+ function renderDetails(message) {
931
1010
  const container = document.createDocumentFragment();
932
1011
 
933
- const head = node("div", "sheet-head");
1012
+ const head = node("div", "modal-head");
934
1013
  const titles = node("div");
935
- titles.appendChild(node("h3", null, "Détails du message"));
936
- titles.appendChild(node("p", "sheet-subject", message.subject));
1014
+ titles.appendChild(node("h2", null, "Détails du message"));
1015
+ titles.appendChild(node("p", "details-subject", message.subject));
937
1016
  head.appendChild(titles);
938
1017
  const close = node("button", "btn btn--icon btn--ghost");
939
1018
  close.type = "button";
940
1019
  close.title = "Fermer";
941
- close.setAttribute("aria-label", "Fermer le volet");
1020
+ close.setAttribute("aria-label", "Fermer les détails");
942
1021
  close.appendChild(icon("close"));
943
- close.addEventListener("click", closeSheet);
1022
+ close.addEventListener("click", () => el.details.close());
944
1023
  head.appendChild(close);
945
1024
  container.appendChild(head);
946
1025
 
947
- const ids = node("div", "sheet-section");
1026
+ const ids = node("div", "details-section");
948
1027
  ids.appendChild(node("span", "section-label", "Message"));
949
1028
  ids.appendChild(kvCopy("Identifiant", message.message_id));
950
- ids.appendChild(kvCopy("Fil", message.thread_id));
1029
+ ids.appendChild(kv("Objet", node("span", "kv-value", message.subject)));
951
1030
  ids.appendChild(kv("Envoyé le", node("span", "kv-value", formatDate(message.created_at))));
952
1031
  ids.appendChild(kv("Priorité", priorityTag(message.priority)));
953
1032
  container.appendChild(ids);
954
1033
 
1034
+ const thread = node("div", "details-section");
1035
+ thread.appendChild(node("span", "section-label", "Fil"));
1036
+ // Sans nom donné par un participant, le fil n'a que son identifiant et le sujet d'ouverture.
1037
+ thread.appendChild(kv("Nom", node("span", "kv-value", message.thread_title || "fil sans nom")));
1038
+ thread.appendChild(kvCopy("Identifiant", message.thread_id));
1039
+ container.appendChild(thread);
1040
+
955
1041
  container.appendChild(partySection("De", message.from));
956
1042
  container.appendChild(partySection("À", message.to));
957
1043
 
958
- const deliveries = node("div", "sheet-section");
1044
+ const deliveries = node("div", "details-section");
959
1045
  deliveries.appendChild(node("span", "section-label", "Distribution"));
960
1046
  deliveries.appendChild(deliveriesTable(message.deliveries || []));
961
1047
  container.appendChild(deliveries);
962
1048
 
963
- el.sheet.replaceChildren(container);
964
- el.sheet.hidden = false;
965
- el.console.classList.add("has-sheet");
1049
+ el.detailsBody.replaceChildren(container);
966
1050
  }
967
1051
 
968
1052
  function kv(key, valueNode) {
@@ -992,7 +1076,7 @@ function priorityTag(priority) {
992
1076
  */
993
1077
  function partySection(label, agentId) {
994
1078
  const who = party(agentId);
995
- const section = node("div", "sheet-section");
1079
+ const section = node("div", "details-section");
996
1080
  section.appendChild(node("span", "section-label", label));
997
1081
 
998
1082
  const block = node("div", "party");
@@ -1034,21 +1118,13 @@ function deliveriesTable(deliveries) {
1034
1118
  return table;
1035
1119
  }
1036
1120
 
1037
- function closeSheet() {
1038
- state.messageId = null;
1039
- el.sheet.hidden = true;
1040
- el.sheet.replaceChildren();
1041
- el.console.classList.remove("has-sheet");
1042
- if (state.threadId) refreshThread();
1043
- }
1044
-
1045
1121
  // --- Navigation ------------------------------------------------------------
1046
1122
 
1047
1123
  async function openThread(threadId) {
1048
1124
  state.threadId = threadId;
1049
- state.messageId = null;
1050
- el.sheet.hidden = true;
1051
- el.console.classList.remove("has-sheet");
1125
+ // Un fil s'ouvre replié, même celui qu'on rouvre : ce qui était déplié l'était pour
1126
+ // l'autre conversation.
1127
+ state.openMessages.clear();
1052
1128
  await refreshThread();
1053
1129
  await refresh({ keepThread: true });
1054
1130
  }
@@ -1065,16 +1141,6 @@ async function refreshThread() {
1065
1141
  }
1066
1142
  }
1067
1143
 
1068
- async function openMessage(messageId) {
1069
- state.messageId = messageId;
1070
- try {
1071
- renderSheet(await api(`/admin/messages/${encodeURIComponent(messageId)}`));
1072
- await refreshThread();
1073
- } catch (error) {
1074
- if (error.message !== "unauthorized") el.sheet.replaceChildren(node("div", "banner", error.message));
1075
- }
1076
- }
1077
-
1078
1144
  // --- Boucle de rafraîchissement --------------------------------------------
1079
1145
 
1080
1146
  async function refresh(options) {
@@ -1112,14 +1178,13 @@ async function refresh(options) {
1112
1178
  /**
1113
1179
  * Ce que chaque séparateur commande.
1114
1180
  *
1115
- * `edge` dit de quel côté se trouve le panneau réglé : les deux premiers bordent le
1116
- * panneau qui les précède, celui du volet de détail borde le panneau qui le suit. Sans
1117
- * cette distinction, tirer vers la droite élargirait un volet ancré à droite.
1181
+ * `edge` dit de quel côté se trouve le panneau réglé : ici, chacun borde le panneau qui
1182
+ * le précède. La distinction reste écrite car un panneau ancré à droite se réglerait à
1183
+ * l'envers, en comptant depuis son bord droit.
1118
1184
  */
1119
1185
  const PANELS = {
1120
1186
  "resize-agents": { name: "--w-agents", panel: "agentsPane", edge: "before", min: 180, max: 520, initial: 240 },
1121
1187
  "resize-threads": { name: "--w-threads", panel: "threadsPane", edge: "before", min: 260, max: 720, initial: 400 },
1122
- "resize-sheet": { name: "--w-sheet", panel: "sheet", edge: "after", min: 280, max: 720, initial: 360 },
1123
1188
  };
1124
1189
 
1125
1190
  const LAYOUT_KEY = "messenger.layout";
@@ -1396,9 +1461,6 @@ async function confirmPurge() {
1396
1461
  state.selection = { kind: "all", key: null };
1397
1462
  }
1398
1463
  state.threadId = null;
1399
- state.messageId = null;
1400
- el.sheet.hidden = true;
1401
- el.console.classList.remove("has-sheet");
1402
1464
  await refresh();
1403
1465
  } catch (error) {
1404
1466
  purge.armed = false;
@@ -1486,11 +1548,9 @@ el.logout.addEventListener("click", async () => {
1486
1548
  state.expanded = new Set();
1487
1549
  state.showEmpty = new Set();
1488
1550
  state.threadId = null;
1489
- state.messageId = null;
1551
+ state.openMessages = new Set();
1490
1552
  state.directory = new Map();
1491
1553
  state.groups = [];
1492
- el.sheet.hidden = true;
1493
- el.console.classList.remove("has-sheet");
1494
1554
  el.agents.replaceChildren();
1495
1555
  el.threads.replaceChildren();
1496
1556
  renderEmptyReader();
@@ -1,217 +1,215 @@
1
- <!doctype html>
2
- <html lang="fr">
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <title>MCP Messenger — supervision</title>
7
- <link rel="stylesheet" href="./styles.css" />
8
- </head>
9
- <body>
10
- <header class="topbar">
11
- <div class="brand">
12
- <svg class="brand-mark" viewBox="0 0 24 24" aria-hidden="true">
13
- <path d="M22 12h-6l-2 3h-4l-2-3H2" />
14
- <path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
15
- </svg>
16
- <h1>MCP Messenger</h1>
17
- <span class="pill pill--idle" id="status">
18
- <span class="pill-dot"></span>
19
- <span id="status-label">connexion…</span>
20
- </span>
21
- </div>
22
-
23
- <div class="topbar-search" id="search-box">
24
- <svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
25
- <circle cx="11" cy="11" r="8" />
26
- <path d="m21 21-4.3-4.3" />
27
- </svg>
28
- <input type="search" id="search" placeholder="Rechercher un sujet, un fil, un agent…" />
29
- </div>
30
-
31
- <div class="topbar-controls" id="controls" hidden>
32
- <label class="switch">
33
- <input type="checkbox" id="auto-refresh" checked />
34
- <span class="switch-track"><span class="switch-knob"></span></span>
35
- <span class="switch-label">auto · 5 s</span>
36
- </label>
37
- <button type="button" id="refresh" class="btn btn--icon" title="Rafraîchir" aria-label="Rafraîchir">
38
- <svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
39
- <path d="M3 12a9 9 0 0 1 15-6.7L21 8" />
40
- <path d="M21 3v5h-5" />
41
- <path d="M21 12a9 9 0 0 1-15 6.7L3 16" />
42
- <path d="M3 21v-5h5" />
43
- </svg>
44
- </button>
45
- <button type="button" id="purge-open" class="btn btn--icon" title="Purger les boîtes inactives" aria-label="Purger les boîtes inactives">
46
- <svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
47
- <path d="M3 6h18" />
48
- <path d="M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2" />
49
- <path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
50
- </svg>
51
- </button>
52
- <button type="button" id="logout" class="btn btn--link">Se déconnecter</button>
53
- </div>
54
- </header>
55
-
56
- <!--
57
- Purge des boîtes inactives : la seule action de la console qui écrit, et elle
58
- détruit. Elle se fait donc en deux temps, l'aperçu puis la confirmation, et ne porte
59
- jamais que sur les boîtes affichées.
60
- -->
61
- <dialog class="modal" id="purge">
62
- <div class="modal-card">
63
- <div class="modal-head">
64
- <h2>Nettoyer les boîtes</h2>
65
- <button type="button" class="btn btn--icon btn--ghost" id="purge-close" aria-label="Fermer">
66
- <svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
67
- <path d="M18 6 6 18" />
68
- <path d="m6 6 12 12" />
69
- </svg>
70
- </button>
71
- </div>
72
- <p class="modal-sub">
73
- Deux nettoyages, deux critères. La suppression est définitive dans les deux cas, et emporte les messages que
74
- plus personne ne pourrait lire ; ce que le correspondant a reçu de son côté reste intact. Un agent dont toutes
75
- les boîtes disparaissent quitte l'annuaire avec elles.
76
- </p>
77
-
78
- <!-- Premier critère : la boîte n'a jamais servi. Il ne dépend d'aucune date. -->
79
- <div class="purge-controls">
80
- <div class="purge-criterion">
81
- <span class="field-label">Boîtes vides</span>
82
- <span class="purge-hint">Rien n'y est jamais passé, ni envoi ni réception, quel que soit leur âge.</span>
83
- </div>
84
- <button type="button" class="btn" id="purge-preview-empty">Prévisualiser</button>
85
- </div>
86
-
87
- <!-- Second critère : la boîte a servi, mais plus rien ne l'a touchée depuis. -->
88
- <div class="purge-controls">
89
- <label class="field">
90
- <span class="field-label">Boîtes sans activité depuis le</span>
91
- <span class="field-input"><input type="date" id="purge-before" /></span>
92
- <span class="purge-hint">Ni connexion, ni message. Une boîte créée après cette date est épargnée.</span>
93
- </label>
94
- <button type="button" class="btn" id="purge-preview">Prévisualiser</button>
95
- </div>
96
-
97
- <div class="purge-result" id="purge-result"></div>
98
-
99
- <div class="modal-foot">
100
- <button type="button" class="btn btn--link" id="purge-cancel">Fermer</button>
101
- <button type="button" class="btn btn--danger" id="purge-confirm" disabled>Supprimer</button>
102
- </div>
103
- </div>
104
- </dialog>
105
-
106
- <!-- Écran de connexion : une carte seule, sans les panneaux vides derrière elle. -->
107
- <main class="login" id="login" hidden>
108
- <form class="card" id="login-form">
109
- <div class="card-badge">
110
- <svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
111
- <rect x="3" y="11" width="18" height="11" rx="2" />
112
- <path d="M7 11V7a5 5 0 0 1 10 0v4" />
113
- </svg>
114
- </div>
115
- <h2>Console de supervision</h2>
116
- <p class="card-sub" id="login-origin">MCP Messenger</p>
117
-
118
- <label class="field">
119
- <span class="field-label">Jeton administrateur</span>
120
- <span class="field-input">
121
- <input type="password" id="token" autocomplete="current-password" placeholder="MESSENGER_ADMIN_TOKEN" />
122
- <button type="button" class="field-eye" id="token-reveal" aria-label="Afficher le jeton">
123
- <svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
124
- <path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7z" />
125
- <circle cx="12" cy="12" r="3" />
126
- </svg>
127
- </button>
128
- </span>
129
- <span class="field-error" id="token-error"></span>
130
- </label>
131
-
132
- <button type="submit" class="btn btn--primary btn--block">Se connecter</button>
133
- <p class="card-note">
134
- Le jeton n'est jamais conservé dans le navigateur : la session tient dans un cookie HttpOnly, fermé à la
135
- déconnexion.
136
- </p>
137
- </form>
138
- </main>
139
-
140
- <!-- Console : agents, fils, lecteur du fil, volet de détail. -->
141
- <main class="console" id="console" hidden>
142
- <aside class="agents" id="agents-pane">
143
- <div class="agents-head">
144
- <span class="section-label">Agents</span>
145
- <button type="button" class="btn btn--icon btn--ghost" id="collapse" title="Replier" aria-label="Replier la liste des agents" aria-expanded="true">
146
- <svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
147
- <rect x="3" y="3" width="18" height="18" rx="2" />
148
- <path d="M9 3v18" />
149
- </svg>
150
- </button>
151
- </div>
152
- <ul class="agent-list" id="agents"></ul>
153
- <p class="agents-foot muted" id="agents-foot"></p>
154
- </aside>
155
-
156
- <!--
157
- Séparateurs déplaçables. Chacun redimensionne le panneau qu'il borde, à la souris
158
- comme aux flèches du clavier ; un double-clic rend sa largeur d'origine.
159
- -->
160
- <div
161
- class="resizer"
162
- id="resize-agents"
163
- role="separator"
164
- aria-orientation="vertical"
165
- aria-label="Largeur de la colonne des agents"
166
- tabindex="0"
167
- ></div>
168
-
169
- <section class="threads">
170
- <div class="threads-head">
171
- <!--
172
- L'en-tête dit sur quoi la liste est filtrée. Quand c'est un agent, il y tient la
173
- place principale, pastille comprise : c'est le sujet de ce qu'on lit en dessous.
174
- -->
175
- <div class="threads-title">
176
- <span class="threads-avatar" id="threads-avatar" hidden></span>
177
- <div class="threads-heading">
178
- <h2 id="threads-name">Fils</h2>
179
- <p class="muted" id="threads-scope"></p>
180
- </div>
181
- </div>
182
- <div class="chips" id="chips">
183
- <button type="button" class="chip is-active" data-filter="all">Tous</button>
184
- <button type="button" class="chip" data-filter="unread">Non lus</button>
185
- <button type="button" class="chip" data-filter="high">Priorité haute</button>
186
- </div>
187
- </div>
188
- <ul class="thread-list" id="threads"></ul>
189
- </section>
190
-
191
- <div
192
- class="resizer"
193
- id="resize-threads"
194
- role="separator"
195
- aria-orientation="vertical"
196
- aria-label="Largeur de la liste des fils"
197
- tabindex="0"
198
- ></div>
199
-
200
- <section class="reader" id="reader"></section>
201
-
202
- <!-- Suit le volet de détail : il n'existe que lorsque le volet est ouvert. -->
203
- <div
204
- class="resizer resizer--sheet"
205
- id="resize-sheet"
206
- role="separator"
207
- aria-orientation="vertical"
208
- aria-label="Largeur du volet de détail"
209
- tabindex="0"
210
- ></div>
211
-
212
- <aside class="sheet" id="sheet" hidden></aside>
213
- </main>
214
-
215
- <script type="module" src="./app.js"></script>
216
- </body>
217
- </html>
1
+ <!doctype html>
2
+ <html lang="fr">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>MCP Messenger — supervision</title>
7
+ <link rel="stylesheet" href="./styles.css" />
8
+ </head>
9
+ <body>
10
+ <header class="topbar">
11
+ <div class="brand">
12
+ <svg class="brand-mark" viewBox="0 0 24 24" aria-hidden="true">
13
+ <path d="M22 12h-6l-2 3h-4l-2-3H2" />
14
+ <path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
15
+ </svg>
16
+ <h1>MCP Messenger</h1>
17
+ <span class="pill pill--idle" id="status">
18
+ <span class="pill-dot"></span>
19
+ <span id="status-label">connexion…</span>
20
+ </span>
21
+ </div>
22
+
23
+ <div class="topbar-search" id="search-box">
24
+ <svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
25
+ <circle cx="11" cy="11" r="8" />
26
+ <path d="m21 21-4.3-4.3" />
27
+ </svg>
28
+ <input type="search" id="search" placeholder="Rechercher un sujet, un fil, un agent…" />
29
+ </div>
30
+
31
+ <div class="topbar-controls" id="controls" hidden>
32
+ <label class="switch">
33
+ <input type="checkbox" id="auto-refresh" checked />
34
+ <span class="switch-track"><span class="switch-knob"></span></span>
35
+ <span class="switch-label">auto · 5 s</span>
36
+ </label>
37
+ <button type="button" id="refresh" class="btn btn--icon" title="Rafraîchir" aria-label="Rafraîchir">
38
+ <svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
39
+ <path d="M3 12a9 9 0 0 1 15-6.7L21 8" />
40
+ <path d="M21 3v5h-5" />
41
+ <path d="M21 12a9 9 0 0 1-15 6.7L3 16" />
42
+ <path d="M3 21v-5h5" />
43
+ </svg>
44
+ </button>
45
+ <button type="button" id="purge-open" class="btn btn--icon" title="Purger les boîtes inactives" aria-label="Purger les boîtes inactives">
46
+ <svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
47
+ <path d="M3 6h18" />
48
+ <path d="M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2" />
49
+ <path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
50
+ </svg>
51
+ </button>
52
+ <button type="button" id="logout" class="btn btn--link">Se déconnecter</button>
53
+ </div>
54
+ </header>
55
+
56
+ <!--
57
+ Purge des boîtes inactives : la seule action de la console qui écrit, et elle
58
+ détruit. Elle se fait donc en deux temps, l'aperçu puis la confirmation, et ne porte
59
+ jamais que sur les boîtes affichées.
60
+ -->
61
+ <dialog class="modal" id="purge">
62
+ <div class="modal-card">
63
+ <div class="modal-head">
64
+ <h2>Nettoyer les boîtes</h2>
65
+ <button type="button" class="btn btn--icon btn--ghost" id="purge-close" aria-label="Fermer">
66
+ <svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
67
+ <path d="M18 6 6 18" />
68
+ <path d="m6 6 12 12" />
69
+ </svg>
70
+ </button>
71
+ </div>
72
+ <p class="modal-sub">
73
+ Deux nettoyages, deux critères. La suppression est définitive dans les deux cas, et emporte les messages que
74
+ plus personne ne pourrait lire ; ce que le correspondant a reçu de son côté reste intact. Un agent dont toutes
75
+ les boîtes disparaissent quitte l'annuaire avec elles.
76
+ </p>
77
+
78
+ <!-- Premier critère : la boîte n'a jamais servi. Il ne dépend d'aucune date. -->
79
+ <div class="purge-controls">
80
+ <div class="purge-criterion">
81
+ <span class="field-label">Boîtes vides</span>
82
+ <span class="purge-hint">Rien n'y est jamais passé, ni envoi ni réception, quel que soit leur âge.</span>
83
+ </div>
84
+ <button type="button" class="btn" id="purge-preview-empty">Prévisualiser</button>
85
+ </div>
86
+
87
+ <!-- Second critère : la boîte a servi, mais plus rien ne l'a touchée depuis. -->
88
+ <div class="purge-controls">
89
+ <label class="field">
90
+ <span class="field-label">Boîtes sans activité depuis le</span>
91
+ <span class="field-input"><input type="date" id="purge-before" /></span>
92
+ <span class="purge-hint">Ni connexion, ni message. Une boîte créée après cette date est épargnée.</span>
93
+ </label>
94
+ <button type="button" class="btn" id="purge-preview">Prévisualiser</button>
95
+ </div>
96
+
97
+ <div class="purge-result" id="purge-result"></div>
98
+
99
+ <div class="modal-foot">
100
+ <button type="button" class="btn btn--link" id="purge-cancel">Fermer</button>
101
+ <button type="button" class="btn btn--danger" id="purge-confirm" disabled>Supprimer</button>
102
+ </div>
103
+ </div>
104
+ </dialog>
105
+
106
+ <!--
107
+ Détails d'un message : tout ce que la conversation ne montre pas — adresses, noms de
108
+ boîtes, identifiants, distribution. Une modale, parce que c'est une consultation
109
+ ponctuelle : elle s'ouvre sur demande et rend la place en se fermant.
110
+ -->
111
+ <dialog class="modal" id="details">
112
+ <div class="modal-card" id="details-body"></div>
113
+ </dialog>
114
+
115
+ <!-- Écran de connexion : une carte seule, sans les panneaux vides derrière elle. -->
116
+ <main class="login" id="login" hidden>
117
+ <form class="card" id="login-form">
118
+ <div class="card-badge">
119
+ <svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
120
+ <rect x="3" y="11" width="18" height="11" rx="2" />
121
+ <path d="M7 11V7a5 5 0 0 1 10 0v4" />
122
+ </svg>
123
+ </div>
124
+ <h2>Console de supervision</h2>
125
+ <p class="card-sub" id="login-origin">MCP Messenger</p>
126
+
127
+ <label class="field">
128
+ <span class="field-label">Jeton administrateur</span>
129
+ <span class="field-input">
130
+ <input type="password" id="token" autocomplete="current-password" placeholder="MESSENGER_ADMIN_TOKEN" />
131
+ <button type="button" class="field-eye" id="token-reveal" aria-label="Afficher le jeton">
132
+ <svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
133
+ <path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7z" />
134
+ <circle cx="12" cy="12" r="3" />
135
+ </svg>
136
+ </button>
137
+ </span>
138
+ <span class="field-error" id="token-error"></span>
139
+ </label>
140
+
141
+ <button type="submit" class="btn btn--primary btn--block">Se connecter</button>
142
+ <p class="card-note">
143
+ Le jeton n'est jamais conservé dans le navigateur : la session tient dans un cookie HttpOnly, fermé à la
144
+ déconnexion.
145
+ </p>
146
+ </form>
147
+ </main>
148
+
149
+ <!-- Console : agents, fils, lecteur du fil. -->
150
+ <main class="console" id="console" hidden>
151
+ <aside class="agents" id="agents-pane">
152
+ <div class="agents-head">
153
+ <span class="section-label">Agents</span>
154
+ <button type="button" class="btn btn--icon btn--ghost" id="collapse" title="Replier" aria-label="Replier la liste des agents" aria-expanded="true">
155
+ <svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
156
+ <rect x="3" y="3" width="18" height="18" rx="2" />
157
+ <path d="M9 3v18" />
158
+ </svg>
159
+ </button>
160
+ </div>
161
+ <ul class="agent-list" id="agents"></ul>
162
+ <p class="agents-foot muted" id="agents-foot"></p>
163
+ </aside>
164
+
165
+ <!--
166
+ Séparateurs déplaçables. Chacun redimensionne le panneau qu'il borde, à la souris
167
+ comme aux flèches du clavier ; un double-clic rend sa largeur d'origine.
168
+ -->
169
+ <div
170
+ class="resizer"
171
+ id="resize-agents"
172
+ role="separator"
173
+ aria-orientation="vertical"
174
+ aria-label="Largeur de la colonne des agents"
175
+ tabindex="0"
176
+ ></div>
177
+
178
+ <section class="threads">
179
+ <div class="threads-head">
180
+ <!--
181
+ L'en-tête dit sur quoi la liste est filtrée. Quand c'est un agent, il y tient la
182
+ place principale, pastille comprise : c'est le sujet de ce qu'on lit en dessous.
183
+ -->
184
+ <div class="threads-title">
185
+ <span class="threads-avatar" id="threads-avatar" hidden></span>
186
+ <div class="threads-heading">
187
+ <h2 id="threads-name">Fils</h2>
188
+ <p class="muted" id="threads-scope"></p>
189
+ </div>
190
+ </div>
191
+ <div class="chips" id="chips">
192
+ <button type="button" class="chip is-active" data-filter="all">Tous</button>
193
+ <button type="button" class="chip" data-filter="unread">Non lus</button>
194
+ <button type="button" class="chip" data-filter="high">Priorité haute</button>
195
+ </div>
196
+ </div>
197
+ <ul class="thread-list" id="threads"></ul>
198
+ </section>
199
+
200
+ <div
201
+ class="resizer"
202
+ id="resize-threads"
203
+ role="separator"
204
+ aria-orientation="vertical"
205
+ aria-label="Largeur de la liste des fils"
206
+ tabindex="0"
207
+ ></div>
208
+
209
+ <section class="reader" id="reader"></section>
210
+
211
+ </main>
212
+
213
+ <script type="module" src="./app.js"></script>
214
+ </body>
215
+ </html>
@@ -554,29 +554,16 @@ body {
554
554
  .console {
555
555
  --w-agents: 240px;
556
556
  --w-threads: 400px;
557
- --w-sheet: 360px;
558
557
  --w-gutter: 5px;
559
558
  display: grid;
560
559
  grid-template-columns: var(--w-agents) var(--w-gutter) var(--w-threads) var(--w-gutter) minmax(0, 1fr);
561
560
  height: calc(100vh - var(--topbar-h));
562
561
  }
563
562
 
564
- .console.has-sheet {
565
- grid-template-columns:
566
- var(--w-agents) var(--w-gutter) var(--w-threads) var(--w-gutter)
567
- minmax(0, 1fr) var(--w-gutter) var(--w-sheet);
568
- }
569
-
570
563
  .console.is-collapsed {
571
564
  grid-template-columns: 57px var(--w-gutter) var(--w-threads) var(--w-gutter) minmax(0, 1fr);
572
565
  }
573
566
 
574
- .console.is-collapsed.has-sheet {
575
- grid-template-columns:
576
- 57px var(--w-gutter) var(--w-threads) var(--w-gutter)
577
- minmax(0, 1fr) var(--w-gutter) var(--w-sheet);
578
- }
579
-
580
567
  /* --- Séparateurs déplaçables ---------------------------------------------- */
581
568
 
582
569
  .resizer {
@@ -598,11 +585,6 @@ body {
598
585
  outline: none;
599
586
  }
600
587
 
601
- /* Le volet de détail n'est pas toujours là ; son séparateur non plus. */
602
- .console:not(.has-sheet) .resizer--sheet {
603
- display: none;
604
- }
605
-
606
588
  /* Colonne repliée : il n'y a plus de largeur à régler, seulement un rail d'avatars. */
607
589
  .console.is-collapsed #resize-agents {
608
590
  cursor: default;
@@ -617,8 +599,7 @@ body {
617
599
 
618
600
  .console.is-resizing .agents,
619
601
  .console.is-resizing .threads,
620
- .console.is-resizing .reader,
621
- .console.is-resizing .sheet {
602
+ .console.is-resizing .reader {
622
603
  pointer-events: none;
623
604
  }
624
605
 
@@ -992,11 +973,39 @@ body {
992
973
  padding-left: 8px;
993
974
  }
994
975
 
995
- /* Le badge des non-lus survit au repli, réduit à une pastille sur l'avatar. */
996
- .is-collapsed .agent.has-unread .avatar {
976
+ /*
977
+ Colonne repliée, deux signes distincts et souvent simultanés.
978
+
979
+ L'anneau dit ce qui est **sélectionné** : c'est un contour, il entoure, il désigne — la
980
+ teinte de fond qui sert à ça dans la colonne dépliée n'a plus de place ici. La pastille
981
+ dit qu'il reste du **non-lu** : un point posé sur l'avatar, la même convention que la
982
+ liste des fils. L'un n'implique pas l'autre, ils doivent donc se lire séparément.
983
+ */
984
+ .is-collapsed .agent.is-selected {
985
+ background: none;
986
+ }
987
+
988
+ .is-collapsed .agent.is-selected .avatar {
997
989
  box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent);
998
990
  }
999
991
 
992
+ .is-collapsed .agent.has-unread .avatar {
993
+ position: relative;
994
+ }
995
+
996
+ .is-collapsed .agent.has-unread .avatar::after {
997
+ content: "";
998
+ position: absolute;
999
+ top: -1px;
1000
+ right: -1px;
1001
+ width: 9px;
1002
+ height: 9px;
1003
+ border-radius: 50%;
1004
+ background: var(--accent);
1005
+ /* Le liseré détache la pastille de l'avatar, quelle que soit la couleur de celui-ci. */
1006
+ box-shadow: 0 0 0 2px var(--bg);
1007
+ }
1008
+
1000
1009
  .is-collapsed .agents {
1001
1010
  overflow-x: hidden;
1002
1011
  }
@@ -1320,14 +1329,25 @@ body {
1320
1329
  .messages {
1321
1330
  list-style: none;
1322
1331
  margin: 0;
1323
- padding: 20px 32px 40px;
1332
+ /* Le retrait latéral du texte vient d'ici plus du cadre d'un message : 20 + 12. */
1333
+ padding: 12px 20px 40px;
1334
+ }
1335
+
1336
+ /*
1337
+ Un trait entre deux messages, et non un simple blanc.
1338
+
1339
+ Repliés, les messages se suivent de près : sans démarcation, on devine où l'un s'arrête
1340
+ à la seule présence de la pastille suivante. Le trait court sur toute la largeur de la
1341
+ liste, et l'écart de part et d'autre vient du cadre des messages eux-mêmes.
1342
+ */
1343
+ .messages > li + li {
1344
+ border-top-width: 1px;
1345
+ border-top-style: solid;
1346
+ border-top-color: var(--border);
1324
1347
  }
1325
1348
 
1326
1349
  .message {
1327
- display: flex;
1328
- gap: 12px;
1329
1350
  padding: 12px;
1330
- margin: 0 -12px;
1331
1351
  border-radius: var(--radius);
1332
1352
  }
1333
1353
 
@@ -1342,24 +1362,111 @@ body {
1342
1362
  }
1343
1363
 
1344
1364
  .message-body-col {
1345
- flex: 1;
1346
1365
  min-width: 0;
1347
1366
  }
1348
1367
 
1368
+ /*
1369
+ Le bandeau qui ouvre et referme le message.
1370
+
1371
+ Il tient tout ce qu'un message replié montre : la pastille, qui écrit à qui, l'heure, et
1372
+ la ligne de résumé. Rien n'y ressemble à un bouton tant qu'on ne le survole pas — c'est
1373
+ le fond gris qui dit alors que la zone entière est une cible, mieux qu'un chevron.
1374
+
1375
+ Deux colonnes, deux lignes : la pastille tient sur les deux, le texte occupe la seconde
1376
+ colonne — qui écrit à qui sur la première ligne, l'objet du message sur la seconde.
1377
+ */
1349
1378
  .message-head {
1379
+ display: grid;
1380
+ grid-template-columns: auto minmax(0, 1fr);
1381
+ align-items: center;
1382
+ gap: 2px 12px;
1383
+ width: 100%;
1384
+ padding: 6px;
1385
+ margin: -6px;
1386
+ border: 0;
1387
+ border-radius: var(--radius);
1388
+ background: none;
1389
+ font: inherit;
1390
+ color: inherit;
1391
+ text-align: left;
1392
+ cursor: pointer;
1393
+ }
1394
+
1395
+ .message-head:hover {
1396
+ background: var(--neutral-soft);
1397
+ }
1398
+
1399
+ .message-head:focus-visible {
1400
+ outline: 2px solid var(--accent);
1401
+ outline-offset: -2px;
1402
+ }
1403
+
1404
+ /*
1405
+ L'information s'ouvre depuis la ligne sans la replier. Visuellement c'est un bouton posé
1406
+ dans un autre ; dans le balisage, non — il est rangé au bout de la ligne d'identité, et
1407
+ son clic ne remonte pas au bandeau.
1408
+ */
1409
+ .message-info {
1350
1410
  display: flex;
1351
- align-items: baseline;
1411
+ padding: 2px;
1412
+ border: 0;
1413
+ border-radius: 50%;
1414
+ background: none;
1415
+ color: var(--muted);
1416
+ cursor: pointer;
1417
+ }
1418
+
1419
+ .message-info:hover {
1420
+ color: var(--accent);
1421
+ }
1422
+
1423
+ .message-info:focus-visible {
1424
+ outline: 2px solid var(--accent);
1425
+ outline-offset: 1px;
1426
+ }
1427
+
1428
+ .message-head > .avatar {
1429
+ grid-row: span 2;
1430
+ }
1431
+
1432
+ /* Qui écrit à qui, et quand : l'heure est poussée au bout de la ligne. */
1433
+ .message-identity {
1434
+ display: flex;
1435
+ align-items: center;
1352
1436
  gap: 8px;
1353
1437
  flex-wrap: wrap;
1438
+ min-width: 0;
1354
1439
  }
1355
1440
 
1356
- .message-from {
1357
- font-weight: 600;
1441
+ /*
1442
+ L'objet du message, sur sa ligne, replié comme déplié : c'est ce qui identifie le
1443
+ message, et le bandeau ne doit pas changer de forme en s'ouvrant. Il se lit sans graisse
1444
+ — c'est déjà la ligne la plus longue du bandeau, l'appuyer en ferait un titre de section.
1445
+ Ce qui dépasse est coupé : la ligne suivante appartient au message suivant.
1446
+ */
1447
+ .message-subject {
1448
+ min-width: 0;
1449
+ white-space: nowrap;
1450
+ overflow: hidden;
1451
+ text-overflow: ellipsis;
1358
1452
  }
1359
1453
 
1360
- .message-to {
1361
- font-size: 13px;
1362
- color: var(--muted);
1454
+ /* Le corps s'aligne sous le texte du bandeau, jamais sous la pastille. */
1455
+ .message-content {
1456
+ padding-left: 40px;
1457
+ }
1458
+
1459
+ /* Non lu : le même point que dans la liste des fils, et non un anneau ni une couleur. */
1460
+ .message-dot {
1461
+ width: 8px;
1462
+ height: 8px;
1463
+ border-radius: 50%;
1464
+ background: var(--accent);
1465
+ flex: none;
1466
+ }
1467
+
1468
+ .message-from {
1469
+ font-weight: 600;
1363
1470
  }
1364
1471
 
1365
1472
  .message-time {
@@ -1369,15 +1476,8 @@ body {
1369
1476
  white-space: nowrap;
1370
1477
  }
1371
1478
 
1372
- .message-subject {
1373
- margin: 8px 0 0;
1374
- font-size: 13px;
1375
- font-weight: 600;
1376
- color: var(--muted);
1377
- }
1378
-
1379
1479
  .message-text {
1380
- margin: 6px 0 0;
1480
+ margin: 8px 0 0;
1381
1481
  white-space: pre-wrap;
1382
1482
  overflow-wrap: anywhere;
1383
1483
  }
@@ -1539,48 +1639,26 @@ body {
1539
1639
  font-size: 12px;
1540
1640
  }
1541
1641
 
1542
- /* --- Volet de détail ------------------------------------------------------ */
1543
-
1544
- .sheet {
1545
- border-left: 1px solid var(--border);
1546
- background: var(--panel);
1547
- overflow-y: auto;
1548
- min-width: 0;
1549
- }
1550
-
1551
- .sheet-head {
1552
- display: flex;
1553
- align-items: flex-start;
1554
- gap: 8px;
1555
- padding: 18px 20px 14px;
1556
- border-bottom: 1px solid var(--border);
1557
- }
1558
-
1559
- .sheet-head h3 {
1560
- margin: 0;
1561
- font-size: 15px;
1562
- }
1642
+ /* --- Détails d'un message ------------------------------------------------- */
1563
1643
 
1564
- .sheet-subject {
1644
+ /* L'objet, sous le titre de la modale : il dit de quel message on lit les détails. */
1645
+ .details-subject {
1565
1646
  margin: 4px 0 0;
1566
1647
  font-size: 13px;
1567
1648
  color: var(--muted);
1568
1649
  }
1569
1650
 
1570
- .sheet-head .btn {
1571
- margin-left: auto;
1572
- }
1573
-
1574
- .sheet-section {
1575
- padding: 16px 20px;
1576
- border-bottom: 1px solid var(--border);
1651
+ .details-section {
1652
+ padding: 16px 0;
1653
+ border-bottom: 1px solid var(--border-soft);
1577
1654
  }
1578
1655
 
1579
- .sheet-section:last-of-type {
1656
+ .details-section:last-of-type {
1580
1657
  border-bottom: 0;
1658
+ padding-bottom: 0;
1581
1659
  }
1582
1660
 
1583
- .sheet-section .section-label {
1661
+ .details-section .section-label {
1584
1662
  display: block;
1585
1663
  margin-bottom: 12px;
1586
1664
  }
@@ -1717,13 +1795,6 @@ body {
1717
1795
 
1718
1796
  /* --- Écrans étroits ------------------------------------------------------- */
1719
1797
 
1720
- @media (max-width: 1200px) {
1721
- .console.has-sheet,
1722
- .console.is-collapsed.has-sheet {
1723
- grid-template-columns: 57px 320px minmax(0, 1fr) 320px;
1724
- }
1725
- }
1726
-
1727
1798
  @media (max-width: 1000px) {
1728
1799
  .topbar-search {
1729
1800
  max-width: 240px;
@@ -1731,22 +1802,6 @@ body {
1731
1802
 
1732
1803
  .console,
1733
1804
  .console.is-collapsed {
1734
- grid-template-columns: 57px 300px minmax(0, 1fr);
1735
- }
1736
-
1737
- /* Trop étroit pour quatre colonnes : le volet recouvre le lecteur. */
1738
- .console.has-sheet,
1739
- .console.is-collapsed.has-sheet {
1740
- grid-template-columns: 57px 300px minmax(0, 1fr);
1741
- }
1742
-
1743
- .console.has-sheet .sheet {
1744
- position: fixed;
1745
- top: var(--topbar-h);
1746
- right: 0;
1747
- bottom: 0;
1748
- width: 340px;
1749
- max-width: 100%;
1750
- box-shadow: -8px 0 24px rgba(0, 0, 0, 0.12);
1805
+ grid-template-columns: 57px var(--w-gutter) 300px var(--w-gutter) minmax(0, 1fr);
1751
1806
  }
1752
1807
  }
@@ -1 +1 @@
1
- export declare const APP_VERSION = "1.0.23";
1
+ export declare const APP_VERSION = "1.0.25";
@@ -1,2 +1,2 @@
1
1
  // Généré par scripts/generate-version.js — ne pas modifier à la main.
2
- export const APP_VERSION = "1.0.23";
2
+ export const APP_VERSION = "1.0.25";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imenam/mcp-messenger",
3
- "version": "1.0.23",
3
+ "version": "1.0.25",
4
4
  "main": "dist/src/index.js",
5
5
  "scripts": {
6
6
  "test": "npm run build && vitest run",