/* ========== Form controls — dark override ========== */

.form-control,
.form-select {
  background-color: var(--surface);
  border-color: var(--border);
  color: var(--ink);
  border-radius: 2px;
  font-family: 'Roboto', sans-serif;
  font-size: 1rem;
}

.form-control:focus,
.form-select:focus {
  background-color: var(--surface);
  border-color: var(--accent);
  color: var(--ink);
  box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--accent) 12%, transparent);
}

.form-control::placeholder {
  color: var(--muted);
  opacity: 0.6;
}

textarea.form-control {
  resize: vertical;
}


/* ========== Button overrides — Figma style ========== */

.btn {
  border-radius: 2px;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  font-size: 0.85rem;
  font-family: 'Cinzel', Georgia, serif;
  font-weight: 600;
}

/* 0,8 rem et non 0,75 : c'est le plancher que CLAUDE.md fixe pour ce sélecteur-là
   nommément, et que cette déclaration contredisait depuis toujours. Trouvé en mesurant le
   cadre de combat (tests/browser/combat-frame-test.mjs), où 67 textes passaient sous le
   plancher — les huit boutons `.btn-sm` du panneau en faisaient partie.
   Règle GLOBALE : elle habille tous les boutons secondaires du projet, pas seulement le
   combat. Conséquence mesurée sur les barres les plus serrées, à 360 px de viewport :
   la barre MJ du roster passe de 160,00 à 165,44 px de haut (elle s'enroule déjà, elle
   prend une ligne de plus tôt), « Passage du temps » de 31,25 à 32,61, le sélecteur
   d'acteur de 434,47 à 435,83. La barre d'outils du panneau, elle, ne bouge pas
   (54,22 px avant comme après) : ses deux libellés étaient DÉJÀ repliés sur deux lignes,
   faute de `flex-wrap` — c'est le défaut que garde l'assertion 3 du même harnais. */
.btn-sm {
  font-size: 0.8rem;
}

.btn-outline-warning {
  color: var(--accent);
  border-color: var(--accent);
  background: transparent;
}

.btn-outline-warning:hover,
.btn-outline-warning:active,
.btn-outline-warning:focus {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--on-accent);
}

.btn-warning {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--on-accent);
}

.btn-warning:hover,
.btn-warning:active {
  background: var(--accent-hover);
  border-color: var(--accent-hover);
  color: var(--on-accent);
}

.btn-outline-danger {
  color: var(--danger);
  border-color: var(--danger);
}

.btn-outline-danger:hover,
.btn-outline-danger:active,
.btn-outline-danger:focus {
  background: var(--danger);
  border-color: var(--danger);
  color: var(--ink);
}

/* `background: transparent` complète une règle qui ne posait que la couleur du texte et
   celle de la bordure — comme le fait déjà `.btn-outline-warning` juste au-dessus.
   Sans lui, un élément portant la classe SANS `.btn` (Bootstrap 5.3 ne fait que poser
   des variables --bs-btn-*, c'est `.btn` qui les consomme) garde son fond natif de
   bouton, clair dans les deux thèmes : mesuré rgb(239, 239, 239) sous un texte
   var(--muted), soit 1,77:1 en thème sombre pour un seuil de 4,5.
   Pour les usages CORRECTS (`btn btn-outline-secondary`), c'est un no-op : Bootstrap
   pose déjà --bs-btn-bg: transparent sur les variantes outline — vérifié en mesurant le
   fond calculé d'un bouton d'action de modale, rgba(0, 0, 0, 0) avant comme après. */
.btn-outline-secondary {
  color: var(--muted);
  border-color: var(--border);
  background: transparent;
}

.btn-outline-secondary:hover,
.btn-outline-secondary:active {
  color: var(--ink);
  background: var(--surface-2);
  border-color: var(--accent);
}


/* ========== Cards ========== */

.card {
  background-color: var(--surface);
  border-color: var(--border);
  color: var(--ink);
  border-radius: 2px;
  position: relative;
  overflow: clip;
}

/* Gold gradient top line on every card */
.card::before {
  content: '';
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 1px;
  background: linear-gradient(to right, transparent, color-mix(in srgb, var(--accent) 50%, transparent), transparent);
  z-index: 1;
  pointer-events: none;
}

/* Le plancher de police du projet (CLAUDE.md) s'applique aussi ici : cette primitive
   habille les en-têtes de carte de toute l'application, ce qui a longtemps servi de
   raison à l'exclure du plancher. Le rayon d'action n'est pas une raison de rester
   sous le plancher, et la remontée est EN PLACE, sans media query — le plancher n'est
   pas conditionné à la largeur. */
.card-header {
  background-color: var(--surface);
  border-bottom-color: var(--border);
  color: var(--muted);
  font-size: 0.8rem;
  font-weight: 600;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  font-family: 'JetBrains Mono', monospace;
}


/* ========== Breadcrumb ========== */

.breadcrumb {
  background: transparent;
  padding: 0;
  margin-bottom: 20px;
  font-size: 0.8rem;
  font-family: 'JetBrains Mono', monospace;
  text-transform: uppercase;
  letter-spacing: 0.08em;
}

.breadcrumb-item a {
  color: var(--muted);
  text-decoration: none;
}

.breadcrumb-item a:hover {
  color: var(--ink);
}

.breadcrumb-item.active {
  color: var(--ink);
}

.breadcrumb-item + .breadcrumb-item::before {
  color: var(--accent);
}


/* ========== Badges ========== */

.badge-gm {
  background: color-mix(in srgb, var(--accent) 12%, transparent);
  color: var(--accent);
  border: 1px solid var(--border);
  font-size: 0.8rem;
  font-weight: 600;
  letter-spacing: 0.1em;
  padding: 2px 7px;
  border-radius: 2px;
  text-transform: uppercase;
  font-family: 'JetBrains Mono', monospace;
}

.badge-player {
  background: color-mix(in srgb, var(--muted) 8%, transparent);
  color: var(--muted);
  border: 1px solid color-mix(in srgb, var(--muted) 20%, transparent);
  font-size: 0.8rem;
  font-weight: 600;
  letter-spacing: 0.1em;
  padding: 2px 7px;
  border-radius: 2px;
  text-transform: uppercase;
  font-family: 'JetBrains Mono', monospace;
}


/* ========== List items — dark theme ========== */

.anima-list-item {
  background: transparent !important;
  border-color: var(--border) !important;
  color: var(--ink);
}


/* ========== Nested place card ========== */

.card-inner {
  background: var(--surface);
  border-color: var(--border);
}


/* ========== Danger zone card ========== */

.card-danger-zone {
  border: 1px solid color-mix(in srgb, var(--danger) 35%, transparent);
}


/* ========== Modal ==========

   DÉFINITION UNIQUE DE LA BOÎTE DE MODALE, pour tout le projet. Elle était éclatée en
   TROIS règles de même spécificité (0,1,0) dans trois fichiers, départagées par le seul
   ordre des `<link>` de index.html :
     components.css (ici)          largeur 100 % / max 800 px, coins 2 px, overflow: hidden
     pages/characters.css:1258     largeur 90 % / max 460 px, marge 24 px  ← « inventaire / bourse »
     pages/encounter-combat.css:737 min 560 px / max 760 px / 90vw, marge 22 px
   Mesuré avant réconciliation : encounter-combat.css est chargé 16e sur les 23 feuilles de
   index.html — sept la suivent, mais aucune ne redéclarait la boîte, si bien que sa règle
   gagnait toute propriété qu'elle déclarait ; les deux autres ne survivaient que par ce
   qu'elles étaient seules à poser. La règle de characters.css n'a survécu par RIEN : ses
   HUIT déclarations étaient toutes redéclarées plus loin. Elle était morte, et avec
   elle l'intention « les modales d'achat et de bourse sont étroites » — les quatre
   familles de modales du projet rendaient exactement la même boîte de 760 px.
   Les deux autres règles ont été supprimées ; ce qui suit est la boîte effective qu'on
   mesurait, moins le `min-width: 560px` (voir la media query plus bas).

   Cette règle doit rester à 0,1,0 et rester ICI, dans le fichier des primitives chargé
   tôt : c'est ce qui permet aux variantes (.anima-modal--wide, .anima-equipment-modal,
   .anima-picker-modal, .pacts-detail-modal…) de la surcharger avec leur propre largeur.
   Sous l'ancienne cascade elles étaient toutes mortes elles aussi. */

.anima-modal-overlay {
  position: fixed;
  inset: 0;
  /* Au-dessus de .anima-floating-window (1200) : atteignable depuis le HUD de combat
     flottant. Doit rester >= 1300, également au-dessus de l'overlay du tableau blanc
     (1050) — modales add-participant / edit-stats / enrich. Ce commentaire vient de la
     copie supprimée dans pages/characters.css, qui posait le même 1300. */
  z-index: 1300;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 16px;
  background: var(--scrim);
  backdrop-filter: blur(4px);
}

/* Le second conteneur de modale du projet — 37 templates l'emploient, contre 16 pour
   `.anima-modal-overlay`. Il vivait dans pages/encounter-combat.css alors qu'il n'a rien
   de propre au combat ; il est ici pour que la media query plus bas puisse l'atteindre
   sans dépendre de l'ordre des fichiers. Différences assumées avec l'overlay : pas de
   marge intérieure, pas de flou d'arrière-plan. */
.anima-modal-backdrop {
  position: fixed;
  inset: 0;
  background: var(--scrim);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1300; /* au-dessus de .anima-floating-window (1200) */
}

/* Le voile cliquable de `components/ui/modal`, sous les quatre racines ci-dessus.
   C'est un vrai `<button>` : il prend le focus, Entrée et Espace l'activent, et
   `no-invalid-interactive` n'a plus rien à compter là où il comptait deux violations par
   modale. Il est TRANSPARENT — l'assombrissement reste porté par la racine, qui garde son
   `background: var(--scrim)` : le voile ne fait que capter le clic.
   `position: absolute` le sort du flux flex de la racine, donc le centrage de la boîte ne
   bouge pas. `appearance: none` et la remise à zéro des marges et bordures neutralisent
   le rendu natif du bouton, qui sinon poserait un fond gris et une bordure sur tout
   l'écran. */
.anima-modal-scrim {
  position: absolute;
  inset: 0;
  z-index: 0;
  appearance: none;
  margin: 0;
  padding: 0;
  border: 0;
  background: transparent;
  cursor: default;
}

/* La boîte est le frère SUIVANT du voile, et cette règle la remonte au-dessus de lui.
   Elle est nécessaire pour les boîtes qui ne déclarent pas leur propre `position` —
   `.anima-roll-modal`, `.trade-modal`, `.anima-module-detail-modal`,
   `.anima-picker-modal` : sans elle, un élément positionné (le voile) peint par-dessus un
   frère non positionné, quel que soit l'ordre du document, et la modale serait
   inaccessible au clic. `.anima-modal` déclare déjà `position: relative` plus haut : pour
   les 62 boîtes qui la portent, cette règle ne change rien. */
.anima-modal-scrim + * {
  position: relative;
  z-index: 1;
}

/* ── Le défilement ne fuit plus derrière la modale, aux QUATRE racines d'overlay ──
   Le défaut rapporté à la main : « dans modifier un personnage, onglet image, si on
   clique sur la modale on scroll l'écran qui est derrière ; pareil depuis identité ».
   Il n'a rien de particulier à ces deux onglets, ni à cette modale. Relevé au navigateur
   sur des décalques de cinq modales réelles (l'édition de personnage, la description
   d'une compétence secondaire, le détail d'un module de combat, une aide en jeu, et une
   modale de jet ouverte DEPUIS la fiche de personnage), à la molette ET au doigt : la
   page derrière bougeait dans les vingt relevés. Aucune règle du projet ne portait
   `overscroll-behavior`, sur aucune modale.
   DEUX PORTES, et il faut les fermer toutes les deux :
   — la boîte défilante de la modale, une fois ÉPUISÉE, enchaîne sur la page. Le
     navigateur VERROUILLE sa cible le temps d'un geste, donc la fuite n'arrive pas
     pendant le geste qui épuise la boîte : elle arrive au geste SUIVANT. Mesuré sur
     l'aide en jeu à 1280x800 : premier geste, la boîte se défile de ses 1 292 px et la
     page ne bouge pas ; second geste, la page prend tout, 3 000 px ;
   — la boîte qui n'a RIEN à défiler ne retient rien du tout : c'est le cas de l'onglet
     Identité, dont le corps ne contient qu'un champ. Là il n'y a rien à verrouiller, et
     le tout premier geste part dans la page — 4 780 px à 1280x800, soit toute sa hauteur
     défilable. C'est pour ça que l'utilisateur l'a vu sur les onglets les plus courts,
     et pas sur les plus longs.
   Le VOILE est une troisième porte, et la plus béante : ce n'est pas une boîte
   défilante du tout, donc la molette au-dessus de la zone assombrie va directement à la
   page — les 2 000 px demandés au geste, dans chacune des cinq modales.
   POURQUOI ICI ET PAS SUR LES BOÎTES. `overscroll-behavior` n'a d'effet que sur une
   boîte défilante ; le voile n'en est pas une, donc l'y déclarer seul n'aurait rien
   fait. Mesuré : la variante qui ne posait `overscroll-behavior: contain` que sur les
   boîtes (`.anima-modal`, `.anima-modal-body`, `.anima-module-detail-modal`,
   `.anima-roll-modal`, `.anima-picker-list`, `.trade-modal`) fermait bien les deux
   premières portes — y compris sur une boîte qui n'a rien à défiler, ce qui n'allait pas
   de soi et a été vérifié plutôt que supposé — mais laissait le voile fuir ses 2 000 px.
   Faire du voile la boîte défilante et l'y arrêter ferme les trois d'un coup : tout ce
   qui est dans la modale remonte jusqu'à lui, et s'y arrête. Cette règle SEULE suffit,
   mesurée sur les cinq décalques ; les déclarations sur les boîtes ont donc été
   retirées plutôt que gardées « au cas où ».
   `overflow: auto` ET NON `hidden` : ce qui compte pour la retenue, c'est que le voile
   devienne une boîte défilante, et les deux valeurs le font. Le choix entre elles se joue
   ailleurs, et c'est un RAISONNEMENT, pas un relevé — rien ne déborde en usage normal, la
   boîte étant plafonnée à 90vh dans un voile qui fait la hauteur de la fenêtre, donc il
   n'y avait rien à mesurer. Ils ne diffèrent que sur une fenêtre très basse, où la boîte
   finit par dépasser son voile : `hidden` la couperait, comme le fait déjà l'état actuel
   où le débordement d'un élément `position: fixed` part hors du cadre ; `auto` le rend
   atteignable. Le choix ne peut donc que gagner de l'atteignabilité, jamais en perdre, ce
   qui est la ligne de la garde des pieds de modale.
   CE QUE `overflow` PEUT CASSER, et qui a été vérifié : une boîte défilante COUPE ses
   descendants `position: fixed` quand elle est aussi leur bloc conteneur.
   `.anima-modal-overlay` en est déjà un, par son `backdrop-filter` — les trois autres
   racines n'ont pas de filtre et ne coupent donc rien. Les modales imbriquées qui
   ouvrent leur propre racine `position: fixed` depuis un `.anima-modal-overlay` (la
   fiche de personnage ouvre les modales de jet de `stats-view`, l'édition de personnage
   ouvre `image-gen/portrait-modal`) sont posées en `inset: 0` : elles font exactement la
   taille du voile qui les contient, donc elles tiennent dedans. Vérifié au navigateur
   sur le cas imbriqué, pas seulement lu.
   MESURÉ PAR SONDE CDP ISOLÉE, pas par un harnais du dépôt : aucun des huit ne dispatche
   d'événement d'entrée, ils relèvent tous de la géométrie, jamais du geste, et une fuite de
   défilement ne se voit que si l'on molette et si l'on glisse le doigt pour de vrai. La
   sonde chargeait ces mêmes feuilles, avec les vraies polices et le charset déclaré, aux
   quatre formes 1280x800, 1920x1080, 390x844 et 360x690, en A/B sur la même page — l'état
   d'avant obtenu en annulant ces deux déclarations par surcharge. Ce que la sonde
   contrôlait en plus de la fuite, parce que c'est le risque propre à ce correctif : que
   les modales LONGUES défilent encore, à la molette ET au doigt, et que la boîte n'ait
   pas bougé d'un pixel au repos. Un verrou qui empêcherait la modale de défiler serait
   pire que le défaut d'origine. */
.anima-modal-overlay,
.anima-modal-backdrop,
.anima-roll-backdrop,
.trade-modal-backdrop {
  overflow: auto;
  overscroll-behavior: contain;
}

.anima-modal {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 22px;
  width: 90vw;
  max-width: 760px;
  max-height: 90vh;
  /* `overflow-x: hidden` venait d'ici (raccourci `overflow: hidden`), `overflow-y: auto`
     de encounter-combat.css : la boîte effective mélangeait les deux fichiers. */
  overflow-x: hidden;
  overflow-y: auto;
  color: var(--ink);
  font-size: 0.8rem;
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6); /* one-off shadow, no matching shadow token */
  position: relative;
}

.anima-modal--wide {
  max-width: 1100px;
}

/* Barre d'actions de modale — primitive elle aussi (elle sert dans les modales de
   combat comme dans les modales d'aide en jeu), remontée de pages/encounter-combat.css
   pour la même raison que le fond : la media query plus bas doit pouvoir la coller. */
.anima-modal-actions {
  display: flex;
  gap: 8px;
  justify-content: flex-end;
  margin-top: 12px;
}

/* ── Pied collant des modales à barre DANS le corps, À TOUTE LARGEUR ──────────
   `.anima-modal-body` est une boîte défilante (`max-height: 75vh; overflow-y: auto`,
   plus bas dans ce fichier). Une barre d'actions écrite DEDANS descend donc avec le
   contenu, et se retrouve hors de la zone visible dès que le corps est plus haut que
   son plafond : il faut défiler jusqu'au fond de la liste pour atteindre les boutons.
   C'est le défaut rapporté à la main sur « Ajouter un participant » (« on doit
   scroller pour pouvoir valider ou quitter ») : l'onglet Bestiaire rend le catalogue
   entier (168 fichiers dans api/data/bestiary/), l'onglet des existants rend les
   personnages liés à l'aventure PLUS tout le roster de PNJ du compte du MJ, que le
   backend ne filtre pas par aventure. La modale, elle, tenait dans l'écran ; ce ne
   sont pas les boutons qui passaient sous le viewport, c'est le fond du corps qui les
   emportait.
   RÈGLE SANS CONDITION DE LARGEUR, et POUR TOUTE LA FAMILLE, délibérément. Le trou
   venait d'une compensation conditionnelle : la barre collante n'était déclarée que
   sous 767,98 px, si bien qu'au-dessus du seuil le pied retombait au fond du corps.
   Une deuxième compensation nommant une modale à la fois (ou deux, ou trois)
   reproduirait la même forme de faute — c'est un choix de sélecteur, pas une liste.
   Les deux sélecteurs sont EXACTEMENT ceux de la règle de gouttière du socle, plus
   bas : les deux formes que le dépôt écrit, pied enfant direct du corps et pied dans
   un `<form>` fils du corps. Ils couvrent aujourd'hui treize modales — dont la
   plupart ont un contenu court et ne défilaient pas ; elles y gagnent le même filet
   et le même bandeau opaque qu'elles portent DÉJÀ sous le seuil mobile.
   ENCHAÎNEMENT D'ENFANTS, jamais un descendant : `creature-stat-block-modal` rend
   deux composants-modales dans son propre corps, et leurs barres d'actions ne
   doivent pas hériter d'une gouttière calculée pour la modale extérieure. Le `>`
   interdit de franchir la frontière d'une modale imbriquée. Même raison, écrite plus
   longuement, que pour la règle de gouttière du socle.
   Sous le seuil, le socle reprend la main sans qu'on ait à l'écrire ici :
   — `position: sticky; bottom: 0`, le fond et le liseré sont les MÊMES valeurs des
     deux côtés ;
   — les gouttières diffèrent (20 px hors mobile, 14 + 14 sous le seuil), et le
     `margin-inline` / `padding-inline` du socle est déclaré PLUS BAS dans ce
     fichier à égalité de spécificité (0,3,0) : il gagne à l'ordre des sources.
   Le corps garde son `overflow-y: auto` au-dessus du seuil : à la souris, un
   conteneur défilant est ordinaire, et c'est le pied — pas la liste — qui doit
   rester atteignable.
   Mesuré par `tests/browser/modal-desktop-reach-test.mjs`, aux cinq formes d'écran
   qu'il émule, sur les décalques d'encounters.hbs, add-effect-modal.hbs et
   creature-stat-block-modal.hbs. */
.anima-modal > .anima-modal-body > .anima-modal-actions,
.anima-modal > .anima-modal-body > form > .anima-modal-actions {
  position: sticky;
  bottom: 0;
  /* La marge négative annule la gouttière du CORPS, le padding la rétablit à
     l'intérieur du bandeau. Elle n'annule PAS celle de la modale : mesuré à 1280 px,
     le bandeau rend 283 → 997 dans une modale à 260 → 1020, donc en retrait de la
     gouttière de la modale à gauche, à droite et — le `bottom: 0` s'ancrant sur le
     corps — en bas. Le socle mobile, lui, annule les deux et va bord à bord ; ici
     l'encadrement est régulier sur les trois côtés, ce qui est le rendu voulu.
     CES 20 px SONT LA GOUTTIÈRE DÉCLARÉE DU CORPS, et la règle vaut exactement ce
     que vaut cette égalité : un `style="padding:…"` en ligne sur le corps l'emporte
     sur la déclaration, la marge négative ne correspond plus, et le bandeau se
     désaccorde. Le popover de tags de places.hbs était dans ce cas ; sa gouttière en
     ligne a été retirée, et la famille est aujourd'hui homogène.
     Le désaccord a été MESURÉ avant d'être payé, sur un décalque du popover, aux six
     largeurs 360 / 422 / 768 / 1024 / 1280 / 1920 px, en comparant les bords du
     bandeau à ceux de son corps et de sa modale (`document.scrollWidth` est aveugle
     sous un `position: fixed`, et l'`overflow-x: hidden` de `.anima-modal` rogne sans
     rien signaler) :
     — aux quatre largeurs de bureau, gouttière en ligne de 16 px contre marge de
       20 px : bandeau plus large que son corps de 4 px de chaque côté, et encore en
       retrait de la modale — ni coupé, ni sorti de la boîte ;
     — sous le seuil mobile, où le socle passe la marge à -28 px pour aller bord à
       bord, la même gouttière en ligne rendait l'écart INVERSE : bandeau 2 px trop
       court de chaque côté, donc pas bord à bord. Le calcul d'origine, qui n'avait
       relevé que les 4 px de bureau, ne l'avait pas vu.
     Deux témoins de CONTRÔLE — le même popover sans sa gouttière en ligne, et le
     décalque d'add-effect-modal, écrit dans un autre fichier — rendaient aux six
     largeurs le MÊME bandeau l'un que l'autre : bord à bord avec le corps au-dessus
     du seuil, bord à bord avec la modale en dessous. Seule la variante à gouttière en
     ligne s'en écartait. C'est ce contrôle-là qui distingue un relevé d'un chiffre :
     sans lui, une mesure fausse aurait donné 4 px à tout le monde.
     La marge négative n'a PAS été retirée pour autant. La famille compte treize
     modales, elle rend ce bandeau pleine largeur DÉJÀ sous le seuil mobile, et une
     règle appliquée à une famille sans mesurer ses membres est précisément ce qui a
     produit la seule régression visible de ce chantier. Corriger le seul membre
     divergent coûtait un attribut ; `tests/browser/modal-footer-guard.mjs` refuse
     désormais le suivant, sans navigateur. */
  margin: 12px -20px 0;
  padding: 10px 20px;
  /* Fond opaque obligatoire : sans lui la liste défile en transparence dessous. */
  background: var(--surface);
  border-top: 1px solid var(--border);
}

/* ── Pied collant des modales SANS corps, À TOUTE LARGEUR ─────────────────────
   La TROISIÈME forme de pied du dépôt, et la plus répandue : la modale n'a pas de
   `.anima-modal-body` du tout. Son contenu est écrit directement dans `.anima-modal`,
   qui EST la boîte défilante (`max-height: 90vh; overflow-y: auto`, règle de base plus
   haut), et le pied en est le dernier enfant. Il descend donc au fond du défilement dès
   que le contenu dépasse le plafond, exactement comme la famille au-dessus — mais un
   cran plus haut dans l'arbre. Le socle mobile les rattrapait déjà sous 767,98 px ; au
   dessus du seuil, rien ne le faisait — il fallait défiler jusqu'au fond du contenu pour
   atteindre « Fermer » ou « Résoudre ».
   RÈGLE SANS CONDITION DE LARGEUR, et POUR TOUTE LA FAMILLE, pour la raison écrite au
   dessus : une compensation conditionnelle est le défaut d'origine, et une compensation
   qui nomme les modales une par une en est la variante.
   DEUX ENCHAÎNEMENTS, parce que le dépôt en écrit deux — pied enfant direct de la
   modale, et pied dans un `<form>` fils de la modale (`forgot-password-modal`, dont la
   branche « formulaire » enveloppe le sien). Certaines modales de cette famille sont
   elles-mêmes un `<form class="anima-modal">` : le sélecteur porte sur la CLASSE, il les
   attrape sans avoir à nommer la balise.
   ENCHAÎNEMENT D'ENFANTS, jamais un descendant, pour la même raison que la famille au
   dessus : `creature-stat-block-modal` rend deux composants-modales dans son propre
   corps, et un descendant franchirait la frontière de la modale imbriquée. Ici le `>`
   fait aussi l'inverse et c'est voulu — la barre de chaque picker est fille directe de sa
   PROPRE `.anima-modal`, donc chacun reçoit un pied collant ancré sur son propre
   défilement, pas sur celui de la fiche qui l'ouvre.
   PAS DE MARGE NÉGATIVE, contrairement aux deux autres jeux de déclarations du fichier,
   et c'est le point qui demande une explication. Un bandeau pleine largeur suppose de
   connaître la gouttière de la modale pour l'annuler ; or une variante la redéclare —
   relevé de `getComputedStyle` au-dessus du seuil mobile, `.anima-nemesis-modal` en pose
   une plus large que la boîte de base. Une valeur codée en dur serait donc juste sur
   l'une et fausse sur l'autre, dans un sens ou dans l'autre. Le bandeau reste donc dans
   la gouttière de la modale, quelle qu'elle soit : il est en retrait à gauche, à droite
   et — quand le défilement arrive au bout — en bas. C'est déjà l'encadrement que rend la
   famille au-dessus, au-dessus du seuil mobile.
   SOUS LE SEUIL, LE SOCLE REPREND LA MAIN, et ici la spécificité demande de l'attention.
   Le socle est à 0,2,0 ; le premier sélecteur ci-dessous y est aussi (il gagne à l'ordre
   des sources, le socle est plus bas dans ce fichier), mais le second vaut 0,2,1 à cause
   de sa balise et l'emporterait. C'est pourquoi ce bloc ne déclare QUE des propriétés
   dont le socle porte la MÊME valeur : `position`, `bottom`, le fond, le liseré, et la
   gouttière VERTICALE seule. Les deux propriétés sur lesquelles les deux jeux divergent —
   la marge et la gouttière horizontale — ne sont pas déclarées ici du tout, donc le socle
   les pose seul sous le seuil. Vérifié par mesure à 360 px, avant et après ce bloc, sur un
   échantillon de modales de la famille — modales de combat, aides en jeu, les plus longues,
   les plus courtes : rectangle du bandeau, marges, gouttières, fond, liseré et position
   calculée rendent tous le même relevé, au pixel.
   DEUX MODALES N'ONT RIEN À RECEVOIR, et ce bloc ne leur enlève rien non plus. Leur pied
   n'est pas le dernier enfant d'une boîte qui défile, mais le FRÈRE du défilement dans une
   colonne flex : rien de ce que contient la boîte ne peut donc le pousser hors de l'écran.
   `.anima-picker-modal` (le sélecteur d'objets de `inventory-panel`) est une colonne flex
   en `overflow: hidden` dont c'est la liste qui défile — le bloc l'attrape, mais un
   `position: sticky` sans conteneur défilant au-dessus est inerte. La modale d'échange,
   elle, n'a jamais porté `.anima-modal` : le bloc ne la touche pas du tout. Les deux ont
   été mesurées aux cinq formes d'écran, aucun conteneur défilant entre leur pied et la
   racine d'overlay, et zéro défilement pour atteindre leurs boutons.
   Mesuré par `tests/browser/modal-desktop-reach-test.mjs`. */
.anima-modal > .anima-modal-actions,
.anima-modal > form > .anima-modal-actions {
  position: sticky;
  bottom: 0;
  padding-block: 10px;
  /* Fond opaque obligatoire : sans lui le contenu défile en transparence dessous. */
  background: var(--surface);
  border-top: 1px solid var(--border);
}

/* ── Modales sous le seuil mobile ────────────────────────────────────────────
   Le défaut corrigé : la boîte partagée portait `min-width: 560px`, si bien qu'une
   modale mesurait 560 px dans un écran de 360 — 100 px coupés de chaque côté par un
   fond en `position: fixed` sans overflow, et ceux de gauche étaient irrécupérables.
   Le `min-width` a été retiré de la règle de base plutôt que remis à 0 ici : mesuré, il
   ne mordait QUE sous 622 px de viewport (au-dessus, `width: 90vw` dépasse déjà 560),
   donc entièrement à l'intérieur de cette media query. C'était du code mort au-dessus du
   seuil, et un piège en dessous.

   SPÉCIFICITÉ 0,2,0, et voici ce que ça change EXACTEMENT — mesuré en dé-spécifiant ce
   bloc à 0,1,0 dans ce fichier même, puis en remontant les seize variantes de boîte à
   360 px :
     — quinze des seize ne bougent pas. Leur largeur est déclarée en `max-width`, qui est
       un PLAFOND : `.anima-picker-modal { max-width: 640px }` ne peut pas élargir quoi
       que ce soit dans un écran de 360, le `width: 100%` d'ici suffit ;
     — une seule bouge, `.anima-equipment-modal`, parce qu'elle déclare `width: 95vw`,
       une LARGEUR et non un plafond : à 0,1,0 elle l'emporte (components.css est chargé
       6e sur 23, elle est plus bas dans ce même fichier) et la feuille rend 342 px
       centrée avec 9 px de voile de chaque côté, au lieu de 360 px à ras bord.
   Le 0,2,0 reste donc le bon choix — mais pour ce cas-là, pas pour un débordement.
   Une version précédente de ce commentaire affirmait qu'à 0,1,0 « la modale resterait à
   640 px dans un écran de 360 » : c'est FAUX, un `max-width` ne peut pas faire ça, et
   personne n'était allé le vérifier. Un avertissement qui invoque un danger inexistant
   dissuade une simplification légitime aussi sûrement qu'il en protège d'une mauvaise.

   Ancrage EN BAS plutôt qu'au centre : c'est le motif déjà retenu pour le panneau de dés
   et la feuille du participant, et il met la barre d'actions à portée du pouce. */
@media (max-width: 767.98px) {
  /* `:has(.anima-modal)` fait DEUX choses, et les deux sont nécessaires.
     1. Il porte le sélecteur à 0,2,0. À 0,1,0 la règle perdait contre les variantes de
        conteneur déclarées dans des fichiers chargés plus tard : mesuré,
        `.anima-picker-overlay` (characters.css) gardait son `align-items: flex-start`
        et son `padding-top: 2rem`, si bien que le sélecteur d'objets s'ancrait EN HAUT
        avec 390 px de voile vide sous lui, tout en portant les coins hauts arrondis et
        la barre collante d'une feuille de bas d'écran.
     2. Il dit ce que la règle vise vraiment : un conteneur qui porte une FEUILLE. Deux
        conteneurs du projet n'en portent pas — l'aperçu d'avatar (une image en boîte à
        lumière, `.anima-character-avatar-preview-frame`) et la fiche de personnage
        (`.anima-char-sheet-modal`) ont leur propre boîte. Les ancrer en bas et leur
        retirer leur gouttière, c'était leur appliquer la moitié d'un habillage dont ils
        n'ont pas l'autre moitié.

     LE `:not(:has(…))` EST CE QUI REND LE POINT 2 VRAI POUR LA FICHE.
     Une version précédente de ce commentaire affirmait que les deux « gardent leur
     centrage et leurs marges » — c'était vrai de l'aperçu d'avatar, dont le contenu ne
     porte aucune `.anima-modal` et qui ne fait donc jamais matcher le `:has`, et FAUX
     de la fiche : aucune exclusion n'était écrite, et il suffisait qu'une modale d'aide
     s'ouvre DANS la fiche (elles portent toutes `.anima-modal`) pour que le `:has`
     matche et fasse basculer la fiche.
     Mesuré par sonde isolée à 360 px, la même fiche dans les deux états : seule,
     overlay centré avec sa marge, fiche de 328 px ; avec une aide imbriquée, overlay
     ancré en bas sans marge, fiche de 360 px — en gardant son `border-radius: 2px`.
     Elle prenait la forme d'une feuille sans en avoir l'apparence, et le déclencheur
     était l'ouverture d'une aide, pas un changement de largeur.
     `.anima-char-sheet-modal` porte sa propre boîte (pages/characters.css) : elle n'a
     pas besoin du socle, et son CONTENU ne doit pas décider de sa mise en page.
     Effet sur la spécificité : `:not(:has(.anima-char-sheet-modal))` compte pour 0,1,0
     de plus, le sélecteur passe à 0,3,0. Il ne perd rien de ce qu'il gagnait.
     L'aide, elle, reste une feuille : elle ouvre sur son propre
     `.anima-modal-backdrop` (vérifié sur les huit `how-to-modal.hbs`), que le premier
     sélecteur de ce groupe couvre toujours. */
  .anima-modal-backdrop:has(.anima-modal),
  .anima-modal-overlay:has(.anima-modal):not(:has(.anima-char-sheet-modal)),
  /* La modale d'échange entre dans le socle par son nom propre — voir le groupe
     suivant et pages/adventures.css. Un seul jeu de déclarations, trois noms de
     conteneur. */
  .trade-modal-backdrop:has(.trade-modal) {
    align-items: flex-end;
    /* L'overlay porte 16 px de marge hors mobile ; à 360 px ils coûtent 32 px de
       largeur utile pour un liseré, et ils feraient flotter la feuille au-dessus du
       bord au lieu de l'y coller. Les trois familles rendent ainsi la même chose — pour
       les conteneurs qui portent une feuille, ce que le `:has` ci-dessus délimite. */
    padding: 0;
  }

  .anima-modal-backdrop .anima-modal,
  .anima-modal-overlay .anima-modal,
  /* La modale d'échange n'a jamais porté `.anima-modal` : elle a ses propres classes
     (`.trade-modal-backdrop` / `.trade-modal`, pages/adventures.css) et rendait donc
     338 px de large décalés de 11 px du bord gauche, avec ses quatre coins arrondis
     à 6 px — une boîte de bureau au milieu d'un écran de téléphone. Elle entre dans
     le socle par son nom plutôt qu'en changeant de classe : le template garde son
     vocabulaire, et il n'y a toujours qu'UN jeu de déclarations pour la feuille de
     bas d'écran. Même motif que `.anima-log-toggle, .anima-fold-toggle` dans
     pages/encounter-combat.css.
     Le `padding: 14px` ci-dessous est exactement la gouttière que les marges
     négatives du groupe suivant annulent : la modale d'échange ne déclarait aucune
     marge intérieure propre (la sienne vit sur son en-tête et ses deux colonnes), la
     barre collante ne déborde donc pas. Les gouttières de ces enfants sont remises à
     zéro à l'horizontale dans pages/adventures.css, sinon elles s'ajouteraient. */
  .trade-modal-backdrop .trade-modal {
    min-width: 0;
    width: 100%;
    max-width: none;
    /* Feuille ancrée en bas : seuls les coins hauts sont arrondis. */
    border-radius: 8px 8px 0 0;
    /* 22 px coûtaient 44 px de largeur utile sur 360. */
    padding: 14px;
    /* La barre d'actions collante fournit elle-même le dégagement bas. */
    padding-bottom: 0;
  }

  .anima-modal-backdrop .anima-modal-actions,
  .anima-modal-overlay .anima-modal-actions,
  /* Le pied de la modale d'échange porte déjà `.anima-modal-actions` (D2) ; ce qui
     lui manquait, c'est un ancêtre que ce groupe reconnaisse. À 0,2,0 ces
     déclarations l'emportent sur `.trade-modal-footer` (0,1,0), qui garde son
     alignement vertical et cède sa marge intérieure — celle du socle est la seule
     accordée aux `-14px` de marge négative ci-dessous. */
  .trade-modal-backdrop .anima-modal-actions {
    /* La modale est elle-même le conteneur défilant (`overflow-y: auto`), donc un
       `sticky; bottom: 0` s'ancre sur SA zone visible : la barre reste au bas de
       l'écran quel que soit le défilement du contenu. Vérifié à la mesure, pas supposé.
       Le fond opaque est obligatoire — sans lui le contenu défile en transparence
       dessous. */
    position: sticky;
    bottom: 0;
    /* Pleine largeur : les marges négatives annulent les 14 px de la modale, et le
       padding les rétablit à l'intérieur du bandeau. */
    margin: 12px -14px 0;
    padding: 10px 14px;
    background: var(--surface);
    border-top: 1px solid var(--border);
    /* Trois boutons (« Dégât spécial », « Confirmer », « Annuler ») ne tiennent pas sur
       une ligne de 332 px : ils passent à la ligne au lieu d'être coupés par
       l'`overflow-x: hidden` de la modale. */
    flex-wrap: wrap;
  }

  /* Le corps de la modale cesse d'être une boîte défilante DANS une boîte défilante.
     Sur téléphone, `max-height: 75vh` + `overflow-y: auto` (règle de base, plus bas dans
     ce fichier) enferme le contenu : mesuré, 4 550 px de contenu dans 518 px de boîte
     pour `creature-stat-block-modal`, 1 861 et 1 845 pour les deux suivantes. Le geste
     au doigt tombe alors dans le mauvais défilement. C'est la modale entière qui défile,
     une fois.
     (Une version précédente de ce commentaire ajoutait que la barre d'actions collante
     ci-dessus « s'ancrerait sur CE conteneur au lieu de l'écran ». Le danger reste
     inexistant, mais plus pour la raison qui était écrite ici : depuis D2.7,
     `add-effect-modal.hbs` et `creature-stat-block-modal.hbs` écrivent bel et bien leur
     barre d'actions DANS leur propre `.anima-modal-body`, et l'argument de
     co-occurrence ci-dessous est donc caduc. Ce qui protège, c'est la règle elle-même :
     sous ce seuil elle retire au corps son `overflow-y: auto`, si bien qu'il n'est plus
     un conteneur défilant et qu'un `position: sticky` remonte jusqu'à `.anima-modal`.
     Les deux arguments d'origine sont conservés parce qu'ils restent le relevé de
     l'état d'avant D2.7 — ils ne prouvent plus rien pour l'état d'aujourd'hui :
     — co-occurrence : `.anima-modal-body` et `.anima-modal-actions` faisaient 21 usages
       chacun, dans 12 et 20 templates, et l'intersection des deux ensembles de
       templates était VIDE. Plus le cas ;
     — composition, que le premier argument ne couvre PAS : un composant portant
       `.anima-modal-actions` pourrait être invoqué depuis le corps d'une autre modale.
       Les 20 templates porteurs (12 modales de combat + 8 how-to) ont donc été remontés
       à leurs invocateurs (`<Namespace::Component`), puis ceux-ci à leurs propres
       invocateurs jusqu'aux templates de route : les 12 modales de combat viennent
       toutes de `combat-panel.hbs` ; les 8 how-to viennent de `character/*-view.hbs`,
       `character/*-form.hbs`, `craft/builder.hbs` et de trois modales de combat, elles
       rendues depuis `combat-panel.hbs`, `sheet-modal.hbs`, `stats-view.hbs`,
       `dashboard/character.hbs` et `dashboard/craft.hbs`. Aucun de ces conteneurs n'est
       un `.anima-modal-body` : `sheet-modal.hbs` a son propre
       `.anima-char-sheet-modal-body`, et les deux `<Encounter::CombatPanel>`
       d'`encounters.hbs` sont hors modale (liste des rencontres, et bloc `:hud` du
       tableau blanc). Le cas limite le plus proche va dans l'autre sens :
       `creature-stat-block-modal.hbs` rend bien deux composants-modales dans son propre
       corps, `Encounter::AuraPickerModal` et `Encounter::CapabilityPickerModal` — mais
       ni l'un ni l'autre ne porte de barre d'actions.
     Leçon : un danger inexistant dissuade une simplification légitime aussi sûrement
     qu'il en protège d'une mauvaise — mais une preuve par co-occurrence seule ne suffit
     pas à établir qu'il est inexistant.)

     Sélecteur en 0,2,0 et non `.anima-modal-body` nu : `.anima-secret-rolls-body`
     (pages/adventures.css, feuille chargée APRÈS celle-ci) porte un plafond de MÊME
     NATURE sur le même élément, en 0,1,0 — `max-height: 60vh; overflow-y: auto`, et non
     les 75vh de la règle de base d'ici ; `secret-rolls-modal.hbs` écrit
     `class="anima-modal-body anima-secret-rolls-body"`. À 0,1,0 la nôtre perdrait à
     l'ordre des sources ; le 0,2,0 gagne et neutralise les deux d'un coup, ce qui évite
     d'écrire une seconde règle pour elle.
     (Contrairement à ce qu'annonce la spec, AUCUN template non-modal ne partage
     `.anima-modal-body` : les 21 usages sont dans une `.anima-modal`.)

     La gouttière descend de 20 à 14 px : elle s'AJOUTE aux 14 px de `.anima-modal`
     ci-dessus, soit 68 px pris sur 360 avant correction, 56 après. 14 et non une autre
     valeur, par symétrie avec ces 14 px de la modale — et NON, comme l'affirmait une
     version précédente, parce que le débordement `margin: 12px -14px 0` de la barre
     d'actions le supposerait : ce `-14px` annule le padding de `.anima-modal`, pas
     celui du corps. Depuis D2.7 il existe deux barres d'actions écrites DANS un corps
     (`add-effect-modal`, `creature-stat-block-modal`) : c'est la règle qui suit, et
     elle seule, qui annule les deux gouttières pour ces deux-là. */
  .anima-modal .anima-modal-body {
    max-height: none;
    overflow-y: visible;
    padding: 14px;
  }

  /* ── En-tête collant : la CONTREPARTIE de `.anima-modal .anima-modal-body` ──
     La convention majoritaire du dépôt écrit la rangée titre + × EN DEHORS de la boîte
     défilante — `.anima-modal-header` frère de `.anima-modal-body`, et c'est le corps qui
     défile. Elle tient au-dessus du seuil mobile. Elle ne tient plus en dessous, et c'est
     la règle ci-dessus qui l'y casse : en retirant au corps son `overflow-y`, elle fait de
     `.anima-modal` la boîte défilante — et l'en-tête, qui était dehors, se retrouve
     DEDANS. Il part donc avec le contenu, et le × avec lui.
     Ces modales sont « conformes » à la convention et cassées quand même : la convention
     suppose un corps défilant que le mobile supprime.
     RELEVÉ SUR LES VINGT, décalque par décalque, molette ET doigt, à 390x844 et 360x690.
     Avant : le haut de l'en-tête tombe entre −1 264 et −6 242 px selon la modale, et
     `elementFromPoint` au centre du × ne tombe plus sur lui sur aucune des vingt. Après :
     il reste à sa place sur les vingt, aux deux formes, et le × est de nouveau touché.

     MÊME VOCABULAIRE QUE LES PIEDS COLLANTS de ce fichier, à l'envers :
     — le FOND OPAQUE est obligatoire, sans lui le contenu défile en transparence sous le
       titre. `var(--surface)` est la valeur que porte déjà `.anima-modal`, donc le
       bandeau ne se voit pas au repos ;
     — le FILET est déjà là : `.anima-modal-header` pose un `border-bottom` d'1 px
       `var(--border)`. Il cesse d'être décoratif dès que l'en-tête est collant — il marque
       où s'arrête le bandeau opaque et où reprend le contenu qui passe dessous.
     PAS DE MARGE À REPORTER EN GOUTTIÈRE, contrairement à l'en-tête collant de la modale
     de détail de module : une marge n'est pas peinte, et le contenu défilant glisserait
     dans la bande transparente qu'elle laisse. Ici `.anima-modal-header` n'en déclare
     aucune, et aucun des en-têtes visés ne porte d'autre classe — relevé sur le texte des
     templates, puis `getComputedStyle` sur les vingt décalques : `margin-bottom` vaut zéro
     partout. Le `margin-bottom` que porte `.anima-modal-title` est, lui, DANS la boîte
     peinte de l'en-tête ; il ne laisse pas de bande, il en gonfle la hauteur.
     LE `z-index` N'EST PAS DÉCORATIF, et c'est la différence avec un pied. Un pied
     collant est le DERNIER enfant : il peint naturellement au-dessus de ses frères. Un
     en-tête est le PREMIER, donc tout ce qui le suit le recouvrirait sans lui — et
     `elementFromPoint` au centre du × rendrait le contenu, pas le bouton.

     LA GOUTTIÈRE HAUTE DE LA MODALE PART, et cette seconde déclaration n'est PAS
     cosmétique. `overflow` coupe à la boîte de padding, mais un `position: sticky` est
     retenu par son bloc conteneur, qui est la boîte de CONTENU : l'en-tête épinglé
     s'arrête donc au bas de la gouttière haute, et le contenu qui défile derrière lui
     reste VISIBLE dans cette gouttière, au-dessus du titre. Vu à la capture d'écran avant
     d'être corrigé : une ligne de la description passait au-dessus du titre de la modale
     de compétence secondaire. C'est la même faute qu'une marge non peinte, du côté haut.
     ZÉRO PLUTÔT QU'UN DÉCALAGE NÉGATIF, pour la raison déjà écrite pour le pied des
     modales sans corps : un `top: -14px` supposerait connaître la gouttière, or des
     variantes la redéclarent, et la valeur serait juste sur l'une et fausse sur l'autre.
     L'en-tête porte sa propre marge intérieure haute, il n'a besoin d'aucune autre.
     CE QUE ÇA COÛTE, dit franchement : le filet d'accent de `.anima-modal::before` est
     à `top: 0` avec le même `z-index`, donc l'en-tête, qui vient après lui dans l'arbre,
     le recouvre. Sur ces vingt modales et sous ce seuil seulement, ce liseré n'est plus
     visible. Un texte qui passe au-dessus du titre est le défaut le plus grave des deux.

     LE `:has(.anima-modal-close)` EXCLUT UNE MODALE, ET UNE SEULE. Sans lui le sélecteur
     attrape 21 en-têtes ; avec lui, 20. Le refusé est celui de `dm-popup`, qui n'a pas de
     × du tout : son titre est centré et ses deux boutons vivent dans une
     `.anima-modal-actions` que le pied collant tient déjà au bas de l'écran. Un en-tête
     collant n'y rendrait atteignable rien qui ne le soit déjà, et lui coûterait sa hauteur
     d'écran en permanence — sur le seul contenu de cette modale, un message du MJ dont
     rien ne borne la longueur. Le `:has` dit la CONDITION plutôt que le nom : on épingle
     la rangée parce qu'elle porte la commande à atteindre. Mesuré comme les autres, et
     c'est ce qui rend l'exclusion chiffrée plutôt que déclarée : son en-tête vaut 103 px
     à 360x690 et son contenu défile, donc elle paierait 14,9 % de l'écran pour rien.
     COÛT EN HAUTEUR D'ÉCRAN, pour les vingt : la rangée mesure 76 px quand le titre tient
     sur une ligne et 103 px quand il en prend deux — 9,0 % et 12,2 % de la fenêtre à
     390x844, 11,0 % et 14,9 % à 360x690. Huit des vingt titres passent à la ligne à
     390 px, douze à 360. Seize de ces pixels sont le `margin-bottom` que
     `pages/characters.css` pose sur `.anima-modal-title` : une règle écrite pour les
     modales d'inventaire, qui atteint tous les titres du projet. Elle n'est PAS touchée
     ici — c'est un autre défaut, qui se mesure à toutes les largeurs et pas seulement sous
     ce seuil, et ce serait le plus gros levier sur ce coût.
     LE COLLANT N'EST PRÉLEVÉ QUE PENDANT LE DÉFILEMENT : un `position: sticky` ne déplace
     rien tant que la boîte ne défile pas. Vérifié plutôt que supposé — chacun des vingt
     décalques ramené à un contenu qui tient dans l'écran rend la même géométrie, au pixel,
     avec et sans le collant, aux deux formes. Une modale courte ne paie rien.
     Le retrait de la gouttière haute, lui, s'applique aussi au repos, et il ne coûte rien
     non plus : mesuré sur les mêmes décalques courts, l'en-tête ne bouge pas d'un pixel et
     la modale perd les 14 px de bande vide qui le surmontaient. Rien ne se déplace, une
     amorce vide disparaît.

     SPÉCIFICITÉ. Les deux sélecteurs valent 0,3,0 et vivent DANS la media query : ils ne
     peuvent rien changer au-dessus du seuil, là où la convention tient déjà. Le second
     doit dépasser le 0,2,0 du socle, qui pose la gouttière de la modale, et il le fait.
     ENCHAÎNEMENT D'ENFANTS, jamais un descendant : `creature-stat-block-modal` rend deux
     composants-modales dans son propre corps. Le `>` interdit de franchir la frontière
     d'une modale imbriquée, comme pour les trois familles de pieds. */
  .anima-modal > .anima-modal-header:has(> .anima-modal-close) {
    position: sticky;
    top: 0;
    z-index: 1;
    background: var(--surface);
  }

  .anima-modal:has(> .anima-modal-header > .anima-modal-close) {
    padding-top: 0;
  }

  /* Un pied imbriqué DANS le corps de la modale a deux gouttières de 14 px à annuler,
     pas une : celle de `.anima-modal` et celle de `.anima-modal-body` ci-dessus. La
     règle de base en annule une seule (`margin: 12px -14px 0`), calibrée pour un pied
     enfant direct de la modale, et la barre resterait en retrait de 14 px de chaque
     bord.
     0,3,0 et 0,3,1, donc les deux l'emportent sur le 0,2,0 de la règle de base sans la
     dupliquer : seule la marge horizontale est reprise.
     Ce 28 est de l'arithmétique (14 + 14). Il n'avait pas été mesuré au moment où il a
     été écrit ; il l'est depuis : à 360 px la barre du bestiaire rend 358 px de large
     (1 → 359), comme les pieds enfants directs de la modale, contre 330 px sans cette
     règle.

     DEUX FORMES, parce que le projet en écrit deux :
     — `add-effect-modal` et `creature-stat-block-modal` posent leur pied dans un
       `<form>` fils du corps ;
     — `encounters.hbs` pose les deux pieds de la modale « Ajouter un participant »
       (onglets bestiaire et « PJ / PNJ de l'aventure ») directement dans le corps, sans
       `<form>`.
     Ce sont aujourd'hui les seuls `.anima-modal-actions` du projet à vivre dans un
     `.anima-modal-body` : les 33 autres templates porteurs écrivent leur barre en frère
     du corps ou dans une modale sans corps (relevé sur les 36 fichiers de
     `app/components/**` qui emploient la classe, `encounters.hbs` compris).

     ENCHAÎNEMENT D'ENFANTS, et non un simple descendant : `creature-stat-block-modal`
     rend DEUX composants-modales dans son propre corps, `Encounter::CapabilityPickerModal`
     et `Encounter::AuraPickerModal`. Depuis D2.8, ces deux-là portent eux aussi une
     `.anima-modal-actions` — un sélecteur descendant les attraperait au passage et
     ferait déborder leur barre de 14 px de chaque côté DE LEUR PROPRE modale. Le `>`
     interdit de franchir la frontière d'une modale imbriquée : la barre d'un picker est
     fille directe de son `.anima-modal`, et ces deux templates n'ont pas de
     `.anima-modal-body` du tout — aucune des deux branches ne peut donc les atteindre.
     Mesuré à 360 px avec les deux pickers ouverts dans le corps de la fiche de
     créature : leurs deux barres gardent le `-14px` de la règle de base (contre `-28px`
     pour le pied du formulaire au-dessus d'elles dans le même corps), et rendent
     1 → 359 dans leur PROPRE modale, elle-même à 0 → 360. Elles ne débordent pas. */
  .anima-modal > .anima-modal-body > .anima-modal-actions,
  .anima-modal > .anima-modal-body > form > .anima-modal-actions {
    margin-inline: -28px;
    padding-inline: 28px;
  }
}

.anima-modal::before {
  content: '';
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 1px;
  background: linear-gradient(to right, transparent, color-mix(in srgb, var(--accent) 60%, transparent), transparent);
  z-index: 1;
  pointer-events: none;
}

.anima-modal-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 16px 20px;
  border-bottom: 1px solid var(--border);
}

.anima-modal-title {
  font-family: 'Cinzel', Georgia, serif;
  font-size: 0.85rem;
  font-weight: 600;
  letter-spacing: 0.15em;
  text-transform: uppercase;
  color: var(--accent);
}

.anima-modal-close {
  background: none;
  border: none;
  color: var(--muted);
  cursor: pointer;
  font-size: 1.3rem;
  line-height: 1;
  padding: 0 2px;
  transition: color 0.15s;
}

.anima-modal-close:hover {
  color: var(--ink);
}

.anima-modal-body {
  padding: 20px;
  max-height: 75vh;
  overflow-y: auto;
}


/* ── Modal tabs ── */
.anima-modal-tabs {
  display: flex;
  border-bottom: 1px solid var(--border);
  padding: 0 16px;
  background: var(--surface);
}

.anima-modal-tab {
  background: none;
  border: none;
  border-bottom: 2px solid transparent;
  color: color-mix(in srgb, var(--accent) 45%, transparent);
  cursor: pointer;
  font-size: 0.8rem;
  padding: 10px 14px;
  transition: color 0.15s, border-color 0.15s;
}

.anima-modal-tab:hover:not(:disabled) {
  color: color-mix(in srgb, var(--accent) 80%, transparent);
}

.anima-modal-tab.active {
  color: var(--accent);
  border-bottom-color: var(--accent);
}

.anima-modal-tab:disabled {
  opacity: 0.35;
  cursor: not-allowed;
}


/* ========== Info button (ⓘ) ========== */

.anima-info-btn {
  background: none;
  border: none;
  padding: 0 3px;
  margin-left: 3px;
  color: var(--accent);
  font-size: 0.9rem;
  line-height: 1;
  cursor: pointer;
  opacity: 0.7;
  vertical-align: middle;
  transition: opacity 0.15s;
}

.anima-info-btn:hover {
  opacity: 1;
}


/* ========== Stat info modal content ========== */

.anima-modal-formula {
  font-size: 0.85rem;
  color: var(--accent);
  margin-bottom: 10px;
  font-style: italic;
}

.anima-modal-desc {
  font-size: 0.85rem;
  color: var(--muted);
  line-height: 1.7;
  margin-bottom: 14px;
}

.anima-modal-table {
  width: 100%;
  font-size: 0.85rem;
  border-collapse: collapse;
  margin-bottom: 10px;
}

.anima-modal-table thead tr {
  color: var(--muted);
}

.anima-modal-table thead th {
  text-align: left;
  padding: 4px 8px;
  font-weight: normal;
}

.anima-modal-table tbody td {
  padding: 5px 8px;
  color: var(--ink);
  border-top: 1px solid color-mix(in srgb, var(--accent) 6%, transparent);
}

.anima-modal-row--current td {
  color: var(--accent);
  font-weight: 600;
  background: color-mix(in srgb, var(--accent) 8%, transparent);
}

.anima-modal-note {
  font-size: 0.8rem;
  color: var(--muted);
  font-style: italic;
  margin-top: 4px;
}

.anima-modal-section-title {
  font-size: 0.8rem;
  font-weight: 600;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--muted);
  margin-top: 18px;
  margin-bottom: 8px;
  padding-top: 12px;
  border-top: 1px solid color-mix(in srgb, var(--accent) 12%, transparent);
}


/* ========== Field label ========== */

.anima-field-label {
  display: block;
  font-family: 'JetBrains Mono', monospace;
  font-size: 0.8rem;
  text-transform: uppercase;
  letter-spacing: 0.15em;
  color: var(--muted);
  margin-bottom: 6px;
}


/* ========== Empty state ========== */

.anima-empty-state {
  text-align: center;
  padding: 48px 16px;
  border: 1px dashed var(--border);
  border-radius: 2px;
}

.anima-empty-state p {
  color: var(--muted);
  font-family: 'Roboto', sans-serif;
  font-size: 0.95rem;
  margin: 0;
}

.anima-empty-hint {
  font-size: 0.85rem !important;
  opacity: 0.7;
  margin-top: 4px !important;
}


/* ========== Manage link ========== */

.anima-manage-link {
  color: var(--accent);
  text-decoration: none;
  font-size: 0.8rem;
  font-family: 'JetBrains Mono', monospace;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  transition: color 0.15s;
}

.anima-manage-link:hover {
  color: var(--ink);
}


/* ========== Back link ========== */

.anima-back-link {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-family: 'JetBrains Mono', monospace;
  font-size: 0.8rem;
  text-transform: uppercase;
  letter-spacing: 0.12em;
  color: var(--muted);
  text-decoration: none;
  margin-bottom: 20px;
  transition: color 0.15s;
}

.anima-back-link:hover {
  color: var(--accent);
}

button.anima-back-link {
  background: none;
  border: none;
  padding: 0;
  cursor: pointer;
}


/* ========== Danger zone card — red gradient line ========== */

.card-danger-zone::before {
  background: linear-gradient(to right, transparent, color-mix(in srgb, var(--danger) 40%, transparent), transparent);
}


/* ========== Scrollbar ========== */

::-webkit-scrollbar {
  width: 6px;
  height: 6px;
}

::-webkit-scrollbar-track {
  background: var(--bg);
}

::-webkit-scrollbar-thumb {
  background: var(--border);
  border-radius: 3px;
}

::-webkit-scrollbar-thumb:hover {
  background: var(--accent);
}


/* ========== Status badge ========== */

.anima-status-badge {
  font-family: 'JetBrains Mono', monospace;
  font-size: 0.8rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.12em;
  padding: 2px 8px;
  border-radius: 2px;
  border: 1px solid;
  flex-shrink: 0;
}

.anima-status-badge--active {
  color: var(--success);
  border-color: color-mix(in srgb, var(--success) 30%, transparent);
  background: color-mix(in srgb, var(--success) 6%, transparent);
}

.anima-status-badge--inactive {
  color: var(--muted);
  border-color: color-mix(in srgb, var(--muted) 25%, transparent);
  background: color-mix(in srgb, var(--muted) 5%, transparent);
}

/* ── Equipment catalog modal ── */
.anima-equipment-modal {
  max-width: 720px;
  width: 95vw;
  max-height: 85vh;
  overflow-y: auto;
}

.anima-equipment-modal-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
  gap: 12px;
}

.anima-modal-separator {
  border-color: var(--border);
  margin: 16px 0 12px;
}

.anima-equipment-actions {
  white-space: nowrap;
  text-align: right;
}

.anima-weapon-types-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
  gap: 6px;
  margin-top: 4px;
}

.anima-weapon-type-check {
  display: flex;
  align-items: center;
  font-size: 0.8rem;
  cursor: pointer;
}

.anima-equipment-modal-full-width {
  grid-column: 1 / -1;
}


/* ── Bouton nu ───────────────────────────────────────────────────────────────

   Neutralise le rendu natif d'un `<button>` pour qu'il se peigne comme le `<div>`
   ou le `<span>` qu'il remplace. Elle s'ajoute À CÔTÉ de la classe d'origine, qui
   reste seule à décrire l'apparence : posée dans `components.css`, cette règle est
   battue par n'importe quelle règle de même spécificité écrite dans un fichier de
   `pages/`, qui sont tous liés après elle dans `app/index.html`.

   `font: inherit` est le raccourci, il remet aussi `font-size` sur l'héritage —
   c'est exactement ce que faisait le `<div>`. Les classes qui déclarent une taille
   (`.anima-secondary-field-header`, `.anima-list-section-label`, …) la reprennent
   par l'ordre des feuilles.

   Rien ici ne touche à `display` : le `<button>` reste `inline-block` tant que sa
   classe d'origine ne dit pas autre chose. Les quelques éléments dont la classe ne
   déclarait aucune disposition reçoivent la leur à côté de leur propre règle. */
.anima-plain-button {
  appearance: none;
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  text-align: inherit;
  cursor: pointer;
}


/* ── Sortable table column headers ── */
.anima-sortable-th {
  cursor: pointer;
  user-select: none;
  white-space: nowrap;
}

.anima-sortable-th:hover {
  opacity: 0.85;
}

/* Le tri est commandé par un `<button>` DANS la cellule, pas par la cellule : un
   `<th>` n'est pas interactif, il ne prend pas le focus et n'a pas d'activation au
   clavier. Le bouton occupe toute la largeur de la cellule pour que la surface
   cliquable ne se réduise pas au libellé. `.anima-sortable-th` garde le curseur, la
   coupe et l'apparence ; `aria-sort` est porté par la cellule, comme l'exige ARIA. */
/* Deux propriétés HÉRITÉES que la feuille de l'agent utilisateur remet à leur valeur
   initiale sur les contrôles de formulaire, et que `font: inherit` ne rattrape pas :
   elles ne font pas partie du raccourci `font`. Le libellé descend donc dans le bouton
   en perdant la typographie de sa cellule.
   MESURÉ en Chromium sur la page Équipement, dont les `<th>` déclarent les deux :
   la cellule calcule `text-transform: uppercase` et `letter-spacing: 1.152px`, le
   bouton `none` et `normal`, et « Disponibilite » se lit en minuscules. La largeur
   propre de l'en-tête tombe de la somme de son interlettrage — 16,1 px sur ce
   libellé — ce qui redistribue toute la ligne d'en-têtes.
   Inerte sur le tableau d'inventaire de la fiche, dont les `<th>` ne déclarent ni
   l'une ni l'autre : `inherit` y reprend la même valeur qu'avant. */
.anima-sortable-th-btn {
  width: 100%;
  letter-spacing: inherit;
  text-transform: inherit;
}

/* La taille est en rem comme partout ailleurs : en `em` elle se calculait contre l'en-tête
   qui la porte, donc hors de portée du plancher de 0,8 rem que modal-font-floor-guard
   vérifie — et cette garde sautait en silence toute valeur qu'elle ne savait pas convertir.
   La flèche grandit ; c'est le plancher qui le veut, il ne connaît pas d'exception pour les
   glyphes. */
.anima-sort-icon {
  display: inline-block;
  margin-left: 4px;
  font-size: 0.8rem;
  opacity: 0.35;
  vertical-align: middle;
}

.anima-sortable-th.anima-sorted .anima-sort-icon {
  opacity: 1;
  color: var(--accent);
}

/* ── Toast notifications ──────────────────────────────────────────────── */

.anima-toast-container {
  position: fixed;
  bottom: 92px; /* was 24px — sits above the dice FAB (56px + margins) */
  right: 24px;
  z-index: 1090;
  display: flex;
  flex-direction: column;
  gap: 10px;
  pointer-events: none;
  max-width: 380px;
}

.anima-toast {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  padding: 12px 14px;
  border-radius: 5px;
  pointer-events: all;
  font-family: 'Roboto', sans-serif;
  font-size: 0.85rem;
  line-height: 1.4;
  animation: anima-toast-in 0.22s ease-out;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5); /* one-off shadow, no matching shadow token */
}

.anima-toast--success {
  background: color-mix(in srgb, var(--success) 15%, var(--bg));
  border: 1px solid color-mix(in srgb, var(--success) 35%, transparent);
  color: var(--success);
}

.anima-toast--error {
  background: color-mix(in srgb, var(--danger) 15%, var(--bg));
  border: 1px solid color-mix(in srgb, var(--danger) 35%, transparent);
  color: var(--danger);
}

.anima-toast--info {
  background: color-mix(in srgb, var(--zeon) 15%, var(--bg));
  border: 1px solid color-mix(in srgb, var(--zeon) 35%, transparent);
  color: var(--zeon);
}

.anima-toast-icon {
  font-size: 0.85rem;
  flex-shrink: 0;
  line-height: 1.4;
}

.anima-toast-message {
  flex: 1;
}

.anima-toast-close {
  background: none;
  border: none;
  color: inherit;
  font-size: 1.1rem;
  cursor: pointer;
  opacity: 0.5;
  padding: 0;
  line-height: 1;
  flex-shrink: 0;
  margin-top: -1px;
}

.anima-toast-close:hover {
  opacity: 1;
}

@keyframes anima-toast-in {
  from { opacity: 0; transform: translateX(16px); }
  to   { opacity: 1; transform: translateX(0); }
}

/* ── Floating window (Ui::FloatingWindow) ─────────────────────────────── */

.anima-floating-window {
  position: fixed;
  z-index: 1200; /* au-dessus du stage du tableau, sous les modals de combat */
  display: flex;
  flex-direction: column;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 2px;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); /* one-off shadow, no matching shadow token */
  overflow: hidden;
  max-width: 96vw;
  max-height: 92vh;
}

.anima-floating-window-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  padding: 0.35rem 0.6rem;
  cursor: move;
  background: var(--surface-2);
  border-bottom: 1px solid var(--border);
  user-select: none;
  /* Indispensable au tactile : sans ça le navigateur requalifie le geste en
     scroll/pan après quelques pixels, envoie `pointercancel` et le drag meurt
     en cours de route. */
  touch-action: none;
}

.anima-floating-window-title {
  font-family: 'Cinzel', Georgia, serif;
  font-size: 0.85rem;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  color: var(--accent);
}

.anima-floating-window-actions {
  display: flex;
  gap: 0.25rem;
}

.anima-floating-window-btn {
  background: transparent;
  border: none;
  color: var(--muted);
  font-size: 0.9rem;
  line-height: 1;
  padding: 0.15rem 0.4rem;
  cursor: pointer;
  border-radius: 2px;
  transition: color 0.15s;
}

.anima-floating-window-btn:hover {
  color: var(--ink);
  background: color-mix(in srgb, var(--accent) 12%, transparent);
}

.anima-floating-window-body {
  flex: 1 1 auto;
  overflow: auto;
  padding: 0.5rem;
}

.anima-floating-window-resize {
  position: absolute;
  right: 0;
  bottom: 0;
  width: 16px;
  height: 16px;
  cursor: nwse-resize;
  background: linear-gradient(135deg, transparent 50%, rgba(255, 255, 255, 0.35) 50%);
  /* Même raison que l'en-tête : le geste doit rester au composant. */
  touch-action: none;
}

/* Au doigt, 16px est sous la cible tactile minimale : on agrandit la poignée
   sans grossir le liseré visible (le dégradé reste sur le coin). */
@media (pointer: coarse) {
  .anima-floating-window-resize {
    width: 28px;
    height: 28px;
    background: linear-gradient(135deg, transparent 62%, rgba(255, 255, 255, 0.35) 62%);
  }
}

/* ========== Animation « page qui se tourne » entre onglets principaux ==========

   Règle de sûreté, à ne jamais assouplir : au repos, .anima-page-turn-stage n'a
   AUCUNE propriété de mise en page. Les pages internes contiennent quantité de
   modales en position:fixed, et un ancêtre porteur d'un transform ou d'une
   perspective redéfinit leur bloc conteneur — elles se retrouveraient décalées.
   Tout ce qui suit n'existe donc que sous .is-turning, posée puis retirée par le
   composant Ui::PageTurn. */

.anima-page-turn-stage.is-turning {
  position: fixed;
  /* Repli statique : la vraie valeur est écrite en style inline par Ui::PageTurn,
     qui mesure le bas du décor immobile (bannière + sélecteur de système + barre
     d'onglets) — hauteur variable selon la largeur de fenêtre et la route, donc
     inexprimable ici. Cette déclaration n'existe que pour que la règle reste
     cohérente si l'écriture inline venait à échouer. */
  top: var(--topnav-h);
  left: 0;
  right: 0;
  bottom: 0;
  overflow: hidden;
  perspective: 1800px;
  background: var(--bg);
  /* Sous la topnav (1046) et sous tous les calques globaux (dés, toasts, HUD). */
  z-index: 1040;
}

/* La feuille qui tourne : la scène vivante en sens arrière, le clone en sens avant. */
.anima-page-turn-stage.is-turning .anima-page-turn-live,
.anima-page-turn-stage.is-turning .anima-page-turn-sheet {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  height: 100%;
  transform-style: preserve-3d;
  transform-origin: left center;
}

.anima-page-turn-stage.is-turning .anima-page-turn-face {
  position: absolute;
  inset: 0;
  overflow: hidden;
  backface-visibility: hidden;
  background: var(--bg);
}

/* Pendant le figeage, la scène ne défile pas : les barres collantes n'ont plus rien
   à quoi s'accrocher, et leur décalage — mesuré depuis la scène, qui commence déjà
   sous la bannière — serait compté deux fois. On les remet dans le flux.
   Même problème par le bas : .anima-edit-actions est collée en bottom:0, et dans une
   face devenue un cadre en overflow:hidden jamais défilé, un élément naturellement
   situé sous le cadre est au contraire remonté dedans — la barre Enregistrer/Annuler
   d'un formulaire de personnage traverserait la page qui tourne.
   Cette liste doit grandir avec toute nouvelle position: sticky rendue dans la page —
   le CSS ne sait pas sélectionner « les éléments collants ».
   .anima-sheet-tabs : la barre d'onglets de la fiche de personnage, collante et rendue
   à l'intérieur du contenu — donc dans la zone qui tourne. (La barre de navigation
   principale, .anima-tab-bar, n'a plus besoin d'être listée : elle est au-dessus de la
   scène, dans le décor immobile. Elle ne figurait ici que parce que la fiche réutilisait
   sa classe ; ce n'est plus le cas.) */
.anima-page-turn-stage.is-turning .anima-sheet-tabs,
.anima-page-turn-stage.is-turning .anima-subtab-bar,
.anima-page-turn-stage.is-turning .anima-edit-actions {
  position: static;
}

/* Le verso n'existe que pendant la rotation : c'est le dos du parchemin, jamais du contenu. */
.anima-page-turn-verso {
  display: none;
}

.anima-page-turn-stage.is-turning .anima-page-turn-verso {
  display: block;
  transform: rotateY(180deg);
  background: repeating-linear-gradient(
    135deg,
    color-mix(in srgb, var(--surface) 88%, #000) 0 10px,
    var(--surface) 10px 20px
  );
}

/* Piège : ce verso ne s'affiche jamais en pratique. La charnière est en left center sur
   un élément posé à left:0, dans une scène en overflow:hidden — la page sort du cadre
   avant d'atteindre 90°, donc rien n'atteint jamais la plage 90°-180° où le dos ferait
   face. Combiné au filter d'animaPageTurnShade ci-dessous (qui aplatit le contexte 3D),
   ça masque un deuxième défaut : si le cadrage change un jour au point de rendre le dos
   visible (gouttière à gauche de la charnière, overflow:visible, charnière centrée), il
   apparaîtra comme le recto retourné en miroir, pas comme ce parchemin — il faudra
   d'abord déplacer le filter sur un élément enveloppant qui ne porte pas preserve-3d.
   Laissé tel quel : la maquette validée a ce cadrage et ce filter, donc l'écran actuel
   est celui qui a été approuvé ; le corriger sur la foi de cette seule analyse inverserait
   l'ordre normal (la QA navigateur doit trancher en premier). */

@keyframes animaPageTurnForward {
  from {
    transform: rotateY(0deg);
  }
  to {
    transform: rotateY(-179.9deg);
  }
}

@keyframes animaPageTurnBackward {
  from {
    transform: rotateY(-179.9deg);
  }
  to {
    transform: rotateY(0deg);
  }
}

/* Assombrissement à mi-course : c'est lui qui donne le volume à la feuille.
   Piège : brightness(1) n'est pas `none` — un filter actif, même neutre, force
   transform-style:flat sur l'élément qui l'applique (CSS Transforms L2). Or c'est ce
   même élément (.anima-page-turn-live ou .anima-page-turn-sheet) qui porte
   preserve-3d un peu plus haut : le contexte 3D est donc aplati pendant toute la
   rotation, et backface-visibility:hidden masque le recto au lieu du verso. Voir la
   note sur .anima-page-turn-verso plus haut pour ce que ça change en pratique. */
@keyframes animaPageTurnShade {
  0% {
    filter: brightness(1);
  }
  45% {
    filter: brightness(0.66);
  }
  100% {
    filter: brightness(1);
  }
}

/* Sens avant : le clone (l'ancienne page) s'en va, la nouvelle est découverte dessous. */
.anima-page-turn-stage.is-forward .anima-page-turn-sheet {
  animation:
    animaPageTurnForward var(--anima-page-turn-duration) cubic-bezier(0.55, 0.08, 0.3, 1) both,
    animaPageTurnShade var(--anima-page-turn-duration) linear;
  /* Seule celle des deux faces qui tourne réellement mérite sa propre couche compositée. */
  will-change: transform;
}

/* Sens arrière : le clone reste immobile dessous, la scène vivante revient par-dessus. */
.anima-page-turn-stage.is-backward .anima-page-turn-live {
  animation:
    animaPageTurnBackward var(--anima-page-turn-duration) cubic-bezier(0.55, 0.08, 0.3, 1) both,
    animaPageTurnShade var(--anima-page-turn-duration) linear;
  /* Seule celle des deux faces qui tourne réellement mérite sa propre couche compositée. */
  will-change: transform;
}

/* ========== Teinte de section (fiche de personnage) ==========

   Une variable --section est posée par le wrapper .anima-section-tint--<teinte>, puis
   héritée par tout le sous-arbre de l'onglet. Une seule recette, neuf teintes.
   Les deux thèmes marchent sans duplication : les jetons pointés (--ki, --combat…)
   sont remappés dans :root[data-theme="dark"] de tokens.css.

   Le :where() n'est PAS cosmétique : il annule la spécificité de .card et
   .card-header, ce qui ramène la recette à 0,1,0. Sans lui elle pèserait 0,3,0 et
   écraserait les styles déjà en place de Magie (.magic-path-card > .card-header,
   0,2,0), Psy (.psy-card-header) et Convocation (.creature-card-header). Ne pas
   le retirer.

   La teinte du header est PLATE, pas un dégradé — c'est délibéré. La première
   version posait un dégradé horizontal (45%→8% de --section) sur cette même
   règle ; test A/B dans le vrai navigateur de l'utilisateur (pas un harnais headless,
   qui dithère et masque le défaut) : le dégradé bandait visiblement (marches de
   quantification d'une rampe alpha à faible contraste, étirée sur ~850px, dans un
   .card Bootstrap dont le parent est en overflow:clip). Seule la suppression du
   dégradé a supprimé les bandes. Ne pas réintroduire de dégradé ici. */
.anima-section-tint            { --section: var(--accent); }   /* repli bordeaux */
.anima-section-tint--stats     { --section: var(--stats); }
.anima-section-tint--combat    { --section: var(--combat); }
.anima-section-tint--martial   { --section: var(--martial); }
.anima-section-tint--ki        { --section: var(--ki); }
.anima-section-tint--nemesis   { --section: var(--nemesis); }
.anima-section-tint--psychic   { --section: var(--psychic); }
.anima-section-tint--magic     { --section: var(--zeon); }
.anima-section-tint--summoning { --section: var(--hp); }
.anima-section-tint--inventory { --section: var(--inventory); }

.anima-section-tint :where(.card) {
  border-color: color-mix(in srgb, var(--section) 40%, transparent);
}

.anima-section-tint :where(.card) > :where(.card-header) {
  background: color-mix(in srgb, var(--section) 26%, transparent);
  border-bottom-color: color-mix(in srgb, var(--section) 35%, transparent);
  color: var(--ink-strong);
}

/* ── Tailles sorties du markup (plancher de police) ──────────────────────────
   Ces classes remplacent des `style="font-size:…"` écrits à la main dans les
   gabarits. Aucune feuille ne pouvait les rattraper sans `!important` : un style
   en ligne bat toute règle de sélecteur. Elles sont ici, et pas dans une feuille
   de page, parce que chacune sert depuis au moins deux pages.
   Le plancher du projet est 0.8rem (CLAUDE.md) ; les valeurs d'origine étaient
   toutes en dessous. */

/* Alerte compacte posée dans une carte ou un panneau, sous un formulaire.
   Bootstrap ne donne pas de taille à `.alert` : sans la déclaration en ligne
   elle prendrait la taille du corps, ce qui n'est pas l'intention. */
.anima-alert-inline {
  font-size: 0.8rem;
}

/* Nom d'entité en version compacte : listes denses, rangées de participants,
   titres de rangée de lieu. Le sélecteur est composé À DESSEIN — la taille de
   base de `.anima-char-name` (et de `.anima-adv-name`) est déclarée dans une
   feuille de page chargée APRÈS celle-ci, donc une classe simple perdrait
   l'arbitrage de l'ordre source à spécificité égale. */
.anima-name-compact,
.anima-char-name.anima-name-compact,
.anima-adv-name.anima-name-compact {
  font-size: 0.8rem;
}

/* Message d'erreur d'un éditeur en ligne (PV, fatigue), rendu sous la rangée. */
.anima-inline-edit-error {
  font-size: 0.8rem;
  color: var(--danger);
}

/* Paire « valider / annuler » d'un éditeur en ligne (PV, fatigue). Elle sert sur
   deux pages — la liste des personnages liés d'une aventure et les dérivés d'une
   fiche — d'où sa place ici plutôt que dans l'une des deux feuilles de page.
   Les deux gouttières diffèrent d'un pixel à l'horizontale : le glyphe « ✓ » est
   plus large que le « ✕ », et les valeurs sont reprises telles quelles. Elles
   battent celles de `.btn-sm` par l'ordre des feuilles — vendor.css est chargée
   avant celle-ci — à spécificité égale. */
.anima-inline-confirm-btn {
  padding: 1px 6px;
}

.anima-inline-cancel-btn {
  padding: 1px 5px;
}
