-
+
{#if participants.length === 0}
{t('participants.grille.vide')}
{:else if lignes.length === 0}
diff --git a/src/interface/participants/conflits.js b/src/interface/participants/conflits.js
new file mode 100644
index 0000000..f37ed27
--- /dev/null
+++ b/src/interface/participants/conflits.js
@@ -0,0 +1,98 @@
+// © 2026 TechnoLibre (http://www.technolibre.ca)
+// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
+
+// Les conflits du placement montré dans la liste groupée (§ 7.5, § 10.2) :
+// ceux de conflitsDuPlacement (src/application/conflits_placement.js), que
+// SectionParticipants lit et que Grille range sous ses groupes. Un groupe,
+// de tout niveau, porte chaque conflit qui touche l'une de ses lignes ; une
+// personne, les règles de ses conflits. Aucune fonction ne modifie ce
+// qu'elle reçoit.
+import { nomAffiche } from '../../application/personnes.js';
+
+/** Les règles qui se localisent à une table, dans l'ordre de conflitsParTable. */
+export const REGLES = Object.freeze(['separerAppartenances', 'nouveauxVoisins', 'nouvelleTable']);
+
+const CLES_RAISONS = new Map(REGLES.map((regle) => [regle, `participants.conflit.raison.${regle}`]));
+
+/**
+ * La clé du texte qui dit la raison d'un conflit de la règle donnée.
+ *
+ * @param {string} regle l'une de REGLES
+ * @returns {string}
+ * @throws {RangeError} une autre règle
+ */
+export function cleDeRaison(regle) {
+ const cle = CLES_RAISONS.get(regle);
+ if (cle === undefined) throw new RangeError(`règle inconnue : ${JSON.stringify(regle)}`);
+ return cle;
+}
+
+/**
+ * Les conflits de chaque groupe de groupesGrille (grille.js), de tout
+ * niveau : ceux dont l'une des personnes est parmi les lignes du groupe —
+ * celles de tous ses sous-groupes —, dans l'ordre reçu. Un conflit dont les
+ * deux personnes sont dans deux groupes paraît dans chacun ; un parent le
+ * compte une fois. Un groupe sans conflit n'a pas d'entrée.
+ *
+ * @param {Array<{cle: string, lignes: Array<{id: number}>, sousGroupes: Array