Commit 623db568 authored by Jordan Grossemy's avatar Jordan Grossemy
Browse files

Une écriture refusée ne disait rien, et l'opérateur croyait avoir agi

C'est la trouvaille sérieuse de cette passe, et je l'ai constatée plutôt que
déduite : j'ai fait refuser un changement de statut d'action par le serveur.
Rien ne s'est affiché. Le sélecteur est revenu à sa valeur d'avant — il est
piloté par les données du serveur, il ne ment donc pas — et aucun message n'a
dit que le geste avait été refusé.

Vingt-quatre écritures de l'interface sont dans ce cas : « void api.poster(…) »,
sans reprise. Changer un statut, ranger un document, épingler un message,
révoquer une session, supprimer une fiche, envoyer un message dans un salon.
Sur le réseau d'une crise — celui qu'on a quand on chiffre le système
d'information — c'est le pire état possible : on annonce en réunion une action
passée en « bloquée » qui ne l'est pas.

── Pourquoi « unhandledrejection » et non un appel par écran

Le filet écoute les promesses que personne n'a rattrapées. C'est ce qui le rend
exact plutôt qu'approximatif : les vingt-et-un écrans qui affichent déjà leur
propre message consomment leur rejet et ne déclenchent donc rien. Aucun double
message, et rien à déclarer fichier par fichier — ni à redire au prochain écran
qu'on écrira.

Les deux propriétés sont vérifiées sur de vrais chemins. Un envoi de message de
salon refusé : le filet parle, et le texte reste dans la boîte. Une consignation
refusée : le panneau affiche « Refus simulé. » et le filet reste muet.

Il ne remplace pas un message local, qui sait ce que l'utilisateur tentait —
« Retrait impossible » vaut mieux que le texte brut du serveur. Il dit qu'il
s'est passé quelque chose, ce qui vaut infiniment mieux que le silence. Et il ne
se referme pas seul : un bandeau qui s'efface au bout de cinq secondes est un
bandeau que celui qui regardait ailleurs n'aura jamais vu.

Posé hors de « Application » : le tunnel d'entrée écrit lui aussi.

Deux garanties, dont celle qui vérifie cette position.

Ce que cela ne corrige pas, et que je signale : trente-et-un fichiers sur les
cinquante-sept qui écrivent n'ont aucun garde contre le double envoi. Un double
clic sur « Enregistrer » crée deux tiers. Les alertes et la mobilisation sont
protégées côté serveur par la fenêtre anti-doublon ; les registres du dossier ne
le sont pas.

Co-Authored-By: Claude (RCA)
parent 3212dc2f
Loading
Loading
Loading
Loading
Loading
+16 −0
Changes for apps/api/src/verification/interface.ts: 16 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -148,6 +148,22 @@ export async function verifierInterface(r: Rapport): Promise<void> {
      && (await readFile(new URL("../../../web/src/coque/DockChat.tsx", import.meta.url),
        "utf8")).includes("Fermer la conversation"));

  // ── Une écriture refusée ne reste pas muette (EX-19) ─────────────────────
  //
  // Vingt-quatre gestes écrivent sans reprise d'erreur : « void api.poster(…) ».
  // Vérifié à l'écran, un refus ne produisait aucun message. Le filet écoute
  // les promesses que personne n'a rattrapées — ce qui le rend exact : les
  // écrans qui affichent déjà leur propre message n'en déclenchent pas un
  // second.
  const filet = await readFile(join(web, "coque/FiletEchec.tsx"), "utf8");
  const racine = await readFile(join(web, "main.tsx"), "utf8");
  r.verifie("EX-19", "une écriture refusée est signalée même sans reprise locale",
    filet.includes("unhandledrejection") && filet.includes("preventDefault")
    && racine.includes("<FiletEchec />"));
  // Hors de « Application » : le tunnel d'entrée écrit lui aussi.
  r.verifie("EX-19", "le filet couvre le tunnel d'entrée comme la crise",
    racine.indexOf("<FiletEchec />") > racine.indexOf("<Application />"));

  r.verifie("EX-21", "une clé absente retombe sur le français, pas sur un identifiant",
    noyau.includes("EN[fr] ?? fr"));
  r.verifie("EX-21", "la langue du document suit la langue choisie",
+52 −0
Changes for apps/web/src/coque/FiletEchec.tsx: 52 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { useEffect, useState } from "react";
import { ErreurApi } from "../api.js";

/**
 * Le filet : une écriture qui échoue ne passe pas inaperçue.
 *
 * Vingt-quatre gestes de l’interface écrivaient sous la forme
 * « void api.poster(…) » sans reprise d’erreur. Vérifié à l’écran : en faisant
 * refuser un changement de statut d’action, rien ne s’affichait — le sélecteur
 * revenait à sa valeur d’avant et l’opérateur pouvait croire avoir agi. Sur le
 * réseau d’une crise, c’est le pire des états : on annonce en réunion une
 * action passée en « bloquée » qui ne l’est pas.
 *
 * Le filet est posé sur « unhandledrejection » plutôt que sur chaque appel, et
 * c’est le point du dispositif. L’événement ne se déclenche que pour une
 * promesse que personne n’a rattrapée : les vingt-et-un écrans qui affichent
 * déjà leur propre message ne déclenchent rien, et n’en affichent donc pas
 * deux. Le filet couvre exactement ce qui n’est pas couvert, sans qu’il faille
 * le dire fichier par fichier — ni le redire au prochain écran écrit.
 *
 * Il ne remplace pas un message local. Un écran qui sait ce que l’utilisateur
 * tentait le dit mieux : « Retrait impossible » vaut mieux que le texte brut du
 * serveur. Le filet dit seulement qu’il s’est passé quelque chose, ce qui vaut
 * infiniment mieux que le silence.
 *
 * Il ne se referme pas tout seul. Un bandeau qui disparaît après cinq secondes
 * est un bandeau que celui qui regardait ailleurs n’aura jamais vu.
 */
export function FiletEchec() {
  const [echec, definirEchec] = useState<string | null>(null);

  useEffect(() => {
    const surRejet = (ev: PromiseRejectionEvent) => {
      if (!(ev.reason instanceof ErreurApi)) return;
      // Rattrapée ici : sans cela, la console garde une erreur non traitée que
      // l'on croira être un défaut alors qu'elle est désormais affichée.
      ev.preventDefault();
      definirEchec(ev.reason.message);
    };
    window.addEventListener("unhandledrejection", surRejet);
    return () => window.removeEventListener("unhandledrejection", surRejet);
  }, []);

  if (echec === null) return null;
  return (
    <div className="filet-echec" role="alert">
      <span>{echec}</span>
      <button className="discret petit" aria-label="Fermer l’avertissement"
        onClick={() => definirEchec(null)}>✕</button>
    </div>
  );
}
+4 −0
Changes for apps/web/src/main.tsx: 4 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -44,6 +44,7 @@ import { Atterrissage } from "./ecrans/Atterrissage.js";
import { Partage } from "./ecrans/Partage.js";
import { Coque } from "./coque/Coque.js";
import { BandeauEnvironnement } from "./coque/BandeauEnvironnement.js";
import { FiletEchec } from "./coque/FiletEchec.js";

const client = new QueryClient({
  defaultOptions: { queries: { refetchOnWindowFocus: true, retry: 1 } },
@@ -116,6 +117,9 @@ createRoot(document.getElementById("racine")!).render(
            <div className="pile-instance">
              <BandeauEnvironnement />
              <div className="pile-scene"><Application /></div>
              {/* Hors de « Application » : le filet doit tenir aussi sur le
                  tunnel d'entrée, et survivre au changement d'écran. */}
              <FiletEchec />
            </div>
          </FournisseurEtat>
        </Routeur>
+13 −0
Changes for apps/web/src/styles.css: 13 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -870,6 +870,19 @@ tbody tr:hover { background: var(--surface-2); }
}
.evitement:focus { left: 0; }

/* Le filet : posé bas et à gauche, là où rien d'autre ne vit — le dock des
   conversations occupe la droite, la barre d'onglets le bas sur petit écran. */
.filet-echec {
  position: fixed; left: 12px; bottom: 12px; z-index: 90;
  display: flex; align-items: center; gap: 10px;
  max-width: min(420px, calc(100vw - 24px));
  background: var(--crit-doux); border: 1px solid var(--crit); color: var(--crit);
  border-radius: var(--rayon); padding: 10px 12px;
  font-size: 13px; box-shadow: var(--ombre-flottant);
}
.filet-echec button { color: inherit; }
@media (max-width: 900px) { .filet-echec { bottom: 68px; } }

/* Une région annoncée par un lecteur d'écran ne doit pas être visible : elle
   dit ce que la couleur dit déjà à ceux qui la voient. */
.annonce {