Commit 4e836903 authored by Jordan Grossemy's avatar Jordan Grossemy
Browse files

Quinze boutons dont l'état ne se disait qu'en couleur

J'en annonçais trois. Le balayage de la forme — un « className » qui dépend d'un
état — en a trouvé vingt, dont quinze sont de vrais défauts. Sixième fois dans
cette session qu'une énumération à la main se révèle courte.

À l'œil, l'accent se voit. Pour un lecteur d'écran, rien ne distinguait la
gravité retenue des trois autres, la portée choisie d'un envoi, le registre
d'une observation, le périmètre du cockpit, ni les bascules du tableau des
actions. Quinze « aria-pressed ».

── Le tri a demandé plus de soin que la correction

Un bouton dont le libellé change avec l'état n'a pas de défaut : l'état est déjà
dans son nom accessible. « Caméra » et « Caméra coupée » disent tout, et un
« aria-pressed » n'y ajouterait qu'une redite. Le balayage mécanique en avait
marqué quatre à tort — la caméra, le partage d'écran, l'enregistrement, le
« Composer » du cockpit — et je les ai retirés. « PanneauVisio.tsx » est revenu
au fichier d'origine, sans trace.

Le cinquième était pire qu'une redite : l'engrenage des réglages ouvre un
panneau et porte « aria-expanded ». Lui poser « aria-pressed » aurait annoncé
une bascule là où il y a un dépliant, et les deux attributs se seraient
contredits.

Deux autres boutons colorés ne portent aucun état : l'écran d'atterrissage
distingue les réponses positives d'un lien de mobilisation, la confirmation
motivée colore son bouton selon que le geste est destructeur. La couleur y dit
ce que le bouton est, non s'il est enfoncé. Ce sont les deux exceptions nommées
de la garantie, avec leur raison — plutôt qu'un motif rétréci en silence, qui
aurait aussi cessé de surveiller les vrais.

── Trois jeux qui n'avaient pas de nom

Le registre de la clôture — « Ce que vous consignez ». Le filtre par terme de la
veille externe. Et le périmètre du cockpit, qui partage sa rangée avec le
« Composer » et l'indication de provenance : il est isolé dans son propre groupe
plutôt que de nommer la rangée entière, qui aurait alors compté un bouton qui
n'en relève pas.

── La garantie, et sa reprise

Première version : six lignes de fenêtre après la classe pour y chercher un
libellé variable. Elle a manqué le partage d'écran, dont le libellé arrive sept
lignes plus bas derrière un « onClick » qui en prend six — et elle acceptait
l'enregistrement pour une mauvaise raison, un « title » conditionnel pris pour
un libellé.

Seconde version : on lit le bouton entier, de « <button » à « </button> », et
l'on compte les fois où sa condition d'état paraît. Une seule, c'est la classe
et rien d'autre ; deux, le libellé en dépend aussi. Pas de fenêtre à régler,
pas de comptage d'accolades.

Et elle a trouvé un seizième site que mon balayage initial ne voyait pas : il
cherchait « accent », or la confirmation motivée se colore en « danger ». C'est
l'une des deux exceptions ci-dessus — mais je ne l'aurais pas su sans elle.

Éprouvée dans les deux états : privées de leurs « aria-pressed », les sources
lui rendent exactement les quinze sites, et aucun des cinq exemptés.

1182 garanties au vert, trois clés d'anglais pour les noms des groupes.

── Vérifié à l'écran

Gravité au déclenchement : « Majeure=true » et le reste à faux ; un clic sur
« Vigilance » déplace l'état. Périmètre du cockpit : le groupe existe, ses trois
boutons portent leur état, et « Composer » est bien hors du groupe. Registre de
la clôture : « Ce qui a tenu=true ». Tableau des actions : « Par cellule » passe
à « true » au clic. Mise en page inchangée.

Le filtre par terme de la veille externe n'est pas vérifié à l'écran : aucun
terme n'est surveillé sur cette instance, le bloc ne se rend donc pas. Sa forme
est celle du registre de clôture, qui l'est.

Co-Authored-By: Claude (RCA)
parent 9fc11b6f
Loading
Loading
Loading
Loading
Loading
+57 −0
Changes for apps/api/src/verification/interface.ts: 57 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -519,6 +519,63 @@ export async function verifierInterface(r: Rapport): Promise<void> {
  r.verifie("EX-19", "toute barre d'onglets commande un panneau qui porte son nom",
    sansPanneau.length === 0, sansPanneau.join(", "));

  // ── Un état d'appui ne se dit pas qu'en couleur (EX-19) ──────────────────
  //
  // Quinze boutons portaient leur état dans leur seule classe : la gravité
  // retenue, la portée d'un envoi, le registre d'une observation, le périmètre
  // du cockpit, les bascules du tableau des actions. À l'œil, l'accent se voit ;
  // pour un lecteur d'écran, rien ne distinguait le bouton choisi des autres.
  //
  // La limite du contrôle a demandé plus de soin que le contrôle : un bouton
  // dont le libellé change avec l'état n'a pas de défaut — « Caméra » et
  // « Caméra coupée » disent tout, et « aria-pressed » n'y ajouterait qu'une
  // redite. Le balayage mécanique en avait marqué quatre à tort, dont
  // l'engrenage des réglages, qui ouvre un panneau et porte « aria-expanded » :
  // lui poser « aria-pressed » aurait contredit l'autre.
  //
  // D'où la reconnaissance d'un libellé variable, et la façon de la mener. Une
  // première version fenêtrait six lignes après la classe : elle a manqué le
  // partage d'écran, dont le libellé arrive sept lignes plus bas, derrière un
  // « onClick » qui en prend six. On lit donc le bouton entier — de « <button »
  // à « </button> » — et l'on compte les fois où sa condition d'état paraît :
  // une seule, c'est la classe et rien d'autre ; deux, le libellé en dépend
  // aussi. Pas de fenêtre à régler, pas de comptage d'accolades.
  const ETAT = /className=\{((?:!?\w+(?:\.\w+)*)(?: === [^?]+?)?) \? "(?:accent|danger|petit accent)/;
  const muets: string[] = [];
  for (const f of fichiers) {
    if (!f.endsWith(".tsx")) continue;
    const court = f.slice(web.length);
    // Deux exceptions, et ni l'une ni l'autre n'est un état : l'écran
    // d'atterrissage colore les réponses positives d'un lien de mobilisation,
    // et la confirmation motivée colore son bouton selon que le geste est
    // destructeur. Dans les deux cas la couleur dit ce que le bouton est, non
    // s'il est enfoncé. Le motif ne sait pas les distinguer d'un état — il ne
    // voit pas qu'il s'agit là d'une propriété reçue — d'où ces deux noms.
    if (court === "ecrans/Atterrissage.tsx") continue;
    if (court === "ecrans/administration/ActionMotivee.tsx") continue;
    const texte = await readFile(f, "utf8");
    const lignes = texte.split("\n");
    let position = 0;
    for (const ligne of lignes) {
      const trouve = ETAT.exec(ligne);
      position += ligne.length + 1;
      if (trouve === null) continue;
      const ouverture = texte.lastIndexOf("<button", position);
      const fermeture = texte.indexOf("</button>", position);
      if (ouverture === -1 || fermeture === -1) continue;
      const bouton = texte.slice(ouverture, fermeture);
      if (/aria-pressed|aria-expanded|role="tab"/.test(bouton)) continue;
      const condition = `${trouve[1]!} ? `;
      let fois = 0;
      for (let k = bouton.indexOf(condition); k !== -1;
        k = bouton.indexOf(condition, k + 1)) fois++;
      if (fois > 1) continue;
      muets.push(`${court}:${texte.slice(0, position).split("\n").length - 1}`);
    }
  }
  r.verifie("EX-19", "aucun bouton ne porte son état dans la seule couleur",
    muets.length === 0, muets.slice(0, 4).join(", "));

  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",
+3 −1
Changes for apps/web/src/ecrans/Cloture.tsx: 3 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -201,9 +201,11 @@ export function Cloture({ criseId, phase }: { criseId: string; phase: string })
          </p>

          <div className="carte" style={{ marginBottom: 14 }}>
            <div className="ligne" style={{ gap: 5, marginBottom: 8 }}>
            <div className="ligne" style={{ gap: 5, marginBottom: 8 }}
              role="group" aria-label={t("Ce que vous consignez")}>
              {REGISTRES.map((r) => (
                <button key={r.cle} className={registre === r.cle ? "accent petit" : "petit"}
                aria-pressed={registre === r.cle}
                  onClick={() => definirRegistre(r.cle)}>{t(r.question)}</button>
              ))}
            </div>
+5 −0
Changes for apps/web/src/ecrans/Cockpit.tsx: 5 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -88,12 +88,17 @@ export function Cockpit({
            les cellules sans le dire : « 2 actions ouvertes » ne disait pas
            lesquelles étaient les miennes. */}
        <div className="ligne" style={{ gap: 6, marginTop: 8, flexWrap: "wrap" }}>
          <span className="ligne" style={{ gap: 6, flexWrap: "wrap" }}
            role="group" aria-label={t("Périmètre du cockpit")}>
            <button className={celluleId === null ? "accent petit" : "petit"}
              aria-pressed={celluleId === null}
              onClick={() => choisir(null)}>{t("Toute la crise")}</button>
            {cockpit.cellules.map((c) => (
              <button key={c.id} className={celluleId === c.id ? "accent petit" : "petit"}
                aria-pressed={celluleId === c.id}
                onClick={() => choisir(c.id)}>{c.libelle}</button>
            ))}
          </span>
          <span className="pousse ligne" style={{ gap: 6 }}>
            <span className="discret" style={{ fontSize: 12.5 }}>
              {disposition.data ? t(PROVENANCE[disposition.data.source]) : ""}
+1 −0
Changes for apps/web/src/ecrans/Declenchement.tsx: 1 added line, 0 removed lines.
Original line number Diff line number Diff line
@@ -132,6 +132,7 @@ export function Declenchement() {
                <button
                  key={g} type="button"
                  className={gravite === g ? "accent" : ""}
                  aria-pressed={gravite === g}
                  onClick={() => definirGravite(g)}
                >
                  {t(LIBELLES_GRAVITE[g])}
+5 −0
Changes for apps/web/src/ecrans/EnvoiMasse.tsx: 5 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -150,6 +150,7 @@ export function EnvoiMasse({ organisationId, criseId }: {
              {([["sms", "SMS"], ["courriel", "Courriel"],
                 ["voix", t("Appel vocal")]] as const).map(([g, libelle]) => (
                <button key={g} className={genre === g ? "accent petit" : "petit"}
                aria-pressed={genre === g}
                  onClick={() => definirGenre(g)}>
                  {libelle}
                </button>
@@ -183,15 +184,18 @@ export function EnvoiMasse({ organisationId, criseId }: {
                 ["tous", t("Tout l’annuaire")]] as const)
                .map(([cle, libelle]) => (
                  <button key={cle} className={portee === cle ? "accent petit" : "petit"}
                  aria-pressed={portee === cle}
                    onClick={() => definirPortee(cle)}>{libelle}</button>
                ))}
              <button className={surDiffusion ? "accent petit" : "petit"}
              aria-pressed={surDiffusion}
                onClick={() => definirSurDiffusion(!surDiffusion)}>
                {t("Destinataires "
                                                                                      + "de "
                                                                                      + "diffusion")}
              </button>
              <button className={avecTiers ? "accent petit" : "petit"}
              aria-pressed={avecTiers}
                onClick={() => definirAvecTiers(!avecTiers)}>
                {t("+ tiers "
                                                                                + "activables")}
@@ -414,6 +418,7 @@ export function EnvoiMasse({ organisationId, criseId }: {
                  {(modeles.data?.modeles ?? []).filter((m) => m.genre === genre).map((m) => (
                    <button key={m.id}
                      className={modeleCharge === m.id ? "accent petit" : "petit"}
                      aria-pressed={modeleCharge === m.id}
                      onClick={() => {
                        definirMessage(m.corps);
                        if (m.objet) definirObjet(m.objet);
Loading