Commit e9158458 authored by Kourser's avatar Kourser
Browse files

Un écran qui plante ne raccroche plus l'appel

Aucune frontière d'erreur n'existait : une exception au rendu d'un seul
écran démontait l'application entière — le rail, la barre d'état, et l'appel
en cours avec eux. Elle entoure désormais la scène seule. Le reste tient, et
changer d'écran efface la panne (EF-803).

Deux défauts d'accessibilité que seul l'écran rendu montrait (EX-19). Le lien
« Aller au contenu » visait un repère en « display: contents », qui ne prend
pas le focus : il ne menait nulle part, pendant que la garantie qui cherchait
sa présence dans le source restait verte. Et le texte discret tombait à
4,42:1 sur le voile des entrées que verse le journal — un fond composé
qu'aucune paire de jetons ne décrivait. axe-core l'a relevé.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 90bda15b
Loading
Loading
Loading
Loading
+6 −1
Changes for apps/api/src/verification/accessibilite.ts: 6 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -153,8 +153,13 @@ export async function verifierAccessibilite(r: Rapport): Promise<void> {
  for (const [nom, f] of [["crise", "coque/Coque.tsx"],
    ["organisation", "ecrans/EspaceOrganisation.tsx"]]) {
    const source = await readFile(`${web}${f}`, "utf8");
    // Ce contrôle lit le source, et il était vert pendant que le lien ne menait
    // nulle part : sa cible, en « display: contents », ne prenait pas le focus.
    // Le comportement est désormais éprouvé au clavier dans un vrai navigateur
    // (apps/e2e, « au clavier : l'évitement… ») ; ceci garde seulement la
    // présence du lien dans chaque coque.
    r.verifie("EX-19", `la coque ${nom} offre un lien d'évitement vers son contenu`,
      source.includes('className="evitement"') && source.includes('<main id="scene"'));
      source.includes("<Evitement />") && source.includes('<main id="scene"'));

    // Le rail se masque sous 900 px. Sans commande pour l'ouvrir, la coque n'a
    // plus aucune navigation — vingt destinations inatteignables sur téléphone.
+5 −1
Changes for apps/web/src/coque/Coque.tsx: 5 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -21,6 +21,8 @@ import { useFormats, useT } from "../langues/index.js";
import { Icone, type NomIcone } from "./Icone.js";
import { Badge } from "./Badge.js";
import { Initiales } from "./Initiales.js";
import { EcranEnPanne } from "./EcranEnPanne.js";
import { Evitement } from "./Evitement.js";
import { aTraiter, entreesVisibles, premiereHeureChaude } from "./rail.js";
import { LIBELLES_GRAVITE, type Gravite } from "@kastell/shared";
import { paresseux } from "../paresseux.js";
@@ -384,7 +386,7 @@ export function Coque({ criseId }: { criseId: string }) {
      {/* Premier élément atteint par la tabulation, et c’est tout son
          intérêt : au clavier, on ne traverse pas onze entrées de rail à
          chaque changement d’écran. */}
      <a className="evitement" href="#scene">{t("Aller au contenu")}</a>
      <Evitement />

      {crise?.exercice && (
        <div className="bandeau-exercice">
@@ -763,6 +765,7 @@ export function Coque({ criseId }: { criseId: string }) {

        {/* ── Scène : un seul écran par module ────────────────────────────── */}
        <main id="scene" className="principal" tabIndex={-1}>
        <EcranEnPanne cle={segments.slice(2).join("/")}>
        <Suspense fallback={<div className="scene"><div className="contenu discret">{t("Chargement…")}</div></div>}>
        {module === "main-courante" ? (
          <MainCourante criseId={criseId} rembobinage={rembobinage}
@@ -834,6 +837,7 @@ export function Coque({ criseId }: { criseId: string }) {
          <EcranCockpit criseId={criseId} cockpit={cockpit.data} aller={aller} />
        )}
        </Suspense>
        </EcranEnPanne>
        </main>

        {/* ── Contexte : ce qui appelle une décision, en permanence ───────── */}
+65 −0
Changes for apps/web/src/coque/EcranEnPanne.tsx: 65 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { Component, type ErrorInfo, type ReactNode } from "react";
import { useT } from "../langues/index.js";

/**
 * Un écran qui plante ne fait pas tomber la coque (EF-803).
 *
 * Aucune frontière d'erreur n'existait. Une exception au rendu d'un seul écran
 * — une réponse inattendue, une liste qui arrive vide là où l'on attendait un
 * tableau — démontait toute l'application : le rail, la barre d'état, et
 * l'appel en cours avec eux. Pendant une crise, cela revient à raccrocher au
 * nez de la cellule parce qu'un tableau de bord a mal lu une date.
 *
 * La frontière entoure la scène seule. Ce qui la borde — rail, barre, visio,
 * conversations — reste debout, et l'on change d'écran pour en sortir : la
 * clé est l'écran demandé, et en changer efface la panne.
 */
interface Proprietes { cle: string; children: ReactNode }
interface Etat { erreur: Error | null; cle: string }

class Frontiere extends Component<Proprietes & { texte: Record<"titre" | "aide" | "reessayer", string> }, Etat> {
  override state: Etat = { erreur: null, cle: this.props.cle };

  static getDerivedStateFromError(erreur: Error): Partial<Etat> {
    return { erreur };
  }

  static getDerivedStateFromProps(p: Proprietes, e: Etat): Partial<Etat> | null {
    return p.cle !== e.cle ? { erreur: null, cle: p.cle } : null;
  }

  override componentDidCatch(erreur: Error, info: ErrorInfo): void {
    // Écrite à la console pour qui diagnostique — et pour les parcours en
    // navigateur, que toute erreur écrite fait échouer.
    console.error("Écran en panne :", erreur, info.componentStack);
  }

  override render() {
    if (!this.state.erreur) return this.props.children;
    const { texte } = this.props;
    return (
      <div className="scene">
        <div className="contenu">
          <div className="erreur" role="alert">
            <strong>{texte.titre}</strong>
            <div style={{ marginTop: 6 }}>{texte.aide}</div>
          </div>
          <button onClick={() => this.setState({ erreur: null })}>{texte.reessayer}</button>
        </div>
      </div>
    );
  }
}

export function EcranEnPanne({ cle, children }: Proprietes) {
  const t = useT();
  return (
    <Frontiere cle={cle} texte={{
      titre: t("Cet écran n’a pas pu s’afficher."),
      aide: t("Le reste tient : le rail, la barre d’état et l’appel en cours ne sont pas touchés. Changez d’écran, ou réessayez."),
      reessayer: t("Réessayer"),
    }}>
      {children}
    </Frontiere>
  );
}
+27 −0
Changes for apps/web/src/coque/Evitement.tsx: 27 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { useT } from "../langues/index.js";

/**
 * « Aller au contenu » — et y aller vraiment (EX-19).
 *
 * Le lien visait « #scene », le repère <main>. Or ce repère est en
 * « display: contents » pour ne rien déplacer à la mise en page, et un élément
 * sans boîte ne prend pas le focus : le lien s'affichait, se suivait, et le
 * focus restait sur lui. La tabulation suivante repartait dans le rail, que
 * le lien promettait justement d'éviter. Aucune lecture du source ne pouvait
 * le voir ; un parcours au clavier dans un vrai navigateur, si.
 *
 * On pose donc le focus sur l'écran lui-même, qui a une boîte.
 */
export function Evitement() {
  const t = useT();
  return (
    <a className="evitement" href="#scene" onClick={(e) => {
      const cible = document.querySelector<HTMLElement>("#scene > .scene")
        ?? document.getElementById("scene");
      if (!cible) return;
      e.preventDefault();
      if (!cible.hasAttribute("tabindex")) cible.setAttribute("tabindex", "-1");
      cible.focus();
    }}>{t("Aller au contenu")}</a>
  );
}
+1 −1
Changes for apps/web/src/ecrans/Declenchement.tsx: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -230,7 +230,7 @@ export function Declenchement() {

          <button className="declencher" type="submit"
            disabled={occupe || intitule.length < 3 || typeRetenu === ""}>
            {occupe ? "Ouverture…" : exercice ? t("Ouvrir l’exercice") : t("Déclencher la crise")}
            {occupe ? t("Ouverture…") : exercice ? t("Ouvrir l’exercice") : t("Déclencher la crise")}
          </button>
          <p className="aide" style={{ marginTop: 10 }}>
            {t("Le dossier de "
Loading