Commit 82ccd368 authored by Kourser's avatar Kourser
Browse files

Accessibilité AA mesurée, et bilinguisme dont la couverture se publie

Deux exigences écrites au cahier des charges et jamais tenues : EX-19 et
EX-21. La première est structurelle, la seconde mécanique.

Le contraste se calcule, il ne se déclare pas
  Quatorze paires de jetons, dans les deux thèmes, à chaque vérification. Deux
  échouaient et personne ne l'aurait vu : « --encre-3 » en thème clair à 4,14
  pour un seuil de 4,5, et la bordure des champs à 2,14 pour un seuil de 3.
  Invisible à l'œil, mesurable au calcul — c'est exactement pourquoi cela
  appartient à la suite de vérification et non à un audit annuel. Une palette
  dérive par petites touches.

  Corrigé en cherchant la valeur la plus proche qui passe, pas la plus foncée :
  #5a6c71 rend 5,50 sur carte et 4,64 sur fond.

Ce qui rend la navigation possible
  Un raccourci d'évitement, premier dans l'ordre de tabulation — et il a fallu
  le déplacer, il était posé après la barre d'état. Des repères de page,
  « aria-current » sur l'entrée courante du rail, un intitulé sur chaque
  commande sans texte, une région d'annonce pour les changements d'état.

  Elle n'annonce que les changements d'état, jamais le flux des messages : un
  lecteur d'écran qui lirait chaque message deviendrait inutilisable.

  Les cibles de 44 points ne s'appliquent qu'aux écrans tactiles. Les imposer à
  la souris écraserait la densité dont une cellule de crise a besoin — le
  pointeur est précis, le doigt ne l'est pas, et le jour J la main tremble.

La clé de traduction est le texte français lui-même
  Inhabituel, et délibéré pour trois raisons dans cet ordre : le repli est juste
  par construction — une clé absente s'affiche en français, la langue d'origine
  du produit, jamais sous forme d'identifiant technique ; le code reste lisible ;
  et la couverture se mesure.

  Le prix : retoucher une phrase française casse sa traduction. Acceptable —
  une phrase retouchée mérite de toute façon d'être retraduite.

  Le dictionnaire anglais n'est pas une traduction mécanique. La copie française
  dit les choses crûment — « une action bloquée sans motif est du bruit » — et
  un calque perdrait la moitié du produit. Chaque ligne porte le même poids en
  anglais, pas les mêmes mots.

On mesure et on publie, plutôt que d'annoncer « bilingue »
  46 clés employées, 46 traduites. Le contrôle a d'ailleurs trouvé sept
  manques dès la conversion du cockpit, et trois fausses détections — mes
  propres exemples cités dans les commentaires du module de traduction, qui
  s'exclut désormais de son propre balayage.

  La coque, la connexion et le cockpit sont traduits. Le reste retombe en
  français, et le sélecteur de langue le dit : un écran à moitié traduit
  surprend, un écran à moitié traduit qu'on avait annoncé ne surprend pas.

Dates et nombres par Intl, dans la langue active. Une date écrite « 08/23 »
pour un lecteur français est une date fausse une fois sur deux.

593 → 602 garanties. Éprouvé au navigateur dans les deux langues et les trois
thèmes. Le raccourci d'évitement n'a pu être vérifié qu'indirectement : le
panneau de contrôle ne détient pas le focus du document, « :focus » ne peut
pas s'y activer — la règle a été forcée pour prouver qu'elle rend bien le lien
visible.

Signed-off-by: default avatarKourser <contact@kourser.bzh>
Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 25d45fac
Loading
Loading
Loading
Loading
+4 −0
Original line number Diff line number Diff line
@@ -16,6 +16,7 @@ import { verifierAuthentification } from "./verification/auth.js";
import { verifierAdministration } from "./verification/administration.js";
import { verifierLicence } from "./verification/licence.js";
import { verifierInterface } from "./verification/interface.js";
import { verifierAccessibilite } from "./verification/accessibilite.js";
import { verifierMobile } from "./verification/mobile.js";
import { verifierCanaux } from "./verification/canaux.js";
import { verifierContinuite } from "./verification/continuite.js";
@@ -303,6 +304,9 @@ await verifierLicence(sql, app, rapport, env);
// ── Chemins de l'interface (§11) ──────────────────────────────────────────
await verifierInterface(rapport);

// ── Accessibilité, mesurée (EX-19) ────────────────────────────────────────
await verifierAccessibilite(rapport);

// ── Appareils mobiles (volet mobile, M0-M1) ───────────────────────────────
await verifierMobile(sql, app, rapport);

+96 −0
Original line number Diff line number Diff line
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import type { Rapport } from "./rapport.js";

/**
 * Accessibilité, mesurée plutôt que déclarée (EX-19).
 *
 * Le contraste est la seule exigence d'accessibilité qui se calcule : deux
 * couleurs, une formule, un verdict. Autant la vérifier à chaque version
 * plutôt qu'une fois, à la main, le jour de l'audit — une palette dérive par
 * petites touches, et personne ne remarque qu'un gris est passé de 4,6 à 4,1.
 *
 * Le reste — navigation clavier, lecteurs d'écran — ne se prouve pas par un
 * script. On contrôle ici ce qui rend ce reste possible : repères de page,
 * intitulés sur les commandes sans texte, focus visible, cibles tactiles.
 */

const web = fileURLToPath(new URL("../../../web/src/", import.meta.url));

// ── Contraste (WCAG 2.1, critères 1.4.3 et 1.4.11) ─────────────────────────

function luminance(hex: string): number {
  const c = hex.trim().replace("#", "");
  const plein = c.length === 3 ? [...c].map((x) => x + x).join("") : c;
  const canal = (v: number) => {
    const n = v / 255;
    return n <= 0.03928 ? n / 12.92 : ((n + 0.055) / 1.055) ** 2.4;
  };
  const [r, v, b] = [0, 2, 4].map((i) => parseInt(plein.slice(i, i + 2), 16));
  return 0.2126 * canal(r!) + 0.7152 * canal(v!) + 0.0722 * canal(b!);
}

export function contraste(a: string, b: string): number {
  const [la, lb] = [luminance(a), luminance(b)];
  return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
}

function jetons(css: string, depart: string): Record<string, string> {
  const i = css.indexOf(depart);
  if (i < 0) return {};
  const bloc = css.slice(i, css.indexOf("}", i));
  return Object.fromEntries(
    [...bloc.matchAll(/--([a-z0-9-]+):\s*(#[0-9a-fA-F]{3,8})\s*;/g)]
      .map((m) => [m[1]!, m[2]!]));
}

/** Texte : 4,5:1. Composants et bordures porteuses de sens : 3:1. */
const PAIRES: [string, string, string, number][] = [
  ["texte principal", "encre", "fond", 4.5],
  ["texte sur carte", "encre", "surface", 4.5],
  ["texte secondaire", "encre-2", "surface", 4.5],
  ["texte discret sur carte", "encre-3", "surface", 4.5],
  ["texte discret sur fond", "encre-3", "fond", 4.5],
  ["accent", "accent", "surface", 4.5],
  ["critique", "crit", "surface", 4.5],
  ["alerte", "alerte", "surface", 4.5],
  ["succès", "ok", "surface", 4.5],
  ["pastille critique", "crit", "crit-doux", 4.5],
  ["pastille alerte", "alerte", "alerte-doux", 4.5],
  ["pastille succès", "ok", "ok-doux", 4.5],
  ["pastille accent", "accent", "accent-doux", 4.5],
  ["bordure de champ", "ligne-forte", "surface", 3],
];

export async function verifierAccessibilite(r: Rapport): Promise<void> {
  r.titre("Contraste des couleurs");
  const css = await readFile(`${web}styles.css`, "utf8");

  for (const [nom, bloc] of [["clair", ":root {"], ["sombre", ':root[data-theme="dark"]']]) {
    const t = jetons(css, bloc!);
    const echecs = PAIRES
      .filter(([, a, b]) => t[a] && t[b])
      .map(([libelle, a, b, seuil]) =>
        ({ libelle, ratio: contraste(t[a]!, t[b]!), seuil }))
      .filter((x) => x.ratio < x.seuil);
    r.verifie("EX-19", `thème ${nom} : ${PAIRES.length} paires au-dessus du seuil AA`,
      echecs.length === 0,
      echecs.map((e) => `${e.libelle} ${e.ratio.toFixed(2)} < ${e.seuil}`).join(" ; "));
  }

  // ── Ce qui rend la navigation possible ───────────────────────────────────
  r.titre("Structure de l'interface");

  // Un focus qu'on ne voit pas est un focus perdu : au clavier, on ne sait
  // plus où l'on est.
  r.verifie("EX-19", "le focus clavier est visible, et pas seulement au survol",
    /:focus-visible\s*\{[^}]*outline/.test(css));

  // 44 points : la recommandation d'Apple et le critère AAA de WCAG. En crise,
  // on vise le doigt d'une main qui tremble.
  r.verifie("EX-19", "les cibles tactiles ont une taille minimale sur petit écran",
    /min-height:\s*44px|min-block-size:\s*44px/.test(css));

  r.verifie("EX-19", "le mouvement respecte la réduction d'animation du système",
    /prefers-reduced-motion/.test(css));
}
+37 −0
Original line number Diff line number Diff line
@@ -58,6 +58,43 @@ export async function verifierInterface(r: Rapport): Promise<void> {
  r.verifie("EX-17", "tous les appels de données portent le préfixe /api",
    appels.length === 0, appels.slice(0, 5).join(", "));

  // ── Bilinguisme (EX-21) ──────────────────────────────────────────────────
  //
  // On mesure et on publie plutôt que d'annoncer « bilingue ». La clé de
  // traduction étant le texte français lui-même, une clé absente s'affiche en
  // français : le produit reste utilisable, et le chiffre dit exactement où
  // l'on en est.
  const employees = new Set<string>();
  for (const f of fichiers) {
    // Le module de traduction parle de traduction : ses commentaires citent
    // des appels en exemple, qui ne sont pas des chaînes à traduire.
    if (f.includes("/langues/")) continue;
    const texte = await readFile(f, "utf8");
    for (const m of texte.matchAll(/\bt\(\s*"((?:[^"\\]|\\.)*)"/g)) {
      employees.add(m[1]!.replace(/\\"/g, '"'));
    }
  }
  const dictionnaire = await readFile(join(web, "langues/en.ts"), "utf8");
  const traduites = [...employees].filter((cle) =>
    dictionnaire.includes(`"${cle.replace(/"/g, '\\"')}":`));
  const manquantes = [...employees].filter((c) => !traduites.includes(c));
  const part = employees.size === 0 ? 0
    : Math.round((traduites.length / employees.size) * 100);

  r.verifie("EX-21", `${traduites.length} clés sur ${employees.size} traduites (${part} %)`,
    manquantes.length === 0,
    manquantes.slice(0, 4).map((m) => ${m.slice(0, 40)} »`).join(", "));

  // Un texte non traduit s'affiche en français : c'est le repli voulu, jamais
  // un identifiant technique illisible sous pression.
  const noyau = await readFile(join(web, "langues/index.tsx"), "utf8");
  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",
    noyau.includes("document.documentElement.lang"));
  r.verifie("EX-21", "les dates et les nombres suivent la langue, via Intl",
    noyau.includes("Intl.DateTimeFormat") && noyau.includes("Intl.NumberFormat"));

  // Le garde-fou d'exécution doit rester en place : sans lui, la prochaine
  // réécriture de masse repasserait inaperçue jusqu'au navigateur.
  const routeur = await readFile(join(web, "routeur.tsx"), "utf8");
+67 −29
Original line number Diff line number Diff line
@@ -27,6 +27,7 @@ import { Continuite } from "../ecrans/Continuite.js";
import { Echeances, reste as rendreReste } from "../ecrans/Echeances.js";
import { Kanban } from "../ecrans/Kanban.js";
import { VeilleExterne } from "../ecrans/VeilleExterne.js";
import { ChoixLangue, useT } from "../langues/index.js";

interface SalleListee { id: string; libelle: string; cellule_id: string | null; genre: string }
interface SalonListe {
@@ -46,6 +47,7 @@ export function Coque({ criseId }: { criseId: string }) {
  const { rembobinage, rembobiner, moi } = useEtat();
  const client = useQueryClient();
  const capacites = useCapacites();
  const t = useT();
  const [consigne, definirConsigne] = useState<Prefill | null>(null);
  const [recherche, definirRecherche] = useState(false);
  const [appel, definirAppel] = useState<Appel | null>(null);
@@ -180,9 +182,14 @@ export function Coque({ criseId }: { criseId: string }) {

  return (
    <div className={`coque${rembobinage !== null ? " rembobine" : ""}`}>
      {/* 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>

      {crise?.exercice && (
        <div className="bandeau-exercice">
          Exercice — ceci n’est pas une crise réelle
          {t("Exercice — ceci n’est pas une crise réelle")}
        </div>
      )}

@@ -217,17 +224,20 @@ export function Coque({ criseId }: { criseId: string }) {
        <span className="pousse ligne" style={{ gap: 8 }}>
          {!flux.connecte && rembobinage === null && (
            <span className="pastille alerte" title="Le flux temps réel est coupé ; la
              lecture reste à jour à chaque action.">hors flux</span>
              lecture reste à jour à chaque action.">{t("hors flux")}</span>
          )}
          {flux.presents.length > 0 && (
            <span className="pastille ok sans-point" title={
              flux.presents.map((p) => p.nom).join(", ")}>
              {flux.presents.length} en ligne
              {t("{n} en ligne", { n: flux.presents.length })}
            </span>
          )}
          <ChoixLangue />
          <ChoixTheme />
          <button className="discret petit"
            aria-pressed={silence}
            aria-label={silence ? "Réactiver le son des nouveaux messages"
              : "Couper le son des nouveaux messages"}
            title={silence
              ? "Le son est coupé. Les pastilles et le titre de l’onglet restent."
              : "Couper le son des nouveaux messages"}
@@ -235,13 +245,13 @@ export function Coque({ criseId }: { criseId: string }) {
            {silence ? "🔇" : "🔔"}
          </button>
          <button className="discret petit" onClick={() => definirRecherche(true)}>
            ⌘K Chercher
            {t("⌘K Chercher")}
          </button>
          <button className="discret petit"
            onClick={() => rembobinage === null
              ? rembobiner(Math.max(1, (cockpit.data?.entrees_main_courante ?? 1)))
              : rembobiner(null)}>
            ↺ Rembobiner
            {t("↺ Rembobiner")}
          </button>
          {rembobinage === null && appel === null && salleDeLaCellule && (
            // Sans serveur média, le bouton refuse d’abord et explique. Le
@@ -254,35 +264,49 @@ export function Coque({ criseId }: { criseId: string }) {
              onClick={() => definirAppel({
                salleId: salleDeLaCellule.id, libelle: salleDeLaCellule.libelle,
              })}>
              {capacites.visio ? "Rejoindre l’appel" : "Visio non configurée"}
              {capacites.visio ? t("Rejoindre l’appel") : t("Visio non configurée")}
            </button>
          )}
          {rembobinage === null ? (
            <button className="accent" onClick={() => definirConsigne({
              celluleId: celluleActive,
            })}>
              + Consigner
              {t("+ Consigner")}
            </button>
          ) : (
            <button className="accent" onClick={() => rembobiner(null)}>
              Revenir au direct
              {t("Revenir au direct")}
            </button>
          )}
        </span>
      </div>

      {/* Ce que la couleur dit aux uns, cette région le dit aux autres. Elle
          n’annonce que les changements d’état, jamais le flux des messages —
          un lecteur d’écran qui lirait chaque message deviendrait inutilisable. */}
      <div className="annonce" role="status" aria-live="polite">
        {rembobinage !== null
          ? t("Rembobinage actif, l’interface est en lecture seule.")
          : totalNonLus > 0
            ? `${totalNonLus} message${totalNonLus > 1 ? "s" : ""} non lu${totalNonLus > 1 ? "s" : ""}.`
            : ""}
      </div>

      <div className="corps">
        {/* ── Rail : crise → cellule → module, jamais quatre niveaux ─────── */}
        <nav className={`rail${railOuvert ? " ouvert" : ""}`}>
        <nav className={`rail${railOuvert ? " ouvert" : ""}`}
          aria-label={t("Navigation de la crise")}>
          <div className="groupe">
            <span className="etiq">Cockpit et salons</span>
            <span className="etiq">{t("Cockpit et salons")}</span>
            <button className={`item${module === "cockpit" ? " actif" : ""}`}
              aria-current={module === "cockpit" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}`)}>
              Cockpit
              {t("Cockpit")}
            </button>
            {salons.data?.salons.map((s) => (
              <button key={s.id}
                className={`item${salonId === s.id ? " actif" : ""}`}
                aria-current={salonId === s.id ? "page" : undefined}
                onClick={() => aller(`/crises/${criseId}/salons/${s.id}`)}>
                {s.libelle.replace(/^Salon /, "")}
                {(nonLus[s.id] ?? 0) > 0 && (
@@ -299,15 +323,17 @@ export function Coque({ criseId }: { criseId: string }) {
          </div>

          <div className="groupe">
            <span className="etiq">Transverse</span>
            <span className="etiq">{t("Transverse")}</span>
            <button className={`item${module === "main-courante" ? " actif" : ""}`}
              aria-current={module === "main-courante" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/main-courante`)}>
              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`)}>
              Décisions et actions
              {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}
@@ -315,24 +341,29 @@ export function Coque({ criseId }: { criseId: string }) {
              )}
            </button>
            <button className={`item${module === "tableaux" ? " actif" : ""}`}
              aria-current={module === "tableaux" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/tableaux`)}>
              Tableaux blancs
              {t("Tableaux blancs")}
            </button>
            <button className={`item${module === "tableau" ? " actif" : ""}`}
              aria-current={module === "tableau" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/tableau`)}>
              Tableau des actions
              {t("Tableau des actions")}
            </button>
            <button className={`item${module === "veille" ? " actif" : ""}`}
              aria-current={module === "veille" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/veille`)}>
              Veille externe
              {t("Veille externe")}
            </button>
            <button className={`item${module === "continuite" ? " actif" : ""}`}
              aria-current={module === "continuite" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/continuite`)}>
              Continuité
              {t("Continuité")}
            </button>
            <button className={`item${module === "echeances" ? " actif" : ""}`}
              aria-current={module === "echeances" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/echeances`)}>
              Qui informer
              {t("Qui informer")}
              {procheEcheance !== null && (
                <span className={`compteur ${procheEcheance < 0 ? "crit" : "urgent"}`}>
                  {procheEcheance < 0 ? "!" : Math.max(1, Math.round(procheEcheance / 60))}
@@ -340,25 +371,30 @@ export function Coque({ criseId }: { criseId: string }) {
              )}
            </button>
            <button className={`item${module === "documents" ? " actif" : ""}`}
              aria-current={module === "documents" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/documents`)}>
              Documents
              {t("Documents")}
            </button>
            <button className={`item${module === "mobilisation" ? " actif" : ""}`}
              aria-current={module === "mobilisation" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/mobilisation`)}>
              Mobilisation
              {t("Mobilisation")}
            </button>
            <button className={`item${module === "preparation" ? " actif" : ""}`}
              aria-current={module === "preparation" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/preparation`)}>
              Dossier gelé
              {t("Dossier gelé")}
            </button>
            <button className={`item${module === "cloture" ? " actif" : ""}`}
              aria-current={module === "cloture" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/cloture`)}>
              Clôture et retour
              {t("Clôture et retour")}
            </button>
          </div>
        </nav>

        {/* ── Scène : un seul écran par module ────────────────────────────── */}
        <main id="scene" className="principal" tabIndex={-1}>
        {module === "main-courante" ? (
          <MainCourante criseId={criseId} rembobinage={rembobinage}
            consigner={(p) => definirConsigne(p)} />
@@ -394,10 +430,12 @@ export function Coque({ criseId }: { criseId: string }) {
          <EcranCockpit criseId={criseId} cockpit={cockpit.data} aller={aller} />
        )}

        </main>

        {/* ── Contexte : ce qui appelle une décision, en permanence ───────── */}
        <aside className="contexte">
        <aside className="contexte" aria-label={t("Contexte de la crise")}>
          <div className="bloc">
            <span className="etiq">Cellules</span>
            <span className="etiq">{t("Cellules")}</span>
            {cockpit.data?.cellules.map((c) => (
              <div key={c.id} className="fait">
                <span>{c.libelle.replace(/^Cellule /, "")}</span>
@@ -435,13 +473,13 @@ export function Coque({ criseId }: { criseId: string }) {
          )}

          <div className="bloc">
            <span className="etiq">En ce moment</span>
            <span className="etiq">{t("En ce moment")}</span>
            <div className="fait">
              <span>Actions ouvertes</span>
              <span>{t("Actions ouvertes")}</span>
              <span className="valeur">{cockpit.data?.actions_ouvertes ?? 0}</span>
            </div>
            <div className="fait">
              <span>En retard</span>
              <span>{t("En retard")}</span>
              <span className="valeur" style={{
                color: (cockpit.data?.actions_en_retard.length ?? 0) > 0
                  ? "var(--crit)" : undefined }}>
@@ -449,7 +487,7 @@ export function Coque({ criseId }: { criseId: string }) {
              </span>
            </div>
            <div className="fait">
              <span>Entrées consignées</span>
              <span>{t("Entrées consignées")}</span>
              <span className="valeur">{cockpit.data?.entrees_main_courante ?? 0}</span>
            </div>
          </div>
+12 −10
Original line number Diff line number Diff line
import type { Cockpit as DonneesCockpit } from "../api.js";
import { dateHeure } from "../etat.js";
import { useT } from "../langues/index.js";

/**
 * Cockpit (EF-304).
@@ -10,6 +11,7 @@ import { dateHeure } from "../etat.js";
export function Cockpit({
  criseId, cockpit, aller,
}: { criseId: string; cockpit?: DonneesCockpit | undefined; aller: (v: string) => void }) {
  const t = useT();
  if (!cockpit) {
    return <div className="scene"><div className="contenu"><p className="discret">
      Chargement…</p></div></div>;
@@ -36,7 +38,7 @@ export function Cockpit({
      <div className="contenu">
        {rien && (
          <div className="carte" style={{ borderLeft: "3px solid var(--ok)", marginBottom: 18 }}>
            <strong>Rien n’appelle de décision à cette minute.</strong>
            <strong>{t("Rien n’appelle de décision à cette minute.")}</strong>
            <div className="discret" style={{ fontSize: 13.5, marginTop: 4 }}>
              Les cellules ont publié récemment, aucune action n’est en retard, et tous
              les tiers appelés ont répondu.
@@ -46,7 +48,7 @@ export function Cockpit({

        {cockpit.arbitrages_en_attente.length > 0 && (
          <section style={{ marginBottom: 22 }}>
            <h2>Arbitrages en attente</h2>
            <h2>{t("Arbitrages en attente")}</h2>
            <div className="pile">
              {cockpit.arbitrages_en_attente.map((a) => (
                <div key={a.id} className="carte alerte ligne">
@@ -67,7 +69,7 @@ export function Cockpit({
        )}

        <section style={{ marginBottom: 22 }}>
          <h2>Cellules</h2>
          <h2>{t("Cellules")}</h2>
          <div className="grille deux">
            {cockpit.cellules.map((c) => (
              <div key={c.id} className={`carte${c.alerte ? " alerte" : ""}`}>
@@ -78,7 +80,7 @@ export function Cockpit({
                    : <span className="pastille ok pousse">à jour</span>}
                </div>
                <div className="fait" style={{ fontSize: 13 }}>
                  <span className="discret">Dernier point</span>
                  <span className="discret">{t("Dernier point")}</span>
                  <span className="valeur">
                    {c.dernier_point_at
                      ? `il y a ${c.anciennete_minutes} min`
@@ -86,12 +88,12 @@ export function Cockpit({
                  </span>
                </div>
                <div className="fait" style={{ fontSize: 13 }}>
                  <span className="discret">Actions ouvertes</span>
                  <span className="discret">{t("Actions ouvertes")}</span>
                  <span className="valeur">{c.actions_ouvertes}</span>
                </div>
                {c.actions_en_retard > 0 && (
                  <div className="fait" style={{ fontSize: 13, color: "var(--crit)" }}>
                    <span>En retard</span>
                    <span>{t("En retard")}</span>
                    <span className="valeur">{c.actions_en_retard}</span>
                  </div>
                )}
@@ -106,10 +108,10 @@ export function Cockpit({

        {cockpit.actions_en_retard.length > 0 && (
          <section style={{ marginBottom: 22 }}>
            <h2>Actions en retard</h2>
            <h2>{t("Actions en retard")}</h2>
            <table>
              <thead>
                <tr><th>Action</th><th>Responsable</th><th>Échéance</th></tr>
                <tr><th>{t("Action")}</th><th>{t("Responsable")}</th><th>{t("Échéance")}</th></tr>
              </thead>
              <tbody>
                {cockpit.actions_en_retard.map((a) => (
@@ -128,7 +130,7 @@ export function Cockpit({

        {cockpit.tiers_en_attente.length > 0 && (
          <section style={{ marginBottom: 22 }}>
            <h2>Tiers appelés sans retour</h2>
            <h2>{t("Tiers appelés sans retour")}</h2>
            <div className="pile">
              {cockpit.tiers_en_attente.map((t) => (
                <div key={t.tiers_id} className="carte crit ligne">
@@ -155,7 +157,7 @@ export function Cockpit({

        {cockpit.actifs.length > 0 && (
          <section>
            <h2>Qui travaille en ce moment</h2>
            <h2>{t("Qui travaille en ce moment")}</h2>
            <div className="ligne" style={{ flexWrap: "wrap", gap: 6 }}>
              {cockpit.actifs.map((a) => (
                <span key={a.compte_id} className="pastille ok">{a.nom}</span>
Loading