Commit 3212dc2f authored by Jordan Grossemy's avatar Jordan Grossemy
Browse files

Ce que le clavier et le lecteur d'écran ne pouvaient pas atteindre

Une passe sur les cinquante-quatre destinations du produit. Quatre manques de
structure, tous dans la même famille : ce qui se voit n'était pas ce qui
s'annonce.

── Quatre écrans sans titre de page

La conduite, les cinq salons, le tableau blanc ouvert, et le dossier de
préparation qui s'annonçait « Version 0 » — son nom était le chapeau, son
numéro de version le titre.

Aucun pixel ne bouge. Le chapeau « CONDUITE » est le titre visuel de cet écran :
il devient le « h1 », en neuf points et demi. L'intitulé du tableau ouvert était
un « strong » dans une rangée : même graisse, même taille, autre balise. Le
dossier garde ses deux lignes, réunies sous un seul titre. Les salons n'avaient
rien à promouvoir — leur nom ne vit que dans le rail — il est donc posé hors
écran, avec la classe qui existait déjà pour cela.

── L'espace organisation n'avait ni repère ni raccourci

La coque de crise offre « Aller au contenu » et un « main ». La coque de
l'organisation, non : au clavier, chaque écran commençait par une traversée des
neuf entrées du rail, sans moyen de l'éviter. La règle « .principal » est déjà
en « display: contents » — le repère s'ajoute sans rien déplacer.

C'est le troisième manque de cette coque après la navigation sous 900 px : elle
a été écrite sans les commodités que la coque de crise s'était données.

── La marque n'était pas atteignable

« Kastell », en haut à gauche, ramène à l'accueil. C'était un « span » avec un
« onClick » : à la souris seulement. Un vrai bouton, débarrassé de son fond et
de sa bordure — l'apparence ne change pas, la touche Tab l'atteint.

── Ce qui empêche le retour

Trois garanties. Les neuf écrans de premier niveau portent un titre ; les deux
coques offrent un lien d'évitement et un « main ». Le contrôle existant ne
portait que sur le contraste, le focus, les cibles tactiles et l'animation — un
titre manquant passait sans bruit.

Co-Authored-By: Claude (RCA)
parent 41f245c3
Loading
Loading
Loading
Loading
+25 −0
Changes for apps/api/src/verification/accessibilite.ts: 25 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -107,4 +107,29 @@ export async function verifierAccessibilite(r: Rapport): Promise<void> {
  }
  r.verifie("EX-19", `les ${tunnel.length} écrans du tunnel d'entrée portent un titre de page`,
    sansTitre.length === 0, sansTitre.join(" ; "));

  // Même règle pour les écrans de premier niveau. Trois y manquaient — la
  // conduite, les salons et le tableau ouvert — et le dossier de préparation
  // annonçait la page par son numéro de version.
  const premierNiveau = ["ecrans/Cockpit.tsx", "ecrans/Conduite.tsx", "ecrans/Salon.tsx",
    "ecrans/Tableaux.tsx", "ecrans/MainCourante.tsx", "ecrans/Mobilisation.tsx",
    "ecrans/Membres.tsx", "ecrans/preparation/DossierPreparation.tsx",
    "ecrans/administration/Administration.tsx"];
  const muets: string[] = [];
  for (const f of premierNiveau) {
    const source = await readFile(`${web}${f}`, "utf8");
    if (!source.includes("<h1")) muets.push(f);
  }
  r.verifie("EX-19", `les ${premierNiveau.length} écrans de premier niveau portent un titre de page`,
    muets.length === 0, muets.join(" ; "));

  // Le lien d'évitement et le repère « main » manquaient dans toute la coque
  // de l'organisation : au clavier, chaque écran commençait par une traversée
  // des neuf entrées du rail.
  for (const [nom, f] of [["crise", "coque/Coque.tsx"],
    ["organisation", "ecrans/EspaceOrganisation.tsx"]]) {
    const source = await readFile(`${web}${f}`, "utf8");
    r.verifie("EX-19", `la coque ${nom} offre un lien d'évitement vers son contenu`,
      source.includes('className="evitement"') && source.includes('<main id="scene"'));
  }
}
+4 −2
Changes for apps/web/src/coque/Coque.tsx: 4 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -320,9 +320,9 @@ export function Coque({ criseId }: { criseId: string }) {
      <div className="barre-etat">
        <button className="discret petit" onClick={() => definirRailOuvert(!railOuvert)}
          aria-label="Cellules" style={{ display: "none" }} />
        <span className="marque" onClick={() => aller("/")} style={{ cursor: "pointer" }}>
        <button className="marque" onClick={() => aller("/")}>
          Kastell
        </span>
        </button>
        {rembobinage !== null ? (
          <span className="marque-rembobinage">
            ↺ Rembobinage — lecture seule
@@ -599,6 +599,8 @@ export function Coque({ criseId }: { criseId: string }) {
            consigner={(p) => definirConsigne(p)} />
        ) : module === "salons" && salonId ? (
          <Salon criseId={criseId} salonId={salonId}
            nom={salons.data?.salons.find((s) => s.id === salonId)
              ?.libelle.replace(/^Salon /, "") ?? ""}
            avertissement={salons.data?.salons.find((s) => s.id === salonId)?.avertissement
              ?? null}
            saisie={flux.saisie}
+3 −1
Changes for apps/web/src/ecrans/Conduite.tsx: 3 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -100,7 +100,9 @@ export function Conduite({
  return (
    <div className="scene">
      <header>
        <div className="etiq">Conduite</div>
        {/* Titre de page : l'écran n'en avait aucun, et c'est bien ce
            chapeau qui le porte visuellement. */}
        <h1 className="etiq">Conduite</h1>
        <div className="ligne" style={{ gap: 6, marginTop: 6 }}>
          {([["decisions", "Décisions"], ["actions", "Actions"],
            ["point", "Point de situation"]] as const).map(([cle, libelle]) => (
+9 −2
Changes for apps/web/src/ecrans/EspaceOrganisation.tsx: 9 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -42,6 +42,11 @@ export function EspaceOrganisation() {

  return (
    <div className="coque">
      {/* Le lien d'évitement et le repère « main » existaient dans la coque de
          crise, pas ici : au clavier, chaque écran de l'organisation commençait
          par une traversée des neuf entrées du rail. « .principal » est en
          « display: contents » — le repère s'ajoute sans rien déplacer. */}
      <a className="evitement" href="#scene">Aller au contenu</a>
      <div className="barre-etat">
        {/* Sous 900 px le rail est masqué : sans cette commande, l'espace
            organisation n'avait plus aucune navigation — ni rail, ni barre
@@ -49,9 +54,9 @@ export function EspaceOrganisation() {
            barre d'onglets ; on ouvre le rail. */}
        <button className="discret petit ouvre-rail" aria-label="Navigation"
          aria-expanded={railOuvert} onClick={() => definirRailOuvert(!railOuvert)}>☰</button>
        <span className="marque" style={{ cursor: "pointer" }} onClick={() => aller("/")}>
        <button className="marque" onClick={() => aller("/")}>
          Kastell
        </span>
        </button>
        {(moi?.organisations.length ?? 0) > 1 ? (
          <select value={organisationId ?? ""} style={{ width: "auto", fontSize: 13,
            padding: "4px 8px" }}
@@ -116,6 +121,7 @@ export function EspaceOrganisation() {
          )}
        </nav>

        <main id="scene" className="principal" tabIndex={-1}>
        {module === "declenchement" ? <Declenchement />
          : module === "preparation" && organisationId
            ? <DossierPreparation organisationId={organisationId} />
@@ -137,6 +143,7 @@ export function EspaceOrganisation() {
          : module === "preferences" ? <Preferences />
          : module === "administration" ? <Administration />
          : <Veille />}
        </main>
      </div>
    </div>
  );
+6 −2
Changes for apps/web/src/ecrans/Salon.tsx: 6 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -14,9 +14,9 @@ const poids = (o: string | number) => {

/** Salon de cellule (§6.7). */
export function Salon({
  criseId, salonId, avertissement, saisie, consigner,
  criseId, salonId, nom, avertissement, saisie, consigner,
}: {
  criseId: string; salonId: string; avertissement: string | null;
  criseId: string; salonId: string; nom: string; avertissement: string | null;
  saisie: (salonId: string, actif: boolean) => void;
  consigner: (p: Prefill) => void;
}) {
@@ -162,6 +162,10 @@ export function Salon({

  return (
    <div className="scene">
      {/* Le salon n'avait pas de titre de page : son nom ne vivait que dans le
          rail. Hors écran — la colonne est déjà pleine, et le nom est visible
          dans le rail pour qui le voit. */}
      <h1 className="annonce">{nom}</h1>
      {avertissement && <div className="bandeau-externe">{avertissement}</div>}
      {erreurMessage && (
        <div className="erreur" onClick={() => definirErreurMessage(null)}>{erreurMessage}</div>
Loading