/* ============================================================
   vinted974 — feuille de style de marque
   Jetons extraits de la maquette v1 validee le 11/09/2026.
   Palette CommandIA : marine #24335a + accent orange #ee7623.
   Aucun CDN : tout est ici, servi par nginx depuis /static/.
   ============================================================ */

:root {
  /* Marque */
  --marine-900:#141c33; --marine-700:#1d2848; --marine:#24335a;
  --marine-500:#3a4b7a; --marine-300:#8493bd;
  --accent:#ee7623; --accent-strong:#d4640f; --accent-soft:#fdf0e4;

  /* Neutres — biaises vers le marine, jamais de gris pur */
  --ground:#f3f5f9; --card:#ffffff; --card-2:#f8f9fc;
  --border:#dde2ed; --border-strong:#c6cddd;
  --ink:#1a2133; --ink-2:#4a5468; --muted:#677086;

  /* Semantique d'etat — distincte de l'accent */
  --ok:#1a7d4f;   --ok-soft:#e4f4ec;
  --warn:#9a6308; --warn-soft:#fbf1de;
  --crit:#c4383e; --crit-soft:#fbe9e9;
  --idle:#656c7e; --idle-soft:#eceef4;

  --shadow: 0 1px 2px rgba(20,28,51,.06), 0 8px 24px -12px rgba(20,28,51,.18);
  --shadow-lift: 0 2px 4px rgba(20,28,51,.08), 0 20px 44px -20px rgba(20,28,51,.30);

  --ui: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
  --data: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;

  --r-sm:6px; --r:10px; --r-lg:16px;
  --fs-xs:.6875rem; --fs-sm:.8125rem; --fs-base:.9375rem;
  --fs-lg:1.0625rem; --fs-xl:1.375rem; --fs-2xl:1.75rem;
}

/* ── Contrastes ───────────────────────────────────────────
   Valeurs mesurees (WCAG 2.1, seuil AA = 4,5:1 pour le texte courant) :
     blanc sur l'accent #ee7623 ......... 2,90:1  ECHEC, meme au seuil gros texte
     encre #141c33 sur l'accent ......... 5,83:1  OK   <- retenu
   L'orange de marque n'est PAS modifie : l'assombrir assez pour sauver le blanc
   en ferait une autre couleur. C'est le texte qui passe en marine fonce.
   Pastilles et texte attenue : assombris a teinte et saturation constantes,
   de 3,33-4,44:1 a 4,50-4,55:1. Ecart imperceptible a l'oeil.
   ───────────────────────────────────────────────────────── */

*, *::before, *::after { box-sizing: border-box; }

body {
  margin: 0;
  background: var(--ground);
  color: var(--ink);
  font-family: var(--ui);
  font-size: var(--fs-base);
  line-height: 1.55;
  -webkit-font-smoothing: antialiased;
  /* ── L11, correctif du 29/09 ──────────────────────────────────────
     Demande du client, mot pour mot : « Footer doit etre au total bottom
     de l'ecran ».

     **Le defaut ne se voit que sur une page COURTE**, et il etait entier.
     Le corps n'avait aucune hauteur minimale : sur un ecran vide — « Tu
     n'as pas encore de commande », une 404, une page legale breve — le
     document etait plus court que la fenetre. Le pied de page s'arretait
     sous la derniere ligne de contenu, au milieu de l'ecran, la barre
     basse juste en dessous, et le fond `--ground` sur tout le reste.
     `position: sticky` n'y pouvait rien : une barre collante ne descend
     jamais plus bas que son BLOC CONTENEUR.

     Le corps devient donc une colonne d'au moins une hauteur d'ecran, et
     c'est `.sitefoot { margin-top: auto }` qui absorbe l'espace libre.
     **L'ordre du document ne change pas** : contenu, pied de page, barre
     basse — la barre reste la derniere chose de la page (demande du
     17/09). On pousse une boite, on ne deplace pas un element.

     `100dvh` et pas `100vh` : sur mobile, `100vh` est le GRAND viewport,
     celui qu'on a barre d'adresse retractee. Une page courte y gagnerait
     une barre de defilement et le pied de page repasserait SOUS la ligne
     de flottaison — c'est-a-dire exactement le defaut qu'on repare. Un
     navigateur qui ignore `dvh` retombe sur le comportement d'avant : la
     page reste entierement utilisable, elle n'est pas poussee.

     **Ce que `display: flex` ne change PAS, et qui a ete verifie** : les
     enfants du corps qui sortent du flux n'en deviennent pas des elements
     flexibles — le sprite et le lien d'evitement sont `position:
     absolute`, les deux bandeaux sont `display: none` par `[hidden]`, et
     une `<dialog>` fermee aussi. Les barres collantes (`.bottomnav`,
     `.consent`) restent collantes : `position: sticky` s'applique a un
     element flexible comme a un bloc. */
  min-height: 100dvh;
  display: flex; flex-direction: column;
}

h1, h2, h3, h4 { margin: 0; line-height: 1.2; text-wrap: balance; letter-spacing: -.015em; }
p { margin: 0; }

a { color: var(--marine); }

/* ── `hidden` doit VRAIMENT cacher ────────────────────────────────────
   **Le defaut du 17/09, et il etait peint sur toutes les pages.** La feuille
   de l'agent utilisateur pose `[hidden] { display: none }`, mais l'origine
   est comparee AVANT la specificite : n'importe quelle declaration d'AUTEUR
   de `display` l'emporte. `.bandeau-maj { display: flex }` (ecrite deux
   lignes sous `.bandeau-reseau`, qui ne pose pas de `display` et fonctionne)
   affichait donc « Une nouvelle version est disponible » et son bouton
   « Recharger » en tete de chaque page, pour chaque visiteur, des la premiere
   visite — alors qu'aucune nouvelle version n'existait. Un mensonge affiche
   (CONVENTIONS §11), et la premiere chose qu'on voyait du lot.

   `!important` est ici la reprise JUSTE, pas un raccourci : c'est la seule
   forme qui redonne a l'attribut `hidden` la priorite sur une declaration
   d'auteur sans avoir a ecrire `:not([hidden])` dans chaque composant — donc
   sans que le prochain composant qui pose un `display` rouvre le trou.
   `tests/test_pwa.py` mesure le `display` RESOLU des deux bandeaux ; il ne
   lit plus l'attribut. */
[hidden] { display: none !important; }

/* ── L'anneau de focus, BICOLORE ──────────────────────────────────────
   Navigation clavier : il doit rester visible SUR TOUS LES FONDS du site.

   **Deux corrections successives, et la premiere etait incomplete.**

   1. La regle d'origine etait un `:where()`, de specificite NULLE :
      `.field input:focus { outline: none }` l'ecrasait, et il ne restait
      qu'un halo a 1,12:1. Le `:where()` a ete retire — d'ou les selecteurs
      en toutes lettres ci-dessous, et surtout PAS de `:where()` a nouveau.

   2. Le contour unique `--marine-900` qui l'a remplace n'a ete mesure que
      sur fond CLAIR. Sur les fonds marine — la barre superieure et les onze
      ecrans d'authentification, qui sont entierement marine — il tombe a
      1,00-1,36:1 : le probleme avait ete deplace, pas resolu.

   La reponse est un anneau a DEUX COUCHES : l'une des deux contraste
   toujours. Mesures faites sur les six fonds reellement utilises
   (`tests/test_a11y_contrastes.py` les refait a chaque execution) :

     fond                          couche sombre   couche claire
     carte blanche  #ffffff            16,89:1          1,00:1
     fond de page   #f3f5f9            15,47:1          1,09:1
     barre marine   #24335a             1,36:1         12,38:1
     marine fonce   #141c33             1,00:1         16,89:1
     bouton accent  #ee7623             5,83:1          2,90:1
     rouge crit     #c4383e             3,21:1          5,27:1

   Sur chaque fond, AU MOINS une des deux couches depasse 3:1 (seuil des
   elements d'interface non textuels, WCAG 1.4.11).

   `outline-offset: 2px` place la couche sombre a 2-4 px du bord ; la couche
   claire (`box-shadow`, 4 px d'etalement) occupe 0-4 px et reste donc
   visible sur les 2 premiers pixels. Les deux se voient ensemble. */
a:focus-visible, button:focus-visible, input:focus-visible,
select:focus-visible, textarea:focus-visible, summary:focus-visible,
[tabindex]:focus-visible {
  outline: 2px solid var(--marine-900);
  outline-offset: 2px;
  box-shadow: 0 0 0 4px var(--card);
  border-radius: var(--r-sm);
}
/* ── L31 — le saut vers une ancre est DOUX, sauf si on n'en veut pas ──────
   Quatre redirections apres POST ramenent la vue sur `#photos` : l'envoi, la
   suppression et le choix de couverture (A7, A8, A9), et depuis L31 la
   creation (A5). Sur telephone, ou le bloc des photos est tres bas, un saut
   instantane fait perdre le fil : on ne sait pas si la page a change ou si
   elle s'est rechargee.

   `scroll-behavior` est pose sur `html` parce que c'est l'element de
   defilement de la page ; sur `body` il ne ferait rien. Et il est RETIRE sous
   `prefers-reduced-motion`, dans le bloc qui existait deja pour les
   transitions et les animations — un defilement anime est exactement ce que
   cette preference demande de ne pas faire, et `transition: none` ne le
   couvre pas. */
html { scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { transition: none !important; animation: none !important; }
  html { scroll-behavior: auto; }
}

/* ── Mise en page ─────────────────────────────────────────── */
.stack { display: flex; flex-direction: column; gap: 1rem; }
.row-between { display: flex; align-items: center; justify-content: space-between; gap: .6rem; }

/* ── Marque ───────────────────────────────────────────────── */
.brand { display: flex; align-items: center; gap: .55rem; text-decoration: none; }
.brand .mark {
  width: 34px; height: 34px; border-radius: 9px; flex: none;
  background: var(--accent); display: grid; place-items: center; color: #fff;
}
.brand .wordmark { font-size: var(--fs-lg); font-weight: 700; letter-spacing: -.02em; color: #fff; }
.brand .wordmark span { color: var(--accent); }
.brand--ink .wordmark { color: var(--marine); }

/* ── Boutons ──────────────────────────────────────────────── */
.btn {
  border: none; border-radius: var(--r-sm);
  padding: .65rem 1rem; min-height: 44px;   /* zone tactile mobile */
  font-family: var(--ui); font-size: var(--fs-base); font-weight: 650;
  cursor: pointer; text-decoration: none;
  display: inline-flex; align-items: center; justify-content: center; gap: .45rem;
  transition: background .15s ease, color .15s ease;
}
/* **L9 — quatre intentions, et `.btn` n'est JAMAIS seule.**
   Un `.btn` sans intention ne pose ni fond ni encre : il rend le gris
   `ButtonFace` de l'agent utilisateur sur un <button>, et du TEXTE NU sur un
   <a>. Il y en avait quinze, dont l'entree du parcours de suppression de
   compte. `tests/test_harmonisation_formes.py` refuse desormais la forme.

   Nommage BEM (`bloc--modificateur`), qui est la convention majoritaire du
   depot (L3, L5, L6, L7) : les formes plates de L1 et L2 convergent vers elle.
   `.btn--bloc` est un modificateur de LARGEUR, pas une intention : il
   s'ajoute toujours a l'une des trois. */
/* Un seul bouton accent par ecran : il designe l'action attendue. */
.btn--accent { background: var(--accent); color: var(--marine-900); }
.btn--accent:hover { background: var(--accent-strong); }
.btn--marine { background: var(--marine); color: #fff; }
.btn--marine:hover { background: var(--marine-700); }
.btn--ghost { background: transparent; color: var(--marine); border: 1px solid var(--border-strong); }
.btn--ghost:hover { background: var(--card-2); }
.btn--bloc { width: 100%; }
.btn[disabled], .btn[aria-disabled="true"] { opacity: .55; cursor: not-allowed; }

/* ── Formulaires ──────────────────────────────────────────── */
.field { display: flex; flex-direction: column; gap: .3rem; }
.field label {
  font-size: var(--fs-xs); font-weight: 650; color: var(--ink-2);
  letter-spacing: .03em; text-transform: uppercase;
}
/* `.ts-control` est DANS cette liste et non dans une regle a lui : le
   selecteur enrichi de L12 est un champ de formulaire, il en a donc
   exactement la forme — meme bord, meme rayon, meme fond, meme hauteur de
   pouce. Une regle jumelle aurait ete une DOUZIEME recette de boite, et
   `tools/mesurer-formes.py` la compte ; celle-ci n'en cree aucune.

   **`.carte-champ` entre ici pour la MEME raison (L16/T7a).** C'est le
   conteneur ou `stripe.js` monte son iframe de saisie de carte : aux yeux de
   l'acheteur c'est un champ de formulaire, il doit donc en avoir exactement la
   forme. L'iframe, elle, est peinte par Stripe — nous ne stylons que la boite
   qui l'entoure, et c'est tout ce que la meme politique d'origine nous permet
   de styler. Aucune nouvelle recette de boite, encore une fois. */
.field input, .field select, .field textarea, .cat-chercher .ts-control,
.field .carte-champ {
  width: 100%; min-height: 44px;
  border: 1px solid var(--border-strong); border-radius: var(--r-sm);
  background: var(--card-2); color: var(--ink);
  padding: .6rem .7rem; font-family: var(--ui); font-size: var(--fs-base);
}
.field input:focus, .field select:focus, .field textarea:focus {
  border-color: var(--accent); background: var(--card);
  box-shadow: 0 0 0 3px var(--accent-soft);
}
/* ══════════════════════════════════════════════════════════════════════
   **Le piege, et POURQUOI cette regle est ICI et pas plus haut.**
   ══════════════════════════════════════════════════════════════════════
   Troisieme tour de cette correction, et les deux premiers etaient INERTES.
   `.field input:focus` (juste au-dessus) et `.field input:focus-visible` ont
   la MEME specificite — (0,2,1) toutes les deux, une classe et deux
   pseudo-classes de part et d'autre. A specificite egale, **c'est la regle la
   plus TARDIVE qui gagne**. Les deux versions precedentes placaient celle-ci
   AVANT celle qu'elle devait battre : elle etait donc systematiquement
   ecrasee, et les champs de formulaire — c'est-a-dire precisement ce qu'on
   utilise au clavier — n'avaient jamais leur couche claire.

   Le commentaire d'origine raisonnait sur la regle GENERALE
   (`input:focus-visible`, specificite 0,1,1), qui est bien ecrasee par
   `.field input:focus` ; mais le correctif, lui, ne l'etait pas par la
   specificite — il l'etait par l'ORDRE. Et le test ne faisait qu'un `in` sur
   le texte de la feuille : il ne pouvait pas distinguer « combine » de
   « ecrase ». `tests/test_a11y_contrastes.py` RESOUT desormais la cascade.

   On ne remplace pas, on COMBINE : la couche claire d'abord (4 px, peinte
   par-dessus le bord), le halo accent ensuite (7 px). */
.field input:focus-visible,
.field select:focus-visible,
.field textarea:focus-visible {
  box-shadow: 0 0 0 4px var(--card), 0 0 0 7px var(--accent-soft);
}
/* Contour retire a la souris seulement : au clavier il doit rester. */
.field input:focus:not(:focus-visible),
.field select:focus:not(:focus-visible),
.field textarea:focus:not(:focus-visible) { outline: none; }
/* **`.aide` — le texte d'aide, et il n'y en a qu'un.** `.hint` n'etait
   definie que comme DESCENDANT de `.field` : 8 occurrences recevaient la
   regle, 16 — toutes dans le back-office — n'en recevaient aucune et
   s'affichaient au poids du corps de page. Une classe a moitie atteignable ne
   se voit ni d'un test de contraste, ni d'un test de structure. */
.aide, .field .aide { font-size: var(--fs-xs); color: var(--muted); }
.check { display: flex; align-items: center; gap: .4rem; font-size: var(--fs-sm); color: var(--ink-2); }

/* ── LA surface, et il n'y en a qu'une ────────────────────────────────
   **L9 — 23 classes construisaient la meme boite blanche, en 10 recettes.**
   Onze d'entre elles posaient exactement `--card` + `1px --border` + `--r`,
   a trois moments differents de l'histoire du projet. Rien ne redefinit plus
   ces trois proprietes : les classes specialisees gardent ce qui leur est
   propre (une grille, un rembourrage, un debordement) et rien d'autre.

   L'ombre n'est plus portee par la surface de contenu : elle est reservee a
   ce qui FLOTTE (`--posee`). Une ombre sur tout ce qui est blanc ne distingue
   plus rien. */
.surface {
  background: var(--card); color: var(--ink);   /* 15,86:1 — jamais herite */
  border: 1px solid var(--border); border-radius: var(--r);
}
.surface--pad   { padding: 1.1rem; }
.surface--liste { overflow: hidden; }            /* contient des rangees a fond perdu */
.surface--posee { box-shadow: var(--shadow); }   /* ce qui flotte au-dessus du reste */
.surface--douce { background: var(--card-2); }   /* surface secondaire, en retrait */
/* L'en-tete d'une surface-liste. `.listcard .head` et `.resultats-head`
   posaient EXACTEMENT les memes proprietes, a deux endroits de la feuille. */
.surface__tete {
  display: flex; align-items: center; justify-content: space-between;
  padding: .7rem .9rem; border-bottom: 1px solid var(--border);
  gap: .6rem; flex-wrap: wrap;
}
.surface__tete h2 { font-size: var(--fs-base); }

/* ── Alertes ──────────────────────────────────────────────── */

.alert {
  display: flex; gap: .5rem; align-items: flex-start;
  border-left: 3px solid var(--muted); border-radius: 0 var(--r-sm) var(--r-sm) 0;
  padding: .55rem .7rem; font-size: var(--fs-sm);
}
.alert-error { background: var(--crit-soft); border-left-color: var(--crit); }
.alert-ok { background: var(--ok-soft); border-left-color: var(--ok); }
.alert-info { background: var(--idle-soft); border-left-color: var(--marine); }
.alert-warn { background: var(--warn-soft); border-left-color: var(--warn); }

/* ── Pastilles d'etat ─────────────────────────────────────── */
/* La couleur DOUBLE le texte, elle ne le remplace jamais : lisible en
   niveaux de gris et pour les daltoniens. */
.pill {
  font-size: var(--fs-xs); font-weight: 650;
  padding: .15rem .5rem; border-radius: 99px;
  display: inline-flex; align-items: center; gap: .3rem; white-space: nowrap;
}
.pill::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
.pill-on { color: var(--ok); background: var(--ok-soft); }
.pill-hold { color: var(--warn); background: var(--warn-soft); }
.pill-sold { color: var(--marine); background: var(--idle-soft); }
.pill-draft { color: var(--idle); background: var(--idle-soft); }
.pill-crit { color: var(--crit); background: var(--crit-soft); }

/* ── Chiffres ─────────────────────────────────────────────── */
/* Chasse fixe : les colonnes restent alignees quand les valeurs changent. */
.num, .price {
  font-family: var(--data); font-variant-numeric: tabular-nums; letter-spacing: -.02em;
}
.price { font-size: var(--fs-lg); font-weight: 600; }

/* Mention obligatoire tant que le paiement n'est pas reel (contenu honnete). */
.simule {
  font-size: var(--fs-xs); font-weight: 650; color: var(--warn);
  background: var(--warn-soft); border-radius: var(--r-sm);
  border: 1px dashed color-mix(in srgb, var(--warn) 45%, transparent);
  padding: .1rem .45rem;
}

/* ══════════════════════════════════════════════════════════════════════
   Barre superieure — **L'UNIQUE en-tete du site depuis L13**
   ══════════════════════════════════════════════════════════════════════
   Demande du client du 30/09 : « Je n'aime pas la double barre de header.
   Corrige pour une seule (la deuxieme), le bouton installer toujours centre
   juste descendu dans la barre header du dessous. »

   `.sitetop` a DISPARU. `base.html` rend ce `<header>`-ci, une seule fois, et
   les onze coquilles ne l'ecrivent plus : elles remplissent des blocs. C'est
   ce qui rend « l'icone ⓘ est a gauche de la deconnexion » vrai PAR
   CONSTRUCTION — aucune coquille ne peut l'oublier, puisqu'aucune ne compose
   l'ordre des trois groupes.

   **Une GRILLE de trois pistes, et deux d'entre elles sont EGALES.**
   `minmax(0, 1fr) auto minmax(0, 1fr)` : le bouton d'installation occupe la
   piste du milieu, donc il est centre SUR LA PAGE — et non entre ses deux
   voisins, ce qu'une rangee souple avec deux cales aurait donne (decale de la
   moitie de la difference entre les deux cotes). C'est l'argument que L12
   avait ecrit pour `.sitetop`, et il ne change pas en changeant d'element.

   `minmax(0, …)` et non `1fr` seul : une piste `1fr` ne descend pas sous la
   taille minimale de son contenu, et la grille DEBORDE alors la fenetre. Avec
   `minmax(0, …)`, la piste peut se reduire et c'est le contenu qui doit tenir
   — d'ou les paliers mesures par `tools/mesurer-entete.py`.

   **Aucun `flex-wrap` ici, et c'est la propriete centrale du lot** : une
   grille a trois colonnes explicites ne produit JAMAIS une deuxieme rangee.
   « Jamais deux rangees empilees, a aucune largeur » n'est donc pas une regle
   qu'on tient a la main, c'est une consequence de la structure. La reprise
   mobile qui posait `flex-wrap: wrap` sur `.topbar` a ete supprimee : c'est
   elle qui empilait la barre en deux ou trois rangees sous 480 px.

   **Il n'y a plus de `.spacer`.** Une cale n'a pas de sens dans une grille
   dont les pistes sont nommees ; les huit coquilles qui en posaient un l'ont
   perdu, et la classe a ete supprimee de la feuille avec son dernier
   appelant. */
.topbar {
  display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
  align-items: center; gap: .9rem;
  padding: .6rem 1rem; background: var(--marine); color: #fff;
}
/* Les trois pistes sont NOMMEES et non deduites de l'ordre des elements. Les
   coquilles qui annulent `{% block bouton_installer %}` — back-office,
   comptoir relais, pages d'erreur, et `/installer` elle-meme — laissent la
   piste du milieu VIDE sans deplacer les deux autres d'un pixel. */
.topbar__gauche {
  grid-column: 1; justify-self: start; min-width: 0;
  display: flex; align-items: center; gap: .9rem;
}
.topbar__installer { grid-column: 2; justify-self: center; }
.topbar__droite {
  grid-column: 3; justify-self: end; min-width: 0;
  display: flex; align-items: center; gap: .6rem;
}
.avatar {
  width: 32px; height: 32px; border-radius: 50%; flex: none;
  background: var(--accent); color: var(--marine-900);
  display: grid; place-items: center; font-size: var(--fs-sm); font-weight: 700;
}

/* ── Onglets ──────────────────────────────────────────────── */
.tabs {
  display: flex; gap: .2rem; padding-inline: 1rem;
  background: var(--card); border-bottom: 1px solid var(--border); overflow-x: auto;
}
.tab {
  padding: .7rem .8rem; font-size: var(--fs-sm); font-weight: 600;
  color: var(--muted); text-decoration: none; white-space: nowrap;
  border-bottom: 2px solid transparent;
}
.tab.on { color: var(--marine); border-bottom-color: var(--accent); }

/* ── Ecran d'authentification ─────────────────────────────── */
/* **`100dvh` et pas `100vh`, et c'est le MEME defaut que celui de `.modale`.**
   Mesure du 01/10 : sur un iPhone 13 (390x844) barres visibles, `100vh` vaut
   844 px pour 659 px reellement visibles. Cette boite est donc 185 px plus haute
   que la fenetre, sur un ecran de CONNEXION qui tiendrait sans defilement — et
   c'est exactement le defaut que l'en-tete de cette feuille decrit pour
   `body { min-height: 100dvh }` : « une page courte y gagnerait une barre de
   defilement ». La lecon avait ete ecrite et pas appliquee ici.

   Ce n'est pas un remplacement automatique : les quatre declarations de hauteur
   de viewport de la feuille ont ete reprises une par une, et la quatrieme
   (`body`) etait deja juste. */
.auth-screen {
  min-height: 100dvh; padding: 2rem 1.2rem;
  background: linear-gradient(178deg, var(--marine-700) 0%, var(--marine) 62%, var(--marine-500) 100%);
  display: flex; flex-direction: column; gap: 1.1rem; align-items: center;
}
.auth-screen .inner { width: 100%; max-width: 24rem; display: flex; flex-direction: column; gap: 1.1rem; }
.auth-screen .pitch { color: #c9d3ec; font-size: var(--fs-sm); }
/* `.auth-card` est CONSERVEE telle quelle : son rayon `--r-lg` et son ombre
   profonde sont propres a l'ecran d'authentification et valides dans la
   maquette. Elle ne diverge de rien — la toucher serait du travail sans
   mesure de sortie. */
.auth-card {
  background: var(--card); color: var(--ink); border-radius: var(--r-lg);
  padding: 1.3rem 1.15rem;
  display: flex; flex-direction: column; gap: .85rem;
  box-shadow: 0 18px 40px -18px rgba(0,0,0,.5);
}
.auth-screen .foot { text-align: center; font-size: var(--fs-sm); color: #c9d3ec; }
.auth-screen .foot a { color: #fff; font-weight: 600; text-decoration: none; border-bottom: 1px solid var(--accent); }

.sep {
  display: flex; align-items: center; gap: .6rem;
  color: var(--muted); font-size: var(--fs-xs);
  text-transform: uppercase; letter-spacing: .1em;
}
.sep::before, .sep::after { content: ""; height: 1px; background: var(--border); flex: 1; }

/* ── Utilitaires ──────────────────────────────────────────── */
.muted { color: var(--muted); }
.sm { font-size: var(--fs-sm); }
.xs { font-size: var(--fs-xs); }
/* `.ts-hidden-accessible` est pose par la bibliotheque tierce sur le
   `<select>` d'origine, dont elle prend la place a l'ecran. Son nom est
   impose ; sa RECETTE est celle du site, pas la sienne — on n'a pas livre
   sa feuille. */
.visually-hidden, .ts-hidden-accessible {
  position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap;
}

/* ============================================================
   Lot L1 — composants d'authentification et de tableau de bord.
   Ajoutes ici et nulle part ailleurs (arbitrage A3 : une seule
   feuille de style). Aucun style inline n'est possible : la CSP
   impose style-src 'self', donc tout attribut style= de la
   maquette devient une classe.
   ============================================================ */

/* ── Icones du sprite ─────────────────────────────────────── */
/* Le sprite est inclus par {% include %}, jamais via |safe. */
.i { width: 16px; height: 16px; flex: none; }
.i-sm { width: 14px; height: 14px; flex: none; }
.i-lg { width: 18px; height: 18px; flex: none; }
.brand .mark svg { width: 19px; height: 19px; color: #fff; }
.alert svg { width: 15px; height: 15px; flex: none; margin-top: 2px; }
.alert-error svg { color: var(--crit); }
.alert-ok svg { color: var(--ok); }
.alert-warn svg { color: var(--warn); }
.alert-info svg { color: var(--marine); }

/* ── Champ avec icone ─────────────────────────────────────── */
/* L'icone est posee DANS le cadre du champ, sans position absolue
   sur un input : le padding gauche suffit et reste accessible. */
.field .with-icon { position: relative; display: flex; align-items: center; }
.field .with-icon > svg {
  position: absolute; left: .65rem; width: 15px; height: 15px;
  color: var(--muted); pointer-events: none;
}
.field .with-icon input { padding-left: 2.1rem; }

/* ── Ecran d'authentification (complements) ───────────────── */
.auth-card h1 { font-size: var(--fs-lg); letter-spacing: -.015em; }
.auth-card .lede { font-size: var(--fs-sm); color: var(--ink-2); }
.auth-screen .brand { justify-content: flex-start; }
.link { color: var(--marine); font-size: var(--fs-sm); font-weight: 600; text-decoration: none; }
.link:hover { text-decoration: underline; }
.form { display: flex; flex-direction: column; gap: .85rem; }
.actions { display: flex; gap: .5rem; flex-wrap: wrap; }

/* ── Barre superieure (complements) ───────────────────────── */
.topbar .brand .wordmark { font-size: var(--fs-base); }
/* **44x44, et c'est le lot des icones d'en-tete qui l'a enfin paye.** Le
   rapport L13 §8.1 avait REFUSE ce passage : il coutait 72 px de largeur
   minimale a une barre qui n'en avait pas. L'argument ne tient plus — « Deposer
   une annonce » et « Mon compte » ont quitte la barre en toutes lettres, soit
   212,4 px de texte mesures, et le budget libere paie les 60 px que coutent ici
   les cinq controles ronds (5 x 12 px). Mesure complete dans
   `tools/mesurer-entete.py --avant` et dans `tests/test_l15_entete_icones.py`.

   Ce sont les SEULS endroits du produit ou une cible tactile servait a 32 px, et
   c'est un telephone qui les touche. `tests/test_parcours_rendu.py` n'a donc
   plus qu'UNE tolerance sous 44 px au lieu de deux. */
.icon-btn {
  position: relative; width: 44px; height: 44px; border-radius: 8px;
  display: grid; place-items: center;
  background: rgba(255,255,255,.10); color: #fff;
  border: none; text-decoration: none; cursor: pointer;
}
.icon-btn svg { width: 16px; height: 16px; }
.topbar-form { display: flex; margin: 0; }
.vue-switch { display: flex; gap: .15rem; background: rgba(255,255,255,.10);
  border-radius: 99px; padding: .15rem; }
/* **Une seule regle pour les deux ecritures du controle** (L9). `.vue-switch a`
   et `.vue-switch button` etaient deux regles pour le meme bouton, et elles
   avaient diverge : la variante en <a> posait `color: #fff` sur l'accent
   (2,90:1, defaut K4) la ou la variante en <button> avait ete corrigee. */
.vue-switch__b {
  appearance: none; border: 0; background: transparent; cursor: pointer;
  font: inherit; font-size: var(--fs-xs); font-weight: 650; color: #c9d3ec;
  padding: .25rem .6rem; border-radius: 99px; text-decoration: none;
  min-height: 32px; display: inline-flex; align-items: center;
}
.vue-switch__b--on { background: var(--accent); color: var(--marine-900); } /* 5,83:1 — K4 */

/* ── Panneau du tableau de bord ───────────────────────────── */
.pane { padding: 1.1rem 1rem 1.4rem; display: flex; flex-direction: column; gap: 1.1rem; }
.pane-head { display: flex; align-items: center; justify-content: space-between;
  gap: .8rem; flex-wrap: wrap; }
.pane-head h1 { font-size: var(--fs-lg); letter-spacing: -.015em; }
.pane-head .sub { font-size: var(--fs-sm); color: var(--muted); }

.kpis { display: grid; gap: .7rem; grid-template-columns: repeat(2, 1fr); }
@media (min-width: 720px) { .kpis { grid-template-columns: repeat(4, 1fr); } }
.kpi {   /* `.surface` + la tuile */
  padding: .75rem .85rem; display: flex; flex-direction: column; gap: .15rem;
  position: relative; overflow: hidden;
}
.kpi::before { content: ""; position: absolute; inset: 0 auto 0 0; width: 3px;
  background: var(--marine-300); }
/* **La forme descendante `.kpi .label/.val/.delta` a ete SUPPRIMEE** (L9) :
   c'etait la seconde convention de nommage du MEME composant, face au BEM
   `.kpi__label/__val/__delta` de L3. Son dernier appelant etait
   `dashboard/index.html`, gabarit orphelin qu'aucune route ne rendait. */


/* Etat vide : la seule chose honnete a afficher au lot L1. */
.empty { display: flex; flex-direction: column; align-items: center; gap: .5rem;
  padding: 2.2rem 1rem; text-align: center; color: var(--muted); }
.empty svg { width: 30px; height: 30px; color: var(--border-strong); }
.empty h2 { font-size: var(--fs-base); color: var(--ink-2); }
.empty p { font-size: var(--fs-sm); max-width: 28rem; }

/* ── Pages simples (erreurs, confirmations, accueil) ──────── */
/* `width: 100%` n'est PAS redondant avec `max-width`, et c'est le corps de
   page en colonne flexible (voir `body`, correctif du 29/09) qui le rend
   necessaire. Un element flexible dont la taille transversale est `auto` est
   ETIRE a la largeur du conteneur — sauf si une de ses marges transversales
   vaut `auto`, ce qui est exactement le cas ici. Sans `width: 100%`, la
   colonne ne vaudrait plus 34 rem mais sa largeur de CONTENU, et elle se
   resserrerait partout ou ce contenu tient en moins de 34 rem — l'ecran de
   saisie du code a six chiffres du comptoir relais, par exemple. Le decalage
   n'aurait ete visible que dans un navigateur, et il n'y en a aucun ici.
   `.legal`, l'autre coquille centree par une marge automatique, porte deja
   la declaration ; `.page-narrow` ne l'avait pas. */
.page-narrow { max-width: 34rem; width: 100%; margin-inline: auto;
  padding: 2.5rem 1rem 3rem;
  display: flex; flex-direction: column; gap: 1rem; }
.page-narrow h1 { font-size: var(--fs-2xl); }
.page-narrow .lede { color: var(--ink-2); }
.code { font-family: var(--data); font-size: var(--fs-2xl); color: var(--marine-500); /* 7,80:1 */
  font-weight: 700; letter-spacing: -.03em; }
.ref { font-family: var(--data); font-size: var(--fs-xs); color: var(--muted); }

/* ── Pied de page ─────────────────────────────────────────── */
/* `margin-top: auto` — « Footer doit etre au total bottom de l'ecran ». Dans
   la colonne flexible du corps (voir `body`), une marge automatique absorbe
   TOUT l'espace libre : sur une page courte, le pied de page descend contre
   le bas de la fenetre. Les deux declarations ne valent que POSEES ENSEMBLE,
   et `tests/test_l11_navigation.py` retire chacune des quatre a son tour
   pour le prouver. */
.sitefoot { margin-top: auto;
  padding: 1.4rem 1rem 2rem; text-align: center;
  font-size: var(--fs-xs); color: var(--muted); }
.sitefoot a { color: var(--muted); }

/* `.hero` a ete SUPPRIMEE (L9) : son seul appelant, `pages_accueil.html`,
   etait un gabarit orphelin. `.accroche` est la banniere vivante. */

/* ============================================================
   Lot L2 — annonces & photos
   Composants portes depuis l'ecran 2 de la maquette, selecteurs
   composes convertis en classes plates (`.pill.on` -> `.pill-on`).
   Cette feuille est UNIQUE : on la complete, on ne la duplique pas.
   ============================================================ */

/* ── Liste d'annonces (ecran 2) ───────────────────────────── */
.item {
  display: grid; gap: .75rem; padding: .75rem .9rem;
  border-bottom: 1px solid var(--border);
  grid-template-columns: 52px 1fr auto; align-items: center;
}
.item:last-child { border-bottom: none; }
.item:hover { background: var(--card-2); }
.item .title {
  font-size: var(--fs-base); font-weight: 600; letter-spacing: -.01em;
  display: flex; align-items: center; gap: .45rem; flex-wrap: wrap;
}
.item .title a { color: var(--ink); text-decoration: none; }
.item .title a:hover { text-decoration: underline; }
.item .sub {
  font-size: var(--fs-xs); color: var(--muted); display: flex; gap: .7rem;
  flex-wrap: wrap; align-items: center; font-family: var(--data);
}
.item .sub .g { display: inline-flex; align-items: center; gap: .25rem; }
.item .sub svg { width: 11px; height: 11px; }
.item .right { display: flex; flex-direction: column; align-items: flex-end; gap: .3rem; }
.item-actions { display: flex; gap: .3rem; flex-wrap: wrap; justify-content: flex-end; }

/* **Une vignette, deux tailles, et les dimensions sont TOUJOURS posees.** */
.vignette {
  width: 52px; height: 52px; border-radius: var(--r-sm);
  display: grid; place-items: center;
  background: var(--marine-700); color: #fff; flex: none; overflow: hidden;
}
.vignette svg { width: 22px; height: 22px; opacity: .9; }
.vignette img { width: 100%; height: 100%; object-fit: cover; display: block; }

@media (max-width: 719px) {
  .item { grid-template-columns: 52px 1fr; }
  .item .right { grid-column: 1 / -1; align-items: flex-start; flex-direction: row;
    justify-content: space-between; width: 100%; }
}

/* ── Barre de filtres ─────────────────────────────────────── */
.filters { display: flex; gap: .3rem; flex-wrap: wrap; }
.f {
  font-size: var(--fs-xs); padding: .2rem .55rem; border-radius: 99px;
  border: 1px solid var(--border); color: var(--muted); background: var(--card-2);
  text-decoration: none; display: inline-block;
}
.f-on { background: var(--marine); color: #fff; border-color: var(--marine); }

/* ── Recherche ────────────────────────────────────────────── */
.search {
  display: flex; align-items: center; gap: .5rem;
  background: rgba(255,255,255,.12); border: 1px solid rgba(255,255,255,.18);
  border-radius: 99px; padding: .35rem .8rem; color: #c9d3ec;
  font-size: var(--fs-sm);
  /* **`min-width: 230px` est devenu `min-width: 0`, et c'est une mesure.**
     La piste de gauche de l'en-tete est `minmax(0, 1fr)` : elle vaut la moitie
     de ce qui reste apres le bouton central. Un plancher de 230 px sur la
     recherche rendait le groupe de gauche INCOMPRESSIBLE a 362 px, et la barre
     demandait alors 822 px pour tenir en une rangee — au-dessus du seuil ou le
     bouton d'installation a deja perdu son libelle. La recherche devient donc
     elastique : elle prend ce que la piste lui laisse, jusqu'a 26 rem, et se
     reduit au lieu de pousser ses voisins.
     `tools/mesurer-entete.py` chiffre les deux regimes. */
  flex: 1 1 auto; min-width: 0; max-width: 26rem;
}
.search svg { width: 14px; height: 14px; }
.search input {
  background: transparent; border: 0; color: #fff; font: inherit;
  width: 100%; outline: none;
}
.search input::placeholder { color: #c9d3ec; }

.filtrebar {   /* la boite vient de `.surface` ; ici, la grille seulement */
  display: grid; gap: .6rem; grid-template-columns: repeat(2, minmax(0, 1fr));
  padding: .9rem;
}
@media (min-width: 760px) { .filtrebar { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
.filtrebar .field { min-width: 0; }
.filtrebar-actions { display: flex; gap: .5rem; align-items: flex-end; flex-wrap: wrap; }

/* ── L10 — les cinq criteres de catalogue, dans un repli ──────────────────
   Douze controles sur une barre, c'est trop. Le repli occupe la largeur de la
   grille pour que les champs qu'il contient ne soient pas comprimes a un
   quart de ligne. Il est ouvert par le GABARIT des qu'un de ces filtres est
   actif : un filtre applique et invisible est pire qu'un filtre absent. */
.filtrebar-plus { grid-column: 1 / -1; }
.filtrebar-plus > summary { cursor: pointer; min-height: 44px;
  display: flex; align-items: center; font-size: var(--fs-sm); }

/* ── L10 — l'arbre des categories, deplie sans une ligne de script ────────
   La CSP stricte interdit le script en ligne : le selecteur est une pile de
   `<details>`. Le retrait est porte par `padding-inline-start`, donc il suit
   la direction d'ecriture ; les puces sont retirees parce que la hierarchie
   est deja dite par l'imbrication et par le triangle de `<summary>`. */
.arbre-cat { list-style: none; margin: 0; padding-inline-start: 0; }
.arbre-cat .arbre-cat { padding-inline-start: 1.1rem;
  border-inline-start: 1px solid var(--border); }
.arbre-cat summary { cursor: pointer; min-height: 44px;
  display: flex; align-items: center; font-size: var(--fs-sm); }

/* ── L10 — grilles de cases a cocher (couleurs, matieres) ─────────────────
   Deux colonnes sur mobile, quatre au-dela : 29 couleurs sur une seule
   colonne feraient un formulaire de trois ecrans de haut. */
.palette { display: grid; gap: .2rem .8rem;
  grid-template-columns: repeat(2, minmax(0, 1fr)); }
@media (min-width: 760px) { .palette { grid-template-columns: repeat(4, minmax(0, 1fr)); } }

/* La pastille de couleur. **Elle ne porte AUCUNE teinte ici** : la teinte est
   une DONNEE, relevee au pixel sur les captures du client et stockee dans
   `couleurs.hex`, rendue par un attribut `fill` de SVG dans le gabarit. La
   mettre dans cette feuille ferait entrer 29 valeurs en dur dans la palette du
   produit, donc 29 teintes de plus disponibles pour diverger. Ici, seulement
   la taille et l'alignement. */
.pastille-couleur { width: 16px; height: 16px; flex: none; margin-top: .2rem; }

/* Le libelle d'un choix et sa phrase d'aide s'empilent. **Par le CSS, jamais
   par un `<br>`** : P29 / D20 — un `<br>` dans un gabarit arrive fatalement
   avec un `|replace('\n','<br>')|safe`, et le `|safe` rend injectable toute
   la chaine. Le rendu est le meme, le risque est nul. */
.radio-texte { display: flex; flex-direction: column; gap: .15rem; }
/* Le cercle porte `stroke="currentColor"` a 25 % dans le gabarit : il herite
   donc de l'encre du libelle voisin, ce qui detoure « Blanc » et
   « Transparence » sans introduire de teinte. Une declaration `color:` ici
   aurait ete lue comme une COULEUR DE TEXTE par le controle de contraste — et
   il aurait eu raison de la refuser a 1,3:1. */

/* ── Page d'annonce : galerie ─────────────────────────────── */
.annonce { display: grid; gap: 1.1rem; grid-template-columns: minmax(0, 1fr); }
@media (min-width: 860px) { .annonce { grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); } }

.gallery { display: flex; flex-direction: column; gap: .5rem; }
.gallery-main {   /* `.surface .surface--douce .surface--liste` + le format */
  display: grid; place-items: center; aspect-ratio: 4 / 3;
}
.gallery-main img { width: 100%; height: 100%; object-fit: contain; display: block; }
.gallery-main .empty { padding: 1rem; }
.gallery-strip { display: flex; gap: .4rem; flex-wrap: wrap; }
.gallery-strip img {
  width: 72px; height: 72px; object-fit: cover; border-radius: var(--r-sm);
  border: 1px solid var(--border); display: block;
}

/* D20 : le retour a la ligne de la description est rendu par le CSS.
   Aucun filtre nl2br : il finirait en |safe et rouvrirait le XSS sur la
   donnee la plus longue et la moins controlee du lot. */
.annonce-desc { white-space: pre-line; overflow-wrap: anywhere; }
/* Indispensable : un titre de 90 caracteres sans espace casserait sinon la
   mise en page sur mobile. */
.item .title, .annonce-titre { overflow-wrap: anywhere; }
.annonce-titre { font-size: var(--fs-xl); }
.annonce-meta { display: flex; flex-wrap: wrap; gap: .6rem; font-size: var(--fs-sm);
  color: var(--muted); font-family: var(--data); align-items: center; }
.annonce-meta .g { display: inline-flex; align-items: center; gap: .25rem; }
.annonce-meta svg { width: 12px; height: 12px; }

/* ── Grille des photos en edition ─────────────────────────── */
.photo-grid {
  display: grid; gap: .6rem;
  grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
}
.photo-cell { display: flex; flex-direction: column; }
.photo-cell img { width: 100%; aspect-ratio: 1 / 1; object-fit: cover; display: block; }
.photo-cell .bar {
  display: flex; gap: .3rem; padding: .4rem; align-items: center;
  justify-content: space-between; flex-wrap: wrap;
}
.photo-cell .rank { font-size: var(--fs-xs); color: var(--muted); font-family: var(--data); }
.photo-cell form { margin: 0; }

/* **`.btn-mini` a ete SUPPRIMEE** (L9) : 23,2 px de haut, c'est-a-dire la
   moitie du seuil de cible tactile, sur une cible majoritairement mobile. Ses
   onze appelants portent `.btn .btn--ghost`, qui fait 44 px. `.btn-mini-crit`
   partait avec : une variante pour un seul appelant n'est pas un composant. */

/* ── Pagination keyset ────────────────────────────────────── */
.pager { display: flex; justify-content: center; padding: 1rem 0; }

/* ── Grille de resultats publics ──────────────────────────── */

/* ── `.pubtop` — le MODIFICATEUR des coquilles publiques ──────────────
   **Ce n'etait pas un modificateur, c'etait un second element** : huit
   coquilles ecrivaient `<header class="pubtop">` a cote de celles qui
   ecrivaient `<header class="topbar">`, avec deux jeux de declarations pour la
   meme bande marine. L13 n'a plus qu'un `<header>`, et `pubtop` arrive par le
   bloc `entete_classe`, exactement comme `admin-bar` et `relais-head`.

   Il ne reste donc ICI que ce qui differe REELLEMENT : sur une coquille
   publique, les actions de droite sont des liens de TEXTE (« Se connecter »,
   « Deposer une annonce »), pas des boutons-icones. Fond, encre, disposition
   et gouttieres viennent de `.topbar` et de nulle part ailleurs — sans quoi on
   aurait garde les deux ecritures dont ce lot se debarrasse.

   **Le fond n'est plus repete ici, et l'encre blanche est donc MESUREE
   AILLEURS.** `.pubtop` ne pose plus `background` : la bande marine vient de
   `.topbar`. Une encre claire doit pourtant dire sur quel fond elle est posee —
   c'est la couture qui a rendu `.admin-bar` illisible a 1,17:1, et
   `tests/test_a11y_contrastes.py` ne resout que les ancetres ECRITS dans le
   selecteur. Cette regle-ci est donc declaree dans `SURFACE_REELLE`, la
   famille 3 de l'inventaire, avec sa raison — exactement comme `.icon-btn`,
   `.search` et `.vue-switch__b`, qui vivent dans cette barre sans que leur
   selecteur le dise. Mesure : 12,38:1 sur `--marine`.

   Repeter le fond ici aurait rendu l'exemption inutile, au prix d'une
   declaration qui ne peint rien — c'est-a-dire d'une regle qu'un relecteur
   croira necessaire. */
.pubtop .topbar__droite a {
  color: #fff; text-decoration: none;
  font-size: var(--fs-sm); font-weight: 600;
}

.form-grid { display: grid; gap: .85rem; grid-template-columns: minmax(0, 1fr); }
@media (min-width: 700px) { .form-grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.field textarea { min-height: 9rem; resize: vertical; font-family: inherit; }
.danger-zone { border: 1px solid var(--crit); border-radius: var(--r);
  padding: 1rem; background: var(--crit-soft); display: flex; flex-direction: column; gap: .6rem; }

/* ============================================================
   Lot L3 — tableaux de bord vendeur & acheteur
   Composants portes depuis les ecrans 2 et 3 de la maquette.
   Les attributs `style=` de la maquette (grid-template-columns,
   width, color, font-size) sont devenus des classes : la CSP
   stricte les rejetterait SILENCIEUSEMENT et la mise en page
   partirait de travers sans la moindre erreur visible.
   Les selecteurs composes de la maquette (`.pill.on`, `.kpi.hot`)
   restent ecrits a plat, comme le reste de cette feuille.

   CONTRASTES — rien a corriger ici : les cinq jetons signales par
   le plan (--muted, --ok, --warn, --idle, --crit, et le texte du
   bouton accent) ont deja ete assombris par L1/L2, au-dessus.
   Mesures refaites le 11/09/2026 sur CES valeurs-la (et non sur celles de la
   maquette, deja corrigees) ; seuil AA du texte courant = 4,5:1 :
     --muted  #677086 sur --card   #ffffff ... 4,95:1
     --muted  #677086 sur --card-2 #f8f9fc ... 4,71:1
     --muted  #677086 sur --ground #f3f5f9 ... 4,54:1
     --ok     #1a7d4f sur --ok-soft   ....... 4,51:1
     --warn   #9a6308 sur --warn-soft ....... 4,50:1
     --idle   #656c7e sur --idle-soft ....... 4,53:1
     --crit   #c4383e sur --crit-soft ....... 4,50:1
     --marine #24335a sur --idle-soft ....... 10,67:1  (pastille « Vendue »)
     --marine-900 #141c33 sur --accent ...... 5,83:1   (texte du bouton orange)
     #c9d3ec sur --marine (barre du haut) ... 8,26:1
   Seule exception, et elle est conforme : le LISERE du bouton accent
   (--accent-strong #d4640f sur blanc) mesure 3,73:1. Ce n'est pas du texte
   mais un composant d'interface, dont le seuil WCAG 1.4.11 est 3:1.
   ============================================================ */

/* ── Tuiles de KPI (ecran 2) ──────────────────────────────── */
.kpi__label { font-size: var(--fs-xs); text-transform: uppercase;
  letter-spacing: .04em; color: var(--muted); font-weight: 600; }
.kpi__val { font-family: var(--data); font-size: 1.45rem; font-weight: 600;
  color: var(--ink); font-variant-numeric: tabular-nums; }
.kpi__delta { font-size: var(--fs-xs); color: var(--muted); font-family: var(--data); }
/* La tuile d'alerte ne prend cet accent que si elle est > 0 : une tuile
   allumee en permanence sur un compte vide apprend a ignorer l'alerte. */
.kpi--hot::before { background: var(--accent); }
.kpi--hot .kpi__val { color: var(--accent-strong); }
/* Une tuile devenue lien reste une cible tactile confortable (44 px) et garde
   le focus visible herite de la feuille. */
a.kpi { text-decoration: none; min-height: 44px; }
a.kpi:hover { box-shadow: var(--shadow-lift); }

@media (min-width: 860px) { .kpis { grid-template-columns: repeat(4, 1fr); } }

/* ── Barre de filtres : les filtres a 0 restent visibles ───── */
/* P13 : les masquer ferait bouger la barre a chaque changement d'etat d'une
   annonce, et la cible tactile que l'utilisateur visait se serait deplacee. */
.f-off { opacity: .55; cursor: not-allowed; }
.tab--off { color: var(--muted); cursor: not-allowed; display: inline-flex;
  align-items: center; gap: .3rem; }

/* ── Vignettes ────────────────────────────────────────────── */
.vignette--vide { background: var(--card-2); color: var(--muted); /* 4,71:1 — N1 */
  border: 1px solid var(--border); }
.vignette--sm { width: 44px; height: 44px; }
.vignette--sm svg { width: 18px; height: 18px; }
.item--compact { grid-template-columns: 44px 1fr auto; }
@media (max-width: 719px) { .item--compact { grid-template-columns: 44px 1fr; } }
.title--sm { font-size: var(--fs-sm); }
.price--sm { font-size: var(--fs-base); }
.adresse { font-style: normal; font-size: var(--fs-xs); color: var(--muted);
  margin-top: .3rem; line-height: 1.4; }

/* ── Etat « pas encore ouvert » ────────────────────────────── */
/* Visuellement DISTINCT de l'etat vide : hachure discrete, aucun bouton,
   aucun chiffre. Un etat vide invite a agir ; celui-ci constate. */
.soon {
  display: flex; flex-direction: column; align-items: center; gap: .5rem;
  padding: 2rem 1rem; text-align: center; color: var(--muted);
  background: repeating-linear-gradient(135deg, var(--card-2), var(--card-2) 10px,
    var(--card) 10px, var(--card) 20px);
  border: 1px dashed var(--border-strong); border-radius: var(--r);
}
.soon svg { width: 30px; height: 30px; color: var(--border-strong); }
.soon h3 { font-size: var(--fs-base); color: var(--ink-2); }
.soon p { font-size: var(--fs-sm); max-width: 30rem; }
.soon__tag {
  font-size: var(--fs-xs); font-weight: 600; text-transform: uppercase;
  letter-spacing: .04em; padding: .1rem .45rem; border-radius: 99px;
  background: var(--idle-soft); color: var(--idle);
}

/* ── Carte de retrait (ecran 3) ───────────────────────────── */
.pickup {   /* `.surface .surface--posee` + la disposition */
  display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; padding: .9rem;
}
/* Un VRAI PNG, genere cote serveur (D11). Jamais le damier decoratif de la
   maquette : un carre inscannable presente comme scannable, devant un
   comptoir, a quelqu'un qui a fait le deplacement. */
/* Les deux dimensions sont fixees ICI, et pas en attributs HTML : la taille du
   PNG depend de la longueur du jeton encode. Pas de reflow au chargement. */
.pickup__qr { width: 132px; height: 132px; aspect-ratio: 1; display: block;
  flex: none; background: #fff; border-radius: var(--r-sm);
  image-rendering: pixelated; }
/* **L38 — le QR de la remise en main propre : un MODIFICATEUR DE TAILLE, et
   rien d'autre.** Il s'emploie avec `.pickup__qr`, qui porte deja le fond
   blanc, le rayon et le rendu pixelise.

   La premiere version redeclarait ces trois proprietes, et
   `tools/mesurer-formes.py` l'a refusee : elle creait une DOUZIEME recette de
   boite blanche dans la feuille, pour un plafond de onze. Le garde a raison —
   deux recettes identiques a l'octet pres finissent par diverger d'un pixel,
   et personne ne sait plus laquelle est la bonne.

   Pourquoi 260 px et non les 132 de `.pickup__qr` : celui-la est REGARDE par
   son proprietaire sur sa propre carte, celui-ci doit etre LU PAR UN AUTRE
   TELEPHONE, a trente centimetres, parfois en plein soleil. `max-width`
   plutot qu'une largeur fixe : sur un telephone etroit, 260 px deborderaient. */
.remise__qr { width: 260px; max-width: 100%; }

.pickup__who { display: flex; flex-direction: column; gap: .15rem; min-width: 0; }
.pickup__lab { font-size: var(--fs-xs); text-transform: uppercase;
  letter-spacing: .04em; color: var(--warn); font-weight: 700; }
.pickup__name { font-size: var(--fs-base); font-weight: 600; }
.pickup__adresse { font-size: var(--fs-xs); color: var(--muted); }
.pickup__titre { font-size: var(--fs-sm); color: var(--ink-2); overflow-wrap: anywhere; }
.pickup .code { font-size: var(--fs-lg); color: var(--marine); letter-spacing: .08em;
  user-select: all; cursor: pointer; }

/* ── Navigation basse (ecran 3) ───────────────────────────── */
/* P15 : sans `env(safe-area-inset-bottom)` et sans reserve de padding sur le
   contenu, la derniere ligne de liste passe SOUS la barre. Invisible en test
   de bureau, systematique sur iPhone, et la cible est majoritairement mobile. */
.bottomnav {
  position: sticky; bottom: 0; z-index: 5;
  /* `repeat(5, 1fr)` etait ecrit en dur : la barre PUBLIQUE en a
     quatre, et la cinquieme colonne laissait un vide a droite. */
  display: grid; grid-auto-flow: column; grid-auto-columns: 1fr;
  background: var(--card); border-top: 1px solid var(--border);
  padding-bottom: env(safe-area-inset-bottom, 0);
}
.bottomnav .b {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: .15rem; min-height: 44px; padding: .45rem .2rem;
  font-size: var(--fs-xs); color: var(--muted); text-decoration: none;
  text-align: center;
}
.bottomnav .b svg { width: 17px; height: 17px; }
.bottomnav .b.on { color: var(--marine); font-weight: 600; }
.bottomnav .b--off { opacity: .5; cursor: not-allowed; }
.bottomnav .soon__tag { display: none; }   /* le titre porte l'info, pas la barre */

/* ── Recherche : un lien dans la barre, pas un champ decoratif ── */
a.search { text-decoration: none; }
a.search:hover { background: rgba(255,255,255,.18); }
@media (max-width: 719px) { .search { display: none; } }

/* ── Accroche de l'accueil (l'accueil EST le catalogue) ───── */
/* Volontairement compacte : elle presente le site sans reléguer les annonces
   sous la ligne de flottaison. Le visiteur doit VOIR la marchandise. */
.accroche {
  background: linear-gradient(178deg, var(--marine-700) 0%, var(--marine) 100%);
  color: #fff; border-radius: var(--r); padding: 1.4rem 1.2rem;
  display: flex; flex-direction: column; gap: .6rem;
}
.accroche h1 { font-size: var(--fs-xl); color: #fff; max-width: 34rem; }
.accroche p { color: #c9d3ec; font-size: var(--fs-sm); max-width: 38rem; }
.accroche .actions { margin-top: .2rem; }
/* ── L30 — le bouton fantome POSE SUR l'accroche ───────────────────────
   Demande du client, mot pour mot : « Bouton j'ai deja un compte passe le
   texte en blanc pour qu'on puisse le lire ».

   Ce n'etait pas « peu lisible » : `.btn--ghost` pose `color: var(--marine)`,
   et le fond de `.accroche` est un degrade `--marine-700` -> `--marine`. Le
   texte avait donc EXACTEMENT la couleur du bas du degrade — 1,00:1, et
   1,17:1 en haut. Le libelle existait dans le DOM et n'existait pas a l'ecran.

   **La portee est le CONTEXTE, pas le composant.** `.btn--ghost` est juste
   partout ailleurs — connexion, 429, achat, 30 autres appels — tous sur fond
   clair, ou l'encre marine mesure 12,38:1 sur `--card`. La corriger a la
   racine aurait casse ces ecrans dans l'autre sens. Un bouton fantome pose
   sur une surface sombre prend l'encre de cette surface, et seulement la.

   Mesures (les deux extremites du degrade, haut puis bas) :
     encre AVANT  --marine   #24335a ....  1,17:1 et 1,00:1   ECHEC
     encre APRES  #fff ....................  14,49:1 et 12,38:1  OK
     survol APRES #fff sur --marine-500 ...  8,51:1             OK

   **La bordure, elle, ne bouge pas, et c'est une MESURE qui le dit.**
   `--border-strong` #c6cddd est un gris clair pense pour du blanc, ou il ne
   fait que 1,59:1 — d'ou l'intuition qu'il serait « fantomatique » sur
   marine. C'est l'inverse : sur le degrade il mesure 9,09:1 et 7,77:1, tres
   au-dessus des 3:1 que WCAG 1.4.11 demande a un element d'interface. Et
   c'est justement la valeur INTERMEDIAIRE qu'il faut : du blanc a pleine
   opacite ferait un cadre dur a 12,38:1, `--marine-300` un cadre eteint a
   4,06:1. On ne change donc rien, plutot que de changer pour montrer qu'on a
   mesure.

   Le survol, en revanche, DEVAIT etre reprise : `.btn--ghost:hover` pose
   `background: var(--card-2)`, un fond clair. Avec l'encre blanche il serait
   passe a 1,08:1 — le meme defaut, deplace dans un etat qu'une capture
   d'ecran ne montre pas. `--marine-500` est un cran plus clair que les deux
   arrets du degrade, donc le survol se voit, et il est MESURABLE : le couple
   (#fff, --marine-500) entre dans l'inventaire tout seul, par la famille
   « fond propre ». */
.accroche .btn--ghost { color: #fff; }
.accroche .btn--ghost:hover { background: var(--marine-500); color: #fff; }
@media (min-width: 760px) { .accroche { padding: 1.8rem 1.6rem; }
  .accroche h1 { font-size: var(--fs-2xl); } }

/* ============================================================
   Lot L4 — favoris, messagerie, signalement.
   Complements de CETTE feuille, jamais une seconde (A3 de L1).
   `.visually-hidden` existe deja : on la REUTILISE, on ne cree
   pas de `.sr-only`. Aucun style inline nulle part : la CSP
   impose style-src 'self'.
   ============================================================ */

/* ── Pastille de non-lu de la maquette (topbar) ─────────────
   La couleur seule ne porte AUCUNE information : un point rouge
   de 7 px n'existe pas pour un lecteur d'ecran (P60). Le compte
   exact est toujours double d'un `.visually-hidden`. */
/* La pastille suit la BOITE, qui est passee de 32 a 44 px : a 4 px du coin elle
   se serait detachee de l'icone de 16 px restee au centre. 10 px la ramene au
   coin superieur droit du symbole, qui est ce qu'elle annote. */
.icon-btn .dot {
  position: absolute; top: 10px; right: 10px; width: 7px; height: 7px;
  border-radius: 50%; background: var(--crit);
}

/* ── Coeur des favoris ───────────────────────────────────── */
.coeur {
  display: inline-flex; align-items: center; justify-content: center; gap: .3rem;
  background: none; border: 1px solid var(--border-strong);
  border-radius: 99px; padding: .3rem .9rem; cursor: pointer;
  /* L9 — le coeur de favori mesurait ~30 px de haut : il etait le dernier
     controle du produit sous le seuil tactile, et le plus souvent touche sur
     un telephone puisqu'il est dans chaque carte d'annonce. */
  min-height: 44px;
  color: var(--ink-2); font: inherit; font-size: var(--fs-sm); font-weight: 600;
}
.coeur svg { width: 15px; height: 15px; }
.coeur:hover { border-color: var(--crit); color: var(--crit); }
.coeur-on { border-color: var(--crit); color: var(--crit); background: var(--crit-soft); }
.coeur-form { display: inline-flex; margin: 0; }

/* ── Fil de discussion ───────────────────────────────────── */
.msg-fil { display: flex; flex-direction: column; gap: .6rem; padding: .2rem 0; }
.msg-bulle {
  max-width: 34rem; padding: .55rem .8rem; border-radius: var(--r-lg);
  background: var(--card-2); border: 1px solid var(--border);
}
.msg-bulle-lui { align-self: flex-start; border-bottom-left-radius: var(--r-sm); }
.msg-bulle-moi {
  align-self: flex-end; border-bottom-right-radius: var(--r-sm);
  background: var(--marine); border-color: var(--marine); color: #fff;
}
.msg-bulle-moi .msg-heure { color: #c9d3ec; }
.msg-heure { display: block; font-size: var(--fs-xs); color: var(--muted); margin-top: .2rem; }

/* D12 — les retours a la ligne d'un message sont rendus par le CSS, jamais par
   des <br> injectes. `pre-wrap` (et non `pre-line`) preserve aussi les suites
   d'espaces : quelqu'un qui colle une adresse sur plusieurs lignes la retrouve
   telle qu'il l'a tapee. `overflow-wrap` traite le mot de 2 000 caracteres sans
   espace. `overflow: hidden` + `max-height` traitent les marques combinantes
   empilees (« zalgo »), que `overflow-wrap` ne borne PAS verticalement (P59). */
.msg-texte {
  white-space: pre-wrap; overflow-wrap: anywhere;
  overflow: hidden; max-height: 40em; margin: 0;
}

.msg-form { display: flex; flex-direction: column; gap: .5rem; margin-top: 1rem; }
.msg-form textarea {
  width: 100%; min-height: 5rem; resize: vertical; font: inherit;
  padding: .6rem .7rem; border: 1px solid var(--border-strong);
  border-radius: var(--r); background: var(--card); color: var(--ink);
}
.msg-form .msg-form-bas { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }

.msg-banniere {
  display: flex; align-items: center; gap: .5rem;
  padding: .6rem .8rem; font-size: var(--fs-sm);
  color: var(--ink-2);                      /* 7,23:1 sur --card-2 */
}
.msg-banniere svg { width: 15px; height: 15px; flex: none; }
.msg-ferme {
  background: var(--warn-soft); border-color: var(--warn); color: var(--warn);
}
.msg-garde { font-size: var(--fs-sm); color: var(--ink-2); }
.msg-entete { display: flex; gap: .7rem; align-items: flex-start; }
.msg-entete .vignette { flex: none; }
.msg-actions { display: flex; gap: .4rem; flex-wrap: wrap; margin-left: auto; }
.msg-plus { text-align: center; padding: .4rem 0; }

/* ── Ligne de la boite de reception ──────────────────────── */
.conv-ligne {
  display: grid; grid-template-columns: 44px 1fr auto; gap: .7rem;
  align-items: center; padding: .65rem .2rem;
  border-bottom: 1px solid var(--border); text-decoration: none; color: inherit;
}
.conv-ligne:last-child { border-bottom: none; }
.conv-ligne:hover { background: var(--card-2); }
.conv-ligne .conv-qui { font-weight: 650; }
.conv-ligne .conv-apercu {
  color: var(--muted); font-size: var(--fs-sm);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.conv-ligne .conv-objet { font-size: var(--fs-xs); color: var(--muted); }
/* Le gras DOUBLE la pastille : un fil non lu reste identifiable en niveaux de
   gris et pour un lecteur d'ecran, qui recoit le compte exact en clair. */
.conv-nonlu .conv-qui, .conv-nonlu .conv-apercu { color: var(--ink); font-weight: 700; }
.conv-compteur {
  display: inline-block; min-width: 1.25rem; padding: 0 .35rem;
  background: var(--crit); color: #fff; border-radius: 99px;
  font-size: var(--fs-xs); font-weight: 700; text-align: center;
}
.conv-droite { display: flex; flex-direction: column; align-items: flex-end; gap: .25rem; }

/* ── Filtres de role de la boite ─────────────────────────── */
.msg-roles { display: flex; gap: .4rem; flex-wrap: wrap; }

/* ══════════════════════════════════════════════════════════════════════
   Lot L5 — commande, et LE bandeau de paiement simule
   ══════════════════════════════════════════════════════════════════════
   Aucun `style=` inline nulle part : la CSP de L1 (`style-src 'self'`) le
   rejetterait SILENCIEUSEMENT, et la mise en page partirait de travers sans
   la moindre erreur visible. Tout ce qui suit est donc une classe.

   Les couleurs reutilisent les jetons existants — aucune nouvelle teinte
   n'est introduite : deux oranges legerement differents sur le meme ecran se
   voient, et personne ne sait lequel est le bon.
   ────────────────────────────────────────────────────────────────────── */

/* Le bandeau permanent. Il doit se voir SANS crier : `--warn` sur
   `--warn-soft` mesure 4,50:1 (voir le tableau de contrastes ci-dessus),
   c'est-a-dire AA pour le texte courant. Une bande rouge vif aurait ete lue
   comme une erreur, et on apprend a ignorer une erreur qui s'affiche toujours.
   La bordure en pointilles reprend celle du badge `.simule` : meme
   vocabulaire visuel pour la meme idee. */
.bandeau-test {
  display: flex; gap: .7rem; align-items: flex-start;
  margin: 0 0 1rem; padding: .8rem .9rem;
  background: var(--warn-soft);
  border: 1px dashed color-mix(in srgb, var(--warn) 45%, transparent);
  border-left: 4px solid var(--warn);
  border-radius: var(--r);
  color: var(--ink);
}
.bandeau-test__icone { flex: none; color: var(--warn); margin-top: .1rem; }
.bandeau-test__icone svg { width: 18px; height: 18px; }
.bandeau-test__titre {
  display: block; color: var(--warn);
  font-size: var(--fs-sm); letter-spacing: .01em;
}
.bandeau-test__texte { margin: .2rem 0 0; font-size: var(--fs-sm); }
.simule-note { margin: .25rem 0 0; font-size: var(--fs-xs); color: var(--warn); }

/* Recapitulatif de montants. `tabular-nums` aligne les chiffres en colonne :
   sans lui, « 24,90 » et « 5,00 » ne se superposent pas et le total semble
   flotter. */
.recap { margin: .6rem 0 0; font-variant-numeric: tabular-nums; }
.recap-ligne, .recap-total {
  display: flex; justify-content: space-between; gap: 1rem;
  padding: .38rem 0; font-size: var(--fs-sm);
  border-bottom: 1px solid var(--border);
}
.recap-total {
  font-weight: 700; font-size: var(--fs-lg);
  border-bottom: none; border-top: 2px solid var(--border-strong); margin-top: .2rem;
}
.recap--vendeur { margin-top: 1rem; }
/* Le montant et son badge forment UN groupe : le badge ne doit jamais se
   retrouver seul en bout de ligne apres un retour a la ligne. */
.montant { display: inline-flex; align-items: baseline; gap: .4rem; white-space: nowrap; }

/* Suivi : des FAITS dates, jamais une promesse de date future. */
.timeline { list-style: none; margin: .5rem 0 0; padding: 0 0 0 .9rem;
  border-left: 2px solid var(--border); }
.timeline li { position: relative; padding: .3rem 0 .3rem .6rem;
  display: flex; justify-content: space-between; gap: 1rem;
  font-size: var(--fs-sm); flex-wrap: wrap; }
.timeline li::before {
  content: ""; position: absolute; left: -1.25rem; top: .75rem;
  width: 8px; height: 8px; border-radius: 50%;
  background: var(--marine); }
.timeline__quand { color: var(--muted); font-size: var(--fs-xs); }

/* Bloc d'actions. Les deux jeux — acheteur et vendeur — ne sont JAMAIS rendus
   ensemble (D19) ; cette classe habille celui qui l'est. */
.actions-bloc { margin-top: 1rem; display: grid; gap: .6rem; }
.actions-bloc form { margin: 0; }
.actions-bloc .btn { width: 100%; }

/* Cases a cocher et boutons radio du parcours : cible tactile de 44 px
   minimum, la cible etant majoritairement mobile. */
.radio { display: flex; align-items: flex-start; gap: .6rem;
  min-height: 44px; padding: .5rem 0; font-size: var(--fs-sm);
  cursor: pointer; }
.radio input { margin-top: .15rem; width: 18px; height: 18px; flex: none; }

/* `.adresse` n'est PAS redefinie ici : L3 la porte deja (ligne ~584). La
   dupliquer avec des valeurs voisines ferait dependre le rendu de l'ordre des
   regles — et c'est exactement l'ecart que l'arbitrage A3 de L1 interdit :
   on COMPLETE la feuille, on ne la duplique pas. */

/* **`.btn-danger` a ete SUPPRIMEE** (L9). Le rouge devient une couleur de
   ZONE et non de bouton : `.danger-zone` le porte deja, et une meme action
   destructrice n'a plus deux poids visuels selon l'ecran.
   Le defaut mesure : « Annuler la commande » etait rouge sur l'ecran de
   confirmation et neutre sur les trois ecrans qui y menent — le poids du
   danger n'apparaissait qu'APRES que la decision etait prise. */
.danger-zone .btn--ghost { border-color: var(--crit); color: var(--crit); }
.danger-zone .btn--ghost:hover { background: var(--card); }

@media (max-width: 640px) {
  .bandeau-test { flex-direction: row; }
  .timeline li { flex-direction: column; gap: .1rem; }
}

/* ══════════════════════════════════════════════════════════════════════
   Lot L6 — le comptoir du point relais
   ══════════════════════════════════════════════════════════════════════
   Aucune classe parallele a `.pickup` : le bloc de la carte de retrait et sa
   regle `.pickup .code { user-select: all }` sont livres par L3 (L1 A3 — deux
   classes pour le meme composant, c'est la divergence garantie). Si le code
   doit etre plus gros au comptoir, c'est une regle sur `.pickup .code`.

   **Cibles tactiles >= 44 px sur tous les boutons du comptoir** : la saisie se
   fait debout, sur le telephone de la caisse, souvent d'une seule main. */
.relais-head { gap: .6rem; }
.relais-head__nom { color: #fff; font-weight: 600; font-size: var(--fs-sm); }
.relais-head__poste {
  font-size: var(--fs-xs); color: #c9d3ec;              /* 8,26:1 sur --marine */
  border: 1px solid var(--marine-500); border-radius: 99px; padding: .1rem .5rem;
}

.remise { padding: 1rem; margin-bottom: 1rem; }
.remise h2 { font-size: var(--fs-base); margin: 0 0 .25rem; }
/* Le champ de code est en chasse fixe et espace : il est relu caractere par
   caractere par-dessus un comptoir, parfois par quelqu'un qui n'a pas ses
   lunettes. L'alphabet Crockford exclut I, L, O et U — mais encore faut-il
   voir la difference entre 8 et B. */
.remise input[type="text"] {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: var(--fs-xl); letter-spacing: .12em; text-transform: uppercase;
  min-height: 44px;
}
.remise .btn, .remise button { min-height: 44px; }
/* ── L17 — la place du scanner de bordereau ──────────────────────────────
   `.scan` est rendue VIDE par le serveur : c'est `static/js/scan.js` qui y met
   le bouton « Scanner le code », et seulement quand le navigateur sait decoder
   un QR. Un conteneur vide ne doit donc rien occuper — pas de marge, pas de
   bordure, rien a voir sur Safari iOS ou sur un poste sans camera.
   `gap` ne s'applique qu'entre des enfants existants, donc il ne cree aucun
   espace tant que la boite est vide.
   `.scan-video` est posee PAR LE SCRIPT (declaree dans `tools/mesurer-classes.py`,
   piege 3). `max-height` bornee : sur le telephone d'un comptoir, une
   previsualisation pleine hauteur pousserait le bouton « Enregistrer le colis »
   hors de l'ecran, et c'est lui qu'on cherche a atteindre apres le scan.

   **`46dvh` et pas `46vh`** — meme defaut que `.modale`, en plus discret, et il
   compte quand meme. Mesure du 01/10 sur un iPhone 13 (390x844), barres
   visibles : `46vh` = 388 px, pour 659 px reellement visibles, soit **59 % de ce
   que l'employe voit** la ou la feuille croit en reserver 46 %. Les 113 px
   d'ecart sont pris sur les 271 px qui restent au titre, au bouton de scan et au
   bouton « Enregistrer le colis » — donc sur le seul geste qui compte apres le
   scan. Avec `dvh`, 46 % veut dire 46 % de ce qu'on voit. */
.scan { display: flex; flex-wrap: wrap; gap: .5rem; }
.scan-video { display: block; width: 100%; max-height: 46dvh;
  border-radius: var(--r); }
.fiche { display: grid; grid-template-columns: auto 1fr; gap: .3rem .9rem;
  margin: .6rem 0 1rem; }
.fiche dt { font-size: var(--fs-xs); text-transform: uppercase;
  letter-spacing: .04em; color: var(--muted); }
.fiche dd { margin: 0; font-size: var(--fs-base); }

.rayon { margin-top: 1rem; }
.rayon-liste { list-style: none; margin: .5rem 0 0; padding: 0; }
/* La grammaire vient de `.rangee` ; ici, rien de plus. */
.rayon-row__titre { font-size: var(--fs-sm); overflow-wrap: anywhere; flex: 1 1 12rem; }
.rayon-row__qui { font-size: var(--fs-sm); color: var(--ink-2); }
.rayon-row__actions { display: flex; gap: .4rem; flex-wrap: wrap; }
.rayon-row__actions button { min-height: 44px; }
/* L'echeance est une DATE ABSOLUE, jamais « il y a N j » (mutant M09 de L3) :
   un employe doit pouvoir dire « ce colis part vendredi ». */
.echeance { font-size: var(--fs-sm); font-variant-numeric: tabular-nums;
  color: var(--ink-2); }
/* La couleur DOUBLE le texte de la pastille, elle ne le remplace jamais : la
   ligne reste lisible en niveaux de gris et pour les daltoniens. */
.echeance-proche .echeance { color: var(--warn); font-weight: 700; }

.relais-nom { font-weight: 600; margin: 0; }

/* L14/T3 — la liste « ou je peux deposer », cote vendeur (Q1).
   `<details>` ferme par defaut : la destination est deja affichee en gros
   au-dessus, et le vendeur qui depose sur place n'a rien a lire de plus. */
.relais-depots { margin-top: .9rem; }
.relais-depots__liste { list-style: none; margin: .5rem 0 0; padding: 0;
  display: flex; flex-direction: column; gap: .5rem; }
.pickup__rotate { margin-top: .5rem; display: flex; flex-direction: column;
  align-items: flex-start; gap: .2rem; }


/* ══════════════════════════════════════════════════════════════════════
   Lot L7 — back-office d'administration
   ══════════════════════════════════════════════════════════════════════
   Trois regles tiennent toute cette section.

   1. **Aucune couleur en dur.** Tout vient des jetons de `:root` (--marine,
      --ink, --crit, --card…). Une valeur ecrite a la main ici serait une
      dix-huitieme teinte que l'harmonisation de L9 devrait retrouver a la
      main — et le premier ecran ou elle divergerait serait celui qu'on
      regarde le moins.

   2. **La couleur DOUBLE le texte, elle ne le remplace jamais**
      (CONVENTIONS §10). Le bandeau dit « Espace d'administration » en toutes
      lettres, l'encadre de preuve dit « contenu enregistre au moment du
      signalement », celui de revelation dit « cet affichage a ete
      journalise ». Retirer toutes les couleurs de cette section laisserait
      les ecrans parfaitement comprehensibles.

   3. **Chaque couple encre/fond introduit ici est MESURE**, pas estime a
      l'oeil, et `tests/test_a11y_contrastes.py` refait le calcul a chaque
      execution de la suite. Les valeurs sont annotees en regard des regles.

   Le defaut que cette reecriture corrige : `.admin-bar` remplacait le fond
   marine de `.topbar` **sans reprendre son `color: #fff`**. Marque, pseudo et
   bouton « Quitter » tombaient a 1,13:1 — du blanc sur du rose pale. Les
   couleurs etaient bonnes une par une ; le defaut etait A LA COUTURE. D'ou
   les surcharges explicites de tous les descendants de `.topbar` ci-dessous.
   ────────────────────────────────────────────────────────────────────── */

/* ── Le bandeau permanent ─────────────────────────────────────────────── */
.admin-bar {
  background: var(--crit-soft);
  color: var(--ink);                     /* 13,70:1 sur --crit-soft */
  border-bottom: 3px solid var(--crit);  /* 4,50:1 : separation non textuelle */
}
/* Les trois descendants qui heritaient d'un blanc devenu invisible. */
.admin-bar .brand .wordmark { color: var(--marine-900); }   /* 14,42:1 */
.admin-bar .brand .wordmark span { color: var(--crit); }    /*  4,50:1 */
.admin-bar .icon-btn {
  background: var(--card);
  color: var(--marine-900);              /* 16,89:1 sur --card */
  border: 1px solid var(--border-strong);
}
.admin-bar__mot {
  background: var(--crit); color: var(--card);   /* 5,27:1 */
  border-radius: var(--r-sm); padding: .15rem .55rem;
  font-weight: 700; letter-spacing: .02em; font-size: var(--fs-sm);
}
.admin-bar__qui   { font-weight: 600; color: var(--ink); }      /* 13,70:1 */
.admin-bar__reste { font-size: var(--fs-sm); color: var(--ink-2); } /* 6,50:1 */
.admin-nav { border-bottom: 1px solid var(--border); }
.admin-grid { display: block; }

/* ── Tuiles de la file d'attente ──────────────────────────────────────── */
.admin-tuiles { display: flex; flex-wrap: wrap; gap: 1rem; margin: 1rem 0; }
.tuile { padding: .75rem 1rem; min-width: 9rem; }
.tuile__n { display: block; font-size: var(--fs-2xl); font-weight: 700;
            color: var(--ink); }                                /* 15,86:1 */
.tuile__l { display: block; font-size: var(--fs-sm); color: var(--ink-2); } /* 7,61:1 */
.tuile__a { font-size: var(--fs-sm); color: var(--marine); }    /* 12,38:1 */

/* ── Filtres ──────────────────────────────────────────────────────────── */
.admin-filtres { display: flex; gap: .5rem; flex-wrap: wrap; margin: .75rem 0; }
.chip {
  border: 1px solid var(--border-strong); border-radius: 999px;
  padding: .55rem .9rem; min-height: 44px;   /* zone tactile mobile */
  display: inline-flex; align-items: center;
  font-size: var(--fs-sm); text-decoration: none; color: var(--marine);
}
.chip.on { border-color: var(--crit); color: var(--ink); font-weight: 700; }

/* ── Listes ───────────────────────────────────────────────────────────── */
.admin-file { list-style: none; padding: 0; margin: 0; }
/* ── LA rangee, et il n'y en a qu'une ─────────────────────────────────
   **Dix grammaires de rangee, six rembourrages differents.** Sur un
   telephone, cela signifie un rythme vertical different selon l'ecran ou
   l'on se trouve — l'inverse exact de « naviguable fluide ». `.rangee` est
   la grammaire ; les noms historiques sont des ALIAS de la meme regle, et
   non des regles paralleles qui rederiveront.

   `align-items: baseline` : les colonnes d'une ligne s'alignent sur le texte,
   pas sur la hauteur de la plus haute. */
.rangee,
.signal-row, .dossier-row, .histo-row, .annonce-row,
.user-row, .cmd-row, .sanction-row,
.rayon-row, .poste-row {
  display: flex; flex-wrap: wrap; gap: .6rem; align-items: baseline;
  padding: .6rem 0; border-bottom: 1px solid var(--border);
  color: var(--ink);
}
.rangee:last-child, .rayon-row:last-child { border-bottom: none; }
.dossier-row { display: block; }
/* Les colonnes d'une ligne. Elles ne sont pas decoratives : ce sont les
   points d'ancrage des tests de rendu, qui verifient que CHAQUE ligne porte
   SA PROPRE valeur — c'est le defaut qui a survecu a 2 728 tests en L6. */
.sr-at, .dr-at, .hr-at, .cr-at, .ur-at, .sa-at, .jr-at, .po-at, .po-vu {
  font-family: var(--data); font-size: var(--fs-sm); color: var(--ink-2);
  white-space: nowrap;
}
.sr-motif, .dr-motif, .hr-motif, .sa-motif, .ar-titre, .cr-titre, .ur-pseudo,
.po-libelle {
  font-weight: 600;
}
.sr-cible, .dr-cible, .ar-qui, .cr-ref, .ur-statut, .ar-etat, .cr-statut,
.sa-type, .sa-fin, .sa-levee, .sa-notif, .hr-decision, .sr-qui, .dr-qui,
.po-etat {
  font-size: var(--fs-sm); color: var(--ink-2);          /* 7,61:1 sur --card */
}
.ar-prix, .cr-total { font-weight: 700; color: var(--ink); }
.sr-dossier { font-size: var(--fs-sm); }
.sr-dossier a { color: var(--marine); font-weight: 600; }
/* **`.admin-ouvrir` a ete SUPPRIMEE** (L9). L7 avait deja fusionne cinq
   classes « Ouvrir » en une ; L9 fait le dernier pas : ce n'est pas un
   composant du back-office, c'est un bouton secondaire. Il portait 44 px et
   une bordure — c'est-a-dire `.btn .btn--ghost`, ecrit une seconde fois.
   Le defaut mesure : `admin/annonces.html` rendait « Ouvrir » ligne 17 en
   <button class="btn"> (gris systeme) et ligne 35 en <a class="admin-ouvrir">
   (stylé). Meme mot, meme ecran, deux poids. */
.admin-file .btn--ghost, .admin-journal .btn--ghost { margin-left: auto; }
.vide { color: var(--ink-2); padding: 1rem 0; }              /* 7,61:1 */
.pager { margin: .8rem 0; }

/* ── Pastilles de statut de signalement ───────────────────────────────
   La couleur double le libelle, qui est toujours ecrit en clair. */
.pill--nouveau  { background: var(--crit-soft); color: var(--marine-900); }  /* 14,42:1 */
.pill--en_cours { background: var(--warn-soft); color: var(--marine-900); }  /* 15,07:1 */
.pill--traite   { background: var(--ok-soft);   color: var(--marine-900); }  /* 14,84:1 */
.pill--rejete   { background: var(--idle-soft); color: var(--marine-900); }  /* 14,56:1 */

/* ── Blocs ────────────────────────────────────────────────────────────── */
.admin-bloc { padding: .9rem 1rem; margin: 1rem 0; }
.admin-recherche { display: flex; gap: .75rem; align-items: flex-end; }

/* L'instantane de preuve (D17). La bordure epaisse ne fait que DOUBLER la
   phrase « contenu enregistre au moment du signalement ». */
.preuve {
  border-left: 4px solid var(--muted);
  background: var(--card-2); color: var(--ink);  /* 15,24:1 — jamais herite */
  padding: .6rem .9rem; margin: .6rem 0; border-radius: 0 var(--r) var(--r) 0;
}
.preuve__quoi {
  font-size: var(--fs-xs); text-transform: uppercase; letter-spacing: .04em;
  color: var(--ink-2); margin: 0 0 .3rem;                  /* 7,23:1 sur --card-2 */
}
/* Les retours a la ligne s'affichent par CSS, jamais en injectant des <br>. */
.preuve__texte { white-space: pre-wrap; margin: 0; color: var(--ink); } /* 15,24:1 */
.preuve__quand { font-size: var(--fs-sm); color: var(--ink-2); margin: .3rem 0 0; }

/* Une donnee personnelle revelee. La mention « journalise » est dans le HTML,
   pas dans un pseudo-element : elle doit survivre a une copie de la page. */
.revele {
  border: 1px dashed var(--warn);
  background: var(--warn-soft); color: var(--ink); /* 14,31:1 — jamais herite */
  padding: .5rem .8rem; margin: .4rem 0; border-radius: var(--r-sm);
}
.revele__l    { font-size: var(--fs-sm); color: var(--ink-2); display: block; } /* 6,79:1 */
.revele__v    { font-weight: 600; color: var(--ink); }        /* 14,31:1 */
.revele__note { display: block; font-size: var(--fs-xs); color: var(--ink-2); }

/* ── Journal ──────────────────────────────────────────────────────────── */
.admin-journal { list-style: none; padding: 0; margin: 0; font-size: var(--fs-sm); }
.admin-journal__row {
  display: flex; gap: .8rem; flex-wrap: wrap;
  padding: .35rem 0; border-bottom: 1px solid var(--border);
}
.jr-dom, .jr-act { font-weight: 600; color: var(--ink); }
.jr-qui, .jr-cible, .jr-detail { color: var(--ink-2); }
.admin-chaine-ok { color: var(--ok); font-weight: 600; }      /* 5,13:1 sur --card */
.admin-chaine-ko { color: var(--crit); font-weight: 700; }    /* 5,27:1 sur --card */
/* Le TROISIEME etat du sceau : la chaine tient, mais rien ne l'ancre
   hors base. Ni vert ni rouge — et le texte le dit en toutes lettres,
   la couleur ne fait que le doubler (CONVENTIONS §10). */
.admin-chaine-avert { color: var(--warn); font-weight: 700; }  /* 5,05:1 sur --card */
.admin-conflit {
  border: 1px solid var(--crit); background: var(--crit-soft);
  color: var(--ink); padding: .5rem .8rem;                    /* 13,70:1 */
  border-radius: var(--r-sm); font-weight: 600;
}
/* La PORTEE d'une decision, avant qu'on ne clique : combien de plaintes,
   combien de personnes. Encadree parce que c'est la seule information de
   l'ecran dont l'oubli a des consequences irreversibles pour un tiers. */
.admin-portee {
  border: 1px solid var(--border-strong); background: var(--card-2);
  color: var(--ink); padding: .5rem .8rem;                    /* 15,24:1 */
  border-radius: var(--r-sm); margin: .6rem 0;
}
.admin-divulgation {
  border: 1px solid var(--border-strong); background: var(--card-2);
  color: var(--ink); padding: .6rem .9rem;                    /* 15,24:1 */
  border-radius: var(--r-sm); margin: .8rem 0;
}

/* Le bandeau cote VENDEUR sur une annonce retiree par la moderation. Le motif
   y est en clair : faire disparaitre l'annonce sans explication serait un
   mensonge par omission, et rendrait tout recours impossible. */
.masque {
  border-left: 4px solid var(--crit); background: var(--crit-soft);
  color: var(--ink); padding: .5rem .8rem; margin: .5rem 0;   /* 13,70:1 */
  border-radius: 0 var(--r) var(--r) 0; font-weight: 600;
}

/* ── La fenetre de conversation ───────────────────────────────────────── */
.admin-fil { list-style: none; padding: 0; margin: 0; }
.conv-entete { color: var(--ink-2); }
.conv-row {
  display: grid; grid-template-columns: 10rem 11rem 1fr; gap: .6rem;
  padding: .4rem 0; border-bottom: 1px solid var(--border);
}
.conv-row--ancre { background: var(--warn-soft); }
.cr-qui { font-weight: 700; color: var(--ink); }
/* Le corps d'un message garde ses retours a la ligne, par CSS. */
.cr-corps { white-space: pre-wrap; color: var(--ink); }

@media (max-width: 40rem) {
  .conv-row { grid-template-columns: 1fr; }
  .admin-recherche { flex-wrap: wrap; }
}

/* ══════════════════════════════════════════════════════════════════════
   Lot L9 — 1. Les quinze classes posees et JAMAIS definies
   ══════════════════════════════════════════════════════════════════════
   Douze sont arrivees avec L8, qui a livre douze gabarits et zero ligne de
   style ; trois sont heritees de L1-L6. Aucune exception, aucun avertissement
   de navigateur, aucun test ne les signalait : une classe inconnue n'est pas
   une erreur en CSS, c'est un silence.

   `tools/mesurer-classes.py` compte ce silence, et
   `tests/test_inventaire_classes.py` le refuse.
   ────────────────────────────────────────────────────────────────────── */

/* ── Le bandeau de consentement ───────────────────────────────────────
   **Refuser doit couter le meme nombre de clics ET le meme poids visuel
   qu'accepter.** La promesse etait ecrite en commentaire depuis L8 et
   verifiee sur un ATTRIBUT ; au rendu, `.btn-consent` n'ayant aucune regle,
   il restait deux boutons gris `ButtonFace` de l'agent utilisateur et un
   texte nu — parce que « Choisir » est un <a> et n'herite d'aucun fond.

   La correction n'est pas d'ecrire `.btn-consent` : c'est de la SUPPRIMER et
   de poser sur les trois controles la meme intention du systeme,
   `.btn .btn--ghost`, qui pose fond, encre, bordure, graisse et 44 px — donc
   qui neutralise la difference <a>/<button>.
   `tests/test_consentement_rendu.py` mesure le RENDU des trois, pas leur
   attribut, et porte la contre-epreuve qui fait rougir l'etat d'avant.

   Non bloquant : en bas de page, il ne recouvre rien, et tant qu'aucun choix
   n'est fait le refus s'applique. */
.consent {
  position: sticky; bottom: 0; z-index: 20;
  display: flex; flex-direction: column; gap: .7rem;
  padding: .9rem 1rem calc(.9rem + env(safe-area-inset-bottom, 0px));
  background: var(--card); color: var(--ink);       /* 15,86:1 */
  border-top: 3px solid var(--accent);
  box-shadow: var(--shadow-lift);
}
.consent__texte { display: flex; flex-direction: column; gap: .25rem; }
.consent__texte .sm { color: var(--ink-2); }        /* 7,61:1 sur --card */
/* Les controles s'etirent a parts EGALES : aucun des trois n'est plus large
   qu'un autre, et sur telephone ils s'empilent sans que l'ordre ne change. */
.consent__actions { display: flex; flex-wrap: wrap; gap: .5rem; margin: 0; }
.consent__actions .btn { flex: 1 1 10rem; }
@media (min-width: 760px) {
  .consent { flex-direction: row; align-items: center; gap: 1.2rem; }
  .consent__texte { flex: 1 1 22rem; }
  .consent__actions .btn { flex: 0 0 auto; }
}

/* ── Case a cocher etiquetee (page de preferences) ────────────────────
   `.field` empile en colonne et met le label en capitales : sur une case a
   cocher, cela donne un libelle au-dessus d'une case orpheline. Le
   modificateur remet la ligne a l'horizontale et rend le libelle lisible. */
.field--check { flex-direction: row; align-items: center; gap: .6rem;
  min-height: 44px; }
.field--check input { width: 20px; height: 20px; min-height: 0; flex: none; }
.field--check label { font-size: var(--fs-base); text-transform: none;
  letter-spacing: 0; color: var(--ink); font-weight: 500; }

/* ── Tableaux de correspondances (pages legales, « Mes donnees ») ─────
   Cinq `<table class="tbl">` sont posees sur des pages PUBLIQUES et `.tbl`
   n'existait pas : filets, rembourrage et fond d'en-tete absents, et surtout
   AUCUN conteneur qui defile — sous 400 px, c'est la page entiere qui partait
   en defilement horizontal. Le §A.6 du plan d'origine declarait le cas resolu
   « par absence de <table> » ; il y en a cinq.

   Un tableau de correspondances (cookie -> finalite -> duree) est la bonne
   forme pour ces donnees : le remplacer par des cartes serait une regression
   de lisibilite. On l'ecrit, on ne le supprime pas. */
.tbl-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.tbl { width: 100%; border-collapse: collapse; font-size: var(--fs-sm);
  min-width: 22rem; }
.tbl th, .tbl td { padding: .5rem .7rem; text-align: left; vertical-align: top;
  border-bottom: 1px solid var(--border); }
.tbl thead th { background: var(--card-2); color: var(--ink);   /* 15,24:1 */
  font-size: var(--fs-xs); text-transform: uppercase; letter-spacing: .04em;
  border-bottom: 2px solid var(--border-strong); white-space: nowrap; }
.tbl tbody th[scope="row"] { font-weight: 650; color: var(--ink); }
.tbl tbody tr:last-child th, .tbl tbody tr:last-child td { border-bottom: none; }

/* ── Coquille des pages legales ───────────────────────────────────────
   Du texte long se lit sur une colonne bornee : au-dela de ~72 caracteres
   l'oeil perd sa ligne en revenant a la marge. */
.legal { max-width: 52rem; margin-inline: auto; width: 100%; }
.legal h2 { font-size: var(--fs-lg); margin-top: .8rem; }
.legal h2::after { content: ""; display: block; width: 2.2rem; height: 3px;
  margin-top: .35rem; background: var(--accent); border-radius: 2px; }
.legal p + p { margin-top: .5rem; }
.legal ul, .legal ol { margin: .4rem 0; padding-left: 1.2rem; }
.legal li + li { margin-top: .25rem; }

/* Definitions des mentions legales : `dt` / `dd` cote a cote, et non un `dd`
   indente de 40 px sous un `dt` nu. Repli en une colonne sur telephone. */
.legal-dl { display: grid; grid-template-columns: auto 1fr; gap: .35rem .9rem;
  margin: .5rem 0; }
.legal-dl dt { font-size: var(--fs-xs); text-transform: uppercase;
  letter-spacing: .04em; color: var(--muted); align-self: baseline; }
.legal-dl dd { margin: 0; overflow-wrap: anywhere; }
@media (max-width: 560px) {
  .legal-dl { grid-template-columns: 1fr; gap: .1rem; }
  .legal-dl dd { margin-bottom: .55rem; }
}

/* ── Listes de faits dates ────────────────────────────────────────────
   Les acces administratifs et les demandes RGPD sortaient en puces, date et
   libelle colles. Ce sont des FAITS OPPOSABLES : ils se lisent en colonnes. */
/* Ses `li` portent `.rangee` : voir la grammaire unique plus haut. */
.liste { list-style: none; margin: 0; padding: 0; }
.liste .num { color: var(--ink-2); white-space: nowrap; }   /* 7,61:1 */

/* Les phrases d'honnetete de la suppression de compte. Elles vivent dans
   `constants` pour etre identiques au mail (§11) ; elles meritent d'etre lues
   une par une, pas en puces serrees. */
.liste-phrases { list-style: none; margin: 0; padding: 0;
  display: flex; flex-direction: column; gap: .5rem; }
.liste-phrases > li { padding-left: .9rem; border-left: 3px solid var(--border-strong);
  font-size: var(--fs-sm); color: var(--ink-2); }            /* 7,61:1 */

/* **`.sitefoot__legal` a ete SUPPRIMEE** (L11). Les quatre liens legaux ne
   sont plus dans le pied de page : ils sont dans le menu d'en-tete
   (`_macros.html::menu_burger`), a la demande du client, et le pied de page
   ne porte plus que sa phrase. La regle restait ici, sans personne pour la
   poser — une regle morte est une regle qu'un relecteur croira vivante, et
   sur laquelle il raisonnera (`tests/test_inventaire_classes.py`). */

/* ── Suivi de commande : le libelle de l'etape ────────────────────────
   Six occurrences dans `commandes/detail.html`, jamais stylees : le FAIT et
   sa date avaient le meme poids que le reste de la page. */
.timeline__quoi { font-weight: 600; color: var(--ink); }     /* 15,86:1 */

/* ── Le statut de retrait sans suite ──────────────────────────────────
   `constants.STATUTS_RETRAIT` renvoie `pill-off` pour les trois etats qui
   signalent un echec (`expire`, `retour_vendeur`, `annule`) : ils sortaient
   sans encre ni fond. La difference d'avec `.pill-draft`, qui partage ses deux
   jetons, est NON CHROMATIQUE — sans quoi les deux etats seraient
   indiscernables en niveaux de gris (CONVENTIONS §10). */
.pill-off { color: var(--idle); background: var(--idle-soft);   /* 4,53:1 */
  text-decoration: line-through; }

/* ══════════════════════════════════════════════════════════════════════
   Lot L9 — 2. PWA, navigation au pouce, et mobile d'abord
   ══════════════════════════════════════════════════════════════════════ */

/* ── Lien d'evitement ─────────────────────────────────────────────────
   Premier element focalisable du document, invisible jusqu'au focus. Sans
   lui, la navigation au clavier traverse l'en-tete, les onglets et la
   recherche a CHAQUE page. Il n'est pas `display: none` — un element masque
   ainsi n'est pas focalisable, et le lien ne servirait a rien. */
/* La boite vient de `.surface`, posee dans `base.html` : L9 ne s'autorise pas
   une douzieme recette de boite blanche apres en avoir supprime onze. */
.skip-link {
  position: absolute; left: .5rem; top: -4rem; z-index: 100;
  color: var(--marine);                            /* 12,38:1 */
  padding: .6rem 1rem; min-height: 44px;
  display: inline-flex; align-items: center;
  font-weight: 650; text-decoration: none;
  transition: top .12s ease;
}
.skip-link:focus-visible { top: .5rem; }

/* ── Bandeau reseau ───────────────────────────────────────────────────
   Il DATE la page plutot que de la maquiller. Une page chargee il y a sept
   minutes peut annoncer un article deja vendu : le dire coute une phrase, le
   taire coute un deplacement a quelqu'un. */
.bandeau-reseau {
  margin: 0; padding: .6rem 1rem;
  background: var(--warn-soft); color: var(--ink);   /* 14,31:1 */
  border-bottom: 3px solid var(--warn);              /* 4,50:1 */
  font-size: var(--fs-sm); text-align: center;
}

/* ── Bandeau de mise a jour ───────────────────────────────────────────
   Une PROPOSITION, jamais une contrainte : on ne recharge pas d'office
   quelqu'un qui ecrit un message ou remplit une adresse de livraison. */
.bandeau-maj {
  display: flex; align-items: center; justify-content: center;
  gap: .8rem; flex-wrap: wrap;
  padding: .6rem 1rem;
  background: var(--card-2); color: var(--ink);      /* 15,24:1 */
  border-bottom: 1px solid var(--border-strong);
  font-size: var(--fs-sm);
}

/* ── Navigation basse : elle EXISTE enfin partout ─────────────────────
   **Le defaut le plus couteux du produit en mobile.** `app.css` posait
   `.pane { padding-bottom: 4.5rem }` SANS CONDITION, pour degager une barre
   basse que SEUL `dash/_layout.html` rendait. Sept coquilles posaient
   `<main class="pane">` ; six reservaient donc 72 px de vide en bas de page
   pour une barre qui n'etait pas la — dont l'annuaire public et les cinq
   pages legales, c'est-a-dire les ecrans les plus vus d'un site public — et
   n'offraient aucune navigation au pouce.

   La reserve est desormais conditionnee par une classe de CORPS,
   `.a-barrenav`, posee par `base.html` si et seulement si la barre est
   reellement rendue. Une page sans barre n'a plus de trou.

   (Ce commentaire annoncait un modificateur `.pane--avec-nav` que le code ne
   pose pas : corrige le 17/09. Un commentaire faux est plus couteux qu'un
   commentaire absent — c'est lui qu'on croit.) */
.a-barrenav .pane { padding-bottom: 4.5rem; }
/* `.barrenav` est le nom francais de la barre ; `.bottomnav` porte deja toute
   la regle (L3) et on ne cree pas un second composant. Les deux classes sont
   posees ensemble, et cette declaration existe pour que la premiere ne soit
   pas une classe fantome de plus. */
.barrenav { position: sticky; bottom: 0; }

/* ── Mobile d'abord : ce qui n'etait pas tenu ─────────────────────────── */

/* Toute image de contenu se borne a sa colonne. Sans cela, une photo plus
   large que l'ecran fait defiler la PAGE ENTIERE horizontalement.

   **`height: auto` n'est pas un ornement : c'est la moitie manquante, et son
   absence a produit le defaut de geometrie du 29/09.** `width` et `height`
   ecrits en ATTRIBUTS de balise sont des indices de presentation — une
   origine de cascade situee SOUS l'auteur. Reprendre `max-width` seul laisse
   donc la hauteur a l'attribut : sur `.carte__photo`, qui declare
   `width: 100%` et `height="400"`, les deux dimensions devenaient DEFINIES,
   et `aspect-ratio` est alors ignore par definition. La photo rendait 400 px
   de haut pour 160 de large la ou le rapport declare en donnait 213 — exactement
   ce que la capture du client mesure, au pixel.

   La declaration etait dans la feuille, verte a la lecture, et ne peignait
   rien. `tests/test_l12_geometrie_carte.py` garde les deux moities ensemble,
   et pour TOUTE image du site qui declare une hauteur en attribut. */
img { max-width: 100%; height: auto; }

/* `.actions` enveloppe la plupart des paires de boutons. Sous 400 px, deux
   boutons de 44 px cote a cote ne tiennent pas : ils s'etirent et s'empilent
   plutot que de deborder. */
@media (max-width: 420px) {
  .actions .btn { flex: 1 1 100%; }
  .surface__tete { gap: .4rem; }
}

/* **Le `flex-wrap: wrap` de `.topbar` / `.pubtop` a ete SUPPRIME ici.**
   Il etait la reponse de L9 au debordement sur 320 px — la marque, la bascule
   de vue, l'avatar et deux boutons-icones ne tiennent pas en une rangee — et
   il repondait en EMPILANT la barre sur deux ou trois rangees. C'est
   exactement ce que le client refuse depuis le 30/09.

   La reponse de L13 est structurelle : trois colonnes explicites, qui ne
   produisent jamais de deuxieme rangee, et un contenu qui S'ALLEGE par paliers
   mesures (voir le bloc L13 en fin de feuille et `tools/mesurer-entete.py`).
   Le debordement ne se replie plus, il se prevoit. */
@media (max-width: 480px) {
  .pane { padding-inline: .7rem; }
}

/* ══════════════════════════════════════════════════════════════════════
   Lot L11 — l'accueil, la carte d'annonce et la navigation
   ══════════════════════════════════════════════════════════════════════
   **Rien ici ne redefinit une boite blanche.** Onze classes construisaient la
   meme boite avant L9, en dix recettes ; la carte d'annonce prend la sienne de
   `.surface`, et ce bloc n'apporte que des dispositions. `tools/mesurer-formes.py`
   compte toujours 9 recettes pour 11 classes, et le cliquet interdit que ce
   nombre remonte. */

/* ── La grille de cartes ───────────────────────────────────────────────
   `auto-fill` et non `auto-fit` : avec deux annonces en ligne, `auto-fit`
   etire les deux cartes sur toute la largeur d'un ecran de bureau et produit
   deux photos geantes. `auto-fill` garde la largeur de colonne, donc la meme
   carte quel que soit le nombre de resultats — c'est la propriete qui fait
   qu'un catalogue qui se remplit ne change pas d'aspect. Cette decision de
   L11 tient ; c'est le PLANCHER qui etait faux.

   **9,2 rem valait 147 px ; la cible en mesure 235.** Sur la capture du
   client (1 913 px), le conteneur fait 1 874 px : `auto-fill` y taillait
   ONZE pistes de 160 px pour cinq annonces, d'ou des cartes deux fois trop
   petites ET six pistes vides — le grand blanc a droite. La capture Vinted
   posee a cote mesure cinq colonnes de 235 px exactement.

   **Pourquoi `min(14.7rem, 46%)` et pas 14,7 rem seul.** 14,7 rem = 235,2 px,
   soit la cible. Mais un plancher de 235 px sur un telephone de 320 px ne
   laisse tenir qu'UNE colonne : la page devient une pile de photos geantes,
   c'est-a-dire le defaut inverse. La part de 46 % reprend la main tant que le
   conteneur est etroit — 46 % en laisse tenir deux et jamais trois — et
   s'efface des que 46 % depasse 235 px, vers 512 px de conteneur. Il n'y a
   donc ni palier ni `@media` : la densite passe de 2 a 3 a 4 colonnes sans
   saut, et la mesure reste DECIDABLE pour `tests/cascade_css.py` — une regle
   enfermee dans un `@media` y serait INDECIDABLE, et le banc muet.

   `tests/test_l12_geometrie_carte.py` ne lit pas cette chaine : il rejoue
   `auto-fill` a sept largeurs d'ecran et compare le NOMBRE de colonnes.

   **Ce qui n'est pas resolu, et qu'il faut dire** : le blanc de droite se
   reduit (deux pistes vides au lieu de six, 539 px au lieu de 1 027), il ne
   disparait pas. Le supprimer demanderait `auto-fit`, que L11 a refuse pour
   une raison qui tient toujours. */
.cartes {
  display: grid; gap: .7rem;
  grid-template-columns: repeat(auto-fill, minmax(min(14.7rem, 46%), 1fr));
}

/* La carte. `overflow: hidden` clippe la photo a fond perdu dans le rayon
   pose par `.surface` : c'est la seule raison d'etre de cette declaration, et
   elle ne pose ni fond ni rayon — la boite vient de `.surface`. */
.carte { display: flex; flex-direction: column; overflow: hidden; }

/* La photo et le coeur : un conteneur positionne, parce que le coeur est un
   FRERE du lien et non son enfant (un `<form>` dans un `<a>` est du HTML
   invalide, et le navigateur le deplace). */
.carte__media { position: relative; display: block; }
.carte__lien { display: block; text-decoration: none; }

/* `aspect-ratio` plutot qu'une hauteur fixe : la place est reservee AVANT
   l'arrivee de l'octet, donc la grille ne saute pas au chargement (CLS).
   `object-fit: cover` recadre sans deformer — une photo etiree est pire
   qu'une photo rognee sur un vetement.

   **`height: auto` est obligatoire ici, et il manquait.** Sans lui, l'indice
   de presentation de l'attribut `height="…"` survit, les deux dimensions
   sont definies et `aspect-ratio` ne s'applique pas. La reprise de base
   (`img { height: auto }`) suffirait ; on la repete sur le composant parce
   que la paire `width` / `aspect-ratio` n'a de sens qu'avec elle, et qu'un
   lecteur doit voir les trois au meme endroit.

   **2/3 et non 3/4 : le nombre de L11 etait une erreur de lecture.** Les DEUX
   captures du client mesurent le meme rapport — 290/435 sur celle du 29/09
   a 15 h, 235/353 sur la cible Vinted du meme soir, soit 0,6667 dans les deux
   cas. `3 / 4` vaut 0,75. La DECISION de L11 — « le format est celui de la
   capture » — ne change pas ; c'est sa mise en oeuvre qui etait fausse, et
   elle n'a jamais pu se voir puisque la declaration etait inerte. */
.carte__photo {
  width: 100%; height: auto; aspect-ratio: 2 / 3;
  object-fit: cover; display: block;
}
/* **Le repli ne repose PAS sa boite, et c'est volontaire.** Il porte les deux
   classes dans le gabarit (`class="carte__photo carte__photo--vide"`) : il
   prend donc `width`, `height`, `aspect-ratio` et `object-fit` de la regle
   ci-dessus, et n'ajoute que le fond et le centrage de l'icone. Une seule
   boite decrite a un seul endroit — si on la dupliquait ici, les deux
   valeurs divergeraient au premier changement de format. */
.carte__photo--vide {
  display: grid; place-items: center;
  background: var(--card-2); color: var(--muted);   /* 4,71:1 */
}
.carte__photo--vide svg { width: 28px; height: 28px; }

.carte__corps { display: flex; flex-direction: column; gap: .15rem; padding: .5rem .6rem .6rem; }
/* La marque est le premier mot que lit l'oeil sur la capture : c'est elle qui
   porte le poids, pas le prix. */
.carte__marque {
  margin: 0; font-size: var(--fs-sm); font-weight: 650;
  color: var(--ink); overflow-wrap: anywhere;      /* 15,86:1 */
}
.carte__meta {
  margin: 0; font-size: var(--fs-xs); color: var(--muted);   /* 4,71:1 */
  display: flex; flex-wrap: wrap; gap: .25rem;
}
/* Le separateur est pose par le CSS et jamais ecrit dans le gabarit : un
   « · » litteral resterait a l'ecran quand la taille est absente. */
.carte__meta span + span::before { content: "· "; }
.carte__prix { margin: .15rem 0 0; display: flex; align-items: center; gap: .4rem; flex-wrap: wrap; }

/* ── Le coeur, pose SUR la photo ───────────────────────────────────────
   Il ne reprend pas la bordure du coeur de rangee : sur une photo, un cadre
   de 1 px disparait. Un fond opaque et une ombre le detachent de n'importe
   quelle image, claire ou sombre — c'est la seule forme qui ne depende pas de
   ce que le vendeur a photographie. */
.carte__media .coeur {
  position: absolute; right: .4rem; bottom: .4rem;
  background: var(--card); color: var(--ink-2);    /* 7,61:1 */
  border-color: transparent; box-shadow: var(--shadow);
  min-height: 34px; padding: .2rem .55rem;
}
.carte__media .coeur-on { color: var(--crit); }    /* 5,27:1 sur --card */
.coeur__n { font-variant-numeric: tabular-nums; }
/* La forme qui CONSTATE : meme dessin, aucun curseur de clic, aucun survol. */
.coeur--lecture { cursor: default; }

/* ── Le tiroir de filtres ──────────────────────────────────────────────
   Un seul balisage pour toutes les tailles d'ecran (demande client). Au dela
   de 960 px, le tiroir OUVERT devient une colonne de gauche et les resultats
   prennent le reste ; en dessous il occupe la largeur. Rien n'est duplique,
   donc les deux versions ne peuvent pas diverger. */
/* **La boite vient de `.surface .surface--douce`**, posee dans le gabarit.
   Ecrire ici un fond et un rayon aurait cree une DOUZIEME classe de boite
   blanche, six jours apres que L9 en a supprime douze — et `tools/mesurer-formes.py`
   l'aurait compte. `.tiroir` n'apporte donc que le comportement du repli. */
.tiroir { display: block; }
.tiroir__poignee {
  display: flex; align-items: center; gap: .45rem; cursor: pointer;
  min-height: 44px; padding: 0 .9rem;
  font-size: var(--fs-sm); font-weight: 650; color: var(--marine);  /* 11,79:1 */
}
.tiroir[open] .tiroir__poignee { border-bottom: 1px solid var(--border); }
/* Le formulaire perd sa bordure : il est DANS le tiroir, et deux cadres
   imbriques a 1 px ne delimitent rien de plus qu'un seul. */
.tiroir .filtrebar { border: 0; border-radius: 0; background: transparent; }
.filtrebar__large { grid-column: 1 / -1; }

/* ── Les curseurs de prix ──────────────────────────────────────────────
   `accent-color` teinte la piste et le pouce natifs : aucun controle
   reconstruit en `<div>`, donc le clavier, le lecteur d'ecran et le retour
   haptique restent ceux du systeme. C'est ce qu'un curseur reecrit perd en
   premier, et il ne le retrouve jamais tout a fait. */
.field--curseur { min-width: 0; }
.curseur { display: flex; align-items: center; gap: .6rem; }
.curseur input[type="range"] {
  flex: 1 1 auto; min-width: 0; min-height: 44px;
  accent-color: var(--accent); background: transparent;
}
.curseur output {
  font-family: var(--data); font-size: var(--fs-xs); font-variant-numeric: tabular-nums;
  color: var(--ink-2); white-space: nowrap; min-width: 4.5rem; text-align: right;  /* 6,50:1 */
}

@media (min-width: 960px) {
  /* La colonne de filtres ne s'ouvre qu'a l'ouverture du tiroir : un
     `<details>` ferme ne doit pas reserver une colonne vide. */
  .pane-filtres { display: grid; gap: 1.1rem; align-items: start; }
  .pane-filtres:has(.tiroir[open]) { grid-template-columns: 17rem minmax(0, 1fr); }
  .pane-filtres:has(.tiroir[open]) .tiroir { position: sticky; top: .8rem; }
  .tiroir[open] .filtrebar { grid-template-columns: minmax(0, 1fr); }
}

/* ══════════════════════════════════════════════════════════════════════
   L13 — le bouton d'installation dans l'en-tete UNIQUE
   ══════════════════════════════════════════════════════════════════════
   `.sitetop`, `.sitetop__installer`, `.burger`, `.burger__poignee`,
   `.burger__panneau` et `.burger__liste` ont ete SUPPRIMEES avec leur dernier
   appelant : le menu deroulant est remplace par une icone ⓘ qui mene a
   `/infos` (demande du client du 30/09). Une regle morte est une regle qu'un
   relecteur croira vivante et sur laquelle il raisonnera —
   `tests/test_inventaire_classes.py` le refuse pour tout le monde, et nomme
   ces six-la une par une. */

/* Le bouton d'installation est compact dans la barre : il doit attirer l'oeil
   par sa couleur, pas par sa taille. Le seuil tactile de 44 px reste porte par
   `.btn`, qu'on ne touche pas. */
.topbar .btn { font-size: var(--fs-xs); padding: .25rem .7rem; }

/* ── Deja installee : le bouton n'existe pas ───────────────────────────
   **Le client a demande qu'il DISPARAISSE, pas qu'il soit grise.** Proposer
   d'installer une application deja installee est une fausse information, et
   un bouton grise en est une aussi : il affirme qu'une action existe et
   qu'elle est momentanement indisponible.

   **C'est une regle CSS et non un script, et c'est le coeur de la reponse.**
   Un element efface APRES le rendu fait sauter la mise en page : la barre se
   peint avec le bouton, puis sans. `@media (display-mode: …)` est evalue a la
   PREMIERE peinture — il n'y a donc rien a reserver et rien qui saute. C'est
   le jumeau exact du `matchMedia('(display-mode: standalone)')`, et il arrive
   plus tot que n'importe quel `<script>`.

   Les quatre modes sont listes parce que le manifeste peut evoluer et que
   `minimal-ui` comme `window-controls-overlay` designent, eux aussi, une
   application DEJA installee. `browser` — le seul mode ou le bouton a un sens
   — n'y est pas.

   Aucun octet d'agent utilisateur n'entre dans cette decision. */
@media (display-mode: standalone), (display-mode: minimal-ui),
       (display-mode: fullscreen), (display-mode: window-controls-overlay) {
  .topbar__installer { display: none; }
}

/* **Aucune reprise locale de `[hidden]` ici, et c'est une mesure, pas un
   oubli.** Le repli JavaScript — Safari iOS trop ancien pour `display-mode` —
   pose `hidden` sur cette enveloppe. `[hidden] { display: none !important }`
   (ligne 115) a ferme le trou GLOBALEMENT, pour tous les composants presents
   et a venir ; c'etait sa raison d'etre, et elle tient. Une reprise locale de
   plus laisserait croire, au prochain lecteur, que la reprise globale ne
   suffit pas. */

/* ══════════════════════════════════════════════════════════════════════
   **Les deux paliers de l'en-tete, et ils sont MESURES.**
   ══════════════════════════════════════════════════════════════════════
   La barre ne peut plus s'empiler : trois colonnes explicites. Mais une
   colonne du milieu `auto` reclame TOUJOURS la largeur intrinseque du bouton,
   et les deux pistes laterales etant EGALES, la barre demande deux fois la
   largeur de son cote le plus charge. Ce prix est arithmetique, et il se
   calcule : `dev/.venv/bin/python tools/mesurer-entete.py`.

   **Mesure du 30/09 REFAITE le 01/10, et elle etait FAUSSE de 212,4 px.** Le
   budget ne comptait pas les deux acces « Deposer une annonce » et « Mon
   compte » : c'etaient des `<a>` NUS, sans classe, donc sans largeur declaree
   nulle part, et l'inventaire de l'outil ne les voyait pas. Le tableau ci-dessous
   disait donc « 323,9 px » pour une barre qui en demandait 781,1 — et le client
   a photographie le resultat sur son iPhone : « Deposer une annonce » sur deux
   lignes, PAR-DESSUS le bouton orange d'installation.

   Mesure a jour — coquille du tableau de bord, la plus chargee du site,
   metriques Helvetica, 1 rem = 16 px, les SEPT pieces du cote droit comptees.
   Chaque ligne donne la FENETRE MINIMALE dans laquelle la barre tient en une
   rangee, et la plus petite fenetre que le palier doit servir :

     palier                                      minimale   sert jusqu'a
     complet, centre SUR LA PAGE                  1107,2      1108 px
     complet, centre LIBERE                        778,4       821 px
     icone seule                                   662,2       705 px
     sans bascule ni avatar                        464,2       481 px
     sans mot-marque                               380,0       320 px

   D'ou CINQ paliers, chacun avec son seuil mesure et sa raison. Un seul
   deborde encore, et il est declare au pixel : celui des telephones de moins
   de 380 px.

   **Palier 1 — sous 820 px, le bouton perd son libelle et garde son icone.**
   C'est la reponse du lot a « quand la place manque » : on n'empile pas, on
   abrege. Le nom accessible ne bouge PAS — `aria-label="Installer
   l'application"` est pose a toutes les largeurs, donc un lecteur d'ecran
   annonce la meme chose avant et apres le palier. Une icone-bouton sans nom
   accessible est un echec WCAG 4.1.2, et l'European Accessibility Act
   s'applique au commerce en ligne depuis le 28/06/2025.

   **Palier 2 — sous 704 px, la bascule d'espace et l'avatar quittent la
   barre.** Les deux departs ne se valent pas et chacun a sa raison :

     - l'avatar est `aria-hidden="true"` : il ne porte AUCUNE information, et
       le `.visually-hidden` « Connecte en tant que … » qui la porte reste en
       place. On retire une decoration, pas un contenu ;
     - la bascule acheteur / vendeur est le seul element assez large (137,3 px
       mesures) pour que son depart fasse tenir la barre sur un telephone, et
       sa fonction est DEJA dans la barre basse : « Vendre » et « Achats » sont
       les deux espaces, a portee de pouce. C'est le meme arbitrage, et la meme
       justification, que `.search` — masquee sous 720 px depuis L9 parce que
       « Rechercher » est dans la barre basse.

   **Palier 0 bis — sous 1108 px, la piste du milieu cesse d'etre centree SUR LA
   PAGE.** Les trois colonnes restent explicites — la barre ne peut toujours PAS
   s'empiler, c'est la propriete que le client a demandee le 30/09 — mais elles
   deviennent `auto 1fr auto` : le bouton d'installation est centre dans la place
   QUI RESTE, entre ses voisins, au lieu d'etre centre sur la page.

   **Le seuil est mesure, pas choisi.** Le centrage sur la page impose deux
   pistes laterales EGALES, donc la barre demande deux fois son cote le plus
   charge : 1107,2 px pour la coquille complete. Elle tient a partir de 1108 px.
   En dessous, elle DEBORDE — et un debordement n'est pas une decision
   d'affichage. Mesure du 01/10, avant ce palier :

     un iPad en portrait      820 px   demandait   991,0   -> deborde de 171,0
     un petit portable       1024 px   demandait  1107,2   -> deborde de  83,2

   Ce ne sont pas des largeurs exotiques, et c'est la tablette qu'on laissait
   cassee apres avoir repare le telephone.

   **Ce que le client a demande le 29/09, il l'a toujours** : il regardait son
   ecran de bureau, a 1920 px, et au-dessus de 1108 px le bouton est centre sur
   la page a l'identique. En dessous, le choix n'etait pas entre deux centrages :
   il etait entre « centre entre ses voisins » et « barre qui deborde ».

   Ce que le depart du centrage vaut, palier par palier : 1107,2 -> 778,4 pour la
   barre complete, 991,0 -> 662,2 en icone seule, 604,4 -> 464,2 sans la bascule
   ni l'avatar. `tools/mutants_l15.py::L15A09` remet le centrage sous 1108 px et
   doit etre TUE : sans ce mutant, rien n'empecherait de le remettre « pour faire
   joli » dans trois semaines.

   **Palier 3 — sous 480 px, le mot-marque part et le pictogramme reste.** Il
   partait a 360 px, ce qui etait calcule sur la barre incomplete. A 480 px :
   464,2 - 84,2 (le mot-marque et sa gouttiere) = **380,0 px, et la barre tient
   sur l'iPhone de 390 px de la capture**. Le pictogramme reste un lien vers
   l'accueil, et le nom du site est dans le `<title>` comme dans le manifeste.

   **Ce qui reste ouvert, dit ici plutot que tu — et c'est tout ce qui reste.**
   A 380,0 px la barre ne tient PAS sur un telephone de 320 ni de 360 px : il
   manque 60 px au plus petit encore en service. Au-dessus de 380 px, plus aucun
   palier ne deborde. La seule facon de les trouver serait de retirer de la barre
   deux des cinq controles ronds — et les deux qui ont un doublon dans la barre
   basse sont precisement les deux acces que le client vient de demander. C'est
   une decision de produit, elle lui est posee, et elle n'est pas prise ici. Le
   deficit est DECLARE au pixel et le garde exige l'EGALITE, pas une borne.
   Voir `docs/RAPPORT-L13.md` §4 et `tests/test_l15_entete_icones.py`. */
@media (max-width: 820px) {
  .btn__libelle { display: none; }
}
/* **Le centrage SUR LA PAGE s'arrete ici, et c'est la ou il cesse de tenir.**
   `auto 1fr auto` : TROIS colonnes toujours explicites — aucun report possible,
   la barre ne peut pas s'empiler — mais plus de piste laterale egale a l'autre,
   donc plus de prix double. Le bouton d'installation reste centre dans la piste
   du milieu ; c'est la piste qui n'est plus au milieu de la page.

   Le seuil est MESURE et non choisi : page centree, la barre complete demande
   1107,2 px. Elle tient donc a partir de 1108 px, et pas en dessous. */
@media (max-width: 1107px) {
  .topbar { grid-template-columns: auto 1fr auto; }
}
@media (max-width: 704px) {
  .topbar { padding-inline: .7rem; }
  .topbar .vue-switch, .topbar .avatar { display: none; }
}
@media (max-width: 480px) {
  .topbar .brand .wordmark { display: none; }
}

/* ── La fenetre d'installation ─────────────────────────────────────────
   `<dialog>` porte nativement le role, le piege a focus, la fermeture a
   `Echap` et le fond inerte. On ne lui ajoute que sa largeur et son fond :
   le reste est deja juste.
   Le fond est pose par `::backdrop`, que seul `showModal()` active — d'ou
   l'insistance de `ui.js` sur cette methode.

   **`dvh` et pas `vh`** : c'est la lecon deja ecrite en tete de cette feuille
   pour `body { min-height: 100dvh }`, et elle n'avait pas ete appliquee ici.
   `vh` se refere au GRAND viewport — celui qu'on a barre d'adresse retractee —
   donc `85vh` depasse la hauteur reellement visible des que les barres du
   navigateur sont la, et le bas de la fenetre passe SOUS l'interface. Mesure du
   01/10 par `tools/mesurer-modale.py`, avec les barres visibles :

       iPhone SE 320x568   85vh = 483 px  pour 460 px visibles  -> 23 px dessous
       iPhone 13 390x844   85vh = 717 px  pour 659 px visibles  -> 58 px dessous

   Un navigateur qui ignore `dvh` retombe sur le comportement d'avant : la
   fenetre reste entierement utilisable. */
.modale {
  border: 0; padding: 0; max-width: min(40rem, calc(100vw - 2rem));
  width: 100%; max-height: min(85dvh, 50rem); overflow: auto;
  background: transparent; color: var(--ink);       /* 15,86:1 */
}
.modale::backdrop { background: rgba(20, 28, 51, .55); }
/* La boite vient de `.surface .surface--posee`, posee dans `base.html` : on
   ne cree pas une douzieme recette de boite blanche pour une fenetre. */
.modale__boite { display: flex; flex-direction: column; }
.modale__corps { padding: 1.1rem; }
.modale__corps h2 { font-size: var(--fs-base); margin-top: .6rem; }
.modale__corps h2:first-child { margin-top: 0; }
.modale__corps p + p { margin-top: .4rem; }
.modale__corps ul { margin: .4rem 0; padding-left: 1.2rem; }
.modale__corps li + li { margin-top: .3rem; }
/* Le pied n'existe QUE sur la feuille du bas (voir le palier ci-dessous). Sur un
   ecran de bureau, la fermeture reste ou elle est : en haut a droite, a cote du
   titre, et elle y est parfaitement atteignable a la souris. */
.modale__pied { display: none; }

/* ══════════════════════════════════════════════════════════════════════
   **La fenetre sur TELEPHONE — demande du client du 01/10.**
   « attention modal sur ecran telephone : pas adapte (exemple modal pour
   guide installation PWA) »
   ══════════════════════════════════════════════════════════════════════
   ── Le palier est MESURE, et le premier critere a ete REJETE ────────────
   Premier critere essaye : la hauteur du contenu. `tools/mesurer-modale.py`
   calcule, pour cinq terminaux, la hauteur du contenu reel du gabarit et la
   compare a la hauteur de la fenetre. Mesure du 01/10 :

       320 px   contenu 997 px   fenetre 460 px visibles   -> DEFILE
       360 px   contenu 907 px   fenetre 680 px            -> DEFILE
       390 px   contenu 840 px   fenetre 717 px            -> DEFILE
       480 px   contenu 686 px   fenetre 726 px            -> tient
       768 px   contenu 616 px   fenetre 800 px            -> tient

   **C'est le defilement qui fait tout le defaut**, et pas la largeur : quand le
   contenu defile, l'UNIQUE bouton de fermeture — qui vit dans
   `.surface__tete`, en haut — sort de l'ecran. Il ne reste plus que `Echap`
   (pas de clavier sur un telephone) ou un appui sur le fond, dont rien ne dit
   qu'il ferme.

   Mais ce critere-la place le croisement entre 390 et 480 px avec `vh`, et
   entre 480 et 768 px avec `dvh` — pour la meme fenetre, le meme jour. **Un
   palier qui se deplace quand on corrige une unite, ou quand on ajoute une
   phrase au gabarit, n'est pas un palier.** Rejete.

   ── Le critere RETENU : la gouttiere, qui ne depend pas du texte ─────────
   `max-width: min(40rem, calc(100vw - 2rem))` rend la gouttiere PROPORTIONNELLE
   au-dessus de 672 px — une vraie boite centree, dont le client ne s'est pas
   plaint — et CONSTANTE a 16 px en dessous. Une boite a seize pixels de
   gouttiere qui flotte quand meme au milieu de l'ecran n'est ni une fenetre ni
   une feuille. Le palier doit donc etre >= 480 et < 672 ; les paliers DEJA
   poses dans cette feuille sont 420, 480, 560, 640, 704, 719, 820 et 1107, et
   **480** est le seul de cet intervalle — c'est aussi celui que la feuille
   emploie deja deux fois pour dire « telephone ». Aucun neuvieme palier n'est
   invente.
   `tests/test_a11y_modale_mobile.py` verifie cette separation sur la mesure
   elle-meme : si le contenu change et qu'une largeur bascule de camp, le test
   rougit au lieu de laisser le palier perimer.

   ── Ce que devient la fenetre : une FEUILLE DU BAS ──────────────────────
   Pleine largeur, collee en bas, coins hauts arrondis. C'est la convention
   mobile, et elle resout le defaut par la geometrie : le bas de la feuille est
   la zone qu'un pouce atteint sans changer la prise.

   `<dialog>` n'est PAS reecrite en `<div>` : elle garde `aria-modal`, le piege
   a focus, `Echap` et le fond inerte. Un modal maison demanderait cent lignes
   de JavaScript pour les retrouver, et en raterait un.

   ── La fermeture descend, elle ne se duplique pas ───────────────────────
   Sous le palier, le bouton de l'en-tete est RETIRE et un bouton pleine largeur
   apparait au pied. Il y a donc **exactement un** controle de fermeture a
   toutes les largeurs, jamais deux libelles identiques dans l'arbre
   d'accessibilite, et **l'ordre du DOM suit l'ordre visuel** — le pied est apres
   le corps dans `base.html`. Reordonner avec `order` aurait casse l'ordre de
   tabulation (WCAG 2.4.3), ce qui est un defaut d'accessibilite pour en
   corriger un d'ergonomie.

   On ne compte PAS sur l'appui sur le fond : `ui.js` le gere deja, mais sur une
   feuille a 85 dvh il ne reste que la bande du haut pour l'atteindre — c'est-a-
   dire precisement l'endroit qu'on cherche a quitter.

   ── C'est le CORPS qui defile, pas la feuille ───────────────────────────
   Sans cela le pied defilerait avec le reste, et on aurait deplace le probleme
   au lieu de le resoudre. L'en-tete et le pied restent donc en place, et seul
   `.modale__corps` defile.

   ── La marge d'accueil ──────────────────────────────────────────────────
   `env(safe-area-inset-bottom)` vaut 34 px en portrait sur les iPhone a barre
   d'accueil gestuelle, et 0 partout ailleurs — d'ou le repli `0px`, sans lequel
   un navigateur qui ignore `env()` rendrait la declaration entiere invalide.

   ══════════════════════════════════════════════════════════════════════
   **CORRECTIF DU 02/10 — LA FEUILLE ETAIT INVISIBLE SUR SAFARI iOS.**
   ══════════════════════════════════════════════════════════════════════
   Rapporte par le client, Safari iOS en navigation privee : « clique -> un
   filtre un peu gris se met en place comme si le modal veut apparaitre, mais
   rien a l'ecran (pas de modal) ».

   **Ce symptome localise le defaut, et il faut le lire precisement** : le
   `::backdrop` EST peint. Or seul `showModal()` l'active. Donc le script
   s'execute, la `<dialog>` est bien ouverte et dans le top layer, le gabarit est
   servi, le service worker ne joue aucun role. **C'est la BOITE qui n'a aucune
   surface visible**, et la seule chose que ce lot a ajoutee autour d'elle est ce
   bloc-ci.

   Deux declarations s'appuyaient sur une resolution IMPLICITE de l'agent
   utilisateur, et la premiere version de ce bloc les cumulait :

     1. `margin-top: auto; margin-bottom: 0` ne colle la feuille en bas que si le
        dialogue a un bloc contenant de hauteur DEFINIE — c'est-a-dire si la
        feuille de l'agent utilisateur pose bien `inset-block: 0 0` sur
        `dialog:modal` et resout la marge automatique restante par la branche
        « sur-contrainte » de CSS 2.1 §10.6.4. Chrome le fait. **On ne peut pas
        en faire une hypothese** : une marge `auto` qui ne se resout pas vaut 0,
        et la boite se retrouve sans position utilisable ;
     2. `max-height: 100%` sur `.modale__boite` se resolvait contre un parent de
        hauteur INDEFINIE — le dialogue est en `height: fit-content`, donc sa
        hauteur depend de celle de son enfant, qui depend d'elle : un cycle. Les
        moteurs le cassent differemment ; traite comme `none` la regle est
        inoffensive, **traite comme `0` la boite s'effondre** — et comme le
        parent porte `overflow: hidden` et `background: transparent`, il ne reste
        plus rien a peindre. C'est le seul des deux qui explique une surface
        NULLE, et c'est donc le suspect principal.

   **Le correctif ne choisit pas entre les deux : il retire la classe de
   defaut.** La position et les dimensions sont posees EXPLICITEMENT, et plus
   aucune ne decoule d'une marge automatique ni d'un pourcentage :

     - `position: fixed` + `top: auto; bottom: 0; left: 0; right: 0` : le bord
       bas de la feuille est le bord bas du viewport, point. Aucune marge
       automatique n'intervient, donc aucune branche de resolution a supposer.
       **Les quatre longhands plutot que `inset`** : un moteur qui ignorerait le
       raccourci jetterait les quatre d'un coup, alors qu'une longhand inconnue
       ne jette qu'elle-meme ;
     - `margin: 0` annule explicitement le `margin: auto` de la feuille de
       l'agent utilisateur, au lieu d'en detourner une moitie ;
     - la hauteur de la boite vient du FLEX, pas d'un pourcentage :
       `.modale` est un conteneur `column` a hauteur bornee par `max-height`, et
       `.modale__boite` porte `flex: 1 1 auto; min-height: 0`. Quand le contenu
       depasse, le conteneur est clampe a `max-height` et l'element est reduit
       pour y tenir — `min-height: 0` est ce qui leve la taille minimale
       automatique qui bloquerait sinon cette reduction. Aucune hauteur
       indefinie n'est interrogee.

   `overflow: hidden` reste, mais il n'est plus porteur : il sert a garder les
   coins arrondis et a empecher la FEUILLE de defiler a la place du corps.

   **Ce que ce depot ne peut pas prouver**, et il faut l'ecrire ici plutot que
   dans un rapport que personne ne relira : `tools/mesurer-modale.py` lit des
   declarations et calcule des largeurs et des hauteurs intrinseques. **Il n'y a
   pas de navigateur dans cette chaine**, donc aucun test de ce depot ne peut
   voir une boite s'effondrer parce qu'un moteur resout une marge automatique ou
   un pourcentage autrement qu'un autre. C'est exactement le trou par lequel la
   regression est passee, en annoncant « 0 depassement » aux cinq largeurs. Les
   gardes ci-dessous interdisent donc la CLASSE de declaration, pas ses deux
   symptomes — c'est tout ce qu'un depot sans navigateur sait faire. */
@media (max-width: 480px) {
  .modale {
    max-width: none; width: 100%;
    /* Position et dimensions EXPLICITES — voir la demonstration ci-dessus.
       Aucune marge automatique, aucun pourcentage de hauteur. */
    position: fixed;
    top: auto; right: 0; bottom: 0; left: 0;
    margin: 0;
    max-height: min(85dvh, 50rem);
    overflow: hidden;
  }
  /* **`display` est porte par `[open]`, et JAMAIS par `.modale` seule.**
     Mesure du 02/10, signalee par le client : « sur iphone, le modal
     d'installation PWA est TOUJOURS OUVERT, et prend tout l'ecran ».
     La feuille de l'agent utilisateur masque une `<dialog>` fermee par
     `dialog:not([open]) { display: none }`. Poser `display: flex` sur
     `.modale` ECRASE cette regle : sous 480 px, la fenetre etait affichee en
     permanence, par-dessus la page, sans qu'aucun script l'ait ouverte.
     Le selecteur `[open]` rend la regle inoperante tant que la fenetre est
     fermee, et retablit donc le masquage natif. */
  .modale[open] { display: flex; flex-direction: column; }
  .modale__boite {
    flex: 1 1 auto; min-height: 0;
    border-radius: var(--r-lg) var(--r-lg) 0 0;
    border-bottom: 0;
  }
  .modale__corps { overflow: auto; min-height: 0; }
  .modale .surface__tete .btn { display: none; }
  .modale__pied {
    display: block; border-top: 1px solid var(--border);
    padding: .8rem .9rem calc(.8rem + env(safe-area-inset-bottom, 0px));
  }
}

/* ══════════════════════════════════════════════════════════════════════
   Lot L12 — le selecteur de recherche de categories
   ══════════════════════════════════════════════════════════════════════
   **Nous n'avons PAS livre la feuille de la bibliotheque.** `tom-select.css`
   pese 10 Ko, apporte sa propre palette et ses propres rayons, et surtout
   elle serait un SECOND fichier de style : `tests/test_a11y_contrastes.py`,
   `tools/mesurer-classes.py` et `tools/mesurer-formes.py` ne lisent que
   `app.css`. Une regle posee ailleurs echapperait aux trois — contraste non
   mesure, forme non comptee, classe non inventoriee. Ce qui suit est donc
   ecrit ici, avec les jetons du site et rien d'autre.

   Deux recettes existantes sont EMPRUNTEES plutot que recopiees :
     - le controle est dans la liste de selecteurs de `.field input` ;
     - le menu deroulant recoit `.surface .surface--posee` par le reglage
       `dropdownClass` de `categorie.js` — c'est la boite blanche du site,
       la meme que la fenetre d'installation.
   Aucune regle ci-dessous ne pose un fond blanc AVEC un rayon : le compteur
   de recettes de boite reste a 9 pour 11 classes, et le cliquet tient. */
.cat-chercher { display: flex; flex-direction: column; gap: .3rem; }
.ts-wrapper { position: relative; }
.cat-chercher .ts-control { display: flex; align-items: center; gap: .3rem; cursor: text; }
/* Le champ de frappe vit DANS le controle, qui porte deja le bord et le fond.
   Sans cette remise a plat il heriterait de `.field input` — deuxieme bord,
   deuxieme fond, 44 px de haut a l'interieur de 44 px. Le selecteur est
   volontairement plus specifique (0,2,1 contre 0,1,1) : a specificite egale
   c'est l'ordre qui tranche, et c'est ce piege-la qui a coute trois tours a
   la regle de focus des champs (voir plus haut). */
.cat-chercher .ts-control > input {
  flex: 1 1 4rem; min-width: 0; min-height: 0; width: auto;
  border: 0; background: transparent; padding: 0; box-shadow: none;
  font-family: var(--ui); font-size: var(--fs-base); color: var(--ink);
}
.cat-chercher .ts-control > input:focus { box-shadow: none; }
.cat-chercher .ts-control > input::placeholder { color: var(--muted); }
/* La valeur choisie, affichee dans le controle. */
.cat-chercher .ts-control > .ts-item { color: var(--ink); }
/* En mode simple, la bibliotheque masque le champ de frappe une fois un
   choix fait — sinon la valeur et le curseur se chevauchent. */
.ts-wrapper.input-hidden .ts-control > input { opacity: 0; position: absolute; left: -10000px; }
/* Le focus REPREND la combinaison des champs de formulaire : le bord accent
   et le halo. L'anneau a deux couches, lui, vient de la regle generale
   `input:focus-visible` posee sur le champ de frappe reel. */
.ts-wrapper.focus .ts-control { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }

/* Le menu. La boite (fond, bord, rayon, ombre) vient de `.surface
   .surface--posee` ; ici, uniquement la mise en place. */
.ts-dropdown { position: absolute; top: calc(100% + 3px); left: 0; right: 0; z-index: 30; overflow: hidden; }
.ts-dropdown-content { max-height: 17rem; overflow-y: auto; overscroll-behavior: contain; }
/* 44 px : c'est une liste qu'on touche au pouce, comme le menu burger.
   **Pas de `border-bottom`** — 176 filets sur un menu de recherche font une
   grille, et `tools/mesurer-formes.py` compte les grammaires de rangee. */
.ts-option { display: flex; align-items: center; min-height: 44px; padding: .45rem .7rem; cursor: pointer; font-size: var(--fs-sm); }
.ts-dropdown .ts-option.active { background: var(--accent-soft); color: var(--ink); }
.ts-dropdown .ts-option.selected { color: var(--ink); font-weight: 650; }
/* **La portion qui a repondu a la frappe, et pourquoi elle n'est pas une
   couleur.** Une teinte de plus rendrait le surlignage invisible sur la
   ligne survolee, qui porte deja `--accent-soft`. La graisse et le
   soulignement se voient sur les deux etats, en niveaux de gris, et pour
   un daltonien — c'est la regle des pastilles de CONVENTIONS.md §10,
   appliquee a autre chose qu'une pastille. */
.ts-dropdown .highlight { font-weight: 700; text-decoration: underline; }
/* §11 — un constat, jamais un zero fabrique. Le texte vient du gabarit. */
.ts-dropdown .no-results { padding: .55rem .7rem; color: var(--muted); font-size: var(--fs-sm); }
