Commit 2514bff5 authored by Kourser's avatar Kourser
Browse files

Le pouce, la couleur, et ce qui ne défile pas

Trois corrections d'interface mobile, qui partent du même constat : ce qui est
commode à écrire ne se lit pas de la même façon à trois heures du matin, sur un
téléphone tenu d'une main.

Le bas de l'écran est ce que le pouce atteint
  La première version y mettait « Retour », sur chaque écran. La chose la plus
  facile à toucher, sur l'écran de mobilisation, était donc celle qui ne répond
  pas. Le retour remonte en tête, à sa place habituelle sur iOS ; le bas revient
  aux actions du chemin critique (EM-63).

  Et ces actions vivaient à la fin d'une liste déroulante : sur un message un
  peu long, « J'arrive » passait dans la moitié haute. Une charpente commune —
  ui/page.tsx — ancre désormais la barre d'action hors de la zone qui défile, et
  place le bandeau au-dessus du titre : l'exercice se lit avant ce qu'il
  qualifie (EM-16).

Une couleur qui sert à deux choses ne dit plus rien
  Le même rouge pour « vous êtes mobilisé » et « une erreur s'est produite ; le
  même ambre pour l'exercice, la file en attente et les données périmées —
  trois sens, une teinte. Sous stress, c'est la couleur qu'on lit avant le mot.
  La palette donne maintenant un sens et un seul à chaque teinte, et le dit en
  commentaire à l'endroit où on serait tenté d'en réemployer une.

Le texte agrandi ne tronque plus
  Les lignes s'empilent au lieu de se couper, le chrome plafonne — un titre à
  quatre-vingts points mangeait l'écran — et le contenu, lui, grossit sans
  limite (EM-64).

Contrôlé
  Les garanties correspondantes sont écrites plutôt que constatées à l'œil :
  que la charpente ancre les actions, qu'aucun écran ne dépense sa barre du bas
  pour un retour, que le bandeau précède le titre, que la troncature disparaît
  quand le texte grossit. 920 garanties au total, aucune en échec, tsc passe.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 34f17e58
Loading
Loading
Loading
Loading
Loading
+91 −9
Changes for apps/api/src/verification/application-mobile.ts: 91 added lines, 9 removed lines.
Original line number Diff line number Diff line
@@ -96,6 +96,10 @@ export async function verifierApplicationMobile(r: Rapport): Promise<void> {

  r.verifie("EM-41", `${lus.size} sources d'application mobile relues`, lus.size >= 12);

  /** La charpente commune : elle porte l'ordre du rendu et l'ancrage des actions. */
  const charpente = lus.get("src/ui/page.tsx") ?? "";


  // ── EM-03 : la même API que le web, sans point de terminaison réservé ────
  const cheminsHorsApi: string[] = [];
  for (const [nom, texte] of lus) {
@@ -253,6 +257,65 @@ export async function verifierApplicationMobile(r: Rapport): Promise<void> {
  r.verifie("EM-64", "boutons et champs reprennent cette mesure",
    (composants.match(/minHeight: MESURES\.cible/g) ?? []).length >= 2);

  /*
    EM-64 : « lisibilité vérifiée à la plus grande taille de texte du système.
    La presbytie ne se déclare pas au moment de la crise. »

    Ce contrôle existe parce que l'écran mentait. À la plus grande taille, une
    rangée « libellé | valeur » tronquait la valeur : « son et… » pouvait être
    « son et bannière » ou n'importe quoi d'autre. Quelqu'un qui grossit le
    texte parce qu'il voit mal recevait un écran faux.

    Deux propriétés, et elles vont ensemble : les lignes s'empilent au-delà du
    seuil, et la troncature disparaît alors — une valeur coupée n'est pas une
    valeur abrégée.
  */
  r.verifie("EM-64", "les lignes s'empilent quand le texte est agrandi",
    /export function texteAgrandi/.test(composants)
      && /fontScale >= SEUIL_EMPILEMENT/.test(composants));
  r.verifie("EM-64", "et la troncature disparaît alors",
    /\{\.\.\.\(empile \? \{\} : \{ numberOfLines: 2 \}\)\}/.test(composants));
  // Le chrome, lui, plafonne : un titre à quatre-vingts points mangeait le
  // tiers supérieur de l'écran et repoussait les crises hors de vue. Le
  // contenu, jamais : le plafonner reviendrait à refuser le réglage.
  // Le corps du composant « Paragraphe », isolé : c'est lui qui porte le texte
  // que la personne a demandé à grossir, et il ne doit connaître aucun plafond.
  const propre = sansCommentaires(composants);
  const paragraphe = propre.slice(propre.indexOf("export function Paragraphe"))
    .split("export function")[1] ?? "";
  r.verifie("EM-64", "les titres plafonnent",
    (propre.match(/maxFontSizeMultiplier/g) ?? []).length >= 3);
  r.verifie("EM-64", "et le contenu grossit sans limite",
    paragraphe.length > 0 && !paragraphe.includes("maxFontSizeMultiplier"));

  /*
    EM-63 : « toute action du chemin critique s'atteint à une main, sur un
    écran de 5,4 pouces, dans la moitié basse. »

    Tant que les boutons vivaient à la fin d'une liste déroulante, ils
    remontaient avec le contenu : sur l'écran de mobilisation, « J'arrive » se
    trouvait à 34 % de la hauteur. La barre d'action vit désormais hors de la
    zone qui défile — c'est cela qu'on vérifie, pas une intention.
  */
  r.verifie("EM-63", "la charpente ancre les actions hors de la zone qui défile",
    charpente.indexOf("</ScrollView>") < charpente.indexOf("<BarreAction>"));
  for (const [ecran, quoi] of [
    ["src/ecrans/Mobilisation.tsx", "les trois réponses"],
    ["src/ecrans/Consigner.tsx", "l'envoi d'une consignation"],
    ["src/ecrans/Crise.tsx", "la consignation"],
    ["src/ecrans/Appel.tsx", "le micro et la sortie d'appel"],
  ] as [string, string][]) {
    r.verifie("EM-63", `${quoi} y sont ancrés`, /actions=\{/.test(lus.get(ecran) ?? ""));
  }
  // Le bas de l'écran est ce que le pouce atteint le plus facilement. La
  // première version y mettait « Retour », partout : la chose la plus facile à
  // toucher, sur l'écran de mobilisation, était celle qui ne répond pas.
  const retoursEnBas = [...lus]
    .filter(([nom]) => nom.startsWith("src/ecrans/"))
    .filter(([, texte]) => /titre=\{t\("Retour"\)\}/.test(sansCommentaires(texte)));
  r.verifie("EM-63", "aucun écran ne dépense sa barre du bas pour un retour",
    retoursEnBas.length === 0, retoursEnBas.map(([n]) => n).join(", "));

  // ── EM-65 : les deux thèmes tenus à égalité ─────────────────────────────
  const clair = palette(theme, "CLAIR");
  const sombre = palette(theme, "SOMBRE");
@@ -269,6 +332,16 @@ export async function verifierApplicationMobile(r: Rapport): Promise<void> {
    ["accent", "fond"], ["alerte", "fond"], ["succes", "fond"],
    ["arbitrage", "fond"], ["information", "fond"],
    ["surAccent", "accent"],
    // Les bandeaux : du texte clair sur une pleine couleur. Ces paires
    // manquaient, et le texte y était quasi noir sur violet — sous 2:1.
    ["surAccent", "exercice"],
    ["surAccent", "perime"],
    ["surAccent", "alerte"],
    ["surAccent", "mobilisation"],
    // Les couleurs sémantiques doivent aussi se lire en texte sur les cartes.
    ["exercice", "surface"],
    ["attente", "surface"],
    ["perime", "surface"],
  ];
  for (const [avant, arriere] of paires) {
    for (const [nom, p] of [["clair", clair], ["sombre", sombre]] as const) {
@@ -316,15 +389,24 @@ export async function verifierApplicationMobile(r: Rapport): Promise<void> {
  const ecranMobilisation = lus.get("src/ecrans/Mobilisation.tsx") ?? "";
  r.verifie("EM-21", "répondre depuis l'application passe par ce déverrouillage",
    /deverrouillage\.exiger\(/.test(ecranMobilisation));
  // EM-16 : « une notification de mobilisation reçue pour une crise marquée
  // exercice le dit sur l'écran verrouillé, avant tout autre mot. » À l'écran,
  // « avant » veut dire plus haut dans le rendu — on mesure donc l'ordre dans
  // le JSX, pas dans le fichier : la logique au-dessus peut citer l'intitulé
  // sans que cela change ce que la personne lit en premier.
  const rendu = ecranMobilisation.slice(ecranMobilisation.lastIndexOf("return ("));
  r.verifie("EM-16", "l'exercice est annoncé avant l'intitulé de la crise",
    rendu.includes("EXERCICE")
      && rendu.indexOf("EXERCICE") < rendu.indexOf("crise.intitule"));
  /*
    EM-16 : « une notification de mobilisation reçue pour une crise marquée
    exercice le dit sur l'écran verrouillé, avant tout autre mot. »

    Une première version mesurait l'ordre du texte dans le fichier de l'écran.
    C'était un mauvais témoin : la charpente commune rend désormais le bandeau
    au-dessus du titre, et l'ordre des propriétés dans le JSX ne dit plus rien
    de ce que la personne lit en premier — le contrôle a échoué sur du code
    devenu plus juste.

    On mesure donc le mécanisme, en deux points : la charpente rend le bandeau
    avant le titre, et l'écran de mobilisation y passe bien l'exercice.
  */
  r.verifie("EM-16", "la charpente rend le bandeau au-dessus du titre",
    charpente.indexOf("{bandeau}") < charpente.indexOf("<Titre>"));
  r.verifie("EM-16", "et l'écran de mobilisation y passe l'exercice",
    /bandeau=\{s\.exercice \?/.test(ecranMobilisation)
      && ecranMobilisation.includes("EXERCICE"));

  // ── EM-35 à EM-38 : ce que l'application ne collecte pas ────────────────
  //
+1 −1
Changes for apps/mobile/src/App.tsx: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -357,7 +357,7 @@ function Racine() {
  }

  return (
    <SafeAreaView style={{ flex: 1, backgroundColor: p.fond }} edges={["top", "left", "right"]}>
    <SafeAreaView style={{ flex: 1, backgroundColor: p.fond }} edges={["top", "left", "right", "bottom"]}>
      <StatusBar style={sombre ? "light" : "dark"} />
      {contenu}
    </SafeAreaView>
+8 −11
Changes for apps/mobile/src/ecrans/APropos.tsx: 8 added lines, 11 removed lines.
Original line number Diff line number Diff line
import Constants from "expo-constants";
import * as WebBrowser from "expo-web-browser";
import { ScrollView, StyleSheet } from "react-native";
import { Bouton, Encart, Ligne, Paragraphe, SousTitre, Titre } from "../ui/composants";
import { StyleSheet } from "react-native";
import { Bouton, Encart, Ligne, Paragraphe, Rangee, SousTitre } from "../ui/composants";
import { Page } from "../ui/page";
import { MESURES, usePalette } from "../theme";
import { useLangue, type Langue } from "../langues";

@@ -26,11 +27,8 @@ export function APropos({ surRetour }: { surRetour: () => void }) {
  const source = extra?.source ?? null;

  return (
    <ScrollView contentContainerStyle={[s.page, { backgroundColor: p.fond }]}>
      <Titre>Kastell</Titre>
      <SousTitre>{t("Cockpit de gestion de crise")}</SousTitre>

      <Encart>
    <Page titre="Kastell" sousTitre={t("Cockpit de gestion de crise")} surRetour={surRetour}>
      <Encart titre={t("Cette version")}>
        <Ligne libelle={t("Version")} valeur={Constants.expoConfig?.version ?? "—"} />
        <Ligne libelle={t("Protocole")} valeur={String(extra?.protocole ?? "—")} />
        <Ligne
@@ -58,6 +56,7 @@ export function APropos({ surRetour }: { surRetour: () => void }) {
      ) : null}

      <SousTitre>{t("Langue")}</SousTitre>
      <Rangee>
        {(["fr", "en"] as Langue[]).map((l) => (
          <Bouton
            key={l}
@@ -66,12 +65,10 @@ export function APropos({ surRetour }: { surRetour: () => void }) {
            sur={() => definir(l)}
          />
        ))}

      <Bouton titre={t("Retour")} genre="second" sur={surRetour} />
    </ScrollView>
      </Rangee>
    </Page>
  );
}

const s = StyleSheet.create({
  page: { padding: MESURES.marge, gap: MESURES.marge, paddingBottom: 48, flexGrow: 1 },
});
+92 −84
Changes for apps/mobile/src/ecrans/Appareil.tsx: 92 added lines, 84 removed lines.
Original line number Diff line number Diff line
import { useCallback, useEffect, useState } from "react";
import { RefreshControl, ScrollView, StyleSheet, View } from "react-native";
import { StyleSheet, View } from "react-native";
import { ErreurApi, ErreurReseau } from "../api/client";
import { moi, type Moi } from "../appareil/enrolement";
import type { Identite } from "../appareil/identite";
import { declarer, demander, type EtatCanal, type Heurtoir } from "../appareil/poussee";
import * as dossiers from "../crise/dossier";
import type { Instance } from "../instance/registre";
import { Bouton, Encart, Ligne, Paragraphe, SousTitre, Titre } from "../ui/composants";
import { Bouton, Encart, Ligne, Paragraphe, SousTitre } from "../ui/composants";
import { Page } from "../ui/page";
import { MESURES, usePalette } from "../theme";
import { useFormats, useLangue } from "../langues";

@@ -53,6 +54,7 @@ export function Appareil({ instance, identite, canal, heurtoir, surCanal, surRev
  const [horsLigne, setHorsLigne] = useState<Moi["crises"]>([]);
  const [erreur, setErreur] = useState<string | null>(null);
  const [occupe, setOccupe] = useState(false);
  const [replie, setReplie] = useState(true);

  const rafraichir = useCallback(async () => {
    setErreur(null);
@@ -137,114 +139,120 @@ export function Appareil({ instance, identite, canal, heurtoir, surCanal, surRev
    : a === "ordinaire" ? t("silencieuse") : t("refusée");

  return (
    <ScrollView
      contentContainerStyle={[s.page, { backgroundColor: p.fond }]}
      refreshControl={<RefreshControl refreshing={occupe} onRefresh={rafraichir}
        tintColor={p.texteAttenue} />}
    <Page
      titre={instance.nomLocal}
      sousTitre={instance.domaine}
      occupe={occupe}
      surRafraichir={() => { void rafraichir(); }}
    >
      <View>
        <Titre>{instance.nomLocal}</Titre>
        <SousTitre>{instance.domaine}</SousTitre>
      </View>

      <Encart teinte={couleurAutorisation}>
        <Ligne libelle={t("Ce téléphone")} valeur={etat?.appareil.nom ?? "—"} />
        <Ligne libelle={t("Compte")} valeur={etat?.compte?.nom ?? "—"} />
        <Ligne libelle={t("Notification")} valeur={motAutorisation} ton={couleurAutorisation} />
        <Ligne
          libelle={t("Jeton déclaré")}
          valeur={etat?.appareil.jeton_declare_at
            ? f.dateHeure(etat.appareil.jeton_declare_at)
            : t("jamais")}
          ton={etat?.appareil.jeton_declare_at ? p.texte : p.alerte}
        />
      </Encart>

      {/*
        EM-12 : l'autorisation d'alerte critique n'est jamais présumée, et son
        absence se dit à la configuration — pas la nuit où le téléphone reste
        muet. Chaque état a sa conséquence écrite en clair.
      */}
      <Paragraphe ton={a === "refusee" ? "alerte" : "attenue"}>
        {a === null
          ? t("L'autorisation de notifier est en cours de relecture auprès du système.")
          : a === "refusee"
          ? t("Ce téléphone ne sonnera pas. Autorisez les notifications de Kastell dans les "
            + "réglages du système, sinon vous serez joint par SMS ou par appel.")
          : a === "ordinaire"
            ? t("Les notifications arrivent sans son. Ce téléphone ne vous réveillera pas.")
            : a === "urgente"
              ? t("Ce téléphone sonnera, sauf s'il est en mode silencieux ou en concentration.")
              : t("Ce téléphone sonnera même en mode silencieux.")}
      </Paragraphe>
        Les crises d'abord.

      {canal && !canal.appareilReel ? (
        La première version ouvrait sur l'état de l'appareil — nom du
        téléphone, compte, autorisation, date du jeton, un paragraphe sur les
        simulateurs — et reléguait les crises à soixante pour cent de la page.
        L'écran était organisé autour de la configuration du téléphone, pas
        autour de la situation de la personne. Quelqu'un qui ouvre
        l'application pendant une crise vient pour la crise.
      */}
      {crises.length > 0 ? (
        <Encart teinte={crises.some((c) => !c.exercice) ? p.mobilisation : p.exercice}
          titre={t("Crises en cours")}>
          <SousTitre>{t("Crises en cours")}</SousTitre>
          {!etat ? (
            <Paragraphe ton="attenue">
          {t("Vous êtes sur un simulateur. Un jeton peut y être délivré et déclaré, mais "
            + "Apple ne remet aucune notification réelle à un simulateur : la remise ne "
            + "s'éprouve que sur un appareil.")}
              {t("Instance injoignable. Ces crises sont celles dont le dossier gelé est "
                + "déjà sur ce téléphone.")}
            </Paragraphe>
          ) : null}
          {crises.map((c) => (
            <Bouton key={c.id}
              titre={c.exercice ? `${c.intitule} — ${t("exercice")}` : c.intitule}
              ampleur="grande"
              {...(c.exercice ? { teinte: p.exercice } : {})}
              sur={() => surCrise(c)} />
          ))}
        </Encart>
      ) : (
        <Encart titre={t("Aucune crise")}>
          <Paragraphe ton="attenue">
            {t("Aucune crise en cours. Cette application vous préviendra.")}
          </Paragraphe>
        </Encart>
      )}

      {/*
        Le dernier heurtoir reçu — c'est-à-dire la preuve, ou son absence, que
        le tuyau fonctionne. On n'affiche que ce que le heurtoir porte : une
        référence opaque et un degré d'urgence. Il n'y a rien d'autre à
        afficher, et c'est le sujet (EM-08).
      */}
      <Encart>
      {heurtoir && heurtoir.urgence === "mobilisation" ? (
        <Encart teinte={p.mobilisation} titre={t("Dernier coup frappé")}>
          <SousTitre>{t("Dernier coup frappé")}</SousTitre>
        {heurtoir ? (
          <>
          <Ligne libelle={t("Reçu à")} valeur={f.dateHeure(heurtoir.recu_at)} />
            <Ligne libelle={t("Urgence")} valeur={t(heurtoir.urgence)} />
            <Ligne libelle={t("Référence")} valeur={heurtoir.reference} />
            {heurtoir.urgence === "mobilisation" ? (
          <Bouton titre={t("Ouvrir cette mobilisation")}
            sur={() => surHeurtoir(heurtoir.reference)} />
            ) : null}
          </>
        ) : (
          <Paragraphe ton="attenue">
            {t("Aucun depuis l'ouverture de l'application. Une notification ne porte jamais "
              + "de texte : seulement une référence opaque et un degré d'urgence.")}
          </Paragraphe>
        )}
        </Encart>
      ) : null}

      {erreur ? <Paragraphe ton="alerte">{erreur}</Paragraphe> : null}

      {/*
        Les crises ouvertes, avant l'état du canal : quelqu'un qui ouvre
        l'application pendant une crise vient pour la crise, pas pour vérifier
        ses réglages de notification.
        L'état du canal, replié.

        Il compte — c'est lui qui dit si ce téléphone sonnera — mais il se
        consulte, il ne se surveille pas. Une ligne suffit à alerter ; le
        détail se déplie pour qui le cherche. Sauf quand quelque chose ne va
        pas : dans ce cas il s'ouvre de lui-même.
      */}
      {crises.length > 0 ? (
        <Encart teinte={crises.some((c) => !c.exercice) ? p.mobilisation : p.arbitrage}>
          <SousTitre>{t("Crises en cours")}</SousTitre>
          {!etat ? (
      <Encart teinte={couleurAutorisation} titre={t("État du canal")}>
        <Ligne libelle={t("Notification")} valeur={motAutorisation}
          ton={couleurAutorisation} />
        {a === "refusee" || a === "ordinaire" || !replie ? (
          <>
            <Paragraphe ton={a === "refusee" ? "alerte" : "attenue"}>
              {a === null
                ? t("L'autorisation de notifier est en cours de relecture auprès du système.")
                : a === "refusee"
                ? t("Ce téléphone ne sonnera pas. Autorisez les notifications de Kastell "
                  + "dans les réglages du système, sinon vous serez joint par SMS ou par "
                  + "appel.")
                : a === "ordinaire"
                  ? t("Les notifications arrivent sans son. Ce téléphone ne vous réveillera "
                    + "pas.")
                  : a === "urgente"
                    ? t("Ce téléphone sonnera, sauf s'il est en mode silencieux ou en "
                      + "concentration.")
                    : t("Ce téléphone sonnera même en mode silencieux.")}
            </Paragraphe>
            <Ligne libelle={t("Ce téléphone")} valeur={etat?.appareil.nom ?? "—"} />
            <Ligne libelle={t("Compte")} valeur={etat?.compte?.nom ?? "—"} />
            <Ligne
              libelle={t("Jeton déclaré")}
              valeur={etat?.appareil.jeton_declare_at
                ? f.dateHeure(etat.appareil.jeton_declare_at)
                : t("jamais")}
              ton={etat?.appareil.jeton_declare_at ? p.texte : p.alerte}
            />
            {canal && !canal.appareilReel ? (
              <Paragraphe ton="attenue">
              {t("Instance injoignable. Ces crises sont celles dont le dossier gelé est "
                + "déjà sur ce téléphone.")}
                {t("Vous êtes sur un simulateur. Un jeton peut y être délivré et déclaré, "
                  + "mais Apple ne remet aucune notification réelle à un simulateur : la "
                  + "remise ne s'éprouve que sur un appareil.")}
              </Paragraphe>
            ) : null}
          {crises.map((c) => (
            <Bouton key={c.id}
              titre={c.exercice ? `${c.intitule} — ${t("exercice")}` : c.intitule}
              sur={() => surCrise(c)} />
          ))}
        </Encart>
            <Bouton titre={t("Éprouver le canal")} genre="second" sur={eprouverLeCanal}
              occupe={occupe}
              indication={t("Redemande l'autorisation, puis redéclare le jeton à "
                + "l'instance.")} />
          </>
        ) : null}
        <Bouton titre={replie ? t("Voir le détail") : t("Replier")} genre="second"
          sur={() => setReplie(!replie)} />
      </Encart>

      <Bouton titre={t("Éprouver le canal")} sur={eprouverLeCanal} occupe={occupe}
        indication={t("Redemande l'autorisation, puis redéclare le jeton à l'instance.")} />
      <Bouton titre={t("À propos")} genre="second" sur={surApropos} />
      <Bouton titre={t("Oublier cette instance")} genre="danger" sur={surOublier}
        indication={t("Efface l'enrôlement et toutes les données locales de cette instance.")} />
    </ScrollView>
        indication={t("Efface l'enrôlement et toutes les données locales de cette "
          + "instance.")} />
    </Page>
  );
}

const s = StyleSheet.create({
  page: { padding: MESURES.marge, gap: MESURES.marge, paddingBottom: 48, flexGrow: 1 },
});
+31 −34
Changes for apps/mobile/src/ecrans/Appel.tsx: 31 added lines, 34 removed lines.
Original line number Diff line number Diff line
import { useCallback, useEffect, useRef, useState } from "react";
import { ScrollView, StyleSheet, View } from "react-native";
import { StyleSheet, View } from "react-native";
import {
  AudioSession, registerGlobals,
} from "@livekit/react-native";
@@ -10,7 +10,8 @@ import type { Identite } from "../appareil/identite";
import * as appel from "../crise/appel";
import type { Crise } from "../crise/api";
import type { Instance } from "../instance/registre";
import { Bouton, Encart, Ligne, Paragraphe, SousTitre, Titre } from "../ui/composants";
import { Bandeau, Bouton, Encart, Ligne, Paragraphe, Rangee } from "../ui/composants";
import { Page } from "../ui/page";
import { MESURES, usePalette } from "../theme";
import { useFormats, useLangue } from "../langues";

@@ -173,20 +174,29 @@ export function Appel({ instance, identite, crise, surRetour }: {

  if (etat === "en_appel" && salleEnCours) {
    return (
      <ScrollView contentContainerStyle={[s.page, { backgroundColor: p.fond }]}>
        <View>
          <Titre>{salleEnCours.libelle}</Titre>
          <SousTitre>{crise.intitule}</SousTitre>
        </View>

        {/* EF-806 : un enregistrement en cours s'annonce, et pas en petit. */}
        {avertissement ? (
          <View style={[s.bandeau, { backgroundColor: p.alerte }]}>
            <Paragraphe>{avertissement}</Paragraphe>
          </View>
        ) : null}

        <Encart teinte={micro ? p.succes : p.bordure}>
      <Page
        titre={salleEnCours.libelle}
        sousTitre={crise.intitule}
        /* EF-806 : un enregistrement en cours s'annonce, et pas en petit. */
        bandeau={avertissement ? (
          <Bandeau teinte={p.alerte}>{avertissement}</Bandeau>
        ) : undefined}
        /*
          Le micro d'abord, en grand : c'est le seul geste qu'on fait sans
          regarder, parfois en conduisant. Quitter est en dessous, et plus
          discret — on ne raccroche pas par mégarde une cellule de crise.
        */
        actions={(
          <>
            <Bouton titre={micro ? t("Couper le micro") : t("Ouvrir le micro")}
              ampleur="grande" {...(micro ? { teinte: p.succes } : {})}
              sur={() => { void basculerMicro(); }} />
            <Bouton titre={t("Quitter l'appel")} genre="danger"
              sur={() => { void quitter(); }} />
          </>
        )}
      >
        <Encart teinte={micro ? p.succes : p.bordure} titre={t("État de l'appel")}>
          <Ligne libelle={t("Votre micro")}
            valeur={micro ? t("ouvert") : t("coupé")}
            ton={micro ? p.succes : p.texteAttenue} />
@@ -199,22 +209,12 @@ export function Appel({ instance, identite, crise, surRetour }: {
            + "Aucune vidéo n'est envoyée ni reçue.")}
        </Paragraphe>

        {/* Grandes, en bas, atteignables au pouce sans regarder (EM-63). */}
        <Bouton titre={micro ? t("Couper le micro") : t("Ouvrir le micro")}
          sur={() => { void basculerMicro(); }} />
        <Bouton titre={t("Quitter l'appel")} genre="danger"
          sur={() => { void quitter(); }} />
      </ScrollView>
      </Page>
    );
  }

  return (
    <ScrollView contentContainerStyle={[s.page, { backgroundColor: p.fond }]}>
      <View>
        <Titre>{t("Rejoindre l'appel")}</Titre>
        <SousTitre>{crise.intitule}</SousTitre>
      </View>

    <Page titre={t("Rejoindre l'appel")} sousTitre={crise.intitule} surRetour={surRetour}>
      {erreur ? <Paragraphe ton="alerte">{erreur}</Paragraphe> : null}

      {salles.length === 0 ? (
@@ -225,7 +225,7 @@ export function Appel({ instance, identite, crise, surRetour }: {
        </Encart>
      ) : (
        salles.map((x) => (
          <Encart key={x.id}>
          <Encart key={x.id} titre={x.libelle}>
            <Ligne libelle={t("Salle")} valeur={x.libelle} />
            <Ligne libelle={t("Présents")}
              valeur={x.presents.length > 0
@@ -234,19 +234,16 @@ export function Appel({ instance, identite, crise, surRetour }: {
            {x.presents[0]
              ? <Ligne libelle={t("Depuis")} valeur={f.heure(x.presents[0].rejoint_at)} />
              : null}
            <Bouton titre={t("Rejoindre")} occupe={etat === "connexion"}
            <Bouton titre={t("Rejoindre")} ampleur="grande" occupe={etat === "connexion"}
              sur={() => { void rejoindre(x); }}
              indication={t("Audio seul. Continue écran verrouillé.")} />
          </Encart>
        ))
      )}

      <Bouton titre={t("Retour")} genre="second" sur={surRetour} />
    </ScrollView>
    </Page>
  );
}

const s = StyleSheet.create({
  page: { padding: MESURES.marge, gap: MESURES.marge, paddingBottom: 48, flexGrow: 1 },
  bandeau: { borderRadius: MESURES.rayon, padding: MESURES.espace },
});
Loading