/* ===========================================================================
   Aurora — anim.css · couche ANIMATION de l'interface web
   ---------------------------------------------------------------------------
   A charger APRES style.css et themes.css : ce fichier ne fait que surcharger,
   il ne redefinit jamais une couleur ni une geometrie.

   PRINCIPES (pourquoi c'est ecrit comme ca)
   -----------------------------------------
   1. Sobriete : 160-320 ms, une seule courbe (--an-ease). Au-dela on percoit
      l'attente ; en deca le mouvement n'est pas lu comme un deplacement.
   2. Performance : on n'anime QUE `transform` et `opacity` — les deux seules
      proprietes que le compositeur traite sans recalcul de mise en page. Aucune
      animation infinie sur un element durablement visible (seules exceptions
      assumees : le squelette de chargement, qui est par nature temporaire, et
      les 3 points de la frappe, qui disparaissent avec la barre).
   3. Zero couleur en dur : tout passe par les jetons de style.css / themes.css,
      donc le rendu suit les 7 palettes et les themes clair comme sombre.
   4. Coherence avec le JS existant : la classe `.hidden` vaut
      `display:none !important`. Une entree est donc gratuite (l'animation
      redemarre quand l'element repasse en `display` visible) mais une SORTIE
      est impossible en CSS pur — d'ou les classes `.closing` / `.an-out`, que
      le JS pose puis retire (voir le champ `integration` de la livraison).
   5. Remplissage : les ENTREES sont en `backwards`, jamais en `both`. Une
      animation remplie vers l'avant (`forwards`/`both`) continue d'imposer sa
      derniere image APRES la fin, et l'origine « animation » l'emporte sur
      toute declaration d'auteur : un `:hover`/`:active` qui touche `transform`
      devient alors definitivement inoperant sur l'element anime. `backwards`
      tient l'etat initial pendant le delai de cascade — le seul besoin reel —
      puis rend la main. Seules les SORTIES gardent `both`, parce qu'elles
      doivent justement figer l'etat final le temps que le JS masque.
   =========================================================================== */

:root {
  /* Courbe unique de toute l'application : depart franc, arrivee posee. */
  --an-ease: cubic-bezier(.22, 1, .36, 1);
  /* Variante « sortie » : pour ce qui quitte l'ecran, on veut l'inverse. */
  --an-ease-in: cubic-bezier(.55, 0, .78, .2);

  --an-fast: 160ms;   /* micro-retours : appui, pastille, survol */
  --an-base: 220ms;   /* cas general : panneau, bulle, menu */
  --an-slow: 320ms;   /* deplacement de grande amplitude : modale, mobile */

  --an-step: 26ms;    /* pas de la cascade de listes */
}

/* ===========================================================================
   1. BIBLIOTHEQUE DE KEYFRAMES
   Toutes prefixees `an-` pour ne jamais entrer en collision avec les
   keyframes deja declarees dans style.css (float, bob, msgIn, dockUp…).
   =========================================================================== */

@keyframes an-fade      { from { opacity: 0; } }
@keyframes an-in        { from { opacity: 0; transform: translateY(6px); } }
@keyframes an-up        { from { opacity: 0; transform: translateY(16px); } }
@keyframes an-down      { from { opacity: 0; transform: translateY(-16px); } }
@keyframes an-left      { from { opacity: 0; transform: translateX(18px); } }
@keyframes an-right     { from { opacity: 0; transform: translateX(-18px); } }
@keyframes an-rise      { from { opacity: 0; transform: translateY(10px) scale(.99); } }

/* Le « pop » depasse legerement (1.12) puis revient : c'est ce depassement
   qui fait lire l'element comme vivant plutot que comme un simple fondu. */
@keyframes an-pop {
  0%   { opacity: 0; transform: scale(.55); }
  62%  { opacity: 1; transform: scale(1.12); }
  100% { opacity: 1; transform: scale(1); }
}

/* Sorties (posees par le JS, cf. `.an-out` et `.closing`). */
@keyframes an-out       { to { opacity: 0; transform: translateY(8px) scale(.98); } }
@keyframes an-out-down  { to { opacity: 0; transform: translateY(14px); } }
@keyframes an-out-up    { to { opacity: 0; transform: translateY(-100%); } }
@keyframes an-panel-out { to { opacity: 0; transform: translateX(18px); } }

@keyframes an-fade-out  { to { opacity: 0; } }

/* Bulles : l'origine est posee par la regle, pas par la keyframe, pour que
   `.mine` et `.theirs` partagent la meme animation. */
@keyframes an-bubble-in { from { opacity: 0; transform: translateY(14px) scale(.94); } }

/* Doublons VOLONTAIRES de an-down / an-rise / an-up, reserves au changement de
   conversation. Pourquoi : une animation CSS ne repart que si son NOM change.
   Comme #chat-active reste visible d'une conversation a l'autre, poser une
   classe qui reutiliserait le meme nom ne relancerait rien du tout. Les deux
   jeux de noms permettent au JS de relancer par simple bascule de classe. */
@keyframes an-head-enter     { from { opacity: 0; transform: translateY(-16px); } }
@keyframes an-msgs-enter     { from { opacity: 0; transform: translateY(10px) scale(.99); } }
@keyframes an-composer-enter { from { opacity: 0; transform: translateY(16px); } }

/* --- Barre laterale -------------------------------------------------------
   Panneaux d'onglets. L'amplitude est volontairement MINUSCULE (6px) : le
   panneau ne traverse pas l'ecran, il « prend sa place ». Une amplitude plus
   grande donnait un glissement lointain et brutal, et surtout elle sortait du
   cadre de `.sidebar-scroll` — dont l'axe horizontal passe en `auto` des que
   l'axe vertical defile — ce qui faisait clignoter une barre de defilement
   horizontale a chaque changement d'onglet. */
@keyframes an-pane-next { from { opacity: 0; transform: translateX(6px); } }
@keyframes an-pane-prev { from { opacity: 0; transform: translateX(-6px); } }

/* Cascade de liste : deplacement PUR, aucune echelle. Une entree qui grandit
   fait « pop » sur 20 lignes alignees ; 6px de montee suffisent a faire lire
   l'arrivee sans transformer la liste en feu d'artifice. */
@keyframes an-stagger-item { from { opacity: 0; transform: translateY(6px); } }

/* Vignettes du bandeau de statuts : le bandeau defile horizontalement, donc
   toute translation POSITIVE en X y ajouterait de la largeur defilable. Une
   mise a l'echelle vers le bas ne peut, elle, jamais deborder. */
@keyframes an-chip-in { from { opacity: 0; transform: scale(.92); } }

/* Conversation choisie par l'utilisateur : la ligne se cale, elle n'apparait
   pas (elle etait deja a l'ecran). Pas d'opacite : rien ne doit clignoter. */
@keyframes an-conv-picked { from { transform: translateX(5px); } }

/* Pastille de non-lus : montee en taille SANS depassement. `an-pop` (qui monte
   a 1.12 avant de redescendre) etait ici du rebond gratuit sur un objet de
   18px, joue a chaque redessin de la liste. */
@keyframes an-badge-in { from { opacity: 0; transform: scale(.75); } }

/* Modale : la carte monte, le fond se pose. */
@keyframes an-card-in   { from { opacity: 0; transform: translateY(26px) scale(.965); } }
@keyframes an-card-out  { to   { opacity: 0; transform: translateY(14px) scale(.975); } }

/* Toast : conserve le -50% horizontal du centrage de style.css. */
@keyframes an-toast-in  { from { opacity: 0; transform: translate(-50%, 22px) scale(.96); } }
@keyframes an-toast-out { to   { opacity: 0; transform: translate(-50%, 14px) scale(.98); } }

/* Balayage du squelette : translation pure, aucun repaint de fond. */
@keyframes an-sweep     { from { transform: translateX(-100%); } to { transform: translateX(100%); } }

/* Points de frappe : remplace `td` avec une amplitude plus douce. */
@keyframes an-dot       { 0%, 62%, 100% { transform: translateY(0); opacity: .35; } 28% { transform: translateY(-4px); opacity: 1; } }

/* ===========================================================================
   2. CLASSES UTILITAIRES REUTILISABLES
   Le JS n'a qu'a poser la classe ; il n'a jamais besoin d'ecrire de style.
   Toutes en `backwards` : l'element reste a l'etat initial pendant son eventuel
   delai (sinon la cascade « clignote » avant de demarrer) et il retrouve ses
   propres styles a la fin, donc `:hover` / `:active` continuent de fonctionner.
   Pas de `will-change` : le navigateur promeut deja de lui-meme un element dont
   `transform`/`opacity` sont anime; le poser en dur laisserait une couche de
   composition ouverte tant que la classe reste sur l'element.
   =========================================================================== */

.an-in,
.an-up,
.an-down,
.an-left,
.an-right,
.an-rise,
.an-fade,
.an-pop {
  animation-duration: var(--an-base);
  animation-timing-function: var(--an-ease);
  animation-fill-mode: backwards;
}

.an-in    { animation-name: an-in; }
.an-up    { animation-name: an-up; }
.an-down  { animation-name: an-down; }
.an-left  { animation-name: an-left; }
.an-right { animation-name: an-right; }
.an-rise  { animation-name: an-rise; }
.an-fade  { animation-name: an-fade; animation-duration: var(--an-fast); }
.an-pop   { animation-name: an-pop; }

/* Sortie : a poser juste avant de masquer / retirer l'element, puis a retirer
   sur `animationend` (le JS ne doit pas se fier a un setTimeout approximatif). */
.an-out {
  animation: an-out var(--an-fast) var(--an-ease-in) both;
  pointer-events: none;
}

/* Modulateurs de duree, combinables avec n'importe quelle classe ci-dessus. */
.an-fast { animation-duration: var(--an-fast); }
.an-slow { animation-duration: var(--an-slow); }

/* --- Cascade -------------------------------------------------------------
   Le delai est plafonne au 8e element : au-dela on ferait attendre le bas
   d'une liste de 100 lignes pour un gain visuel nul, et on paierait 100
   compositions inutiles. Plafond a 7 pas = 182 ms : la derniere ligne visible
   d'une barre laterale finit AVANT que l'oeil ait fini de descendre.
   Le mouvement est `an-stagger-item` (6px, aucune echelle) : voir le
   commentaire de la keyframe. */
.an-stagger > * {
  animation: an-stagger-item var(--an-base) var(--an-ease) backwards;
}
.an-stagger > *:nth-child(1)  { animation-delay: 0ms; }
.an-stagger > *:nth-child(2)  { animation-delay: calc(var(--an-step) * 1); }
.an-stagger > *:nth-child(3)  { animation-delay: calc(var(--an-step) * 2); }
.an-stagger > *:nth-child(4)  { animation-delay: calc(var(--an-step) * 3); }
.an-stagger > *:nth-child(5)  { animation-delay: calc(var(--an-step) * 4); }
.an-stagger > *:nth-child(6)  { animation-delay: calc(var(--an-step) * 5); }
.an-stagger > *:nth-child(7)  { animation-delay: calc(var(--an-step) * 6); }
.an-stagger > *:nth-child(n+8) { animation-delay: calc(var(--an-step) * 7); }

/* ===========================================================================
   3. BARRE LATERALE — entete, onglets, panneaux
   Tout ce qui bouge dans la colonne de gauche est regroupe ici : c'est la zone
   que l'oeil parcourt en permanence, donc celle ou un mouvement de trop se
   remarque le plus vite.
   =========================================================================== */

/* --- 3.a Coiffe de la barre (entete, recherche, onglets, pied) -------------
   Ces quatre blocs ne bougent jamais ensuite : ils n'ont besoin QUE d'une
   entree, et cette entree ne doit se jouer qu'une fois — a l'ouverture de
   l'application. `#app` porte `.hidden` (display:none) tant que la connexion
   n'est pas faite : le passage a l'etat visible relance l'animation tout seul,
   sans une ligne de JS, et rien ne la rejoue ensuite.
   Precision mobile : la bascule liste <-> conversation passe par un transform
   (section 4.b) et NON par `.hidden` sur `#app` ; revenir a la liste ne
   redeclenche donc pas cette entree. */
#app:not(.hidden) .me-bar,
#app:not(.hidden) .search-wrap,
#app:not(.hidden) .sidebar > .tabs,
#app:not(.hidden) .sidebar-foot {
  animation: an-in var(--an-base) var(--an-ease) backwards;
}
/* Un pas de cascade tres court : la coiffe se compose de haut en bas, elle ne
   defile pas. Au-dela de 3 pas on attendrait la barre de recherche. */
#app:not(.hidden) .search-wrap      { animation-delay: calc(var(--an-step) * 1); }
#app:not(.hidden) .sidebar > .tabs  { animation-delay: calc(var(--an-step) * 2); }
#app:not(.hidden) .sidebar-foot     { animation-delay: calc(var(--an-step) * 3); }

/* Reactions des commandes de l'entete (#me-avatar, les quatre boutons icones,
   #presence-btn) : VOLONTAIREMENT absentes d'ici. header.css, charge apres ce
   fichier, les prend deja en charge avec la meme courbe et des durees de meme
   ordre, et il le fait sur des selecteurs a identifiant (#me-avatar, 1-0-0) que
   rien d'ecrit ici ne pourrait surcharger sans `!important`. Deux fichiers ne
   peuvent pas animer la meme propriete du meme element : on laisse la main au
   proprietaire, exactement comme pour `.msg-actions` et polish.css (section 6).
   header.css assume aussi sa propre coupure sous mouvement reduit (l.640-675),
   y compris le cas particulier du chevron de `#presence-btn`, dont le
   `rotate(45deg)` DESSINE la fleche et ne doit donc pas etre neutralise. La
   section 11 se contente de doubler cette coupure, en filet. */

/* --- 3.b Pastille glissante des onglets -----------------------------------
   style.css peint le fond actif sur le BOUTON : impossible a animer, le fond
   saute d'un bouton a l'autre. On le deplace donc sur un pseudo-element unique
   de `.tabs`, qui se translate.

   POURQUOI la position vient du JS et non d'un calcul CSS
   ------------------------------------------------------
   Une premiere version supposait 4 onglets de largeur EGALE : `flex: 1` avec
   `width: calc((100% - 20px) / 4)` et 4 crans en dur. C'est faux. Les items
   flex ont `min-width: auto`, donc aucun d'eux ne descend sous sa largeur de
   contenu ; avec `white-space: nowrap` (style.css l.485) « Discussions » est
   nettement plus large que « Statuts », et la pastille se decale des le premier
   onglet. On lit donc la geometrie REELLE du bouton actif.

   POURQUOI QUATRE variables et plus deux
   --------------------------------------
   La version precedente ecrivait `left: 4px` en dur et transmettait
   `offsetLeft - 4`. Ces deux « 4 » sont le padding de `.tabs` : le jour ou
   header.css le passe a 5px ou 6px, la pastille se decale d'autant sur TOUS
   les onglets, et personne ne relie la cause a l'effet. On ancre donc le
   pseudo-element a `left: 0` (soit l'origine du bloc de positionnement, qui
   est la boite de REMPLISSAGE de `.tabs`) et on lui transmet la geometrie
   brute du bouton, mesuree dans le meme referentiel : `offsetLeft` /
   `offsetTop` sont eux aussi comptes depuis cette meme origine. Le calage est
   alors exact quel que soit le remplissage, la bordure ou la hauteur choisis
   par la feuille de style du look. C'est aussi ce qui garantit que la pastille
   reste alignee quand les onglets CHANGENT de largeur (autre langue, barre
   laterale redimensionnee, passage en mobile) : il suffit de re-mesurer.

   `top` / `height` ne sont poses qu'au changement d'onglet, jamais pendant le
   mouvement : le deplacement lui-meme reste 100 % compositeur.

   Progressif et sur : le declencheur est `data-an-pill`, un attribut qui
   n'existe QUE pour cette pastille. Tant que personne ne le pose, style.css
   garde son fond sur `.tab-btn.active` et rien n'est perdu — contrairement a
   un declencheur `data-tab`, qu'un autre module pourrait poser pour une raison
   sans rapport, ce qui aurait laisse la pastille bloquee sur le 1er onglet.
   (app.js pose deja `data-tab` sur #tabs ; c'est sans effet ici, et c'est
   voulu.)

   INTEGRATION (app.js) : voir `placerPastilleOnglets()` dans la livraison. */
.tabs { position: relative; isolation: isolate; }

.tabs[data-an-pill]::after,
.ip-tabs[data-an-pill]::after {
  content: "";
  position: absolute;
  z-index: -1;                       /* derriere les boutons, devant le fond */
  left: 0;                           /* l'origine exacte du bloc de position */
  top: var(--an-tab-y, 4px);
  height: var(--an-tab-h, calc(100% - 8px));
  /* Gabarit de reference : la largeur reelle est appliquee par --an-tab-s. */
  width: 100px;
  border-radius: 10px;
  background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 32%, var(--glass-3)), var(--glass-3));
  /* Jetons uniquement : un noir/blanc en dur inverserait le relief en theme
     clair. --text est la couleur la plus contrastee du theme courant : le filet
     et le lisere suivent donc la palette.
     UNIQUEMENT DES OMBRES INTERNES. header.css fait de `.tabs` une boite a
     defilement (`overflow: auto hidden`, l.400), qui rogne net tout ce qui
     deborde de sa boite de remplissage : l'ancienne ombre portee `0 4px 14px`
     etait tranchee a gauche et a droite — une bavure grise au lieu d'un relief.
     C'est exactement la raison pour laquelle header.css a lui aussi renonce a
     l'ombre exterieure sur l'onglet actif (l.446-450). */
  box-shadow:
    inset 0 1px 0 color-mix(in srgb, var(--text) 16%, transparent),
    inset 0 0 0 1px color-mix(in srgb, var(--accent) 34%, transparent);
  transform-origin: left center;

  /* `translate` et `scale` INDEPENDANTS plutot qu'un seul `transform`.
     Pourquoi : ce sont deux proprietes distinctes, donc deux transitions
     distinctes — et c'est precisement ce qui donne l'ELAN demande. La pastille
     arrive a sa place (translate), sa largeur finit de se caler 40 ms plus
     tard (scale) : on lit un tres leger etirement a l'arrivee, pas un rebond.
     Un rebond serait une courbe qui DEPASSE puis revient ; ici la courbe reste
     `--an-ease`, qui n'overshoote jamais. Les deux proprietes se composent en
     une seule matrice : rien ne quitte le compositeur.
     L'ordre d'application est translate puis scale, exactement comme l'ancien
     `translateX(...) scaleX(...)`, et avec la meme origine. */
  translate: var(--an-tab-x, 4px) 0;
  scale: var(--an-tab-s, 1) 1;
  transition:
    translate var(--an-base) var(--an-ease),
    scale calc(var(--an-base) + 40ms) var(--an-ease);

  /* Premiere apparition (le JS vient de poser l'attribut) : la pastille se
     revele sur place au lieu de surgir. Elle ne touche que l'opacite, donc
     elle n'entre pas en conflit avec translate/scale ci-dessus. */
  animation: an-fade var(--an-fast) var(--an-ease) backwards;
}
.ip-tabs[data-an-pill]::after { border-radius: 9px; }

/* Le bouton actif rend son fond a la pastille, mais garde sa couleur de texte. */
.tabs[data-an-pill] .tab-btn { position: relative; }
.tabs[data-an-pill] .tab-btn.active,
.ip-tabs[data-an-pill] .ip-tab.active {
  background: none;
  box-shadow: none;
  color: var(--text);
}

/* Meme mecanique pour les onglets du panneau d'infos (geometrie identique). */
.ip-tabs { position: relative; isolation: isolate; }

/* --- 3.c Reaction des libelles d'onglet -----------------------------------
   La pastille seule ne suffisait pas : elle glissait, les quatre libelles
   restaient parfaitement inertes, et l'ensemble avait l'air d'un decor qui
   bouge derriere un texte mort.

   POURQUOI UNE ECHELLE ET PAS UNE GRAISSE
   ---------------------------------------
   « Raffermir » un libelle appelle spontanement `font-weight`. C'est un piege
   ici : la graisse change la LARGEUR du texte, donc celle du bouton (les
   onglets sont des items flex qui ne descendent pas sous leur contenu), donc
   la geometrie que la pastille vient de mesurer. Resultat : les quatre onglets
   se redistribuent a chaque clic et la pastille arrive a cote. Une echelle est
   purement visuelle, ne declenche aucun calcul de mise en page, et laisse la
   mesure valide. 1.045 se voit sans deformer le texte.
   PAS D'OPACITE SUR LES LIBELLES INACTIFS — c'est un piege de contraste.
   Une version precedente estompait les onglets inactifs a `opacity: .8`. Mesure
   en theme CLAIR : `--muted` (#5a618a) sur le fond de `.tabs` (--sunken pose sur
   le verre de la barre laterale, soit ~#eaecf2) donne 5,05:1 — conforme. Le meme
   libelle a 80 % d'opacite se composite en ~#777d9f et tombe a 3,45:1, sous le
   seuil AA de 4,5:1 exige pour du texte de 11,7 px (header.css l.423 : .73rem,
   graisse 620). Et l'opacite ne pardonne pas : meme a 90 % on reste a 4,3:1.
   La hierarchie est de toute facon deja portee par header.css, qui peint les
   inactifs en `--muted`, les passe en `--text` au survol (l.433) et donne a
   l'actif `--text` + graisse 750 (l.453). Ajouter un voile par-dessus etait
   redondant ET non conforme. On ne garde donc ici que le MOUVEMENT.

   A SAVOIR : header.css fait bien passer le libelle actif en `font-weight: 750`
   (l.422). C'est son droit — c'est du look — mais cela CHANGE la largeur du
   bouton actif, et donc la geometrie de toute la rangee (`flex: 1 0 auto` +
   `min-width: max-content`). D'ou la regle imperative cote JS : on mesure la
   pastille APRES avoir bascule la classe `.active`, jamais avant. C'est aussi
   pour cela que l'echelle reste a 1.03 : l'accent est deja porte par la
   graisse, il ne s'agit ici que de rendre le passage PERCEPTIBLE pendant les
   160 ms de la transition, pas d'ajouter une seconde emphase permanente. */
.tabs .tab-btn,
.ip-tabs .ip-tab {
  /* On reconduit background/color, deja transitionnes par style.css (l.888) :
     cette declaration, plus specifique, remplace la sienne entierement, et il
     serait dommage de perdre le fondu de couleur au passage. */
  transition:
    transform var(--an-fast) var(--an-ease),
    background var(--an-fast) linear,
    color var(--an-fast) linear;
  transform-origin: center;
}

/* L'echelle reste MODESTE aussi pour une raison de mise en page : header.css
   fait de `.tabs` une boite a defilement horizontal (`overflow: auto hidden`,
   l.400). Un agrandissement deborde de la boite de remplissage et s'ajoute a la
   largeur defilable ; a 1.03 sur des onglets de ~30 px cela reste sous le pixel
   et l'ascenseur est de toute facon masque (`scrollbar-width: none`). Ne pas
   monter au-dela sans verifier ce point. */
.tabs .tab-btn.active,
.ip-tabs .ip-tab.active { transform: scale(1.03); }

/* Survol : nettement distinct de l'actif — l'actif GRANDIT sur place, le
   survol SOULEVE. Deux gestes differents, aucune ambiguite sur l'etat.
   L'onglet deja actif ne se souleve pas : il est arrive, il ne se propose
   plus. 1 px seulement : `.tabs` rogne son axe vertical (`overflow: … hidden`)
   et ne laisse que les 4 px de son remplissage. */
.tabs .tab-btn:not(.active):hover,
.ip-tabs .ip-tab:not(.active):hover { transform: translateY(-1px); }

/* Appui : retour IMMEDIAT (60 ms) et sur les deux etats, actif compris.
   style.css l.831 donne bien un `scale(.94)` a `.tab-btn:active`, mais sa
   specificite (0,2,0) est egale a celle de `.tab-btn.active` ci-dessus et il
   est charge AVANT : sans cette regle plus specifique, l'onglet actif ne
   repondrait plus du tout a l'appui. .955 plutot que .94 : sur un libelle de
   11px, .94 se lit comme un tressaillement.

   LES DEUX ECRITURES SONT NECESSAIRES, ce n'est pas un doublon. `:active` seul
   pese 0,3,0 — soit MOINS que le `:not(.active):hover` juste au-dessus (0,4,0).
   A la souris on survole forcement le bouton qu'on enfonce : sur un onglet
   INACTIF le soulevement l'emportait et l'appui ne se voyait pas du tout. La
   seconde paire remonte le meme geste a 0,4,0 et, ecrite apres, repasse devant.
   (Sur un onglet DEJA actif le probleme ne se pose pas : `:not(.active):hover`
   ne s'y applique pas, et 0,3,0 suffit a battre `.tab-btn.active`.) */
.tabs .tab-btn:active,
.ip-tabs .ip-tab:active,
.tabs .tab-btn:not(.active):active,
.ip-tabs .ip-tab:not(.active):active {
  transform: scale(.955);
  transition-duration: 60ms;
}

/* Sur ecran tactile, `:hover` reste « colle » apres le tap : le soulevement
   resterait fige sur le dernier onglet touche. style.css neutralise deja ce
   cas pour .btn/.icon-btn/.conv (l.837) mais pas pour les onglets. */
@media (hover: none) {
  .tabs .tab-btn:not(.active):hover,
  .ip-tabs .ip-tab:not(.active):hover { transform: none; }
}

/* --- 3.d Panneau entrant --------------------------------------------------
   `.hidden` vaut display:none : passer a visible relance l'animation, donc
   aucune ligne de JS n'est necessaire pour l'entree.
   Sens de glissement : `data-an-dir` sur <html>, pose par le JS en comparant
   l'index de l'onglet quitte et de l'onglet vise. Sans attribut : « next ».

   COURT ET NET : 6px et `--an-fast`. Un panneau d'onglet ne vient pas de
   loin — il remplace son voisin, il n'arrive pas de l'exterieur de l'ecran.
   Au-dela de ~8px le mouvement se lit comme un glissement de page et devient
   penible quand on parcourt les quatre onglets d'affilee. */
.pane:not(.hidden)     { animation: an-pane-next var(--an-fast) var(--an-ease) backwards; }
html[data-an-dir="prev"] .pane:not(.hidden) { animation-name: an-pane-prev; }

/* --- PAS DE BARRE DE DEFILEMENT HORIZONTALE -------------------------------
   `.sidebar-scroll` est declare `overflow-y: auto` (style.css l.262) et rien
   sur l'axe X. Or la regle CSS est implacable : des qu'un axe vaut autre chose
   que `visible`, l'autre axe passe de `visible` a `auto`. Cet element est donc
   deja, sans le savoir, un conteneur defilable HORIZONTALEMENT — et les 6px de
   translation du panneau entrant y ajoutent 6px de largeur defilable, le temps
   de l'animation. C'est exactement la barre de defilement qui apparaissait et
   disparaissait a chaque changement d'onglet.
   On ecrit `clip` pour dire l'intention — couper, sans creer de conteneur de
   defilement. A savoir tout de meme : ici le moteur le RECALCULE en `hidden`,
   parce que la specification impose que `clip` devienne `hidden` des que
   l'autre axe defile (verifie : la valeur calculee vaut bien `hidden`). Le
   resultat visible est identique — aucune barre, aucune largeur defilable —
   et la declaration reste juste le jour ou cet element cesserait de defiler
   verticalement.
   Second reglage, tout aussi indispensable : `overflow-anchor`. Le bandeau de
   statuts apparait et disparait selon l'onglet, donc la hauteur du contenu
   change ; sans cela l'ancrage de defilement du navigateur corrige le
   scrollTop pour « conserver » ce qu'on regardait, et la liste saute d'un cran
   juste apres l'animation. */
.sidebar-scroll {
  overflow-x: clip;
  overflow-anchor: none;
}

/* Le bandeau de statuts coiffe le panneau « Discussions » et disparait sur les
   autres onglets. Il se contente d'un FONDU : le panneau qu'il coiffe glisse
   deja en X, lui ajouter un deplacement propre ferait deux mouvements
   d'amplitudes et d'axes differents a 10px l'un de l'autre. */
.stories-strip:not(.hidden)  { animation: an-fade var(--an-base) var(--an-ease) backwards; }
.search-results:not(.hidden) { animation: an-in var(--an-fast) var(--an-ease) backwards; }

/* Vignettes du bandeau : cascade tres courte, et UNIQUEMENT quand `.an-stagger`
   est pose sur le bandeau. VERIFIE : aujourd'hui app.js ne pose cette classe que
   sur `#conv-list` (l.408-412), jamais sur `#stories-strip` — cette regle est
   donc en attente, et c'est le bon defaut. renderStoriesStrip() (l.2642) vide et
   reconstruit le bandeau a chaque statut recu et a chaque changement de
   presence : une cascade inconditionnelle se rejouerait plusieurs fois par
   minute. Si le JS l'active un jour, ce doit etre avec le meme garde-fou
   « une fois par session » que la liste des conversations.
   On ne change QUE le nom de l'animation : utiliser la forme courte
   `animation:` ici effacerait les delais poses par
   `.an-stagger > *:nth-child(…)`, de specificite egale.
   `an-chip-in` remplace l'ancien `an-pop` — un depassement a 1.12 sur douze
   pastilles rondes alignees, c'etait la definition meme du feu d'artifice. */
.stories-strip.an-stagger > * {
  animation-name: an-chip-in;
  animation-duration: var(--an-fast);
}

/* --- 3.e `view-transition` : ce qu'on en garde ----------------------------
   VERIFIE dans app.js : `switchTab()` (l.1772-1782) enveloppe TOUJOURS le
   changement d'onglet dans `document.startViewTransition()`. C'est la vraie
   cause du « glissement brutal », et elle est purement CSS-visible :
     • pendant une transition de vue, tout ce qui n'est pas nomme part dans un
       unique instantane `root` — la colonne de conversation comprise. Chaque
       clic sur un onglet faisait donc fondre la moitie droite de l'ecran, qui
       n'avait pas change d'un pixel ;
     • ce fondu plein ecran se superposait au glissement du panneau entrant,
       d'ou l'impression de mouvement double et lointain.
   On ne peut pas retirer l'appel depuis une feuille de style, mais on peut
   retirer son effet : on coupe l'animation de la paire `root`. L'ancien
   instantane cesse de s'effacer en fondu et le nouveau — qui est un rendu VIVANT
   du document — s'affiche immediatement dessous ; le changement d'onglet revient
   donc aux animations CSS ci-dessus, locales et interruptibles.
   `mix-blend-mode: normal` est indispensable : la feuille de l'agent utilisateur
   pose `plus-lighter` sur les deux instantanes pour que leur croisement reste
   neutre. Sans fondu, deux images opaques en `plus-lighter` se cumuleraient et
   l'ecran virerait au blanc le temps de la transition.
   La partie utile de l'API est conservee telle quelle : le deplacement d'une
   conversation qui remonte en tete de liste (paires `conv-<id>` nommees par
   app.js l.388), qui, lui, est un VRAI mouvement de mise en page impossible a
   obtenir autrement.
   INTEGRATION : le mieux reste de supprimer le `startViewTransition` de
   `switchTab()` — voir `integration`. Ces regles restent alors sans effet. */
@supports (view-transition-name: none) {
  /* Deplacement doux de toute paire ancien/nouveau nommee par le JS. */
  ::view-transition-group(*) {
    animation-duration: var(--an-base);
    animation-timing-function: var(--an-ease);
  }
  ::view-transition-old(root),
  ::view-transition-new(root) {
    animation: none;
    mix-blend-mode: normal;
  }
}

/* ===========================================================================
   4. CHANGEMENT DE CONVERSATION
   =========================================================================== */

/* Deux etages, et c'est indispensable :
   • `:not(.hidden)` couvre le passage « aucune conversation -> une
     conversation » ; le retour en `display` visible relance l'animation seul,
     donc ce niveau fonctionne meme sans une ligne de JS.
   • `.conv-enter` couvre « conversation A -> conversation B » : l'element ne
     change ni de display ni de visibilite, rien ne repartirait. Le JS retire
     puis repose la classe, ce qui bascule vers un AUTRE nom d'animation — la
     seule chose qui relance vraiment une animation CSS. */
#chat-active:not(.hidden) > .chat-header { animation: an-down var(--an-base) var(--an-ease) backwards; }
#chat-active:not(.hidden) > .messages    { animation: an-rise var(--an-slow) var(--an-ease) backwards; }
#chat-active:not(.hidden) > .composer    { animation: an-up   var(--an-base) var(--an-ease) backwards; animation-delay: 40ms; }

#chat-active.conv-enter > .chat-header { animation: an-head-enter     var(--an-base) var(--an-ease) backwards; }
#chat-active.conv-enter > .messages    { animation: an-msgs-enter     var(--an-slow) var(--an-ease) backwards; }
#chat-active.conv-enter > .composer    { animation: an-composer-enter var(--an-base) var(--an-ease) backwards; animation-delay: 40ms; }

/* Barres contextuelles : elles apparaissent/disparaissent en cours de route. */
.pinned-bar:not(.hidden),
.ephemeral-bar:not(.hidden),
.reply-bar:not(.hidden)   { animation: an-down var(--an-fast) var(--an-ease) backwards; }
.recorder:not(.hidden)    { animation: an-up   var(--an-base) var(--an-ease) backwards; }
.mention-pop:not(.hidden) { animation: an-up   var(--an-fast) var(--an-ease) backwards; transform-origin: bottom left; }
.drop-overlay:not(.hidden){ animation: an-fade var(--an-fast) var(--an-ease) backwards; }

/* Bouton « aller en bas » : il surgit, il ne clignote pas. */
.scroll-bottom:not(.hidden) { animation: an-pop var(--an-base) var(--an-ease) backwards; }

/* --- 4.b Mobile : la conversation glisse depuis la droite -----------------
   style.css bascule les deux colonnes en `display:none` (l.460-461), ce qui
   interdit toute transition. On remplace par visibilite + transform : la
   barre laterale est deja `position:absolute` en mobile, donc la sortir du
   flux visuel ne change RIEN a la mise en page. `visibility` est transitionnee
   avec un delai egal a la duree pour que l'element reste peint jusqu'au bout,
   puis cesse d'etre focusable. */
@media (max-width: 820px) {
  .app > .sidebar,
  .app > .chat {
    transition:
      transform  var(--an-slow) var(--an-ease),
      opacity    var(--an-base) linear,
      visibility 0s linear var(--an-slow);
  }

  /* Etats VISIBLES : la visibilite revient immediatement (delai 0). */
  .app:not(.chat-open) > .sidebar,
  .app.chat-open > .chat {
    transform: none;
    opacity: 1;
    visibility: visible;
    pointer-events: auto;
    transition-delay: 0s, 0s, 0s;
  }

  /* Etats SORTIS : on neutralise le display:none de style.css (l.460-461).
     Sans `!important` : nos deux selecteurs ont la meme specificite que les
     siens (0,2,0 et 0,3,0) et cette feuille est chargee APRES — la cascade
     suffit. Verifie : aucune des feuilles suivantes (polish.css, header.css, ni
     le CSS injecte par settings.js) ne redeclare `display` sur ces deux
     colonnes. Un `!important` ici n'aurait rien gagne et aurait complique toute
     surcharge ulterieure. */
  .app.chat-open > .sidebar {
    display: flex;
    transform: translateX(-16%) scale(.97);
    opacity: 0;
    visibility: hidden;
    pointer-events: none;
  }
  .app:not(.chat-open) > .chat {
    display: flex;
    transform: translateX(100%);
    opacity: 0;
    visibility: hidden;
    pointer-events: none;
  }

  /* Hors ecran, la conversation ne doit pas creer de defilement horizontal. */
  .app { overflow-x: clip; }

  /* En mobile l'entree de la conversation est deja portee par le glissement
     du panneau : on evite d'empiler une seconde animation sur l'en-tete. */
  #chat-active:not(.hidden) > .chat-header,
  #chat-active:not(.hidden) > .composer { animation: none; }
}

/* ===========================================================================
   5. LISTE DES CONVERSATIONS
   =========================================================================== */

/* LE PIEGE DE CETTE LISTE, a lire avant d'y ajouter quoi que ce soit
   ------------------------------------------------------------------
   renderConvList() vide le <ul> et le reconstruit ENTIEREMENT. Il est appele
   a l'ouverture d'une conversation, a chaque message recu, a chaque changement
   de presence, a chaque frappe dans la recherche, a chaque epinglage… soit
   plusieurs fois par minute en usage normal. Chaque <li>, chaque avatar,
   chaque pastille est donc un element NEUF a chaque fois.
   Consequence : toute animation d'ENTREE ecrite ici se rejoue integralement a
   chacun de ces appels. C'est la source du clignotement general. La regle
   qu'on s'impose dans cette section : une entree n'est permise que si le JS
   peut garantir qu'elle ne se declenchera pas en boucle — sinon on n'utilise
   que des etats CSS (`:hover`, `:active`), qui ne dependent d'aucun rendu.

   Cascade : c'est l'utilitaire generique `.an-stagger > *` de la section 2 qui
   fait le travail, et le JS ne pose la classe QU'UNE FOIS par session (voir le
   champ `integration`). On n'ajoute AUCUNE regle plus specifique ici, sinon la
   forme courte `animation:` remettrait `animation-delay` a zero et ecraserait
   les paliers. */

/* Etats de la ligne. `--an-fast` partout : le 90ms d'origine etait un nombre
   isole, hors de la grammaire des trois durees du fichier. */
.conv {
  transition:
    background var(--an-fast) var(--an-ease),
    transform var(--an-fast) var(--an-ease);
}

/* Survol : la ligne avance de 2px vers le contenu, comme si elle se presentait.
   2px et pas plus — la liste est dense, une amplitude plus large ferait onduler
   les lignes voisines a chaque passage de souris. Sans danger pour la mise en
   page : `.sidebar-scroll` est desormais coupe sur l'axe X (section 3.d). */
.conv:hover { transform: translateX(2px); }

/* La conversation ACTIVE est legerement en avant en permanence : c'est un etat,
   pas une animation, donc rien ne se rejoue quand la liste est reconstruite.
   Combine a la barre d'accent de style.css (l.877-885), l'ecart avec le simple
   survol reste net. */
.conv.active { transform: translateX(3px); }

/* Appui : style.css l.883 donne deja `scale(.985)`, mais sa specificite (0,2,0)
   est desormais egale a celle de `.conv.active` ci-dessus et il est charge
   avant — sans cette regle, la conversation active ne repondrait plus a
   l'appui. On reconduit le meme geste, translation comprise. */
.conv:active           { transform: scale(.985); }
.conv.active:active    { transform: translateX(3px) scale(.985); }

@media (hover: none) {
  /* Le survol reste « colle » apres un tap sur mobile : la ligne touchee
     resterait decalee de 2px pour toujours. style.css neutralise deja
     `.conv:hover` (l.837) mais avec un `transform: none` qui effacerait aussi
     le decalage de la ligne active — d'ou cette reecriture. */
  .conv:hover          { transform: none; }
  .conv.active:hover   { transform: translateX(3px); }
}

/* Selection par l'utilisateur : la ligne se cale d'un cran. Volontairement
   PILOTEE PAR LE JS (`.an-picked`, posee dans openConversation() puis retiree
   sur `animationend`) et non par `.conv.active`, qui est reapplique a chaque
   redessin : sur `.active` seul, la ligne active rejouerait son animation a
   chaque message recu dans n'importe quelle conversation.
   Sans remplissage (`forwards` interdit) : la ligne doit rendre la main a
   `.conv.active` et a `:hover` des la derniere image. */
.conv.an-picked { animation: an-conv-picked var(--an-fast) var(--an-ease); }

/* Pastille de non-lus : AUCUNE animation par defaut, et c'est un choix.
   Elle est recreee a chaque renderConvList() : une entree inconditionnelle la
   ferait « pop » quand un ami se connecte, quand on tape dans la recherche,
   quand on ouvre une autre conversation — a chaque fois sans que le compteur
   ait bouge. Le JS pose `.an-new` UNIQUEMENT quand le nombre augmente
   reellement (voir `integration`) ; sans cette classe la pastille se contente
   d'exister. */
.conv .badge { transform-origin: center; }
.conv .badge.an-new {
  animation: an-badge-in var(--an-fast) var(--an-ease) backwards;
}

/* ===========================================================================
   6. MESSAGES
   =========================================================================== */

/* style.css (l.855) anime TOUS les `.msg`. renderMessages() reconstruit la
   liste entiere a chaque evenement : 100 bulles repartent donc en animation
   pour un seul message recu. On ne l'accorde qu'aux nouvelles bulles,
   marquees `.fresh`.

   VERIFIE dans app.js : le marquage EXISTE bel et bien.
     • `renderMessage()` (l.567-572) concatene ` fresh` quand `msgFrais` contient
       l'identifiant du message ;
     • `msgFrais.add(m.id)` (l.1090) n'est appele que sur un message reellement
       nouveau ;
     • `msgFrais.clear()` (l.545, en fin de rendu, et l.441 a l'ouverture d'une
       conversation) consomme les marques — une bulle ne s'anime qu'une fois.
   La coupure peut donc etre SECHE. La version precedente la conditionnait a un
   attribut `data-an-fresh` sur #messages que personne ne pose : la regle ne
   ciblait rien, le `msgIn` de style.css continuait de rejouer les 100 bulles a
   chaque evenement, et l'`an-bubble-in` ci-dessous venait s'empiler par-dessus.
   Le correctif central du fichier etait donc inoperant.
   `.msg.highlight .bubble` (style.css l.552) vise `.bubble` et non `.msg` :
   le saut vers un message cite garde son surlignage. */
.messages .msg { animation: none; }

.messages .msg.fresh {
  animation: an-bubble-in var(--an-base) var(--an-ease) backwards;
}
/* L'origine du mouvement suit le cote d'ou vient la bulle. */
.messages .msg.mine.fresh   { transform-origin: bottom right; }
.messages .msg.theirs.fresh { transform-origin: bottom left; }

/* Separateur de jour : il accompagne la premiere bulle fraiche du jour. */
.messages .date-sep.fresh { animation: an-fade var(--an-base) var(--an-ease) backwards; }

/* --- Barre d'actions au survol : VOLONTAIREMENT non animee ici -------------
   polish.css est charge APRES ce fichier et prend `.msg-actions` entierement a
   sa charge : il abandonne la bascule display:none/flex de style.css au profit
   d'un couple opacity + visibility + transform, avec ses propres origines
   (l.129 et l.138) et sa propre coupure sous mouvement reduit (l.826-844).
   Y ajouter une animation d'ici serait doublement nuisible :
     • l'origine « animation » l'emporte sur toute declaration d'auteur, donc
       notre `an-pop` ecraserait silencieusement le `transform` de polish.css
       et le mouvement d'entree qu'il a regle ;
     • nos `transform-origin` seraient de toute facon morts, polish.css les
       redeclarant plus loin dans la cascade.
   Deux fichiers ne peuvent pas animer la meme propriete du meme element : on
   laisse la pilule a son proprietaire. */

/* Reactions : la pastille grossit quand on la survole, pas quand elle existe. */
.reaction { transition: transform var(--an-fast) var(--an-ease), background var(--an-fast) linear; }
.reaction:hover  { transform: scale(1.1); }
.reaction:active { transform: scale(.94); }

/* Etat d'envoi : la bulle en attente respire par son opacite, sans boucle. */
.messages .msg.pending .bubble { transition: opacity var(--an-base) linear; }

/* ===========================================================================
   7. MODALES
   =========================================================================== */

/* Le fond s'assombrit progressivement : `opacity` sur `.modal` fait aussi
   monter en douceur son `backdrop-filter`, on obtient le flou progressif
   gratuitement, sans animer une propriete couteuse.
   NB : on n'applique volontairement AUCUN transform sur #app pour le « recul »
   du fond — un ancetre transforme devient une racine de backdrop-filter dans
   Chrome, et toutes les surfaces `.glass` perdraient leur flou de l'aurore. */
.modal:not(.hidden) { animation: an-fade var(--an-base) var(--an-ease) backwards; }

/* `.floating` applique un `bob` INFINI aux cartes de modale (style.css l.69).
   Une animation perpetuelle sur une carte defilable gene la lecture et tourne
   en permanence : on la remplace par une entree unique. */
.modal:not(.hidden) .modal-card,
.modal:not(.hidden) .modal-card.floating {
  animation: an-card-in var(--an-slow) var(--an-ease) backwards;
}

/* Fermeture symetrique : le JS pose `.closing`, attend `animationend`, puis
   remet `.hidden` et retire `.closing`. */
.modal.closing { animation: an-fade-out var(--an-fast) var(--an-ease-in) both; }
.modal.closing .modal-card,
.modal.closing .modal-card.floating { animation: an-card-out var(--an-fast) var(--an-ease-in) both; }

/* Appel entrant : la carte monte comme les autres, l'avatar garde son `pulse`
   (il porte l'information « ca sonne »). */
.modal:not(.hidden) .modal-card.incoming { animation: an-card-in var(--an-slow) var(--an-ease) backwards; }

/* Panneau d'infos lateral : il entre par la droite. */
.info-panel:not(.hidden) { animation: an-left var(--an-slow) var(--an-ease) backwards; }
.info-panel.closing      { animation: an-panel-out var(--an-fast) var(--an-ease-in) both; }

/* Visionneuse de statuts et lightbox : plein ecran, donc entree plus posee. */
.story-viewer:not(.hidden) { animation: an-fade var(--an-base) var(--an-ease) backwards; }
.story-viewer:not(.hidden) .sv-body { animation: an-pop var(--an-slow) var(--an-ease) backwards; }
.lightbox:not(.hidden)     { animation: an-fade var(--an-fast) var(--an-ease) backwards; }
.lightbox:not(.hidden) #lightbox-content { animation: an-pop var(--an-base) var(--an-ease) backwards; }
.story-viewer.closing, .lightbox.closing { animation: an-fade-out var(--an-fast) var(--an-ease-in) both; }

/* Fenetre d'appel : style.css lui donne deja `callIn`, on aligne la courbe. */
.call-overlay:not(.hidden) { animation: an-fade var(--an-base) var(--an-ease) backwards; }

/* ===========================================================================
   8. BOUTONS, PASTILLES, INDICATEUR DE FRAPPE
   =========================================================================== */

/* style.css couvre deja .btn / .icon-btn / .control / .tab-btn / .conv a
   l'appui. On etend aux commandes qui avaient ete oubliees, avec le meme
   geste pour rester coherent. */
.ctx-menu button:active,
.modal-close:active,
.sv-nav:active,
.story-kind:active,
.theme-opt:active,
.attach-opt:active,
.presence-pill:active,
.react-picker .quick span:active,
.forward-list > *:active {
  transform: scale(.94);
  transition-duration: 60ms;
}
/* `.msg-actions button` est volontairement absent : polish.css (charge apres)
   lui donne deja son propre appui et sa propre transition. */

/* Transition de retour : sans elle, le bouton REVIENT d'un coup apres l'appui. */
.ctx-menu button,
.modal-close,
.sv-nav,
.story-kind,
.theme-opt,
.attach-opt,
.presence-pill {
  transition: transform var(--an-fast) var(--an-ease), background var(--an-fast) linear;
}

/* Bouton d'envoi : petit accuse de reception visuel. */
#send-btn:active { transform: scale(.9) rotate(-8deg); }

/* Pastilles generiques (compteurs, `.pill`) : meme « pop » que les non-lus. */
.pill.an-pop, .badge.an-pop { animation: an-pop var(--an-base) var(--an-ease) backwards; }

/* --- Indicateur de frappe -------------------------------------------------
   L'animation infinie est acceptee ici : la barre #typing porte `.hidden` des
   que personne n'ecrit, donc rien ne tourne en permanence.
   L'entree passe de `an-up` (16px) a `an-in` (6px) : la pastille « ecrit… »
   fait deux lignes de haut et se glisse juste au-dessus du composeur. A 16px
   elle traversait sa propre hauteur et la barre entiere semblait sursauter a
   chaque fois que quelqu'un posait le doigt sur son clavier — c'est-a-dire
   tres souvent. */
.typing:not(.hidden) { animation: an-in var(--an-fast) var(--an-ease) backwards; }
.typing-pill { transform-origin: bottom left; }
.typing-pill .dots i {
  animation: an-dot 1.25s var(--an-ease) infinite;
}
.typing-pill .dots i:nth-child(2) { animation-delay: .16s; }
.typing-pill .dots i:nth-child(3) { animation-delay: .32s; }

/* Bandeau reseau : le point de reconnexion tourne deja (spin), on ne touche
   qu'a l'entree/sortie du bandeau. */
.net-banner:not(.hidden) { animation: an-down var(--an-base) var(--an-ease) backwards; }
.net-banner.closing      { animation: an-out-up var(--an-fast) var(--an-ease-in) both; }

/* ===========================================================================
   9. TOASTS, MENU CONTEXTUEL, PANNEAUX DOCK
   =========================================================================== */

/* Le toast est centre par `transform: translateX(-50%)` (style.css l.452) : les
   deux keyframes doivent donc reconduire ce -50%, sans quoi il partirait vers
   la droite pendant l'animation. C'est le seul point delicat ici. */
.toast:not(.hidden) { animation: an-toast-in var(--an-base) var(--an-ease) backwards; }
.toast.closing      { animation: an-toast-out var(--an-fast) var(--an-ease-in) both; }

/* Le menu contextuel jaillit du point clique : le JS peut affiner l'origine
   via --an-org-x / --an-org-y ; sans cela il part du coin haut-gauche, ce qui
   est deja le comportement attendu dans 90 % des cas. */
.ctx-menu:not(.hidden) {
  transform-origin: var(--an-org-x, 0) var(--an-org-y, 0);
  animation: an-pop var(--an-fast) var(--an-ease) backwards;
}
.ctx-menu.closing { animation: an-out var(--an-fast) var(--an-ease-in) both; }

.react-picker:not(.hidden) {
  transform-origin: var(--an-org-x, 50%) var(--an-org-y, 100%);
  animation: an-pop var(--an-fast) var(--an-ease) backwards;
}

/* Panneaux emoji / GIF / joindre : ils sont ancres au-dessus de la barre de
   saisie, ils montent donc depuis leur coin bas-gauche. On remplace `dockUp`
   pour unifier la courbe. */
.dock-panel:not(.hidden) {
  transform-origin: bottom left;
  animation: an-up var(--an-base) var(--an-ease) backwards;
}
.dock-panel.closing { animation: an-out-down var(--an-fast) var(--an-ease-in) both; }

/* Contenu du panneau emoji : la grille se pose juste apres le panneau. */
.dock-panel:not(.hidden) .emoji-scroll,
.dock-panel:not(.hidden) .giphy-masonry {
  animation: an-fade var(--an-base) var(--an-ease) backwards;
  animation-delay: 60ms;
}
.emoji-grid .emoji { transition: transform var(--an-fast) var(--an-ease), background var(--an-fast) linear; }
.emoji-grid .emoji:hover { transform: scale(1.22); }
.giphy-masonry img { transition: transform var(--an-fast) var(--an-ease), box-shadow var(--an-fast) linear; }
.giphy-masonry img:hover { transform: scale(1.03); }

/* Menu joindre : chaque ligne suit avec un tout petit decalage.
   `backwards` et surtout PAS `both` : le menu reste ouvert, donc un remplissage
   vers l'avant figerait `transform` a jamais sur chaque ligne et le retour
   tactile `.attach-opt:active { transform: scale(.94) }` (plus bas, et aussi
   style.css l.832) ne s'appliquerait plus jamais — l'origine « animation » bat
   toute declaration d'auteur. */
.attach-menu:not(.hidden) .attach-opt {
  animation: an-right var(--an-fast) var(--an-ease) backwards;
}
.attach-menu:not(.hidden) .attach-opt:nth-child(1)  { animation-delay: calc(var(--an-step) * 0); }
.attach-menu:not(.hidden) .attach-opt:nth-child(2)  { animation-delay: calc(var(--an-step) * 1); }
.attach-menu:not(.hidden) .attach-opt:nth-child(3)  { animation-delay: calc(var(--an-step) * 2); }
.attach-menu:not(.hidden) .attach-opt:nth-child(4)  { animation-delay: calc(var(--an-step) * 3); }
.attach-menu:not(.hidden) .attach-opt:nth-child(5)  { animation-delay: calc(var(--an-step) * 4); }
.attach-menu:not(.hidden) .attach-opt:nth-child(n+6){ animation-delay: calc(var(--an-step) * 5); }

/* ===========================================================================
   10. SQUELETTES DE CHARGEMENT
   Le balayage est une TRANSLATION d'un pseudo-element : aucun recalcul de
   degrade a chaque image. C'est la seule boucle infinie tolerée, et elle
   disparait avec le squelette lui-meme.
   =========================================================================== */

.skeleton {
  position: relative;
  overflow: hidden;
  /* --sunken est defini pour les deux themes (style.css l.843-844) : pas de
     repli en dur, un rgba noir serait une tache en theme clair. */
  background: var(--sunken);
  border-radius: var(--radius-sm);
  /* Le squelette ne porte pas de texte : on neutralise tout contenu herite. */
  color: transparent;
  user-select: none;
  pointer-events: none;
}
.skeleton::after {
  content: "";
  position: absolute;
  inset: 0;
  transform: translateX(-100%);
  background: linear-gradient(
    90deg,
    transparent,
    color-mix(in srgb, var(--text) 14%, transparent),
    transparent
  );
  animation: an-sweep 1.3s var(--an-ease) infinite;
}

/* Formes pretes a l'emploi pour composer une liste en attente. */
.skeleton-line   { height: 11px; border-radius: 6px; margin: 6px 0; }
.skeleton-line.w-40 { width: 40%; }
.skeleton-line.w-60 { width: 60%; }
.skeleton-line.w-80 { width: 80%; }
.skeleton-avatar { width: 44px; height: 44px; border-radius: 50%; flex: none; }
.skeleton-row {
  display: flex; align-items: center; gap: 12px;
  padding: 10px; border-radius: var(--radius-sm);
}
.skeleton-row .skeleton-main { flex: 1; min-width: 0; }
.skeleton-bubble { height: 38px; border-radius: 16px; margin: 4px 0; max-width: 62%; }
.skeleton-bubble.mine { margin-left: auto; border-bottom-right-radius: 5px; }
.skeleton-bubble.theirs { border-bottom-left-radius: 5px; }

/* ===========================================================================
   11. ACCESSIBILITE — coupure globale
   Tout ce qui precede, plus les animations heritees de style.css / themes.css,
   est neutralise d'un seul bloc. `!important` est ici legitime : c'est une
   preference systeme de l'utilisateur, elle prime sur toute regle applicative.
   =========================================================================== */

@media (prefers-reduced-motion: reduce) {
  /* On RACCOURCIT (1 ms) au lieu de supprimer : une animation supprimee
     n'emet plus `animationend`, et tous les JS de fermeture qui attendent cet
     evenement pour reposer `.hidden` resteraient bloques sur une modale, un
     toast ou un menu jamais refermes. */
  *, *::before, *::after {
    animation-duration: 1ms !important;
    animation-delay: 0ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 1ms !important;
    transition-delay: 0ms !important;
    scroll-behavior: auto !important;
  }

  /* EXCEPTION : ce `transition` de style.css (l.212) n'est pas une animation
     mais une ruse qui empeche Chrome de peindre son fond jaune d'autofill sur
     les champs de connexion. La raccourcir ferait reapparaitre le fond. */
  .field-input:-webkit-autofill,
  .field-input:-webkit-autofill:focus {
    transition: background-color 9999s !important;
  }

  /* Les etats finaux doivent rester CORRECTS, pas seulement instantanes. */
  .an-in, .an-up, .an-down, .an-left, .an-right,
  .an-rise, .an-fade, .an-pop, .an-stagger > * {
    animation: none !important;
    opacity: 1 !important;
    transform: none !important;
  }
  .messages .msg.fresh,
  .conv .badge.an-new,
  .conv.an-picked,
  .stories-strip:not(.hidden),
  .stories-strip.an-stagger > *,
  #app:not(.hidden) .me-bar,
  #app:not(.hidden) .search-wrap,
  #app:not(.hidden) .sidebar > .tabs,
  #app:not(.hidden) .sidebar-foot,
  .modal:not(.hidden) .modal-card,
  .modal:not(.hidden) .modal-card.floating,
  .dock-panel:not(.hidden),
  .ctx-menu:not(.hidden),
  .react-picker:not(.hidden),
  .net-banner:not(.hidden),
  .pane:not(.hidden),
  .info-panel:not(.hidden),
  #chat-active > .chat-header,
  #chat-active > .messages,
  #chat-active > .composer {
    animation: none !important;
    opacity: 1 !important;
    transform: none !important;
  }

  /* Le toast est traite A PART : son `transform` n'est pas decoratif, c'est son
     centrage (translateX(-50%), style.css l.452). Un `transform: none` le
     decalerait d'une demi-largeur vers la droite, jusqu'a sortir de l'ecran sur
     un message long. On coupe donc l'animation sans toucher au transform. */
  .toast:not(.hidden) { animation: none !important; opacity: 1 !important; }

  /* La pastille d'onglet se place sans glisser. On coupe la transition ET
     l'animation d'apparition, mais surtout on NE TOUCHE PAS a `translate` /
     `scale` : ce ne sont pas des effets, ce sont eux qui portent sa position et
     sa largeur. Un `transform: none` generique la renverrait sur le premier
     onglet, reduite au gabarit de 100px. */
  .tabs[data-an-pill]::after,
  .ip-tabs[data-an-pill]::after {
    transition: none !important;
    animation: none !important;
    opacity: 1 !important;
  }

  /* Onglets : l'etat actif reste LISIBLE (pleine opacite, echelle nette), il
     cesse simplement d'y arriver en bougeant. On garde donc l'echelle de
     l'onglet actif — c'est un etat, pas un mouvement — et on supprime le
     soulevement au survol ainsi que l'enfoncement a l'appui. */
  .tabs .tab-btn:not(.active):hover,
  .ip-tabs .ip-tab:not(.active):hover,
  .tabs .tab-btn:active,
  .ip-tabs .ip-tab:active { transform: none !important; }

  /* Liste des conversations : les decalages de survol / de selection sautent,
     l'etat actif reste reconnaissable a sa barre d'accent et a son fond. */
  .conv:hover,
  .conv:active,
  .conv.active,
  .conv.active:active,
  .conv.active:hover { transform: none !important; }

  /* --- Filet sur les transforms de header.css -------------------------------
     VERIFIE : header.css neutralise deja ces memes etats dans son propre bloc
     `prefers-reduced-motion` (l.655-666), et comme il est charge APRES nous il
     gagnerait de toute facon. Ces trois lignes sont donc une ceinture, pas la
     bretelle : elles ne servent que si header.css est un jour remanie ou retire.
     On les garde a ce titre — `!important` est ici la seule facon de passer
     devant un selecteur a identifiant (#me-avatar, 1-0-0) — mais il ne faut
     surtout pas les etendre.
     Une exclusion assumee, deja traitee par header.css l.668-672 :
     `#presence-btn::after` est un chevron dont le `rotate(45deg)` DESSINE la
     forme — le neutraliser donnerait un carre. Il n'apparait donc pas ci-dessous.
     `.side-foot` accompagne `.sidebar-foot` : index.html n'utilise que le
     second, mais header.css cite les deux (l.650-651). */
  #me-avatar:hover,
  #me-avatar:active,
  #me-avatar:hover::before,
  .me-bar .icon-btn.small:hover,
  .me-bar .icon-btn.small:active,
  .sidebar-foot .btn:hover,
  .sidebar-foot .btn:active,
  .side-foot .btn:hover,
  .side-foot .btn:active { transform: none !important; }

  /* Le squelette reste lisible comme bloc plein, sans balayage. */
  .skeleton::after { animation: none !important; opacity: .25; transform: none !important; }

  /* Les 3 points de la frappe s'arreteraient sur la derniere image de leur
     boucle, donc a 35 % d'opacite : illisible. On les fige a pleine opacite —
     la barre « ecrit… » reste porteuse d'information sans bouger. */
  .typing-pill .dots i {
    animation: none !important;
    opacity: 1 !important;
    transform: none !important;
  }

  /* Aucun retour tactile par deformation. */
  .ctx-menu button:active, .modal-close:active, .sv-nav:active,
  .story-kind:active, .theme-opt:active, .attach-opt:active,
  .presence-pill:active,
  .react-picker .quick span:active, .forward-list > *:active,
  #send-btn:active, .reaction:hover, .reaction:active,
  .emoji-grid .emoji:hover, .giphy-masonry img:hover {
    transform: none !important;
  }

  /* Mobile : la bascule liste <-> conversation redevient immediate. */
  @media (max-width: 820px) {
    .app > .sidebar, .app > .chat { transition: none !important; }
  }

  /* Transitions de vue : on les court-circuite entierement. */
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) { animation: none !important; }
}
