/* Animations and motion-related rules.
   Loaded after layout.css. The single source of truth for whether
   animations are enabled is `:root[data-reduce-motion="on"]`, posted by
   applyAppPrefs() based on the user's choice (Auto/Activées/Réduites).
   Do NOT use `@media (prefers-reduced-motion: reduce)` directly — that
   would override an explicit "Activées" choice on a system where the
   global OS animations toggle is off (very common on Windows). */

/* ============================================================
   Typing indicator (#1)
   ============================================================ */

.typing-indicator {
  display: inline-flex !important;
  align-items: center;
  gap: 6px;
  font-style: italic;
  color: var(--c-text-soft);
}
.typing-dots {
  display: inline-flex;
  align-items: center;
  gap: 3px;
  margin-left: 2px;
}
.typing-dots > span {
  display: inline-block;
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: currentColor;
  opacity: 0.4;
  animation: typing-dot 1.2s ease-in-out infinite;
}
.typing-dots > span:nth-child(2) { animation-delay: 0.15s; }
.typing-dots > span:nth-child(3) { animation-delay: 0.3s; }
@keyframes typing-dot {
  0%, 60%, 100% { opacity: 0.35; transform: translateY(0); }
  30%           { opacity: 1;    transform: translateY(-2px); }
}

/* ============================================================
   Reply citation flash (#6)
   ============================================================ */

@keyframes bubble-flash {
  0%   { box-shadow: 0 0 0 0 var(--c-accent-ring); }
  20%  { box-shadow: 0 0 0 6px var(--c-accent-ring); }
  100% { box-shadow: 0 0 0 0 transparent; }
}
.flash-target {
  animation: bubble-flash 1s ease-out 1;
}

/* ============================================================
   Failed-send shake + outline (#26)
   ============================================================
   Outgoing bubbles whose send rejected get `data-status="failed"` from
   buildBubble. Shakes once, plus a persistent red outline so the user
   can still tell the message failed after the animation ends. */

@keyframes bubble-shake {
  0%   { transform: translateX(0); }
  10%  { transform: translateX(-9px); }
  20%  { transform: translateX(9px); }
  30%  { transform: translateX(-7px); }
  40%  { transform: translateX(7px); }
  50%  { transform: translateX(-5px); }
  60%  { transform: translateX(5px); }
  70%  { transform: translateX(-3px); }
  80%  { transform: translateX(3px); }
  100% { transform: translateX(0); }
}
/* Persistent failure marker — outline + halo stay as long as the bubble
   is in 'failed' state. The shake itself runs only once and is driven
   by JS adding `.bubble-shake-once` at the moment of the status flip
   (see threadPane.js). Doing the shake purely via the `[data-status]`
   selector caused it to replay every time the diff renderer rebuilt or
   re-attached the node in the DOM (e.g. when a newer bubble was
   inserted), making old failed bubbles shake again on each new send. */
.bubble[data-status="failed"] {
  outline: 2px solid var(--c-danger, #d24d4d);
  outline-offset: -1px;
  box-shadow: 0 0 0 4px rgba(210, 77, 77, 0.18);
}
.bubble-shake-once {
  animation: bubble-shake 700ms ease-out 1;
}
:root[data-reduce-motion="on"] .bubble-shake-once {
  animation: none !important;
}

/* ============================================================
   Jump-to-bottom pulse (#26)
   ============================================================ */

.jump-bottom:not([hidden]) {
  animation: jump-pulse 2.4s ease-in-out infinite;
}
@keyframes jump-pulse {
  0%, 100% { box-shadow: 0 0 0 0 var(--c-accent-ring); }
  50%      { box-shadow: 0 0 0 8px transparent; }
}

/* ============================================================
   Thread-pane open fade (#26)
   ============================================================
   The thread-pane host runs a single quick fade-in when it gets attached
   children (i.e. a thread is being opened). Hides the multi-step paint
   (header → bubbles → composer fade) under one continuous motion so the
   user perceives a smooth open rather than a "settling" sequence. The
   class is added by JS in switchThread() and removed shortly after. */

@keyframes thread-fade-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}
.thread-wrap.thread-open-fade {
  animation: thread-fade-in 220ms ease-out 1;
}
:root[data-reduce-motion="on"] .thread-wrap.thread-open-fade {
  animation: none;
}

/* ============================================================
   Chat keyword effects (#7)
   ============================================================
   Particles spawned by chatEffects.triggerEffectFor() into `.thread-pane`.
   Each particle reads its randomized parameters (start position,
   duration, drift, rotation, size) from CSS custom properties set
   inline by the JS — keeps the keyframes themselves shared across all
   instances. The JS removes the node on `animationend`. */

.chat-effect {
  position: absolute;
  left: var(--ce-left, 50%);
  font-size: var(--ce-size, 24px);
  pointer-events: none;
  user-select: none;
  /* Keep particles above the wallpaper/scrim/messages but below modals
     and toasts — z-index 4 is the messages layer; we sit just above. */
  z-index: 6;
  /* `will-change: transform` promotes the particle to its own compositor
     layer so the keyframe animates without triggering paint on
     surrounding bubbles. */
  will-change: transform, opacity;
  /* Animation parameters come from CSS vars set by JS — duration, delay
     and a per-particle drift / rotation. */
  animation-duration: var(--ce-dur, 2.4s);
  animation-delay: var(--ce-delay, 0s);
  animation-timing-function: cubic-bezier(0.22, 0.61, 0.36, 1);
  animation-fill-mode: both;
  animation-iteration-count: 1;
}

/* Confetti — fall from above the pane, with lateral drift and rotation.
   Note on units: `translateY(110%)` would only mean "110% of the
   particle's own height" (≈ 28 px) — the original bug that made
   confetti stop just under the top edge. We use `vh` instead so each
   particle reliably crosses the entire pane regardless of its own size.
   The pane is sized to roughly `100vh - composer` so 100vh is enough
   to clear it from top to bottom. */
.chat-effect-confetti {
  top: -32px;
  animation-name: ce-fall;
}
@keyframes ce-fall {
  0%   { transform: translate(0, 0) rotate(0); opacity: 0; }
  10%  { opacity: 1; }
  90%  { opacity: 1; }
  100% { transform: translate(var(--ce-drift, 0), 100vh) rotate(var(--ce-rot, 360deg));
         opacity: 0; }
}

/* Hearts — rise from the bottom of the pane, with a gentle sway. Same
   `vh`-based travel as confetti for the same reason: percentage in
   `translateY` references the particle, not the parent. */
.chat-effect-hearts {
  bottom: -32px;
  animation-name: ce-rise;
}
@keyframes ce-rise {
  0%   { transform: translate(0, 0) scale(0.6); opacity: 0; }
  15%  { opacity: 1; transform: translate(0, -10vh) scale(1); }
  100% { transform: translate(var(--ce-drift, 0), -100vh) scale(0.9);
         opacity: 0; }
}

/* Applause — bounce up from below with a few oscillations, fading. */
.chat-effect-applause {
  bottom: -24px;
  animation-name: ce-bounce;
}
@keyframes ce-bounce {
  0%   { transform: translateY(0) scale(0.7); opacity: 0; }
  20%  { transform: translateY(-30vh) scale(1.15); opacity: 1; }
  40%  { transform: translateY(-22vh) scale(1); }
  60%  { transform: translateY(-40vh) scale(1.05); opacity: 1; }
  100% { transform: translateY(-90vh) scale(0.9); opacity: 0; }
}

/* Fireworks — burst outward from the centre. The drift CSS var is the
   final radial offset; we rely on per-particle randomization for the
   "explosion" pattern. */
.chat-effect-fireworks {
  top: 50%;
  left: 50%;
  margin-left: 0;
  animation-name: ce-burst;
}
@keyframes ce-burst {
  0%   { transform: translate(-50%, -50%) scale(0.3); opacity: 0; }
  20%  { transform: translate(-50%, -50%) scale(1.1); opacity: 1; }
  100% {
    transform:
      translate(calc(-50% + var(--ce-drift, 0) * 3),
                calc(-50% + var(--ce-drift, 0) * -2.5))
      scale(0.7) rotate(var(--ce-rot, 0));
    opacity: 0;
  }
}

/* Stars — twinkle in place, gentle scale + opacity pulse. */
.chat-effect-stars {
  top: calc(10% + (var(--ce-drift, 0) * -0.5));
  animation-name: ce-twinkle;
}
@keyframes ce-twinkle {
  0%   { transform: translate(0, 0) scale(0.4); opacity: 0; }
  30%  { transform: translate(var(--ce-drift, 0), 30vh) scale(1.1); opacity: 1; }
  70%  { transform: translate(0, 60vh) scale(0.8); opacity: 0.85; }
  100% { transform: translate(var(--ce-drift, 0), 100vh) scale(0.5); opacity: 0; }
}

/* When the user disabled motion (settings or OS), the chatEffects
   module already short-circuits the spawn — these rules cover the
   defensive case where a particle escapes the gate (e.g. pref toggled
   between spawn and animation). */
:root[data-reduce-motion="on"] .chat-effect {
  animation: none !important;
  display: none !important;
}

/* ============================================================
   MSN nudge / wizz (#27 easter egg)
   ============================================================
   Local-only — when the user is on the MSN theme and clicks the 💢
   button in the thread header, the thread pane shakes briefly the way
   Windows Live Messenger used to nudge a contact's window. The class
   `.msn-nudging` is set on `.thread-pane` by JS and stripped after the
   keyframe finishes. */

/* Amplitudes et durée relevées d'un cran à la demande de l'utilisateur (« le
   mouvement de secousses et la durée pourrait être très légèrement augmenté,
   pour un effet légèrement plus marqué et plus long, de pas beaucoup ») :
   amplitude max 7px → 10px, durée 600ms → 780ms. On garde la décroissance
   progressive (la secousse s'épuise) — c'est ce qui la rend crédible plutôt
   que mécanique. */
@keyframes msn-nudge-shake {
  0%   { transform: translate(0, 0); }
  10%  { transform: translate(-9px, -4px); }
  20%  { transform: translate(10px, 3px); }
  30%  { transform: translate(-7px, 6px); }
  40%  { transform: translate(8px, -4px); }
  50%  { transform: translate(-6px, 4px); }
  60%  { transform: translate(5px, -3px); }
  70%  { transform: translate(-4px, 3px); }
  80%  { transform: translate(3px, -2px); }
  90%  { transform: translate(-2px, 1px); }
  100% { transform: translate(0, 0); }
}
.thread-pane.msn-nudging {
  animation: msn-nudge-shake 780ms ease-out 1;
}
:root[data-reduce-motion="on"] .thread-pane.msn-nudging {
  animation: none !important;
}

/* ============================================================
   Reduce-motion killswitch
   ============================================================
   Targeted, NOT a global `*` selector — a wildcard !important would
   stomp on transitions used by the live UI (focus rings, hover states,
   modal open/close). Disabling each of OUR animations explicitly keeps
   the rest of the app responsive. */

:root[data-reduce-motion="on"] .typing-dots > span {
  animation: none !important;
  opacity: 0.7 !important;
}
:root[data-reduce-motion="on"] .flash-target {
  animation: none !important;
  outline: 2px solid var(--c-accent);
  outline-offset: 2px;
}
:root[data-reduce-motion="on"] .bubble[data-status="failed"] {
  animation: none !important;
}
:root[data-reduce-motion="on"] .jump-bottom:not([hidden]) {
  animation: none !important;
}

/* ══ AMBIANCES ═══════════════════════════════════════════════════════════════
   Décor TEMPORAIRE posé sur le fil par `/ambiance <nom>` (voir ambiances.js et
   ambianceEngine.js). C'est une PONCTUATION : quelques secondes, puis ça part.

   ⚠️ RÈGLE : le décor de CHAQUE ambiance vit ICI, pas dans le moteur. Ajouter
   une ambiance = ajouter une entrée dans ambiances.js + un bloc CSS ici.
   Le moteur, lui, ne change JAMAIS.

   ⚠️ La couche est en `position:absolute` par-dessus le fil, `pointer-events:
   none` : elle ne peut ni intercepter un clic, ni décaler quoi que ce soit, ni
   déclencher un recalcul de mise en page (donc aucun risque pour le scroll —
   la leçon du bug v141). Elle est RETIRÉE du DOM à la fin, pas juste masquée. */

.ambiance-layer {
  position: absolute;
  inset: 0;
  z-index: 3;                 /* au-dessus du wallpaper, sous les bulles */
  pointer-events: none;
  opacity: 0;
  animation: ambiance-in-out var(--ambiance-duration, 8000ms) ease-in-out forwards;
}

/* Un fondu d'entrée franc, un long plateau, un fondu de sortie doux : le décor
   s'installe vite (on veut le voir), reste le temps de lire, puis s'efface sans
   qu'on ait à y penser. */
@keyframes ambiance-in-out {
  0%   { opacity: 0; }
  8%   { opacity: 1; }
  75%  { opacity: 1; }
  100% { opacity: 0; }
}

/* Le décor est décoratif : sous reduce-motion, on n'affiche RIEN plutôt que de
   figer une couche opaque sur le fil (ce serait pire que rien). */
:root[data-reduce-motion="on"] .ambiance-layer { display: none; }

/* ── 🎉 FÊTE — guirlandes chaudes qui pulsent ─────────────────────────────── */
.ambiance-layer[data-ambiance="fete"] {
  background:
    radial-gradient(60% 40% at 20% 0%,  rgba(255, 196, 61, 0.30), transparent 70%),
    radial-gradient(50% 40% at 80% 0%,  rgba(255, 90, 140, 0.28), transparent 70%),
    radial-gradient(70% 50% at 50% 100%, rgba(120, 80, 255, 0.22), transparent 75%);
  animation:
    ambiance-in-out var(--ambiance-duration, 8000ms) ease-in-out forwards,
    amb-pulse 1.6s ease-in-out infinite;
}
@keyframes amb-pulse {
  0%, 100% { filter: saturate(1)   brightness(1); }
  50%      { filter: saturate(1.4) brightness(1.12); }
}

/* ── 💗 AMOUR — halo rose qui respire ─────────────────────────────────────── */
.ambiance-layer[data-ambiance="amour"] {
  background:
    radial-gradient(70% 60% at 50% 100%, rgba(255, 105, 160, 0.32), transparent 75%),
    radial-gradient(50% 40% at 50% 0%,   rgba(255, 170, 200, 0.20), transparent 70%);
  animation:
    ambiance-in-out var(--ambiance-duration, 8000ms) ease-in-out forwards,
    amb-breathe 2.8s ease-in-out infinite;
}
@keyframes amb-breathe {
  0%, 100% { transform: scale(1);    opacity: 1; }
  50%      { transform: scale(1.04); opacity: 0.85; }
}

/* ── 👏 BRAVO — flashs dorés qui montent ──────────────────────────────────── */
.ambiance-layer[data-ambiance="bravo"] {
  background:
    radial-gradient(80% 50% at 50% 100%, rgba(255, 214, 92, 0.30), transparent 70%),
    linear-gradient(0deg, rgba(255, 190, 60, 0.14), transparent 55%);
  animation:
    ambiance-in-out var(--ambiance-duration, 8000ms) ease-in-out forwards,
    amb-shine 1.1s ease-out infinite;
}
@keyframes amb-shine {
  0%   { filter: brightness(1);    }
  35%  { filter: brightness(1.25); }
  100% { filter: brightness(1);    }
}

/* ── 🎆 NOUVEL AN — ciel profond, lueurs froides qui éclatent ─────────────── */
.ambiance-layer[data-ambiance="nouvel-an"] {
  background:
    radial-gradient(40% 30% at 25% 25%, rgba(120, 200, 255, 0.30), transparent 70%),
    radial-gradient(35% 28% at 75% 35%, rgba(255, 120, 200, 0.26), transparent 70%),
    radial-gradient(45% 35% at 50% 15%, rgba(255, 240, 160, 0.22), transparent 72%),
    linear-gradient(180deg, rgba(8, 12, 40, 0.35), transparent 60%);
  animation:
    ambiance-in-out var(--ambiance-duration, 8000ms) ease-in-out forwards,
    amb-burst 2.2s ease-out infinite;
}
@keyframes amb-burst {
  0%   { transform: scale(0.98); filter: brightness(0.95); }
  25%  { transform: scale(1.02); filter: brightness(1.3);  }
  100% { transform: scale(0.98); filter: brightness(0.95); }
}

/* ── 🌙 NUIT — bleu profond, dérive lente ─────────────────────────────────── */
.ambiance-layer[data-ambiance="nuit"] {
  background:
    linear-gradient(180deg, rgba(10, 16, 48, 0.45), rgba(20, 30, 70, 0.20) 60%, transparent),
    radial-gradient(50% 35% at 70% 15%, rgba(180, 200, 255, 0.16), transparent 70%);
  animation:
    ambiance-in-out var(--ambiance-duration, 8000ms) ease-in-out forwards,
    amb-drift 9s ease-in-out infinite;
}
@keyframes amb-drift {
  0%, 100% { background-position: 0 0,   0 0; }
  50%      { background-position: 0 0, 3% 2%; }
}

/* ── 🍃 CALME — vert doux, ondulation très lente ──────────────────────────── */
.ambiance-layer[data-ambiance="calme"] {
  background:
    radial-gradient(70% 55% at 30% 100%, rgba(120, 200, 150, 0.22), transparent 75%),
    radial-gradient(60% 45% at 80% 10%,  rgba(180, 230, 190, 0.16), transparent 72%);
  animation:
    ambiance-in-out var(--ambiance-duration, 8000ms) ease-in-out forwards,
    amb-breathe 5.5s ease-in-out infinite;
}

/* ── 📼 NOSTALGIE — bleu MSN, léger scintillement d'écran cathodique ──────── */
.ambiance-layer[data-ambiance="nostalgie"] {
  background:
    linear-gradient(180deg, rgba(60, 130, 220, 0.24), transparent 65%),
    repeating-linear-gradient(0deg,
      rgba(255, 255, 255, 0.035) 0px,
      rgba(255, 255, 255, 0.035) 1px,
      transparent 1px,
      transparent 3px);
  animation:
    ambiance-in-out var(--ambiance-duration, 8000ms) ease-in-out forwards,
    amb-crt 0.14s steps(2) infinite;
}
@keyframes amb-crt {
  0%   { opacity: 1;    }
  50%  { opacity: 0.97; }
  100% { opacity: 1;    }
}

/* ── 🛸 FUTUR — pour les annonces (nouveautés de l'app, news tech) ──────────
   ⭐ C'est L'AMBIANCE QUI APPELLE UNE VRAIE IMAGE (demande user : ville
   japonaise futuriste, vaisseaux, OVNIs). En attendant l'illustration, ce décor
   CSS « néon cyberpunk » tient la place : lueurs magenta/cyan + un balayage
   lumineux qui traverse l'écran, comme un vaisseau qui passe.
   Le jour où l'image existe, elle se pose PAR-DESSUS (voir `.has-image`) et ce
   décor devient son arrière-plan. Rien à réécrire. */
.ambiance-layer[data-ambiance="futur"] {
  background:
    radial-gradient(45% 35% at 15% 80%, rgba(255, 60, 180, 0.30), transparent 70%),
    radial-gradient(45% 35% at 85% 75%, rgba(60, 220, 255, 0.28), transparent 70%),
    radial-gradient(60% 40% at 50% 105%, rgba(120, 60, 255, 0.26), transparent 75%),
    linear-gradient(180deg, rgba(6, 10, 30, 0.50), transparent 55%);
  animation:
    ambiance-in-out var(--ambiance-duration, 8000ms) ease-in-out forwards,
    amb-neon 2.4s ease-in-out infinite;
}
@keyframes amb-neon {
  0%, 100% { filter: saturate(1.1) brightness(1);    }
  50%      { filter: saturate(1.5) brightness(1.15); }
}
/* Le « vaisseau » : un trait lumineux qui traverse le fil en diagonale. */
.ambiance-layer[data-ambiance="futur"]::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(115deg,
    transparent 0%,
    transparent 42%,
    rgba(160, 240, 255, 0.55) 47%,
    rgba(255, 255, 255, 0.75) 50%,
    rgba(160, 240, 255, 0.55) 53%,
    transparent 58%,
    transparent 100%);
  background-size: 300% 100%;
  animation: amb-sweep 3.2s cubic-bezier(0.4, 0, 0.2, 1) infinite;
  pointer-events: none;
}
@keyframes amb-sweep {
  0%   { background-position: 130% 0; opacity: 0;   }
  15%  { opacity: 0.9; }
  55%  { opacity: 0.9; }
  70%  { background-position: -30% 0; opacity: 0;   }
  100% { background-position: -30% 0; opacity: 0;   }
}

/* ══ VRAIES IMAGES D'AMBIANCE ════════════════════════════════════════════════
   Quand une ambiance déclare `image:` (ambiances.js), le moteur pose l'URL dans
   `--ambiance-image` et ajoute `.has-image`. L'illustration se superpose au
   décor CSS (qui reste en arrière-plan, et sert de repli si l'image ne charge
   pas). Format conseillé : SVG animé, ou WebP/GIF animé.

   ⚠️ `pointer-events:none` hérité de .ambiance-layer, et `position:absolute` :
   l'image ne peut ni intercepter un clic, ni décaler la mise en page, ni
   provoquer un recalcul du scroll. */
.ambiance-layer.has-image::before {
  content: '';
  position: absolute;
  inset: 0;
  background-image: var(--ambiance-image);
  background-size: cover;
  background-position: center;
  background-repeat: no-repeat;
  pointer-events: none;
  /* Un léger fondu pour que l'illustration s'installe au lieu d'apparaître d'un
     coup — cohérent avec le reste des animations du projet. */
  animation: amb-image-in 900ms ease-out both;
}
@keyframes amb-image-in {
  from { opacity: 0; transform: scale(1.04); }
  to   { opacity: 1; transform: scale(1);    }
}

/* ══ SCÈNES D'AMBIANCE (nouveau moteur immersif) ═════════════════════════════
   Posée par sceneEngine.js : un FOND (image/vidéo) + des ÉLÉMENTS "satellites"
   à trajectoires ALÉATOIRES (chaque élément a ses propres variables CSS) + une
   pluie d'emojis + l'estompage des bulles existantes.

   La géométrie/vitesse/rotation de chaque élément vient de variables inline
   posées par le moteur (--sc-*). Le CSS ne décrit QUE la mécanique d'animation ;
   les valeurs (donc l'aléatoire) sont fournies par le JS. Ajouter une ambiance
   ne touche donc NI ce CSS NI le moteur — juste le catalogue déclaratif.

   ⚠️ pointer-events:none partout : la scène n'intercepte aucun clic et ne
   décale rien (aucun risque scroll, leçon v141). Retirée du DOM à la fin. */

.scene-layer {
  position: absolute;
  inset: 0;
  /* z-index 1 : le décor est posé DERRIÈRE les bulles (comme un wallpaper
     temporaire), au-dessus du wallpaper permanent (z-index 0). Les bulles
     précédentes deviennent quasi-transparentes (voir plus bas) → on VOIT le
     décor à travers elles ; les bulles nettes (ambiance + suivantes) restent
     opaques et lisibles PAR-DESSUS. Ainsi aucun conflit de contexte d'empilement
     (le piège des z-index imbriqués), et le message principal reste visible. */
  z-index: 1;
  pointer-events: none;
  overflow: hidden;
  opacity: 0;
  animation: scene-in-out var(--scene-duration, 8000ms) ease-in-out forwards;
}
@keyframes scene-in-out {
  0%   { opacity: 0; }
  6%   { opacity: 1; }
  80%  { opacity: 1; }
  100% { opacity: 0; }
}
/* Décoratif : sous reduce-motion on ne montre RIEN (mieux qu'une couche figée). */
:root[data-reduce-motion="on"] .scene-layer { display: none; }

/* ── LE FOND ──────────────────────────────────────────────────────────────── */
.scene-bg {
  position: absolute;
  inset: 0;
  animation: scene-bg-in 900ms ease-out both;
}
.scene-bg-image {
  background-size: cover;
  background-position: center;
  background-repeat: no-repeat;
}
.scene-bg-video {
  width: 100%;
  height: 100%;
}
/* Cas courant (mobile portrait) : la vidéo remplit, `videoFocus` dit où cadrer. */
.scene-bg-video-cover { object-fit: cover; }

/* ── DOUBLE VIDÉO (panneau paysage + vidéo portrait, ex. Electron) ────────────
   `cover` seul zoomerait la vidéo ~2× → gros plan qui perd le contexte ;
   `contain` seul laisserait deux grosses bandes vides. On empile donc :
     • `-fill`    : la MÊME vidéo en cover, remplit l'écran. Son zoom la rend
                    naturellement diffuse — PAS de `filter: blur()`, qui coûte
                    cher en GPU (surtout mobile). Un voile/flou reste ajoutable
                    ici plus tard si le rendu le demande.
     • `-contain` : la vidéo ENTIÈRE par-dessus, rien n'est coupé.
   Le moteur ne crée cette paire QUE si le panneau est nettement plus large que
   le format de la vidéo (sinon une seule vidéo — pas de décodage en double). */
.scene-bg-video-fill {
  object-fit: cover;
  /* ⚠️ PAS de `transform: scale()` ici. Un agrandissement décale cette couche par
     rapport à la vidéo de devant → l'utilisateur voyait « un décalage d'une
     dizaine de pixels entre les 2 ». `cover` remplit déjà exactement le cadre :
     l'échelle n'apportait rien et ne faisait que créer ce défaut. */
}
.scene-bg-video-contain {
  object-fit: contain;
  object-position: center center;   /* la vidéo entière, CENTRÉE dans le panneau */
}
@keyframes scene-bg-in {
  from { opacity: 0; transform: scale(1.06); }
  to   { opacity: 1; transform: scale(1);    }
}

/* ── EFFET KEN BURNS (fond image) ─────────────────────────────────────────────
   Le fond ne reste pas figé : il zoome/panoramique lentement pendant toute la
   scène → il « respire », on gagne en profondeur. 4 variantes (le moteur en pose
   une au hasard via `ken-N`) pour ne pas toujours avoir le même mouvement.
   S'ajoute AU fondu d'entrée. La durée suit celle de la scène (--scene-duration).
   Toujours partir/rester au-dessus de scale(1) pour ne jamais laisser voir de
   bord (le fond couvre toujours l'écran). */
.scene-bg-image.ken-1 { animation: scene-bg-in 900ms ease-out both, ken-burns-1 var(--scene-duration, 8000ms) ease-out both; }
.scene-bg-image.ken-2 { animation: scene-bg-in 900ms ease-out both, ken-burns-2 var(--scene-duration, 8000ms) ease-out both; }
.scene-bg-image.ken-3 { animation: scene-bg-in 900ms ease-out both, ken-burns-3 var(--scene-duration, 8000ms) ease-out both; }
.scene-bg-image.ken-4 { animation: scene-bg-in 900ms ease-out both, ken-burns-4 var(--scene-duration, 8000ms) ease-out both; }
/* Zoom avant, léger pan vers le haut-gauche. */
@keyframes ken-burns-1 {
  from { transform: scale(1.02) translate(0, 0); }
  to   { transform: scale(1.16) translate(-2.5%, -2%); }
}
/* Zoom avant, pan vers le bas-droite. */
@keyframes ken-burns-2 {
  from { transform: scale(1.03) translate(0, 0); }
  to   { transform: scale(1.15) translate(2.5%, 2%); }
}
/* Zoom arrière (part serré, se relâche), pan vers la droite. */
@keyframes ken-burns-3 {
  from { transform: scale(1.18) translate(2%, 1%); }
  to   { transform: scale(1.04) translate(-1.5%, 0); }
}
/* Zoom avant lent, pan vertical vers le haut (montée dans la ville). */
@keyframes ken-burns-4 {
  from { transform: scale(1.05) translate(0, 2.5%); }
  to   { transform: scale(1.17) translate(0, -3%); }
}
/* Sous reduce-motion : pas de Ken Burns (le fond reste fixe). */
:root[data-reduce-motion="on"] .scene-bg-image { animation: scene-bg-in 900ms ease-out both !important; }

/* ── LES ÉLÉMENTS (satellites) ────────────────────────────────────────────── */
.scene-elements { position: absolute; inset: 0; }
.scene-el {
  position: absolute;
  left: var(--sc-x, 50%);
  top: var(--sc-y, 50%);
  width: var(--sc-size, 60px);
  height: auto;
  opacity: var(--sc-op, 1);
  /* PAS de filter: blur — décision user : on veut des images NETTES. Le flou de
     « profondeur » était moche, inutile la plupart du temps, coûteux en perf, et
     son halo débordant laissait deviner le texte derrière (fausse « transparence »).
     La profondeur reste portée par la TAILLE et le z-index. */
  will-change: transform;
  /* Échelle de profondeur appliquée en base ; la dérive/rotation s'ajoutent
     via les keyframes (qui repartent de translate(-50%,-50%) pour centrer). */
  transform: translate(-50%, -50%) scale(var(--sc-depth, 1));
}
/* QUATRE trajectoires de dérive distinctes : les éléments ne bougent pas tous
   pareil ni dans le même sens (le user ne veut surtout pas de mouvement
   synchrone). Chacune combine un aller-retour x/y avec une courbe différente.
   Le --sc-delay NÉGATIF décale la phase → deux éléments de la même classe ne
   sont jamais au même point de leur boucle. */
.scene-el.drift-a { animation: sc-drift-a var(--sc-dur, 12s) var(--sc-delay, 0s) ease-in-out infinite alternate; }
.scene-el.drift-b { animation: sc-drift-b var(--sc-dur, 12s) var(--sc-delay, 0s) ease-in-out infinite alternate; }
.scene-el.drift-c { animation: sc-drift-c var(--sc-dur, 12s) var(--sc-delay, 0s) ease-in-out infinite alternate; }
.scene-el.drift-d { animation: sc-drift-d var(--sc-dur, 12s) var(--sc-delay, 0s) ease-in-out infinite alternate; }
/* La rotation est un 2e animation, combinée à la dérive (deux propriétés
   distinctes → on empile via une variable de transform séparée n'est pas
   possible en pur CSS ; on met la rotation sur un enfant implicite via un
   wrapper serait plus lourd — ici on accepte que .spin REMPLACE la dérive par
   une dérive+rotation dédiée). */
.scene-el.spin.drift-a,
.scene-el.spin.drift-b,
.scene-el.spin.drift-c,
.scene-el.spin.drift-d {
  animation:
    sc-drift-a var(--sc-dur, 12s) var(--sc-delay, 0s) ease-in-out infinite alternate,
    sc-spin var(--sc-spin-dur, 12s) linear infinite;
}
@keyframes sc-drift-a {
  from { transform: translate(-50%, -50%) scale(var(--sc-depth,1)) translate(0, 0); }
  to   { transform: translate(-50%, -50%) scale(var(--sc-depth,1)) translate(var(--sc-dx, 60px), var(--sc-dy, 40px)); }
}
@keyframes sc-drift-b {
  from { transform: translate(-50%, -50%) scale(var(--sc-depth,1)) translate(0, 0); }
  to   { transform: translate(-50%, -50%) scale(var(--sc-depth,1)) translate(calc(var(--sc-dx, 60px) * -1), var(--sc-dy, 40px)); }
}
@keyframes sc-drift-c {
  from { transform: translate(-50%, -50%) scale(var(--sc-depth,1)) translate(0, 0); }
  to   { transform: translate(-50%, -50%) scale(var(--sc-depth,1)) translate(var(--sc-dx, 60px), calc(var(--sc-dy, 40px) * -1)); }
}
@keyframes sc-drift-d {
  from { transform: translate(-50%, -50%) scale(var(--sc-depth,1)) translate(0, 0); }
  to   { transform: translate(-50%, -50%) scale(var(--sc-depth,1)) translate(calc(var(--sc-dx, 60px) * -0.6), calc(var(--sc-dy, 40px) * 0.8)); }
}
@keyframes sc-spin {
  from { rotate: 0deg; }
  to   { rotate: var(--sc-spin, 360deg); }
}

/* ── LES VAISSEAUX QUI VOLENT (move:'fly') ─────────────────────────────────────
   Traversent l'écran horizontalement, à une HAUTEUR (--fl-y), TAILLE (--fl-size),
   PROFONDEUR (--fl-depth → échelle) et VITESSE (--fl-dur) aléatoires, dans un
   SENS aléatoire (fly-ltr → / fly-rtl ←). z-index posé inline (parallaxe : les
   proches passent devant). Léger tangage vertical (--fl-bob) pour ne pas voler
   parfaitement droit. Surcharge le positionnement de .scene-el (qui vise --sc-*). */
.scene-el.fly {
  left: 0;
  top: var(--fl-y, 40%);
  width: var(--fl-size, 110px);
  opacity: var(--fl-op, 1);
  /* PAS de filter: blur (décision user, cf. .scene-el). Netteté partout. */
  transform: translateX(-25vw);
}
/* Traversée HORIZONTALE FRANCHE. Le tangage vertical est MINIME (--fl-bob petit)
   pour ne pas transformer la traversée en diagonale/rond. On NE combine PAS de
   rotation ici : c'est la rotation (sc-spin, centre décalé) qui faisait tourner
   le vaisseau EN ROND au lieu de le laisser traverser. Un vaisseau qui traverse
   ne tournoie pas sur lui-même. L'échelle de profondeur est appliquée DANS la
   keyframe (sinon une 2e règle transform l'écraserait). Le sens ← retourne
   l'image horizontalement (scaleX négatif) pour qu'elle "regarde" où elle va. */
.scene-el.fly-ltr { animation: sc-fly-ltr var(--fl-dur, 10s) var(--fl-delay, 0s) linear infinite; }
.scene-el.fly-rtl { animation: sc-fly-rtl var(--fl-dur, 10s) var(--fl-delay, 0s) linear infinite; }
@keyframes sc-fly-ltr {
  0%   { transform: translateX(-25vw) translateY(0)                scale(var(--fl-depth,1)); }
  50%  { transform: translateX(52vw)  translateY(var(--fl-bob,0px)) scale(var(--fl-depth,1)); }
  100% { transform: translateX(130vw) translateY(0)                scale(var(--fl-depth,1)); }
}
@keyframes sc-fly-rtl {
  0%   { transform: translateX(130vw) translateY(0)                scale(calc(var(--fl-depth,1) * -1), var(--fl-depth,1)); }
  50%  { transform: translateX(52vw)  translateY(var(--fl-bob,0px)) scale(calc(var(--fl-depth,1) * -1), var(--fl-depth,1)); }
  100% { transform: translateX(-25vw) translateY(0)                scale(calc(var(--fl-depth,1) * -1), var(--fl-depth,1)); }
}

/* ── PLUIE D'EMOJIS ───────────────────────────────────────────────────────── */
.scene-emoji-rain { position: absolute; inset: 0; }
.scene-emoji {
  position: absolute;
  left: var(--em-x, 50%);
  font-size: var(--em-size, 24px);
  will-change: transform, opacity;
}
/* MODE 'fall' (défaut) : tombe du haut (neige, étincelles, confettis). */
.scene-emoji.em-fall {
  top: -8%;
  animation: sc-emoji-fall var(--em-dur, 6s) var(--em-delay, 0s) linear infinite;
}
@keyframes sc-emoji-fall {
  0%   { transform: translate(-50%, 0) rotate(0deg);            opacity: 0; }
  10%  { opacity: 0.9; }
  90%  { opacity: 0.9; }
  100% { transform: translate(calc(-50% + var(--em-drift, 0px)), 118vh) rotate(220deg); opacity: 0; }
}
/* MODE 'float' : FLOTTE doucement en remontant (bulles, feuilles portées par le
   vent, ambiance plage). Un 🌴 ou un 🐚 qui TOMBE du ciel n'a aucun sens — même
   erreur que les vaisseaux qui tombaient. Ici ils dérivent et s'élèvent, avec un
   balancement latéral. */
.scene-emoji.em-float {
  bottom: -10%;
  animation: sc-emoji-float var(--em-dur, 9s) var(--em-delay, 0s) ease-in-out infinite;
}
@keyframes sc-emoji-float {
  0%   { transform: translate(-50%, 0) rotate(-6deg);                             opacity: 0; }
  12%  { opacity: 0.85; }
  50%  { transform: translate(calc(-50% + var(--em-drift, 0px)), -55vh) rotate(6deg); }
  88%  { opacity: 0.85; }
  100% { transform: translate(-50%, -115vh) rotate(-4deg);                        opacity: 0; }
}

/* ── SUPERPOSITION SCÈNE ↔ BULLES ─────────────────────────────────────────────
   La couche de scène (z-index 1) et `.thread-wrap` (qui contient la liste des
   bulles) sont TOUS DEUX enfants directs de `.thread-pane` (mesuré) → on peut
   les ordonner par z-index sans piège de contexte imbriqué.
   On remonte `.thread-wrap` AU-DESSUS de la scène : toutes les bulles passent
   donc devant le décor. L'effet « précédentes effacées / ambiance+suivantes
   visibles » vient alors de la seule TRANSPARENCE des bulles estompées (on voit
   le décor à travers elles), pas d'un jeu de z-index sur des enfants imbriqués.
   ⚠️ Piège évité : elementFromPoint IGNORE la scène (pointer-events:none) et
   retournait la bulle « visible » alors qu'elle était cachée dessous — la sonde
   mentait. La vraie correction est ce z-index sur `.thread-wrap`. */
.thread-pane.scene-on > .thread-wrap { position: relative; z-index: 2; }
.thread-pane[data-scene-bubbles="fade"] .bubble.scene-dimmed {
  transition: opacity 700ms ease;
  /* ⚠️ VRAIMENT invisibles (0, pas 0.12). À 0.12, la bulle « fantôme » restait
     présente et REDEVENAIT visible dès qu'un élément clair/contrasté (robot, OVNI
     lumineux) passait derrière : le contraste local la faisait ressortir — bug
     signalé « les robots révèlent les bulles cachées ». opacity:0 = aucun fantôme,
     quoi qu'il passe derrière. (Le décor + les bulles non estompées restent.) */
  opacity: 0;
}
/* La bulle d'ambiance + les suivantes (NON estompées) doivent RESSORTIR sur le
   décor sombre et chargé. Sans fond renforcé, une bulle au fond sombre/semi-
   transparent devient invisible sur l'image (mesuré : la bulle était bien au
   premier plan mais illisible). On force un fond opaque + une ombre portée +
   un liseré, uniquement pendant la scène, uniquement sur les bulles avec du
   contenu (une bulle vide n'a rien à montrer, autant ne pas afficher un cartouche
   vide). */
.thread-pane[data-scene-bubbles="fade"] .bubble:not(.scene-dimmed):not(:empty) {
  background: rgba(15, 18, 28, 0.82) !important;
  color: #f2f5fb !important;
  box-shadow: 0 6px 22px rgba(0, 0, 0, 0.55);
  border: 1px solid rgba(255, 255, 255, 0.14) !important;
  backdrop-filter: none;      /* pas de blur (préférence utilisateur) */
}
:root[data-reduce-motion="on"] .bubble.scene-dimmed {
  transition: none;
  opacity: 1;                 /* pas d'estompage sous reduce-motion */
  filter: none;
}
