Commit 939e2a69 authored by Kourser's avatar Kourser
Browse files

Un rail qu'on lit en pleine crise, et un thème moins austère

Le rail listait vingt-huit entrées de même taille et de même gris, sans icône,
sous des titres de 9,5 px. Ce qui appelait une décision se cherchait dans le
panneau de droite, et à 900 px de haut « Qui informer », avec son compte à
rebours réglementaire, tombait sous la ligne de flottaison (EF-304b).

En tête, « À traiter » : un arbitrage en attente et depuis quand, une échéance
dépassée ou proche, les actions en retard, un service du jour J en panne, une
cellule muette — l'urgent avant l'imminent, cinq lignes au plus, absent quand
rien n'attend. Puis ce qu'on conduit, en plus grand ; les canaux, qui disent en
gras le non-lu et par des initiales qui est en appel ; les équipes, l'extérieur.
Ce qu'on consulte se replie sans jamais cacher la page active. Le rail se réduit
à ses icônes et garde le nom de chaque commande pour qui ne le voit pas.

Les emojis laissent place à un jeu d'icônes unique, colorées par famille —
lucide-react, figé en 1.40.0 plutôt qu'en la version publiée le matin même, et
embarqué : aucun appel à un tiers. Une seule grammaire de pastilles : rouge
urgent, ambre imminent, pétrole non lu. Le compteur « 347 » d'entrées, qui
n'appelait aucun geste, disparaît.

La gravité colore la barre d'état sur toute sa largeur ; un exercice y est rayé.
Un vrai ambre, des fonds d'état plus saturés, des coins moins secs. L'interface
passe sans empattement, les textes longs le gardent, et plus aucun texte n'est
sous 11 px. Chaque personne a sa couleur. Un affichage « salle de crise »
agrandit et renforce le contraste pour un écran projeté (EX-20b). Les nouvelles
couleurs sont mesurées à chaque vérification, en clair comme en sombre.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 1b0c1ed1
Loading
Loading
Loading
Loading
Loading
+2 −0
Changes for apps/api/src/verif.ts: 2 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -18,6 +18,7 @@ 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 { verifierRail } from "./verification/rail.js";
import { verifierMobile } from "./verification/mobile.js";
import { verifierApplicationMobile } from "./verification/application-mobile.js";
import { verifierRelais } from "./verification/relais.js";
@@ -345,6 +346,7 @@ await verifierInterface(rapport);

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

// ── Appareils mobiles (volet mobile, M0-M1) ───────────────────────────────
await verifierMobile(sql, app, rapport);
+13 −0
Changes for apps/api/src/verification/accessibilite.ts: 13 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -64,6 +64,19 @@ const PAIRES: [string, string, string, number][] = [
  // d'instance portait « color: #fff » en dur, ce qui tenait en thème clair
  // et tombait à 2,7:1 en sombre. La paire est désormais mesurée.
  ["bandeau d'environnement", "fond", "crit", 4.5],
  // Les pastilles pleines du rail (R5) : le texte sur l'aplat.
  ["pastille urgente", "fond", "crit", 4.5],
  ["pastille imminente", "sur-alerte-vif", "alerte-vif", 4.5],
  ["pastille non lus", "fond", "accent", 4.5],
  // Les familles d'icônes : des graphiques porteurs de repère, 3:1 (1.4.11).
  ["famille conduite", "famille-conduite", "surface", 3],
  ["famille canaux", "famille-canaux", "surface", 3],
  ["famille équipes", "famille-equipes", "surface", 3],
  ["famille extérieur", "famille-exterieur", "surface", 3],
  ["famille ressources", "famille-ressources", "surface", 3],
  // Les initiales : du texte blanc sur la couleur de la personne.
  ...[0, 1, 2, 3, 4, 5, 6, 7].map((i) =>
    [`initiales ${i}`, "sur-personne", `personne-${i}`, 4.5] as [string, string, string, number]),
];

export async function verifierAccessibilite(r: Rapport): Promise<void> {
+1 −1
Changes for apps/api/src/verification/interface.ts: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -720,7 +720,7 @@ export async function verifierInterface(r: Rapport): Promise<void> {
  r.verifie("EF-209b", "une panne se voit dans la barre d'état d'une crise",
    coqueCrise.includes("/services-en-panne") && coqueCrise.includes('href="/preparation/services"'));
  r.verifie("EF-209b", "et sur l'entrée du dossier dans le rail, avec un nombre qui se lit",
    espace.includes("useServices(") && /compteur crit" aria-label=/.test(espace));
    espace.includes("useServices(") && /<Badge niveau="urgent" dit=\{enPanne/.test(espace));
}

/**
+185 −0
Changes for apps/api/src/verification/rail.ts: 185 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { readFile } from "node:fs/promises";
import { readdir } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import type { Rapport } from "./rapport.js";

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

interface ModuleRail {
  aTraiter: (e: {
    criseId: string; arbitrages: { depuis: string }[]; actionsEnRetard: number;
    cellulesMuettes: { libelle: string }[]; tiersSansRetour: number;
    echeance: { nom: string; reste_minutes: number } | null;
    servicesEnPanne: { libelle: string }[]; propositionsEnAttente: number; maintenant?: number;
  }) => { cle: string; niveau: "urgent" | "imminent"; badge: string; vers: string; ailleurs?: boolean }[];
  entreesVisibles: <T extends { module: string }>(e: readonly T[], module: string, ouvert: boolean) => T[];
  premiereHeureChaude: (declencheeAt: string | null, maintenant?: number) => boolean;
  couleurPersonne: (cle: string) => number;
  initiales: (nom: string) => string;
  MAX_URGENCES: number;
  NB_COULEURS_PERSONNE: number;
}

/**
 * Le rail de crise, et le thème qui l'habille (EX-19, §10).
 *
 * « En pleine crise, je ne la trouve pas très lisible. » Le rail listait
 * vingt-huit entrées de même taille, sans icône, avec des titres de groupe à
 * 9,5 px ; ce qui appelait une décision se cherchait ailleurs, et une partie du
 * rail tombait sous la ligne de flottaison.
 *
 * Deux sortes de contrôles. La règle de conduite — ce qui remonte en tête, et
 * dans quel ordre — s'éprouve en exécutant le module, sans navigateur. Le reste
 * est ce qui ne doit pas revenir : des emojis à la place des icônes, un
 * compteur qui n'appelle aucune action, une taille illisible.
 */
export async function verifierRail(r: Rapport): Promise<void> {
  r.titre("Le rail de crise");
  const rail = await import(new URL("../../../web/src/coque/rail.js", import.meta.url).href) as ModuleRail;

  const maintenant = Date.parse("2026-09-11T12:00:00Z");
  const il = (min: number) => new Date(maintenant - min * 60_000).toISOString();
  const calme = {
    criseId: "c", arbitrages: [], actionsEnRetard: 0, cellulesMuettes: [], tiersSansRetour: 0,
    echeance: null, servicesEnPanne: [], propositionsEnAttente: 0, maintenant,
  };

  // ── Ce qui remonte en tête (R1) ──────────────────────────────────────────
  r.verifie("EF-304b", "rien n'attend : le bloc « À traiter » n'existe pas",
    rail.aTraiter(calme).length === 0);

  const charge = rail.aTraiter({ ...calme,
    arbitrages: [{ depuis: il(18) }], actionsEnRetard: 3,
    cellulesMuettes: [{ libelle: "Cellule opérationnelle" }], tiersSansRetour: 1,
    echeance: { nom: "CNIL (72 h)", reste_minutes: 180 },
    servicesEnPanne: [{ libelle: "Relais de courriel" }], propositionsEnAttente: 2 });
  const rangs = charge.map((u) => u.niveau);
  r.verifie("EF-304b", "ce qui attend quelqu'un passe avant ce qui approche",
    rangs.indexOf("imminent") === -1 || rangs.lastIndexOf("urgent") < rangs.indexOf("imminent"),
    charge.map((u) => `${u.cle}:${u.niveau}`).join(", "));
  r.verifie("EF-304b", "l'arbitrage en attente est en tête, avec depuis combien de temps",
    charge[0]?.cle === "arbitrages" && charge[0].badge === "18 min", JSON.stringify(charge[0]));
  r.verifie("EF-304b", `le bloc ne dépasse pas ${rail.MAX_URGENCES} lignes, qu'on lit encore`,
    charge.length === rail.MAX_URGENCES, `${charge.length} ligne(s)`);
  r.verifie("EF-304b", "un service en panne s'ouvre hors de la crise, dans un nouvel onglet",
    charge.some((u) => u.cle === "services" && u.ailleurs === true
      && u.vers === "/preparation/services"));

  const echeance = (reste: number) => rail.aTraiter({ ...calme,
    echeance: { nom: "ARS", reste_minutes: reste } })[0];
  r.verifie("EF-304b", "une échéance dépassée est urgente",
    echeance(-30)?.niveau === "urgent");
  r.verifie("EF-304b", "une échéance dans la journée est imminente, avec le temps qui reste",
    echeance(180)?.niveau === "imminent" && echeance(180)?.badge === "3 h");
  r.verifie("EF-304b", "une échéance lointaine n'encombre pas le bloc",
    echeance(3 * 24 * 60) === undefined);

  // ── Ce qui se replie (R2) ────────────────────────────────────────────────
  const entrees = [{ module: "documents" }, { module: "agents" }, { module: "cloture" }];
  r.verifie("EF-304b", "replié, le groupe ne montre rien",
    rail.entreesVisibles(entrees, "cockpit", false).length === 0);
  r.verifie("EF-304b", "sauf la page où l'on est : un rail ne cache pas la page active",
    rail.entreesVisibles(entrees, "agents", false).map((x) => x.module).join() === "agents");
  r.verifie("EF-304b", "ouvert, il montre tout",
    rail.entreesVisibles(entrees, "cockpit", true).length === entrees.length);
  r.verifie("EF-304b", "la première heure passe aux ressources au bout de trois heures",
    rail.premiereHeureChaude(il(120), maintenant) && !rail.premiereHeureChaude(il(240), maintenant));

  // ── Les personnes (T6) ───────────────────────────────────────────────────
  const noms = ["c1", "c2", "c3", "c4", "c5", "c6", "c7", "c8", "c9", "c10"];
  const couleurs = noms.map(rail.couleurPersonne);
  r.verifie("EF-304b", "une personne garde sa couleur, et les couleurs se répartissent",
    noms.every((n) => rail.couleurPersonne(n) === rail.couleurPersonne(n))
      && couleurs.every((c) => c >= 0 && c < rail.NB_COULEURS_PERSONNE)
      && new Set(couleurs).size >= 4, couleurs.join(","));
  r.verifie("EF-304b", "les initiales se lisent : prénom et nom, ou deux lettres",
    rail.initiales("Marie Le Gall") === "MG" && rail.initiales("Anne") === "AN"
      && rail.initiales("Jean-Yves Kerbrat") === "JK");

  // ── Ce qui ne doit pas revenir ───────────────────────────────────────────
  const coque = await readFile(join(web, "coque/Coque.tsx"), "utf8");
  const nav = coque.slice(coque.indexOf('<nav id="rail-crise"'), coque.indexOf("</nav>"));
  r.verifie("EF-304b", "le rail de crise n'affiche aucun emoji à la place d'une icône (R3)",
    !/[🔒🔊✉+◆📌📎]/u.test(nav) && !/>\s*#\s/.test(nav));
  const boutons = [...nav.matchAll(/<button[\s\S]*?<\/button>/g)].map((m) => m[0])
    .filter((b) => /className=\{?[`"]item/.test(b));
  // Les lignes « À traiter » partagent leur contenu entre lien et bouton : on
  // suit le fragment plutôt que de conclure qu'il manque une icône.
  const fragmentIcone = /const contenu = \([\s\S]*?<Icone/.test(nav);
  const sansIcone = boutons.filter((b) =>
    !b.includes("<Icone") && !(b.includes("{contenu}") && fragmentIcone));
  r.verifie("EF-304b", `chaque entrée du rail porte son icône (${boutons.length} écrites à la main,`
    + " les autres par EntreeRail)", boutons.length > 0 && sansIcone.length === 0,
  sansIcone.map((b) => b.slice(0, 60)).join(" | "));
  r.verifie("EF-304b", "l'entrée du rail se dessine en un seul endroit, icône comprise",
    /function EntreeRail[\s\S]*?<Icone nom=\{icone\}/.test(coque));
  r.verifie("EF-304b", "les pastilles du rail parlent une seule grammaire (R5)",
    !nav.includes('className="compteur') && nav.includes("<Badge niveau="));
  r.verifie("EF-304b", "un compteur qui n'appelle aucune action n'est pas dans le rail",
    !nav.includes("entrees_main_courante"));
  r.verifie("EF-304b", "les ressources sont repliées tant qu'on ne les a pas ouvertes",
    /useState\(\(\) =>\s*lirePreference\("kastell\.rail\.ressources"\) === "ouvert"\)/.test(coque));
  r.verifie("EX-19", "réduit, le rail garde le nom de chaque commande pour qui ne le voit pas",
    /\.rail\.reduit \.libelle, \.lecteur \{[^}]*clip-path: inset\(50%\)/.test(
      await readFile(join(web, "styles.css"), "utf8")));
  const espace = await readFile(join(web, "ecrans/EspaceOrganisation.tsx"), "utf8");
  const navEspace = espace.slice(espace.indexOf("<nav"), espace.indexOf("</nav>"));
  const sansIconeEspace = [...navEspace.matchAll(/<button[\s\S]*?<\/button>/g)].map((m) => m[0])
    .filter((b) => b.includes("item") && !b.includes("<Icone"));
  r.verifie("EF-304b", "le rail de l'organisation parle la même langue : icônes et pastilles",
    sansIconeEspace.length === 0 && !navEspace.includes('className="compteur'),
    sansIconeEspace.map((b) => b.slice(0, 60)).join(" | "));

  // ── Le thème ─────────────────────────────────────────────────────────────
  r.titre("Le thème");
  const css = await readFile(join(web, "styles.css"), "utf8");

  // Aucune taille illisible (T3). La feuille en portait dix-huit sous 11 px,
  // dont tous les titres de groupe du rail à 9,5 px.
  const petitesCss = [...css.matchAll(/font-size:\s*(\d+(?:\.\d+)?)px/g)]
    .map((m) => Number(m[1])).filter((n) => n < 11);
  const petitesEnLigne: string[] = [];
  for (const f of await sources(web)) {
    if (!f.endsWith(".tsx")) continue;
    const texte = await readFile(f, "utf8");
    for (const m of texte.matchAll(/fontSize:\s*(\d+(?:\.\d+)?)\b/g)) {
      if (Number(m[1]) < 11) petitesEnLigne.push(`${f.slice(web.length)} : ${m[1]}`);
    }
  }
  r.verifie("EX-20b", "aucun texte sous 11 px, ni dans la feuille ni en ligne",
    petitesCss.length === 0 && petitesEnLigne.length === 0,
    [...petitesCss.map((n) => `${n} px`), ...petitesEnLigne].slice(0, 4).join(" ; "));

  r.verifie("EX-20b", "l'interface se lit sans empattement, les textes longs avec (T3)",
    /body \{[^}]*font-family: Archivo/.test(css)
      && /\.texte-long, \.entree \.texte \{[^}]*Source Serif/.test(css));

  // La gravité se voit sur toute la largeur, et un exercice ne se confond pas (T1).
  const graviteDite = ["vigilance", "mineure", "majeure", "critique"]
    .filter((g) => css.includes(`.coque[data-gravite="${g}"]`));
  r.verifie("EX-20b", "la barre d'état porte la couleur de la gravité, pour les quatre niveaux",
    graviteDite.length === 4 && coque.includes("data-gravite={crise?.gravite}"),
    graviteDite.join(", "));
  r.verifie("EX-20b", "un exercice est rayé, jamais d'un aplat qu'on prendrait pour le réel",
    /\.coque\.exercice \.barre-etat::before \{[^}]*repeating-linear-gradient/.test(css)
      && coque.includes('crise?.exercice ? " exercice"'));

  // Le mode salle de crise (T7) : un attribut posé par le thème, une règle qui le lit.
  const theme = await readFile(join(web, "theme.tsx"), "utf8");
  r.verifie("EX-20b", "l'affichage « salle de crise » agrandit et renforce le contraste",
    theme.includes('racine.setAttribute("data-affichage", "salle")')
      && /:root\[data-affichage="salle"\] \{[^}]*zoom/.test(css)
      && (await readFile(join(web, "ecrans/Preferences.tsx"), "utf8")).includes("<ChoixAffichage"));
}

async function sources(dossier: string): Promise<string[]> {
  const entrees = await readdir(dossier, { withFileTypes: true });
  const fichiers: string[] = [];
  for (const e of entrees) {
    const chemin = join(dossier, e.name);
    if (e.isDirectory()) fichiers.push(...await sources(chemin));
    else if (/\.tsx?$/.test(e.name)) fichiers.push(chemin);
  }
  return fichiers;
}
+1 −1
Changes for apps/web/index.html: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -9,7 +9,7 @@
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link
      rel="stylesheet"
      href="https://fonts.googleapis.com/css2?family=Archivo:wght@500;600;700&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;1,8..60,400&family=JetBrains+Mono:wght@400;500&display=swap"
      href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;1,8..60,400&family=JetBrains+Mono:wght@400;500&display=swap"
    />
  </head>
  <body>
Loading