Commit ba1d6bb0 authored by Kourser's avatar Kourser
Browse files

Le rail de crise nommait des écrans, pas des gestes

Douze onglets à plat, dans l'ordre où ils avaient été écrits. « Les gens »,
« Documents », « Veille » : des noms de tables, pas des noms de ce qu'on vient
y faire. En crise on ne parcourt pas un menu, on cherche le geste — et on le
cherche dans l'urgence.

Sept groupes désormais, et des intitulés qui disent l'acte : « Première
heure », « Main courante », « Décisions et points de situation », « Suivi des
actions », « Gestion humaine », « Point sur les personnes », « Fin de crise ».
Rien n'est retiré : c'est le même produit, rangé.

Deux réorganisations du rail sont passées sans qu'aucune garantie ne bouge.
EX-35 ferme cette porte : elle ne fige ni l'ordre ni les groupes — les
réorganiser est une décision de produit, et un garde-fou qui l'interdirait
serait un frein — mais elle vérifie que les deux moitiés se répondent. Un
onglet qui mène à un écran qui n'existe pas se découvre en cliquant ; un écran
devenu inatteignable ne se découvre jamais.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent dbb6efb8
Loading
Loading
Loading
Loading
+43 −0
Changes for apps/api/src/verification/interface.ts: 43 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -370,6 +370,49 @@ export async function verifierInterface(r: Rapport): Promise<void> {
    srvManquantes.length === 0,
    srvManquantes.slice(0, 4).map((m) => `« ${m.slice(0, 40)} »`).join(", "));


  // ── Aucun onglet ne mène nulle part, aucun écran n'est orphelin (EX-35) ──
  //
  // Deux réorganisations du rail sont passées sans qu'aucune garantie ne bouge :
  // un onglet perdu en route, ou un écran qu'on ne peut plus atteindre, ne
  // serait signalé par rien. L'un se découvre en cliquant, l'autre jamais.
  //
  // Ce contrôle ne fige ni l'ordre ni les groupes — les réorganiser est une
  // décision de produit, et un garde-fou qui l'interdirait serait un frein. Il
  // vérifie seulement que les deux moitiés se répondent.
  for (const [coque, fichier, chemin, defaut] of [
    ["la coque de crise", "coque/Coque.tsx", "/crises/${criseId}/", "cockpit"],
    ["l'espace organisation", "ecrans/EspaceOrganisation.tsx", "/", "veille"],
  ] as const) {
    const source = await readFile(join(web, fichier), "utf8");
    const nav = source.slice(source.indexOf("<nav"), source.indexOf("</nav>"));

    /** Ce que la répartition sait rendre. */
    const rendus = new Set([...source.matchAll(/module === "([a-z-]+)"/g)]
      .map((m) => m[1]!));
    /** Ce vers quoi le rail envoie. */
    const echappe = chemin.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
    const vises = new Set([...nav.matchAll(
      new RegExp(`aller\\([\`"']${echappe}([a-z-]*)[\`"']\\)`, "g"))].map((m) => m[1]!));
    // Le chemin nu mène à l'écran par défaut de la coque : « /crises/<id> »
    // ouvre le cockpit, « / » ouvre l'accueil. Sans cela, ces deux-là passent
    // pour orphelins alors qu'ils sont la première chose qu'on voit.
    if (vises.has("")) vises.add(defaut);

    const morts = [...vises].filter((v) => v !== "" && !rendus.has(v));
    r.verifie("EX-35", `${coque} : aucun onglet ne mène à un écran qui n'existe pas`,
      morts.length === 0, morts.join(", ") || `${vises.size} destinations, toutes servies`);

    // L'autre sens : un écran que plus rien n'atteint. On cherche dans toute
    // l'interface, et non dans le seul rail — le menu du compte, une carte,
    // un bouton d'un autre écran comptent autant qu'une entrée de rail.
    const partout = (await Promise.all(fichiers.map((f) => readFile(f, "utf8")))).join("\n");
    const orphelins = [...rendus].filter((m) =>
      m !== defaut && !new RegExp(`aller\\([\`"']?[^\`"')]*${m}`).test(partout));
    r.verifie("EX-35", `${coque} : aucun écran n'est devenu inatteignable`,
      orphelins.length === 0, orphelins.join(", ") || `${rendus.size} écrans, tous atteints`);
  }

  // ── Les réactions d'un message ne s'affichent qu'une fois (EF-702) ───────
  //
  // Le salon en portait deux rendus : une liste passive de pastilles, héritée
+84 −69
Changes for apps/web/src/coque/Coque.tsx: 84 added lines, 69 removed lines.
Original line number Diff line number Diff line
@@ -453,13 +453,54 @@ export function Coque({ criseId }: { criseId: string }) {
        <nav className={`rail${railOuvert ? " ouvert" : ""}`}
          aria-label={t("Navigation de la crise")}
          onClick={() => definirRailOuvert(false)}>
          <div role="group" aria-label={t("Cockpit et salons")} className="groupe">
            <span className="etiq">{t("Cockpit et salons")}</span>
          {/* Dix-sept entrées tenaient sous un seul mot, « Transverse », qui ne
              disait rien de ce qu'on y trouvait. Six groupes, et le plus gros
              en compte six. */}
          <div role="group" aria-label={t("Conduite")} className="groupe">
            <span className="etiq">{t("Conduite")}</span>
            <button className={`item${module === "cockpit" ? " actif" : ""}`}
              aria-current={module === "cockpit" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}`)}>
              {t("Cockpit")}
            </button>
            <button className={`item${module === "premiere-heure" ? " actif" : ""}`}
              aria-current={module === "premiere-heure" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/premiere-heure`)}>
              {t("Première heure")}
            </button>
            <button className={`item${module === "main-courante" ? " actif" : ""}`}
              aria-current={module === "main-courante" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/main-courante`)}>
              {t("Main courante")}
              <span className="compteur">{cockpit.data?.entrees_main_courante ?? 0}</span>
            </button>
            <button className={`item${module === "conduite" ? " actif" : ""}`}
              aria-current={module === "conduite" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/conduite`)}>
              {t("Décisions et points de situation")}
              {(cockpit.data?.actions_en_retard.length ?? 0) > 0 && (
                <span className="compteur" style={{ color: "var(--crit)" }}>
                  {cockpit.data!.actions_en_retard.length}
                </span>
              )}
            </button>
            <button className={`item${module === "tableau" ? " actif" : ""}`}
              aria-current={module === "tableau" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/tableau`)}>
              {t("Suivi des actions")}
            </button>
            <button className={`item${module === "continuite" ? " actif" : ""}`}
              aria-current={module === "continuite" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/continuite`)}>
              {t("Continuité d’activité")}
            </button>
          </div>

          {/* Les salons ont leur groupe depuis que le cockpit a rejoint la
              conduite : c'est la zone la plus fréquentée du rail, et la seule
              dont le contenu change en cours de crise. */}
          <div role="group" aria-label={t("Canaux")} className="groupe">
            <span className="etiq">{t("Canaux")}</span>
{salons.data?.salons.map((s) => {
              // La salle qui porte la voix de ce canal, s'il en a une. On la
              // pose sous lui plutôt que dans une liste séparée : un canal et
@@ -478,7 +519,7 @@ export function Coque({ criseId }: { criseId: string }) {
                        chose ». */}
                    {interpelle[s.id] && (
                      <span className="compteur interpelle"
                        title={t("Vous avez été nommé dans ce salon.")}>@</span>
                        title={t("Vous avez été nommé dans ce canal.")}>@</span>
                    )}
                    {(nonLus[s.id] ?? 0) > 0 && (
                      <span className="compteur non-lus"
@@ -525,63 +566,51 @@ export function Coque({ criseId }: { criseId: string }) {
            </button>
          </div>

          <div role="group" aria-label={t("Transverse")} className="groupe">
            <span className="etiq">{t("Transverse")}</span>
            {/* En tête : c'est le premier écran d'une crise qui vient de
                s'ouvrir, et il ne sert à rien s'il faut le chercher. */}
            <button className={`item${module === "premiere-heure" ? " actif" : ""}`}
              aria-current={module === "premiere-heure" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/premiere-heure`)}>
              {t("Première heure")}
            </button>
            <button className={`item${module === "main-courante" ? " actif" : ""}`}
              aria-current={module === "main-courante" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/main-courante`)}>
              {t("Main courante")}
              <span className="compteur">{cockpit.data?.entrees_main_courante ?? 0}</span>
            </button>
          <div role="group" aria-label={t("Gestion humaine")} className="groupe">
            <span className="etiq">{t("Gestion humaine")}</span>
            <button className={`item${module === "cellules" ? " actif" : ""}`}
              aria-current={module === "cellules" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/cellules`)}>
              {t("Cellules")}
            </button>
            <button className={`item${module === "conduite" ? " actif" : ""}`}
              aria-current={module === "conduite" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/conduite`)}>
              {t("Décisions et actions")}
              {(cockpit.data?.actions_en_retard.length ?? 0) > 0 && (
                <span className="compteur" style={{ color: "var(--crit)" }}>
                  {cockpit.data!.actions_en_retard.length}
                </span>
              )}
            <button className={`item${module === "mobilisation" ? " actif" : ""}`}
              aria-current={module === "mobilisation" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/mobilisation`)}>
              {t("Mobilisation")}
            </button>
            <button className={`item${module === "recensement" ? " actif" : ""}`}
              aria-current={module === "recensement" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/recensement`)}>
              {t("Point sur les personnes")}
            </button>
          </div>

          <div role="group" aria-label={t("Ce qu'on consulte")} className="groupe">
            <span className="etiq">{t("Ce qu'on consulte")}</span>
            <button className={`item${module === "tableaux" ? " actif" : ""}`}
              aria-current={module === "tableaux" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/tableaux`)}>
              {t("Tableaux blancs")}
            </button>
            <button className={`item${module === "tableau" ? " actif" : ""}`}
              aria-current={module === "tableau" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/tableau`)}>
              {t("Tableau des actions")}
            <button className={`item${module === "documents" ? " actif" : ""}`}
              aria-current={module === "documents" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/documents`)}>
              {t("Documents")}
            </button>
            {/* Le service d'encodage tourne par défaut ; rien ne menait à ce
                qu'il produit. */}
            <button className={`item${module === "enregistrements" ? " actif" : ""}`}
              aria-current={module === "enregistrements" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/enregistrements`)}>
              {t("Enregistrements")}
              {t("Réunions enregistrées")}
            </button>
            <button className={`item${module === "veille" ? " actif" : ""}`}
              aria-current={module === "veille" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/veille`)}>
              {t("Veille externe")}
            </button>
            <button className={`item${module === "continuite" ? " actif" : ""}`}
              aria-current={module === "continuite" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/continuite`)}>
              {t("Continuité")}
            <button className={`item${module === "preparation" ? " actif" : ""}`}
              aria-current={module === "preparation" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/preparation`)}>
              {t("Dossier gelé")}
            </button>
          </div>

          <div role="group" aria-label={t("Vers l'extérieur")} className="groupe">
            <span className="etiq">{t("Vers l'extérieur")}</span>
            <button className={`item${module === "echeances" ? " actif" : ""}`}
              aria-current={module === "echeances" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/echeances`)}>
@@ -592,38 +621,20 @@ export function Coque({ criseId }: { criseId: string }) {
                </span>
              )}
            </button>
            <button className={`item${module === "documents" ? " actif" : ""}`}
              aria-current={module === "documents" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/documents`)}>
              {t("Documents")}
            </button>
            <button className={`item${module === "recensement" ? " actif" : ""}`}
              aria-current={module === "recensement" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/recensement`)}>
              {t("Recensement de sécurité")}
            </button>
            <button className={`item${module === "mobilisation" ? " actif" : ""}`}
              aria-current={module === "mobilisation" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/mobilisation`)}>
              {t("Mobilisation")}
            </button>
            <button className={`item${module === "preparation" ? " actif" : ""}`}
              aria-current={module === "preparation" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/preparation`)}>
              {t("Dossier gelé")}
            <button className={`item${module === "veille" ? " actif" : ""}`}
              aria-current={module === "veille" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/veille`)}>
              {t("Veille externe")}
            </button>
            {/* L'ouverture à un agent est une décision de conduite, prise
                dans la crise : sa commande est donc ici, pas dans les
                réglages de l'organisation (EI-13). */}
          </div>

          <div role="group" aria-label={t("Agents")} className="groupe">
            <span className="etiq">{t("Agents")}</span>
            <button className={`item${module === "agents" ? " actif" : ""}`}
              aria-current={module === "agents" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/agents`)}>
              {t("Agents")}
            </button>
            {/* Un compteur, jamais une alerte : les propositions attendent
                qu'on vienne les voir et n'interrompent personne (EI-31). Le
                nombre n'est affiché que s'il y en a — un « 0 » permanent
                inviterait à passer, ce qui est déjà une sollicitation. */}
            <button className={`item${module === "propositions" ? " actif" : ""}`}
              aria-current={module === "propositions" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/propositions`)}>
@@ -632,6 +643,10 @@ export function Coque({ criseId }: { criseId: string }) {
                <span className="compteur">{propositions.data!.enAttente}</span>
              )}
            </button>
          </div>

          <div role="group" aria-label={t("Fin de crise")} className="groupe">
            <span className="etiq">{t("Fin de crise")}</span>
            <button className={`item${module === "cloture" ? " actif" : ""}`}
              aria-current={module === "cloture" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/cloture`)}>
+10 −0
Changes for apps/web/src/langues/en.ts: 10 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -2474,4 +2474,14 @@ export const EN: Record<string, string> = {
  "Aucune crise en cours": "No crisis under way",
  "Une crise en cours": "One crisis under way",
  "{n} crises en cours": "{n} crises under way",
  "Décisions et points de situation": "Decisions and situation reports",
  "Suivi des actions": "Action tracking",
  "Gérer les salons": "Manage rooms",
  "Gestion humaine": "People management",
  "Point sur les personnes": "Head count",
  "Ce qu'on consulte": "What you consult",
  "Réunions enregistrées": "Recorded meetings",
  "Vers l'extérieur": "Outward",
  "Fin de crise": "End of crisis",
  "Vous avez été nommé dans ce canal.": "You were named in this channel.",
};