/* App layout + screens. */

.app {
  display: flex;
  flex-direction: column;
  width: 100%;
  height: 100%;
  min-height: 0;
  overflow: hidden;
}

/* Drag region (top 28px of the app) — lets the user move the frameless window if we ever remove the title bar.
   Harmless if native title bar is used. */
.drag-region {
  -webkit-app-region: drag;
  height: 28px;
  flex-shrink: 0;
  /* Float above the grid so it doesn't count as a layout row. The grid
     `auto 1fr` of .chat-screen/.main-screen would otherwise have THREE
     in-flow children, and CSS Grid would either inject an implicit row or
     auto-place `.topbar` into the `1fr` track (which stretched it to ~90%
     of the screen). With `position: absolute`, it overlays the topbar and
     stays a pure Electron drag affordance. On mobile it's `display:none`. */
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  z-index: 20;
  pointer-events: none; /* visual region only; Electron picks the drag */
}

/* --- Pairing screen --- */
.pair-screen {
  flex: 1;
  min-height: 0;
  display: grid;
  place-items: center;
  padding: var(--sp-7);
  /* Scrollable so that when the on-screen keyboard shrinks the viewport, the
     lower fields (name / "Se connecter") stay reachable instead of being
     clipped by `#app { overflow: hidden }`. `align-content: start` keeps the
     card from being vertically centered once its content is taller than the
     visible area (otherwise centering pushes the top off-screen). */
  align-content: safe center;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  overscroll-behavior: contain;
}

.pair-card {
  width: min(460px, 100%);
  padding: var(--sp-7);
  display: flex;
  flex-direction: column;
  gap: var(--sp-5);
}

.pair-header {
  display: flex;
  flex-direction: column;
  gap: var(--sp-2);
  text-align: center;
}
.pair-header p { color: var(--c-text-soft); font-size: var(--fs-sm); }

.pair-form {
  display: flex;
  flex-direction: column;
  gap: var(--sp-4);
}

.pair-actions {
  display: flex;
  gap: var(--sp-3);
  justify-content: flex-end;
  margin-top: var(--sp-2);
}

.pair-hint {
  color: var(--c-text-dim);
  font-size: var(--fs-xs);
  text-align: center;
  margin-top: var(--sp-2);
}

/* « Options avancées » (serveur) — repliées par défaut, discrètes. */
.pair-advanced {
  margin-top: var(--sp-3);
  border-top: 1px solid var(--c-border, rgba(128,128,128,0.2));
  padding-top: var(--sp-3);
}
.pair-advanced > summary {
  cursor: pointer;
  color: var(--c-text-soft);
  font-size: var(--fs-sm);
  user-select: none;
  list-style-position: inside;
}
.pair-advanced[open] > summary { margin-bottom: var(--sp-3); }

.server-row {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: var(--sp-2);
  align-items: center;
}
.pair-server-status {
  display: block;
  margin-top: var(--sp-1);
  font-size: var(--fs-xs);
  min-height: 1em;
  color: var(--c-text-dim);
}
.pair-server-testing { color: var(--c-text-soft); }
.pair-server-ok  { color: var(--c-success); }
.pair-server-err { color: var(--c-danger); }

.pair-quick {
  display: flex;
  gap: var(--sp-2);
  justify-content: center;
  flex-wrap: wrap;
}

/* Cas A — raccourci « J'ai déjà un appareil connecté » sous le formulaire. */
.pair-alt {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--sp-2);
  margin-top: var(--sp-3);
  padding-top: var(--sp-4);
  border-top: 1px solid var(--c-border, rgba(128,128,128,0.2));
  text-align: center;
}
.pair-alt-sep {
  color: var(--c-text-dim);
  font-size: var(--fs-xs);
  text-transform: uppercase;
  letter-spacing: 0.08em;
}
.pair-alt .settings-hint { text-align: center; margin: 0; }

/* Cas A — écran AFFICHEUR (le nouvel appareil montre son QR + code). */
.dlink-card { align-items: stretch; }
.dlink-qr {
  align-self: center;
  width: min(240px, 70vw);
  aspect-ratio: 1;
  background: #fff;               /* le QR exige un fond clair pour être lisible */
  border-radius: var(--radius-md, 12px);
  padding: var(--sp-3);
  box-shadow: 0 1px 4px rgba(0,0,0,0.12);
  display: flex;
  align-items: center;
  justify-content: center;
}
.dlink-qr svg { width: 100%; height: 100%; display: block; }
.dlink-qr:empty { display: none; }   /* pas de cadre vide si la génération échoue */

.dlink-codewrap {
  display: flex;
  flex-direction: column;
  gap: var(--sp-2);
  align-items: stretch;
}
.dlink-code {
  font-family: var(--font-mono, ui-monospace, monospace);
  font-size: var(--fs-xs);
  word-break: break-all;
  resize: none;
  text-align: center;
  color: var(--c-text-soft);
}
.dlink-codewrap .btn { align-self: center; }

.dlink-statuswrap {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--sp-2);
  min-height: 1.5em;
}
.dlink-status {
  color: var(--c-text-soft);
  font-size: var(--fs-sm);
  text-align: center;
  margin: 0;
}
.dlink-actions { justify-content: center; }
/* Le bouton « nouveau code » est caché tant qu'il n'y a pas d'expiration/erreur.
   `.btn` force display:inline-flex, qui écrase l'attribut HTML `hidden` → on rétablit
   explicitement (piège connu : toute règle display doit avoir sa contre-règle [hidden]). */
.dlink-actions .btn[hidden] { display: none !important; }

/* Écran de choix de l'identifiant (handle) — réutilise pair-screen/pair-card. */
.handle-setup-card { text-align: left; }
.handle-form {
  display: flex;
  flex-direction: column;
  gap: var(--sp-4);
}
.handle-title {
  font-size: var(--fs-lg);
  text-align: center;
  margin: 0;
}
.handle-intro {
  color: var(--c-text-soft);
  font-size: var(--fs-sm);
  line-height: 1.5;
  margin: 0;
}
/* Verdict de forme en direct (le serveur reste l'autorité sur l'unicité). */
.handle-hint {
  min-height: 1.2em;
  font-size: var(--fs-sm);
  font-variant-numeric: tabular-nums;
}
.handle-hint-ok  { color: var(--c-success); }
.handle-hint-bad { color: var(--c-danger); }

/* « Mon identifiant » (réglages) : l'identifiant courant affiché à son propriétaire. */
.handle-current {
  font-family: var(--font-mono, monospace);
  font-size: var(--fs-md);
  padding: var(--sp-2) 0;
  word-break: break-all;
}
.profile-handle-btn { align-self: flex-start; margin-top: var(--sp-2); }

/* QR scan overlay */
.qr-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.85);
  display: grid;
  place-items: center;
  z-index: 1000;
  padding: var(--sp-5);
}
.qr-video {
  width: min(90vw, 480px);
  aspect-ratio: 1 / 1;
  object-fit: cover;
  border-radius: var(--r-lg);
  background: #000;
  box-shadow: 0 0 0 2px var(--c-accent), 0 8px 40px rgba(0,0,0,0.6);
}
.qr-hint {
  color: var(--c-text-soft);
  text-align: center;
  margin-top: var(--sp-3);
}
.qr-close { margin-top: var(--sp-3); }

/* --- Connected screen ---
   Two in-flow children: .topbar then .body. (`.drag-region` is taken out of
   flow with position: absolute — see its rule above.) Two rows: header
   (auto) + scrolling body (1fr). */
.main-screen {
  flex: 1;
  display: grid;
  grid-template-rows: auto 1fr;
  min-height: 0;
  position: relative; /* containing block for the absolute .drag-region */
}

.topbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: var(--sp-3) var(--sp-5);
  border-bottom: 1px solid var(--c-border);
  /* Solid background (no backdrop-filter) avoids repaint storms when the
     keyboard opens on Android. Blur was pretty but triggered a full-layer
     invalidation every frame during viewport resize. */
  background: var(--c-bg-0);
  position: relative;
  z-index: 10;
}

/* --- App background show-through ---
   When an app-wide background is active (<html data-app-wp>), the chrome
   surfaces adopt a TRANSLUCENT version of their theme color so the background
   shows through the whole UI (sidebar, topbar, headers) — not just the content
   area. The translucency level is the user's "interface transparency" setting:
   --c-bg-0-surf / --c-bg-1-surf are the theme colors at appSurfaceAlpha,
   computed in JS (reliable everywhere). Gated on [data-app-wp] so without a
   background the surfaces keep their original solid var() (zero visual change).
   The theme itself acts as the readability veil. */
:root[data-app-wp] .topbar,
:root[data-app-wp] .thread-header {
  background: var(--c-bg-0-surf, var(--c-bg-0));
}
:root[data-app-wp] .contacts-pane {
  background: var(--c-bg-1-surf, var(--c-bg-1));
}
/* Note: .thread-pane's base background is already mostly transparent (a faint
   gradient over `transparent`), so the app background shows through the
   message area on its own. A per-chat wallpaper still wins via
   .thread-pane.has-wallpaper (opaque layer above) — the per-conversation
   background is PRIORITY over the general one, by design. */

.topbar-left {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
}

.topbar-user {
  display: flex;
  flex-direction: column;
  line-height: 1.2;
  min-width: 0;
}
.topbar-user .name { font-weight: 600; }
.topbar-user .sub { font-size: var(--fs-xs); color: var(--c-text-dim); }
/* Own status broadcast back to ourselves so we can see what our contacts
   see. Italic, dim, ellipsised so a long status doesn't push the right-
   side action buttons off the topbar. */
.topbar-user-status {
  font-style: italic;
  color: var(--c-text-soft) !important;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  max-width: min(280px, 40vw);
}

.topbar-right { display: flex; gap: var(--sp-2); align-items: center; }

.body {
  padding: var(--sp-7);
  overflow: auto;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--sp-5);
}

.placeholder-card {
  width: min(640px, 100%);
  padding: var(--sp-7);
  display: flex;
  flex-direction: column;
  gap: var(--sp-4);
  text-align: center;
}
.placeholder-card p { color: var(--c-text-soft); }
.placeholder-card .kbd {
  display: inline-block;
  padding: 2px 8px;
  border: 1px solid var(--c-border-strong);
  border-radius: var(--r-sm);
  font-family: var(--font-mono);
  font-size: var(--fs-xs);
  color: var(--c-text-soft);
  background: var(--c-bg-2);
}

.details-grid {
  display: grid;
  grid-template-columns: 120px 1fr;
  gap: var(--sp-3) var(--sp-4);
  text-align: left;
  font-size: var(--fs-sm);
}
.details-grid dt { color: var(--c-text-dim); }
.details-grid dd { margin: 0; color: var(--c-text); font-family: var(--font-mono); word-break: break-all; }

/* --- Chat screen ---
   Top-level layout: [topbar fixed height] [chat-main fills the rest].
   Nothing here scrolls directly — the only scrolling region in the whole
   app is `.thread-list`. Everything else is fixed so the keyboard doesn't
   reflow the structure. */
.chat-screen {
  flex: 1;
  display: grid;
  /* Two in-flow children: .topbar then .chat-main. (`.drag-region` is taken
     out of flow with position: absolute — see its rule above.) On mobile,
     `.thread-open .topbar { display: none }` collapses the topbar to 0; the
     `.chat-main { grid-row: 2 }` pin in pwa.css ensures it stays in the
     `1fr` track even when the topbar is gone. */
  grid-template-rows: auto 1fr;
  min-height: 0;
  position: relative; /* containing block for the mobile thread overlay AND .drag-region */
}

.chat-main {
  display: grid;
  /* Largeur de la colonne Contacts pilotée par --contacts-w (défaut 300px),
     redimensionnable par la poignée (.pane-resizer) et mémorisée en prefs.
     Corrige le 300px figé qui compressait tout sur Electron (audit). */
  grid-template-columns: var(--contacts-w, 300px) 1fr;
  min-height: 0;
  overflow: hidden;
  position: relative; /* mobile overlay is anchored here */
}

/* Poignée de redimensionnement entre la colonne Contacts et le fil. Fine bande
   posée sur le bord droit de la colonne ; curseur col-resize ; zone de saisie
   élargie. Desktop uniquement (masquée en overlay mobile, cf. pwa.css). */
.pane-resizer {
  position: absolute;
  top: 0;
  bottom: 0;
  left: var(--contacts-w, 300px);
  width: 8px;
  transform: translateX(-4px);
  cursor: col-resize;
  z-index: 6;
  background: transparent;
}
.pane-resizer::after {
  content: "";
  position: absolute;
  top: 0; bottom: 0; left: 3px;
  width: 1px;
  background: transparent;
  transition: background 0.12s;
}
.pane-resizer:hover::after,
.pane-resizer.dragging::after { background: var(--c-accent); }
body.pane-resizing { cursor: col-resize; user-select: none; }

/* Contacts pane */
.contacts-pane {
  border-right: 1px solid var(--c-border);
  display: flex;
  flex-direction: column;
  min-height: 0;
  /* Was `rgba(17,20,27,0.4)` — a dark color baked-in. On light themes
     that produced a grimy grey overlay regardless of the theme tokens.
     Using `--c-bg-1` (the "panels" surface, defined per theme in
     tokens.css) makes the column adopt the right tint everywhere. */
  background: var(--c-bg-1);
}

.pane-header {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  padding: var(--sp-3) var(--sp-4);
  border-bottom: 1px solid var(--c-border);
}
.pane-header[hidden] { display: none !important; }
/* Le titre prend l'espace restant et NE WRAPPE JAMAIS (ellipsis si trop long).
   Corrige « Contacts »/« Mon code » qui cassaient sur 2 lignes dans la colonne
   étroite (300px Electron). */
.pane-header h2 {
  flex: 1 1 auto;
  min-width: 0;
  margin: 0;
  font-size: var(--fs-md);
  color: var(--c-text-soft);
  letter-spacing: 0.04em;
  text-transform: uppercase;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.pane-actions { display: flex; gap: 4px; flex-shrink: 0; }

/* Bouton-icône CARRÉ (loupe, ⋮…) : taille fixe → cohérent Electron ET S23,
   plus jamais d'icône écrasée (l'ancien .search-toggle dépendait du contenu). */
.pane-icon-btn {
  width: 34px;
  height: 34px;
  flex-shrink: 0;
  padding: 0;
  border: 0;
  border-radius: 9px;
  background: transparent;
  color: var(--c-text);
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
}
.pane-icon-btn:hover { background: var(--c-bg-2); }
.pane-icon-btn.active { color: var(--c-accent); background: var(--c-accent-weak); }
.pane-icon-btn svg { display: block; }

.contacts-list {
  overflow: auto;
  flex: 1;
  padding: var(--sp-2);
  display: flex;
  flex-direction: column;
  gap: 2px;
}
/* .contacts-list est display:flex → l'attribut HTML `hidden` seul ne suffit pas
   (une règle display l'écrase). Règle-compagne obligatoire (leçon mémoire). */
.contacts-list[hidden] { display: none !important; }

/* La loupe (.search-toggle) réutilise .pane-icon-btn ; sa classe .active vient
   de .pane-icon-btn.active. Rien de spécifique ici. */

/* ---- Barre de recherche (globale, dans les messages) — repliable ---- */
.search-bar {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  margin: var(--sp-2) var(--sp-3) 0;
  padding: 6px 10px;
  background: var(--c-bg-2);
  border: 1px solid var(--c-border);
  border-radius: 999px;
  flex-shrink: 0;
}
.search-bar[hidden] { display: none !important; }
.search-icon { display: inline-flex; align-items: center; opacity: 0.65; flex-shrink: 0; }
.search-icon svg { display: block; }
.search-input {
  flex: 1 1 auto;
  min-width: 0;
  border: 0;
  background: transparent;
  color: var(--c-text);
  font: inherit;
  font-size: var(--fs-sm);
  outline: none;
  padding: 2px 0;
}
.search-input::placeholder { color: var(--c-text-dim); }
.search-clear {
  flex-shrink: 0;
  width: 24px; height: 24px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: var(--c-text-dim);
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
}
.search-clear svg { display: block; }
.search-clear:hover { background: var(--c-bg-3); color: var(--c-text); }

/* ---- Liste de résultats (remplace la liste des chats quand active) ---- */
.search-results {
  overflow-y: auto;
  overflow-x: hidden;      /* jamais de débordement horizontal (bug S23) */
  flex: 1;
  min-width: 0;
  padding: var(--sp-2);
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.search-results[hidden] { display: none !important; }
.search-result-row {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  padding: var(--sp-3);
  background: transparent;
  border: 0;
  border-radius: var(--r-md);
  text-align: left;
  color: var(--c-text);
  cursor: pointer;
  width: 100%;
  max-width: 100%;
  box-sizing: border-box;  /* le padding ne pousse pas la largeur au-delà de 100% */
}
.search-result-row:hover { background: var(--c-bg-2); }
.search-result-row .avatar { width: 36px; height: 36px; font-size: var(--fs-sm); flex-shrink: 0; }
.search-result-main { display: flex; flex-direction: column; min-width: 0; flex: 1 1 auto; }
.search-result-top { display: flex; align-items: baseline; justify-content: space-between; gap: var(--sp-2); }
.search-result-name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.search-result-when { font-size: var(--fs-xs); color: var(--c-text-dim); flex-shrink: 0; }
/* Extrait sur 2 LIGNES (au lieu d'1 tronquée) : bien assez pour distinguer une
   conversation. Clamp CSS + coupe des mots longs pour ne jamais déborder. */
.search-result-snippet {
  font-size: var(--fs-xs);
  color: var(--c-text-dim);
  margin-top: 2px;
  line-height: 1.35;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  overflow-wrap: anywhere;
  word-break: break-word;
}
.search-hit {
  background: var(--c-accent-weak);
  color: var(--c-text);
  border-radius: 3px;
  padding: 0 1px;
  font-weight: 600;
}
.search-empty { padding: var(--sp-6); text-align: center; color: var(--c-text-dim); font-size: var(--fs-sm); }
.search-empty p { margin: 0; }
.search-empty-sub { margin-top: var(--sp-2); font-size: var(--fs-xs); }
.search-truncated {
  padding: var(--sp-3);
  text-align: center;
  color: var(--c-text-dim);
  font-size: var(--fs-xs);
  font-style: italic;
}

.contacts-empty {
  padding: var(--sp-6);
  text-align: center;
  color: var(--c-text-dim);
  font-size: var(--fs-sm);
}
.contacts-empty p { margin: 0; }
.contacts-empty-sub { margin-top: var(--sp-2); color: var(--c-text-dim); font-size: var(--fs-xs); }

.contact-row {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: var(--sp-3);
  align-items: center;
  padding: var(--sp-3);
  background: transparent;
  border: 0;
  border-radius: var(--r-md);
  text-align: left;
  cursor: pointer;
  transition: background var(--dur-fast) var(--ease-out);
  color: var(--c-text);
}
.contact-row:hover { background: var(--c-bg-2); }
.contact-row.active { background: var(--c-accent-weak); }
.contact-row.selected { background: var(--c-bg-3); outline: 2px solid var(--c-accent); }
.contact-row .avatar { width: 36px; height: 36px; font-size: var(--fs-sm); }

.contact-main { display: flex; flex-direction: column; min-width: 0; }
.contact-top { display: flex; align-items: center; gap: var(--sp-2); }
.contact-name { font-weight: 600; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* 📌 conversation épinglée : petit repère discret à droite du nom, avant le badge
   de non-lus. flex-shrink:0 pour ne jamais être écrasé ; taille réduite pour rester
   sobre. */
.contact-pin { flex-shrink: 0; font-size: 12px; line-height: 1; opacity: 0.8; }
.contact-sub { display: flex; align-items: center; gap: var(--sp-2); color: var(--c-text-dim); font-size: var(--fs-xs); }
.contact-sub .dot { width: 6px; height: 6px; box-shadow: none; }
/* Optional freeform status line shown under the presence row. Italic +
   dim + truncated with an ellipsis so a long status never breaks the
   single-line layout of a contact row. Hidden when empty (the JS skips
   the element entirely so there's no whitespace cost). */
.contact-status {
  font-size: var(--fs-xs);
  color: var(--c-text-soft);
  font-style: italic;
  margin-top: 2px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Sélecteur de contact (transfert). Liste scrollable de contacts cliquables dans
   un modal. Réutilise l'esthétique des lignes de contact. */
.pick-contact { display: flex; flex-direction: column; gap: var(--sp-3); min-width: 260px; }
.pick-contact-list {
  display: flex;
  flex-direction: column;
  gap: 2px;
  max-height: 46vh;
  overflow-y: auto;
  overflow-x: hidden;
}
.pick-contact-empty { color: var(--c-text-dim); font-size: var(--fs-sm); text-align: center; padding: var(--sp-4); }
.pick-contact-row {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  padding: var(--sp-2) var(--sp-3);
  border: 0;
  border-radius: var(--r-md);
  background: transparent;
  color: var(--c-text);
  text-align: left;
  cursor: pointer;
  width: 100%;
}
.pick-contact-row:hover { background: var(--c-bg-2); }
.pick-contact-row.checked { background: var(--c-accent-weak); }
.pick-contact-row .avatar { width: 34px; height: 34px; font-size: var(--fs-sm); flex-shrink: 0; }
.pick-contact-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pick-contact-check { flex-shrink: 0; color: var(--c-accent); opacity: 0; font-weight: 700; }
.pick-contact-row.checked .pick-contact-check { opacity: 1; }
.pick-contact-actions { display: flex; justify-content: flex-end; }
.pick-contact-confirm:disabled { opacity: 0.5; cursor: default; }

/* Thread pane.
   Structural rule: `.thread-root` is a vertical flexbox with three rows:
   [header fixed] [messages flex-grow, only scroller] [composer fixed].
   Flex + min-height: 0 on the middle row is THE canonical recipe for a
   scrollable region inside a capped container. No nested grids. */
.thread-root {
  display: flex;
  flex-direction: column;
  min-height: 0;
  height: 100%;
  overflow: hidden; /* nothing leaks out of the container */
}

.thread-pane {
  display: flex;
  flex-direction: column;
  flex: 1 1 auto;
  min-height: 0;
  position: relative;
  overflow: hidden;
  background:
    radial-gradient(1400px 500px at 50% -10%, rgba(91,140,255,0.03), transparent 60%),
    transparent;
}

/* Chat wallpaper layer.
   Concrete <div class="wallpaper-layer"> (rather than ::before pseudo) so the
   image and its scrim are inspectable in DevTools. `applyChatPrefs` writes
   `background-image` directly on the layer when a wallpaper is selected, and
   adds `.active` to flip it visible. The pane itself gets `.has-wallpaper`
   so any sibling rule that needs to know "wallpaper is on" can match. */
.wallpaper-layer,
.wallpaper-scrim {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 0;
  display: none;
}
.wallpaper-layer {
  background-size: cover;
  background-position: center;
  background-repeat: no-repeat;
}
.wallpaper-layer.active,
.wallpaper-scrim.active { display: block; }
/* Scrim — softens the wallpaper so bubbles/text remain readable. Tone is
   theme-aware: light themes get a white veil, dark themes a dark one. The
   actual veil intensity is controlled per-thread by `style.opacity` set on
   the element by `applyChatPrefs`; the colors below are at full alpha so
   the multiplier works predictably (final = element opacity × 1). */
.wallpaper-scrim { background: #0b0d12; }
[data-theme="light"] .wallpaper-scrim,
:root[data-theme="light"] .wallpaper-scrim {
  background: #ffffff;
}
[data-theme="paper"] .wallpaper-scrim,
:root[data-theme="paper"] .wallpaper-scrim {
  background: #faf3e2;
}
/* Messages list and overlays must paint above the wallpaper. We target
   only `.thread-wrap` (the in-flow flex child holding header + list +
   composer area) so we don't disturb overlays like `.react-picker`,
   `.drop-overlay`, `.jump-bottom`, `.thread-empty` — those already carry
   their own `position: absolute` and `z-index`, and forcing
   `position: relative` on them would re-insert them into the flex flow
   and break the layout (the symptom: react-picker landed at the bottom
   of the column and stole vertical space from `.thread-wrap`, so the
   message list got truncated).

   `position: relative` only — NO `z-index`. Setting z-index here would
   trap the selection topbar's kebab menu inside `.thread-wrap`'s stacking
   context, so even at z-index 100 the menu would be capped under the
   react picker (which paints in the parent .thread-pane context at z-index 10).
   In source order, `.thread-wrap` is laid out AFTER the wallpaper layers,
   so it naturally paints above them without needing a z-index promotion. */
.thread-pane > .thread-wrap {
  position: relative;
}
/* When a wallpaper is on, hide the soft purple gradient on the pane so it
   doesn't fight the image. */
.thread-pane.has-wallpaper { background: transparent; }

.thread-wrap {
  display: flex;
  flex-direction: column;
  flex: 1 1 auto;
  min-height: 0;
}

.thread-header {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  padding: var(--sp-3) var(--sp-5);
  border-bottom: 1px solid var(--c-border);
  background: var(--c-bg-0);
  position: relative;
  z-index: 5;
  flex-shrink: 0;
}
.thread-header[hidden] { display: none !important; }

/* Pinned-messages bar. Sits between the thread header and the message list.
   One row per active pin (max 3). Uses theme tokens so it tracks light/dark
   automatically. Kept compact so it never eats much of the thread. */
.pinned-bar {
  display: flex;
  flex-direction: column;
  gap: 1px;
  background: var(--c-bg-0);
  border-bottom: 1px solid var(--c-border);
  flex-shrink: 0;
  z-index: 4;
}
.pinned-bar[hidden] { display: none !important; }
.pinned-entry {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  padding: var(--sp-2) var(--sp-4);
  /* A faint accent wash + left rail marks it as pinned without shouting. */
  background: var(--c-accent-weak);
  border-left: 3px solid var(--c-accent);
}
.pinned-entry + .pinned-entry { border-top: 1px solid var(--c-border); }
.pinned-icon { font-size: 13px; line-height: 1; flex-shrink: 0; opacity: 0.85; }
.pinned-label {
  flex: 1 1 auto;
  min-width: 0;
  text-align: left;
  border: 0;
  background: transparent;
  color: var(--c-text);
  font-size: var(--fs-xs);
  line-height: 1.3;
  padding: 2px 0;
  cursor: pointer;
  /* Single-line ellipsis: a pinned RDV snippet stays on one tidy row. */
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.pinned-label:hover { color: var(--c-accent); }
.pinned-unpin {
  flex-shrink: 0;
  width: 24px; height: 24px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: var(--c-text-dim);
  font-size: 16px;
  line-height: 1;
  cursor: pointer;
}
.pinned-unpin:hover { background: var(--c-bg-2); color: var(--c-text); }

/* Duration picker (openModal body) shown when pinning. */
.pin-duration-hint { color: var(--c-text-dim); font-size: var(--fs-xs); margin-bottom: var(--sp-3); }
.pin-duration-list { display: flex; flex-direction: column; gap: var(--sp-2); }
.pin-duration-btn {
  display: block;
  width: 100%;
  text-align: left;
  padding: var(--sp-3) var(--sp-4);
  border: 1px solid var(--c-border);
  border-radius: var(--radius-md, 12px);
  background: var(--c-bg-1, var(--c-bg-0));
  color: var(--c-text);
  font-size: var(--fs-sm, 14px);
  cursor: pointer;
}
.pin-duration-btn:hover { border-color: var(--c-accent); background: var(--c-accent-weak); }
/* Back button inside the thread header. Hidden on desktop (the contacts pane
   is always visible); the mobile overlay in pwa.css unhides it. */
.thread-back {
  display: none;
  width: 40px; height: 40px;
  padding: 0;
  border: 0;
  background: transparent;
  color: var(--c-text);
  font-size: 22px;
  line-height: 1;
  border-radius: 50%;
  cursor: pointer;
  flex-shrink: 0;
}
.thread-back:active { background: var(--c-bg-2); }

/* Spacer pushes the per-thread settings kebab to the far right of the header. */
.thread-header-spacer { flex: 1 1 auto; }

/* Per-thread settings kebab. Lives inside the regular thread-header (not the
   selection topbar), so when selection mode toggles `header.hidden = true`,
   this button vanishes with the rest of the header — no collision possible
   with the selection-mode kebab on the right side of the selection topbar. */
.thread-settings-btn {
  width: 36px; height: 36px;
  padding: 0;
  border: 0;
  background: transparent;
  color: var(--c-text);
  font-size: 20px;
  line-height: 1;
  border-radius: 50%;
  cursor: pointer;
  flex-shrink: 0;
}
.thread-settings-btn:hover { background: var(--c-bg-2); }
.thread-settings-btn:focus-visible {
  outline: 2px solid var(--c-accent);
  outline-offset: 1px;
}
/* MSN-only nudge button — same dimensions as the settings kebab so the
   header stays a tidy row of 36px circular buttons. Only mounted when
   the active theme is `msn` (see threadPane.js). */
.thread-nudge-btn {
  width: 36px; height: 36px;
  padding: 0;
  border: 0;
  background: transparent;
  color: var(--c-accent);
  font-size: 20px;
  line-height: 1;
  border-radius: 50%;
  cursor: pointer;
  flex-shrink: 0;
}
.thread-nudge-btn:hover { background: var(--c-bg-2); }
.thread-nudge-btn:active { transform: scale(0.92); }
.thread-nudge-btn:focus-visible {
  outline: 2px solid var(--c-accent);
  outline-offset: 1px;
}

/* Loupe de l'en-tête du fil — même gabarit que le kebab pour un en-tête aligné. */
.thread-search-btn {
  width: 36px; height: 36px;
  padding: 0;
  border: 0;
  background: transparent;
  color: var(--c-text);
  line-height: 1;
  border-radius: 50%;
  cursor: pointer;
  flex-shrink: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
}
.thread-search-btn svg { display: block; }
.thread-search-btn:hover { background: var(--c-bg-2); }
.thread-search-btn.active { color: var(--c-accent); background: var(--c-accent-weak); }
.thread-search-btn:focus-visible { outline: 2px solid var(--c-accent); outline-offset: 1px; }

/* Barre de recherche DANS le fil — 2e ligne pleine largeur sous l'en-tête. Champ
   + compteur « n/N » + flèches ⌃⌄ + croix. Suit les tokens (clair/sombre). */
.thread-search-bar {
  display: flex;
  align-items: center;
  gap: 4px;
  padding: 6px 8px;
  border-bottom: 1px solid var(--c-border);
  background: var(--c-bg-0);
  flex-shrink: 0;
  position: relative;
  z-index: 5;
}
.thread-search-bar[hidden] { display: none !important; }
/* Le champ CÈDE en premier (les contrôles n.º/⌃⌄/✕ restent toujours visibles,
   même sur un écran étroit type S23). flex-basis 0 + shrink élevé = il rétrécit
   avant tout le reste ; min-width 0 lève le minimum intrinsèque de l'<input>. */
.thread-search-bar .search-input {
  flex: 1 1 0;
  min-width: 0;
  margin-right: 2px;
}
.thread-search-counter {
  flex-shrink: 0;
  font-size: var(--fs-xs);
  color: var(--c-text-dim);
  font-variant-numeric: tabular-nums;
  min-width: 30px;
  text-align: right;
}
/* Aucun résultat (requête non vide) : compteur en rouge discret. */
.thread-search-bar.no-match .thread-search-counter { color: var(--c-danger, #d9534f); }
.thread-search-nav {
  flex-shrink: 0;
  width: 34px; height: 34px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: var(--c-text);
  line-height: 1;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
}
.thread-search-nav svg { display: block; width: 16px; height: 16px; }
.thread-search-nav:hover:not(:disabled) { background: var(--c-bg-2); }
.thread-search-nav:disabled { opacity: 0.35; cursor: default; }
/* La croix « fermer » est ISOLÉE du groupe compteur/⌃⌄ : un écart + un fin
   séparateur vertical (::before) évitent la fermeture accidentelle au pouce
   (mobile). Zone tactile agrandie (34px) sans grossir l'icône. Le bouton reste
   rond ; le séparateur vit dans la marge, pas sur le bouton. */
.thread-search-bar .search-clear {
  flex-shrink: 0;
  width: 34px; height: 34px;
  margin-left: 14px;
  position: relative;
}
.thread-search-bar .search-clear::before {
  content: '';
  position: absolute;
  left: -8px;
  top: 50%;
  transform: translateY(-50%);
  width: 1px;
  height: 20px;
  background: var(--c-border);
}

/* Bulle résultat courant : liseré accent doux (distinct du flash éphémère et de
   la sélection). Reste tant que la recherche pointe dessus. */
.bubble.search-current {
  outline: 2px solid var(--c-accent);
  outline-offset: 1px;
}
/* Terme trouvé surligné dans le texte de la bulle. */
mark.search-term-mark {
  background: var(--c-accent);
  color: #fff;
  border-radius: 3px;
  padding: 0 1px;
}

.thread-title { display: flex; flex-direction: column; line-height: 1.2; }
.thread-title .name { font-weight: 600; }
.thread-title .sub { font-size: var(--fs-xs); color: var(--c-text-dim); display: inline-flex; align-items: center; gap: 4px; }
.thread-title .dot { width: 6px; height: 6px; box-shadow: none; }
/* Status line in the thread header. Wider than `.contact-status` since
   the header isn't constrained to a single column, but still ellipsised
   so the settings kebab on the right never gets pushed off-screen. */
.thread-title-status {
  font-size: var(--fs-xs);
  color: var(--c-text-soft);
  font-style: italic;
  margin-top: 2px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  max-width: min(420px, 60vw);
}

.thread-list {
  /* The ONLY scroll container in the chat UI. `min-height: 0` is mandatory
     so flex children don't force it to grow beyond the available space.
     Without it, the intrinsic min-content size of the bubble stack would
     push the flex parent past the viewport, the composer would slide off
     the bottom and earlier messages would scroll out the top — that's the
     "messages invisible / input mal positionné" symptom.
     `-webkit-overflow-scrolling: touch` enables native momentum on iOS,
     `overscroll-behavior: contain` stops scroll chaining to the page. */
  flex: 1 1 auto;
  min-height: 0;
  height: 0;            /* belt-and-suspenders for engines where min-height alone
                           leaves the flex item at its content min-size */
  overflow-y: auto;
  overflow-x: hidden;
  -webkit-overflow-scrolling: touch;
  overscroll-behavior: contain;
  padding: var(--sp-5) var(--sp-6);
  display: flex;
  flex-direction: column;
  gap: var(--sp-2);
}

.thread-empty {
  display: grid;
  place-items: center;
  height: 100%;
  color: var(--c-text-dim);
  text-align: center;
  /* Sit above the wallpaper layer when shown as the "no thread selected"
     placeholder, since it has no other ancestor to lift it. */
  position: relative;
  z-index: 1;
}
.thread-empty-art { font-size: 56px; margin-bottom: var(--sp-3); }

.day-sep {
  align-self: center;
  font-size: var(--fs-xs);
  color: var(--c-text-dim);
  padding: 2px 10px;
  background: var(--c-bg-2);
  border-radius: var(--r-pill);
  margin: var(--sp-3) 0;
}

.bubble {
  max-width: min(560px, 70%);
  padding: 10px 14px;
  border-radius: var(--r-lg);
  position: relative;
  word-wrap: break-word;
  /* Don't let the column flex parent shrink bubbles below their content size.
     Without this, .file-bubble (padding:0 + overflow:hidden) collapses to ~2px
     when the thread-list runs out of space and only the border is visible. */
  flex-shrink: 0;
}
/* Pop-in on a bubble's FIRST appearance only. Driven by the `.bubble-enter`
   class that threadPane adds exactly once per message id (see renderList), NOT
   by a `:last-child` selector. The old `:last-child` approach re-ran the
   keyframe every time the last bubble was rebuilt by the diff renderer — e.g.
   a file bubble flipping uploading→complete becomes a NEW last-child node and
   re-animated, which is exactly the "image appears a second time / grows"
   jank. Class-driven means a rebuild keeps its final resting state and only a
   genuinely new message animates in. The class is self-removing on
   animationend so it never re-triggers. */
.bubble.bubble-enter {
  animation: bubble-pop-in 320ms cubic-bezier(0.34, 1.56, 0.64, 1) 1;
  transform-origin: bottom center;
}
/* Entrance animation is a FORM transform only — NO opacity. Rationale (user
   decision, to keep as a rule for all future bubble animations): opacity here
   double-fades with the image's own reveal fade → the "big flash" of the whole
   message the user saw. Shape transforms (scale / translate / later: squash,
   bounce, deform) never fight the image fade, read as livelier, and cause no
   flash. Opacity stays reserved for the IMAGE reveal alone. The move is small
   and lands straight at the final position (no far offset that would read as a
   glitch / décalage). See project_bubble_animations memory. */
@keyframes bubble-pop-in {
  0%   { transform: translateY(4px) scale(0.96); }
  100% { transform: translateY(0) scale(1); }
}
/* ⭐ ROOT CAUSE of the "media zoom + scroll jump" (PROVEN by box-diag, 2026-07):
   the entrance animation SCALEs the bubble 0.96→1 (with an overshoot bezier that
   even passes 1.0 mid-flight). getBoundingClientRect INCLUDES the transform, so a
   media bubble measured 168x360 at rest but 161x346 (=168×0.96, 360×0.96) while
   animating — the exact "original→zoom→original" the user saw, and on a TALL image
   bubble pinned to the bottom that scale visibly moves the scroll. Six measurements
   chased CSS width / grid / ancestor / img intrinsic size; the culprit was this
   scale all along. Fix: MEDIA bubbles (image/video/GIF) animate with translate
   ONLY — no scale → the box never changes rendered size → no zoom, no scroll jump.
   Text bubbles keep the lively scale (they're small; the scroll impact is nil). */
/* ⭐ v204 MEDIA ENTRANCE = a REAL animation, not a 6px frisson (user: the tiny
   translateY read as a "refresh bug", not an entrance). The image now SLIDES IN
   from the SENDER'S SIDE (mine → from the right, theirs → from the left) over a
   real distance (~40px) with a lively overshoot bounce on landing — the iMessage/
   WhatsApp language. CRITICAL, learned the hard way (v199): media entrances use
   translateX ONLY. NO scale (was the zoom + scroll-jump root cause), NO opacity
   (double-fades with the image reveal = the flash). translateX cannot change the
   bubble's height, so it CANNOT move the scroll — the bounce overshoots sideways
   and settles, fully horizontal. Direction is set per side below. */
.bubble.file-bubble.bubble-enter,
.bubble.bubble-has-gif.bubble-enter {
  animation-name: media-slide-in-mine;
  animation-duration: 420ms;
  /* Overshoot bezier: passes the target then eases back = the "bounce" landing.
     On translateX this is a pure horizontal spring, zero vertical/scroll impact. */
  animation-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* Received media slides from the LEFT (the sender is on the left). */
.bubble-wrap.theirs .bubble.file-bubble.bubble-enter,
.bubble-wrap.theirs .bubble.bubble-has-gif.bubble-enter,
.bubble.theirs.file-bubble.bubble-enter,
.bubble.theirs.bubble-has-gif.bubble-enter {
  animation-name: media-slide-in-theirs;
}
@keyframes media-slide-in-mine {
  0%   { transform: translateX(40px); }
  100% { transform: translateX(0); }
}
@keyframes media-slide-in-theirs {
  0%   { transform: translateX(-40px); }
  100% { transform: translateX(0); }
}
:root[data-reduce-motion="on"] .bubble.bubble-enter {
  animation: none;
}

.bubble.theirs {
  align-self: flex-start;
  background: var(--c-bubble-theirs);
  color: var(--c-bubble-theirs-text);
  border: 1px solid var(--c-bubble-theirs-border);
  border-top-left-radius: var(--r-sm);
}
.bubble.mine {
  align-self: flex-end;
  background: var(--c-bubble-mine);
  color: var(--c-bubble-mine-text);
  border-top-right-radius: var(--r-sm);
}
/* WhatsApp-style highlight for the message in selection mode. Placed after
   .bubble.mine/.theirs so it overrides their backgrounds by source order. */
.bubble.selected,
.bubble.mine.selected,
.bubble.theirs.selected {
  background: var(--c-bg-3);
  outline: 2px solid var(--c-accent);
  color: var(--c-text);
}

/* "Message supprimé" placeholder for soft-deleted messages. Muted, italic,
   no reactions, no interactions — purely informational. */
.bubble-deleted .bubble-text {
  font-style: italic;
  opacity: 0.7;
}

/* Reactions strip — Messenger-style overflow chip(s) attached to the bubble.
   The strip lives as a SIBLING of the bubble inside `.bubble-wrap`
   (a position:relative box) so the absolute strip can overflow the bubble
   without being clipped by its `overflow: hidden` (file/GIF previews).
   The wrapper hugs the bubble's intrinsic width — that way the absolute
   strip anchored to the wrapper sits flush against the bubble edge, not
   on the far side of the row. The wrapper carries the bubble's
   `max-width` so the inner bubble can still be `width: 100%` of its
   wrapper without exceeding 70 % of the thread-list. */
.bubble-wrap {
  position: relative;
  /* `width: max-content` lets the wrapper hug the bubble's natural width,
     so the absolute strip anchored on the wrapper sits flush against the
     bubble edge. `max-width` mirrors the bubble's original constraint so
     long messages still cap at 70 % of the row. The bubble inside is
     width:100% so its background reaches the wrapper's right edge. */
  display: block;
  width: max-content;
  max-width: min(560px, 70%);
  flex-shrink: 0;
}
.bubble-wrap.mine   { align-self: flex-end; }
.bubble-wrap.theirs { align-self: flex-start; }
.bubble-wrap.has-reactions { margin-bottom: 18px; }

/* Clearance for the floating reaction chip so the bottom-corner meta
   (heure + ticks) doesn't end up under the chip. Width is derived from
   the chip's known size — a single emoji chip is `28 px × scale`, but
   when a counter is present the chip widens into a pill (extra padding
   + count digit). The +32 px budget covers up to ~2-digit counts at
   any emoji-scale; beyond that (10+ same reactions) the count clips
   slightly, which is acceptable given the rarity. */
.bubble-wrap.has-reactions > .bubble:not(.file-bubble):not(.bubble-has-gif) {
  --reaction-clearance: calc(28px * var(--app-emoji-scale, 1) + 32px);
  min-width: calc(var(--reaction-clearance) + 80px);
}
.bubble-wrap.mine.has-reactions   > .bubble:not(.file-bubble):not(.bubble-has-gif) {
  padding-right: var(--reaction-clearance);
}
.bubble-wrap.theirs.has-reactions > .bubble:not(.file-bubble):not(.bubble-has-gif) {
  padding-left: var(--reaction-clearance);
}

/* Media bubbles (GIF, file with image preview): we DON'T pad the bubble
   itself — that would leave an empty strip next to the picture, since
   the media keeps its intrinsic width and the bubble would just stretch.
   Instead, give the wrapper extra bottom margin so the chip overflows
   below the media without overlapping the meta row. The meta row in
   media bubbles already sits at the very bottom, so we also nudge the
   chip a bit lower to clear it. */
.bubble-wrap.has-reactions:has(> .bubble.bubble-has-gif),
.bubble-wrap.has-reactions:has(> .file-bubble) {
  margin-bottom: calc(28px * var(--app-emoji-scale, 1) + 8px);
}
.bubble-wrap.has-reactions:has(> .bubble.bubble-has-gif) > .bubble-reactions,
.bubble-wrap.has-reactions:has(> .file-bubble) > .bubble-reactions {
  /* Drop fully below the media so the strip stops crossing the meta. */
  transform: translateY(100%);
  bottom: 4px;
}

/* Mobile: mirror the .bubble 85 % width allowance so wrapped bubbles can
   grow to the same width as un-wrapped ones (the 70 % default is the
   desktop value). The pwa.css rule that does this for `.bubble` doesn't
   reach the wrapper, hence this dedicated rule. */
@media (max-width: 720px), (orientation: landscape) and (max-height: 900px) {
  .bubble-wrap { max-width: 85%; }
}

/* When wrapped, the wrapper alone owns the row max-width constraint —
   reset the bubble's own max-width so we don't double-clamp into a
   self-referential cycle (which produces sub-pixel layout drift on some
   engines). The file-bubble keeps its OWN width (min(420px, 80%) on
   desktop, auto on mobile) so its picture/video preview doesn't get
   stretched to fill the wrapper. */
.bubble-wrap > .bubble { max-width: 100%; }

.bubble-reactions {
  position: absolute;
  bottom: 0;
  display: flex;
  flex-wrap: nowrap;
  gap: 4px;
  z-index: 2;
  pointer-events: auto;
}
/* Anchor on the SENDER's side of the bubble, with the chip overflowing
   only DOWNWARD (translateY(50 %)) and a small INWARD horizontal shift
   so the chip sits in the bottom-inside corner of the bubble — never
   beyond the bubble's outer edge. This is what Messenger does, and it
   guarantees the chip is always inside the thread-list horizontal
   bounds (no risk of being clipped by `overflow-x: hidden` or by the
   viewport edge on narrow phones). The bubble pads the same corner via
   `.has-reactions` so the meta (heure + ticks) doesn't end up under
   the chip. */
.bubble-wrap.theirs > .bubble-reactions {
  left: 8px;
  right: auto;
  transform: translateY(50%);
}
.bubble-wrap.mine > .bubble-reactions {
  right: 8px;
  left: auto;
  transform: translateY(50%);
}

/* The chip itself — circular when alone, pill when a count is shown. */
.reaction-chip {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 4px;
  padding: 0;
  width: calc(28px * var(--app-emoji-scale, 1));
  height: calc(28px * var(--app-emoji-scale, 1));
  border-radius: 999px;
  background: var(--c-bg-1);
  color: var(--c-text);
  /* The 2 px border in the page-bg color is the trick that makes the chip
     read as a separate pinned object (it "cuts" against the bubble + the
     thread background). */
  border: 2px solid var(--c-bg-0);
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.18);
  font: inherit;
  line-height: 1;
  cursor: pointer;
  /* Subtle pop-in. Keep below 200 ms so it doesn't drag in long
     conversations where renderList rebuilds chips on every diff. */
  animation: reaction-pop 180ms cubic-bezier(0.34, 1.56, 0.64, 1);
  transition: transform var(--dur-fast) var(--ease-out),
              box-shadow var(--dur-fast) var(--ease-out);
}
.reaction-chip:hover {
  transform: scale(1.06);
  box-shadow: 0 4px 10px rgba(0, 0, 0, 0.22);
}
.reaction-chip:active { transform: scale(0.94); }
@keyframes reaction-pop {
  from { transform: scale(0); opacity: 0; }
  to   { transform: scale(1); opacity: 1; }
}
.reaction-chip-emoji {
  font-size: max(15px, calc(16px * var(--app-emoji-scale, 1)));
  line-height: 1;
}
/* Pill form: when there are ≥ 2 reactors, the chip widens to fit the
   counter on the right and switches from circle to oval. */
.reaction-chip.has-count {
  width: auto;
  padding: 0 10px 0 8px;
  gap: 4px;
}
.reaction-chip-count {
  font-size: max(11px, calc(12px * var(--app-emoji-scale, 1)));
  font-weight: 600;
  letter-spacing: 0.02em;
  color: inherit;
}

/* Contributor coloring (1:1 conversation):
     by="me"   → my-bubble accent tint, framed with my bubble color
     by="peer" → their-bubble surface
     by="both" → diagonal split, my color on top-left, theirs on bottom-right */
.reaction-chip[data-by="me"] {
  background: color-mix(in srgb, var(--c-bubble-mine) 20%, var(--c-bg-1));
  outline: 1.5px solid var(--c-bubble-mine);
  outline-offset: -3.5px;
}
.reaction-chip[data-by="peer"] {
  background: var(--c-bubble-theirs);
  color: var(--c-bubble-theirs-text);
}
.reaction-chip[data-by="both"] {
  background: linear-gradient(135deg,
    color-mix(in srgb, var(--c-bubble-mine) 30%, var(--c-bg-1)) 0%,
    color-mix(in srgb, var(--c-bubble-mine) 30%, var(--c-bg-1)) 50%,
    var(--c-bubble-theirs) 50%,
    var(--c-bubble-theirs) 100%);
  outline: 1.5px solid var(--c-bubble-mine);
  outline-offset: -3.5px;
}

/* Overlay « qui a réagi » (modèle WhatsApp), unifié 1v1 + groupe. Rendu dans le
   modal générique (openModal) → hérite du fond/thème du modal. Une liste : entête
   par emoji, puis une ligne « avatar — Nom » par réacteur ; bouton « Retirer » sur
   ma propre ligne. Tokens uniquement → clair + sombre gérés par le thème. */
.reactions-panel { display: flex; flex-direction: column; gap: 2px; min-width: min(320px, 78vw); }
.reactions-panel-row {
  display: flex; align-items: center; gap: 10px;
  padding: 6px 4px;
  border-radius: var(--r-md);
}
.reactions-panel-row.is-me { background: color-mix(in srgb, var(--c-accent) 10%, transparent); }
.reactions-panel-row .avatar.avatar-sm { width: 32px; height: 32px; font-size: var(--fs-sm); }
.reactions-panel-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.reactions-panel-row-count {
  font-size: var(--fs-sm); font-weight: 600; color: var(--c-text-soft);
  font-variant-numeric: tabular-nums; letter-spacing: 0.02em; flex-shrink: 0;
}
.reactions-panel-row-emoji { font-size: 18px; line-height: 1; opacity: 0.9; flex-shrink: 0; }
.reactions-panel-remove { padding: 2px 10px; font-size: var(--fs-sm); color: var(--c-danger, #e5484d); flex-shrink: 0; }

/* La pastille de réaction est désormais CLIQUABLE (ouvre l'overlay) → curseur. */
.reaction-chip { cursor: pointer; }

/* « VU PAR N » — panneau « Infos du message » (modèle WhatsApp). Deux sections
   (« Vu par N » avec heure / « Pas encore »), avatar + nom par membre. Tokens only. */
.seen-panel { display: flex; flex-direction: column; gap: 4px; min-width: min(320px, 78vw); }
.seen-panel-section { display: flex; flex-direction: column; gap: 2px; }
.seen-panel-head {
  padding: 10px 4px 4px; font-size: var(--fs-sm); font-weight: 600; color: var(--c-text-soft);
}
.seen-panel-section:first-child .seen-panel-head { padding-top: 2px; }
.seen-panel-row { display: flex; align-items: center; gap: 10px; padding: 6px 4px; border-radius: var(--r-md); }
.seen-panel-row .avatar.avatar-sm { width: 32px; height: 32px; font-size: var(--fs-sm); }
.seen-panel-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.seen-panel-at { font-size: var(--fs-sm); color: var(--c-text-soft); font-variant-numeric: tabular-nums; flex-shrink: 0; }
.seen-panel-empty { padding: 8px 4px; color: var(--c-text-soft); }

.bubble-text { white-space: pre-wrap; user-select: text; overflow-wrap: anywhere; word-break: break-word; }

/* 🎬 Libellé d'un message d'ambiance SANS texte (« Ambiance « Futur » envoyée »).
   Italique + légèrement grisé : signale que c'est une ACTION, pas un texte tapé.
   Bulle normale par ailleurs (garde l'heure, l'alignement, les ticks). */
.bubble-ambiance-notice { font-style: italic; opacity: 0.8; }

/* Quoted block above a reply bubble's body. Visually a small left-bar +
   2-line block (sender name on top, snippet below). Acts as a button
   (click → scrolls to the original bubble + flash). The colors flip with
   the bubble owner: a "mine" reply has its quote on a darker tint of the
   accent; a "theirs" reply uses the neutral surface. */
.bubble-reply-quote {
  display: flex;
  align-items: stretch;
  gap: 6px;
  width: 100%;
  margin: 0 0 6px;
  padding: 4px 8px;
  border: 0;
  border-left: 3px solid currentColor;
  border-radius: var(--r-sm);
  background: rgba(0, 0, 0, 0.12);
  color: inherit;
  font: inherit;
  cursor: pointer;
  text-align: left;
  min-width: 0;
}
.bubble.theirs .bubble-reply-quote {
  background: rgba(0, 0, 0, 0.18);
  color: var(--c-accent);
}
.bubble.mine .bubble-reply-quote {
  background: rgba(0, 0, 0, 0.18);
  /* On the bubble's mine background (bubbleMine), the bar uses the bubble's
     own text color so it stays high-contrast even when the user picked a
     dark mine color. */
  color: var(--c-bubble-mine-text, currentColor);
}
.bubble-reply-quote:hover { filter: brightness(1.05); }
.bubble-reply-quote:active { transform: translateY(1px); }
/* Citation-CONTEXTE (transfert dont l'original n'est pas dans ce fil) : ce n'est
   pas un lien → curseur normal, aucun effet cliquable (ni hover ni press). */
.bubble-reply-quote.is-context { cursor: default; }
.bubble-reply-quote.is-context:hover { filter: none; }
.bubble-reply-quote.is-context:active { transform: none; }
.bubble-reply-quote-bar {
  display: none; /* the border-left on the quote already carries the accent */
}
.bubble-reply-quote-body {
  display: flex;
  flex-direction: column;
  min-width: 0;
  flex: 1 1 auto;
}
.bubble-reply-quote-name {
  font-size: var(--fs-xs);
  font-weight: 600;
  color: inherit;
  opacity: 0.95;
}
.bubble-reply-quote-text {
  font-size: var(--fs-xs);
  color: inherit;
  opacity: 0.78;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* Full-width preview for citations of file/audio bubbles. Mirrors the
   `file-preview` layout from the original bubble — same look, just
   smaller cap height so the citation stays compact. The <img>/<video>
   reuse the existing local media URL (blob: in PWA, mf-media:// in
   Electron) — no second copy is created anywhere. */
.bubble-reply-quote.has-preview {
  flex-direction: column;
  align-items: stretch;
  padding: 0;
  border-left: 0;
  overflow: hidden;
  /* The accent bar moves to the side of the body row so the preview
     spans the full width with no left gutter. */
}
/* HAUTEUR RÉSERVÉE (fix du « saut » de miniature de citation) : la vignette garde
   une hauteur FIXE identique dans les deux états — fallback (icône) ET miniature
   réelle. Sans ça, le fallback faisait 96px et la miniature jusqu'à 180px selon le
   ratio → la bulle sautait en hauteur quand la miniature (souvent tardive, ex.
   vidéo transférée) remplaçait l'icône. La miniature REMPLIT ce cadre (cover) —
   léger recadrage assumé, courant pour une vignette de contexte (le ratio exact du
   média cité n'est pas dans le fileRef, cf. isReplyToShape ; réserver au vrai ratio
   demanderait d'étendre le protocole pour un gain imperceptible sur une mini-vignette). */
.bubble-reply-quote-preview {
  width: 100%;
  height: 120px;
  background: rgba(0, 0, 0, 0.25);
  display: block;
  overflow: hidden;
}
.bubble-reply-quote-preview img,
.bubble-reply-quote-preview video {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  background: rgba(0, 0, 0, 0.25);
}
.bubble-reply-quote-preview-fallback {
  /* height héritée de .bubble-reply-quote-preview (120px) → même hauteur que la
     miniature réelle → aucun saut à la bascule icône → miniature. */
  display: flex;
  align-items: center;
  justify-content: center;
}
.bubble-reply-quote-preview-icon {
  font-size: 36px;
  line-height: 1;
  opacity: 0.85;
}
/* When a preview is present the body sits in its own padded row below
   the banner, with the accent bar reinstated as a left border so the
   citation still reads as a quote. */
.bubble-reply-quote.has-preview .bubble-reply-quote-row {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 6px 8px;
  border-left: 3px solid currentColor;
  min-width: 0;
}
.bubble-meta {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: var(--sp-2);
  margin-top: 4px;
  font-size: var(--fs-xs);
  opacity: 0.7;
}

.enc-badge { font-size: 11px; opacity: 0.75; }

/* Mention « modifié » dans la meta d'une bulle éditée. Discrète (hérite de la
   couleur de la meta, légèrement en italique), avant l'heure. */
.bubble-edited { font-style: italic; opacity: 0.85; }

/* Mention « transféré » (↪) — même discrétion, distincte de « modifié ». */
.bubble-forwarded { font-style: italic; opacity: 0.75; }

/* 🔒 C1 — bandeau « clé de sécurité modifiée ». Discret mais visible : un liseré
   d'accent sur un fond très légèrement teinté, sans blur. Informe sans bloquer. */
.key-changed-banner {
  font-size: var(--fs-xs);
  padding: 3px 8px;
  margin-bottom: 4px;
  border-radius: 6px;
  border: 1px solid color-mix(in srgb, var(--accent, #c98a2b) 45%, transparent);
  background: color-mix(in srgb, var(--accent, #c98a2b) 12%, transparent);
  color: var(--text, inherit);
  opacity: 0.9;
  cursor: help;
}
.tick { font-family: var(--font-mono); font-size: 11px; }
.tick-sending  { opacity: 0.6; }
.tick-sent     { opacity: 0.7; }
.tick-delivered{ opacity: 1; }
/* WhatsApp-style "read" tick: bright cyan that contrasts against the
   accent-blue bubble background. The previous near-black tone was readable
   but didn't read as "blue" at a glance — users couldn't visually
   distinguish delivered (✓✓) from read (✓✓ blue) without squinting. */
.tick-seen     { color: #34d2ff; opacity: 1; }
.tick-failed   { color: var(--c-danger); opacity: 1; }

/* Composer — single flexbox row: [attach] [mic] [textarea grows] [send].
   Fixed at the bottom of `.thread-root` (flex-shrink: 0, flex-grow: 0) so it
   never gets pushed off-screen. Solid background (no blur) for predictable
   compositing when the on-screen keyboard opens. */
.composer {
  display: flex;
  align-items: flex-end;
  gap: var(--sp-2);
  /* The reply strip (.composer-reply) needs to take a full row above the
     textarea row. We use flex-wrap so a child with `flex-basis: 100%` on
     order:-1 wraps onto its own line. The textarea has `min-width: 0` so
     it can shrink rather than force the input row to wrap. */
  flex-wrap: wrap;
  padding: var(--sp-3) var(--sp-5);
  border-top: 1px solid var(--c-border);
  background: var(--c-bg-0);
  flex-shrink: 0;
  position: relative;
  z-index: 5;
}
/* When no thread is open we DON'T set `display: none` — that would make
   the .thread-root's inner height jump when a thread opens (the composer
   suddenly takes ~64 px and the messages area shrinks by the same), which
   the user perceives as a layout shift / "composer arrives after".
   Instead we keep it laid out (so .thread-root computes the same heights
   in both states) but make it visually inert: faded out, pointer-events
   off so it doesn't capture clicks, aria-hidden in JS would be the
   accessibility nicety. */
.composer.hidden {
  opacity: 0;
  pointer-events: none;
  user-select: none;
}
.composer.hidden * { visibility: hidden; }

.composer-leading {
  display: flex;
  gap: var(--sp-2);
  align-items: flex-end;
  flex-shrink: 0;
}
.composer-leading[hidden] { display: none !important; }

/* Reply context strip: flex-basis 100% + order: -1 so it sits on its own
   row above the input row (the input row reuses its existing layout
   unchanged). Hidden when not replying. */
.composer-reply {
  flex: 1 1 100%;
  order: -1;
  display: flex;
  align-items: stretch;
  gap: var(--sp-2);
  padding: 6px 10px;
  margin-bottom: 6px;
  background: var(--c-bg-2);
  border: 1px solid var(--c-border);
  border-radius: var(--r-md);
  border-left: 3px solid var(--c-accent);
  min-width: 0;
}
.composer-reply[hidden] { display: none !important; }

/* Bandeau MODE ÉDITION — même gabarit que le bandeau réponse (il réutilise les
   classes internes .composer-reply-*), avec un liseré distinct pour ne pas le
   confondre avec une réponse. */
.composer-edit {
  flex: 1 1 100%;
  order: -1;
  display: flex;
  align-items: stretch;
  gap: var(--sp-2);
  padding: 6px 10px;
  margin-bottom: 6px;
  background: var(--c-bg-2);
  border: 1px solid var(--c-border);
  border-radius: var(--r-md);
  border-left: 3px solid var(--c-accent);
  min-width: 0;
}
.composer-edit[hidden] { display: none !important; }

/* Staged attachment strip — sits on its own full-width row ABOVE the input row
   (same flex-basis/order trick as the reply strip). Holds a thumbnail/icon, the
   file name + size, and a remove (×) button. */
/* Attachment preview as an OVERLAY above the composer, NOT an in-flow row.
   Previously it was `flex: 1 1 100%; order: -1` — a full-width row INSIDE the
   composer flexbox, so showing/hiding it changed the composer's height and
   pushed the whole chat up/down. At send time the preview vanished and the chat
   SHIFTED, overlapping the new bubble's appearance = the "décalage / not fluid"
   the user reported (WhatsApp avoids this: its gallery floats OVER the composer).
   Anchoring it absolutely to the composer's top edge (composer is position:
   relative) takes it out of the height calculation: appear/disappear no longer
   moves the composer or the chat. */
.composer-attach {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 100%;
  margin: 0 0 6px;
  padding: 0 var(--sp-5);
  min-width: 0;
  z-index: 6;
  pointer-events: auto;
}
.composer-attach[hidden] { display: none !important; }
.composer-attach-item {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  padding: 6px 8px;
  background: var(--c-bg-2);
  border: 1px solid var(--c-border);
  border-radius: var(--r-md);
  min-width: 0;
}
.composer-attach-thumb {
  width: 44px;
  height: 44px;
  object-fit: cover;
  border-radius: 8px;
  flex-shrink: 0;
  background: var(--c-bg-3);
}
.composer-attach-icon {
  width: 44px;
  height: 44px;
  display: grid;
  place-items: center;
  font-size: 24px;
  border-radius: 8px;
  background: var(--c-bg-3);
  flex-shrink: 0;
}
.composer-attach-meta {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.composer-attach-name {
  font-size: var(--fs-sm);
  color: var(--c-text);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.composer-attach-size {
  font-size: var(--fs-xs);
  color: var(--c-text-soft);
}
.composer-attach-remove {
  flex: 0 0 auto;
  width: 30px;
  height: 30px;
  border: 0;
  background: transparent;
  color: var(--c-text-soft);
  font-size: 22px;
  line-height: 1;
  border-radius: 50%;
  cursor: pointer;
}
.composer-attach-remove:hover { background: var(--c-bg-3); color: var(--c-text); }

/* ── ALBUM étape 3 : bandeau MULTI (2 fichiers et plus) ──────────────────────
   À un seul fichier, la présentation d'origine (vignette + nom + taille) est
   conservée telle quelle — c'est le cas de très loin le plus courant et il ne
   doit pas régresser. À partir de deux, on passe à une rangée de vignettes :
   chacune se retire indépendamment (« pas celle-là »), et le POIDS TOTAL est
   affiché AVANT l'envoi. */
.composer-attach-row {
  display: flex;
  gap: 6px;
  overflow-x: auto;
  overflow-y: hidden;
  padding: 6px 8px;
  background: var(--c-bg-2);
  border: 1px solid var(--c-border);
  border-radius: var(--r-md) var(--r-md) 0 0;
  scrollbar-width: thin;
}
.composer-attach-tile {
  position: relative;
  flex: 0 0 auto;
  width: 56px;
  height: 56px;
  border-radius: 8px;
  overflow: hidden;
  background: var(--c-bg-3);
}
.composer-attach-tile .composer-attach-thumb,
.composer-attach-tile .composer-attach-icon {
  width: 100%;
  height: 100%;
  border-radius: 0;
}
/* Croix de retrait POSÉE SUR la vignette (elle n'a pas de place à côté). */
.composer-attach-remove.tile {
  position: absolute;
  top: 2px;
  right: 2px;
  width: 20px;
  height: 20px;
  font-size: 14px;
  display: grid;
  place-items: center;
  background: rgba(8, 11, 16, 0.72);
  color: #fff;
  padding: 0;
}
.composer-attach-remove.tile:hover { background: rgba(8, 11, 16, 0.9); color: #fff; }
.composer-attach-summary {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
  padding: 4px 10px 6px;
  background: var(--c-bg-2);
  border: 1px solid var(--c-border);
  border-top: 0;
  border-radius: 0 0 var(--r-md) var(--r-md);
  font-size: var(--fs-xs);
  color: var(--c-text-soft);
  min-width: 0;
}
/* `min-width:0` sur les DEUX : sans lui, un flex item refuse de passer sous sa
   taille min-content — la ligne débordait alors du composeur et le poids total
   sortait du cadre à droite (vu à la capture ; le texte brut le trahissait aussi :
   « 4 fichiers18 Ko », les deux blocs collés faute de place). */
.composer-attach-count { min-width: 0; flex: 0 1 auto; }
.composer-attach-total {
  font-variant-numeric: tabular-nums;
  flex: 0 0 auto;
  white-space: nowrap;
}

.composer-reply-bar {
  /* Visual marker — kept as a separate element so the border-left can
     blend with the wrapper's accent. Currently empty / decorative; the
     border-left on `.composer-reply` carries the accent color. */
  display: none;
}
.composer-reply-body {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  flex-direction: column;
  justify-content: center;
}
.composer-reply-name {
  font-size: var(--fs-xs);
  font-weight: 600;
  color: var(--c-accent);
}
.composer-reply-snippet {
  font-size: var(--fs-sm);
  color: var(--c-text-soft);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.composer-reply-close {
  flex: 0 0 auto;
  width: 28px;
  height: 28px;
  align-self: center;
  border: 0;
  background: transparent;
  color: var(--c-text-soft);
  font-size: 20px;
  line-height: 1;
  border-radius: 50%;
  cursor: pointer;
}
.composer-reply-close:hover { background: var(--c-bg-3); color: var(--c-text); }
/* Full-width preview inside the composer's reply strip — same single-
   store reuse as the bubble version, with a slightly smaller cap height
   so the strip doesn't push the textarea off-screen on mobile. */
.composer-reply.has-preview {
  flex-direction: column;
  align-items: stretch;
  padding: 0;
  overflow: hidden;
  border-left: 0;
}
.composer-reply-preview {
  width: 100%;
  background: rgba(0, 0, 0, 0.18);
  display: block;
  overflow: hidden;
}
.composer-reply-preview img,
.composer-reply-preview video {
  display: block;
  width: 100%;
  max-height: 140px;
  object-fit: contain;
  background: rgba(0, 0, 0, 0.25);
}
.composer-reply-preview-fallback {
  height: 80px;
  display: flex;
  align-items: center;
  justify-content: center;
}
.composer-reply-preview-icon {
  font-size: 32px;
  line-height: 1;
  opacity: 0.85;
}
.composer-reply.has-preview .composer-reply-row {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  padding: 6px 10px;
  border-left: 3px solid var(--c-accent);
}
.composer-reply.has-preview .composer-reply-body {
  flex: 1 1 auto;
  min-width: 0;
}

/* composer-main wraps the field and send button so the recorder can
   replace both in one swap. Flex row so the field grows and send sits
   right after it. */
.composer-main {
  display: flex;
  gap: var(--sp-2);
  align-items: flex-end;
  flex: 1 1 auto;
  min-width: 0;
}
.composer-main[hidden], .recorder[hidden] { display: none !important; }

/* The field is the rounded "pill": it holds the textarea (which grows) and a
   trailing action cluster (emoji + GIF) pinned to the bottom-right. We carry
   the border/background/focus-ring HERE (not on the textarea) so the trailing
   icons live visually INSIDE the pill, like WhatsApp/Snap/Messenger.
   `flex: 1 1 auto; min-width: 0` lets the field take the full remaining row
   width and shrink below its intrinsic content size instead of wrapping. */
.composer-field {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  align-items: flex-end;
  gap: 2px;
  background: var(--c-bg-2);
  border: 1px solid var(--c-border);
  border-radius: 22px;
  transition: border-color var(--dur-fast) var(--ease-out),
              box-shadow var(--dur-fast) var(--ease-out);
}
.composer-field:focus-within {
  border-color: var(--c-accent);
  box-shadow: 0 0 0 3px var(--c-accent-ring);
}

.composer-input {
  resize: none;
  padding: 10px 4px 10px 14px;
  min-height: 44px;
  max-height: 160px;
  flex: 1 1 auto;
  /* `width: 0` (paired with `flex: 1 1 auto`) is the canonical way to make
     a flex child ignore its intrinsic content min-width. Without it, a
     textarea can demand more than the row offers (Chrome respects the
     textarea's `cols` default of ~20em when computing min-content) and
     force the input row to wrap, kicking the send button off the row. */
  width: 0;
  min-width: 0;
  background: transparent;
  border: 0;
  color: var(--c-text);
  font-family: var(--font-sans);
  line-height: 1.4;
  user-select: text;
}
.composer-input:focus { outline: none; }

/* Trailing in-field actions (emoji + GIF). Pinned to the bottom so they stay
   aligned with the first text line as the textarea grows taller. */
.composer-field-actions {
  display: flex;
  align-items: center;
  flex-shrink: 0;
  padding-right: 4px;
  padding-bottom: 4px;
}
.composer-field-actions[hidden] { display: none !important; }

.btn-attach, .btn-mic, .btn-file, .btn-emoji, .btn-gif {
  width: 40px;
  height: 40px;
  padding: 0;
  font-size: 18px;
  line-height: 1;
  border-radius: 50%;
  border: 1px solid var(--c-border);
  flex-shrink: 0;
}
.btn-gif {
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.05em;
}
/* Inside the field pill the emoji/GIF buttons drop their circle border and
   background so they read as in-field affordances, not standalone buttons. */
.composer-field-actions .btn-emoji,
.composer-field-actions .btn-gif {
  width: 34px;
  height: 34px;
  border: 0;
  background: transparent;
  color: var(--c-text-soft);
}
.composer-field-actions .btn-emoji:hover,
.composer-field-actions .btn-gif:hover { color: var(--c-text); }
.btn-send {
  min-width: 44px;
  height: 44px;
  padding: 0 16px;
  border-radius: 22px;
  flex-shrink: 0;
}

/* Recorder (replaces text input while recording) */
.recorder {
  display: grid;
  grid-template-columns: auto auto 1fr auto auto;
  gap: var(--sp-3);
  align-items: center;
  padding: 6px 10px;
  border: 1px solid rgba(242,109,109,0.4);
  background: rgba(242,109,109,0.06);
  border-radius: var(--r-md);
  min-height: 42px;
  flex: 1 1 auto;
  min-width: 0;
}
.recorder-dot {
  width: 10px; height: 10px;
  border-radius: 50%;
  background: var(--c-danger);
  box-shadow: 0 0 0 4px rgba(242,109,109,0.18);
  animation: rec-pulse 1.2s var(--ease-out) infinite;
}
@keyframes rec-pulse {
  0%, 100% { opacity: 1; transform: scale(1); }
  50% { opacity: 0.6; transform: scale(0.85); }
}
.recorder-timer {
  font-family: var(--font-mono);
  font-size: var(--fs-md);
  color: var(--c-danger);
  min-width: 42px;
}
.recorder-level {
  height: 6px;
  background: rgba(255,255,255,0.06);
  border-radius: var(--r-pill);
  overflow: hidden;
}
.recorder-level-bar {
  height: 100%;
  width: 0%;
  background: linear-gradient(90deg, var(--c-warning), var(--c-danger));
  transition: width 80ms linear;
}

/* Audio bubble */
.audio-bubble {
  padding: var(--sp-3) !important;
  min-width: 280px;
}
.audio-bubble.mine { background: var(--c-bubble-mine); color: var(--c-bubble-mine-text); }
.audio-bubble.theirs {
  background: var(--c-bubble-theirs);
  color: var(--c-bubble-theirs-text);
  border: 1px solid var(--c-bubble-theirs-border);
}

.audio-card {
  display: grid;
  grid-template-columns: 40px 1fr;
  gap: var(--sp-3);
  align-items: center;
}
.audio-play {
  width: 40px; height: 40px;
  border-radius: 50%;
  border: 0;
  display: grid; place-items: center;
  cursor: pointer;
  background: rgba(10,18,35,0.15);
  color: inherit;
  transition: background var(--dur-fast) var(--ease-out);
}
.audio-bubble.theirs .audio-play { background: var(--c-bg-3); color: var(--c-text); }
.audio-play:hover:not(:disabled) { background: rgba(10,18,35,0.25); }
.audio-bubble.theirs .audio-play:hover:not(:disabled) { background: var(--c-bg-2); border: 1px solid var(--c-border-strong); }
.audio-play:disabled { opacity: 0.5; cursor: not-allowed; }
.audio-play-icon { font-size: 13px; line-height: 1; }

.audio-body {
  display: flex;
  flex-direction: column;
  gap: var(--sp-2);
  min-width: 0;
}
.audio-progress {
  height: 6px;
  background: rgba(255,255,255,0.18);
  border-radius: var(--r-pill);
  cursor: pointer;
  position: relative;
  overflow: hidden;
}
.audio-bubble.theirs .audio-progress { background: rgba(255,255,255,0.08); }
.audio-progress-bar {
  height: 100%;
  width: 0%;
  background: currentColor;
  opacity: 0.85;
  transition: width 80ms linear;
}
.audio-bubble.theirs .audio-progress-bar { background: var(--c-accent); opacity: 1; }
.audio-meta {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: var(--sp-2);
  font-size: var(--fs-xs);
  opacity: 0.85;
}
.audio-time { font-family: var(--font-mono); }
.audio-status {
  opacity: 0.8;
  font-size: var(--fs-xs);
}

.audio-bubble .bubble-meta {
  margin-top: var(--sp-2);
  opacity: 0.7;
}

/* File bubble */
.file-bubble {
  padding: 0 !important;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  /* Allow the bubble to accommodate a preview image/video cleanly. */
  width: min(420px, 80%);
  max-width: 80%;
}
/* When the bubble hosts an inline image/video preview, LOCK its width to the
   target media width from the very first render. Otherwise the bubble adapts to
   its widest CURRENT content: while only the narrow file-card is shown (short
   filename ≈ 139px) the bubble is narrow, then it JUMPS wide when the full-width
   image lands — the "medium → large" the user reported and the harness measured
   (139px → 304px on the emulated S23). Fixing the width up front means only the
   image FILLS a box that's already at final size. `flex-basis:auto`+`flex:none`
   stops the flex column from letting the bubble shrink to content. */
/* v191: an image bubble shrinks to its IMAGE. The bubble is at most
   min(420px,80%) wide, but for a tall portrait it becomes narrow (the preview
   sizes itself by ratio, capped at 360 high, and the bubble wraps it) — like
   WhatsApp, instead of a wide box letterboxing a narrow image. */
/* v208: video bubbles now follow the SAME model as images — the bubble width is
   pinned in px by JS (renderFileBubble) to the poster/video box width (displayW),
   so a portrait video hugs its frame. This rule is only the FALLBACK for legacy
   videos with no dims (no displayW → the JS pin is absent): keep them bounded and
   non-shrinking. When displayW is set, the inline px width wins over this. */
.file-bubble:has(.file-preview-vid) {
  min-width: 140px;
  max-width: min(420px, 80%);
  flex: none;
}
.file-bubble:has(.file-preview-img) {
  /* Width is pinned in px by JS (renderFileBubble) to the preview box width, so
     the bubble hugs the image and the filename/caption wrap within it. No
     `fit-content` (which re-widened the bubble to the widest child on load).
     ⚠️ PAS de `min-width: 140px` ici (mesuré 2026-08-13) : sur un portrait EXTRÊME
     (ex. 1080×3786 → displayW ≈ 103px, ou 1080×4467 → 87px), le min-width forçait
     la bulle à 140px alors que l'image ne fait que 103px → l'image se collait à
     GAUCHE et laissait ~37px de fond de bulle VIDE à DROITE (bug user). Le filename
     est de toute façon CACHÉ sur une bulle image (.file-card { display:none }), donc
     le min-width ne servait à rien ici et cassait le rendu. La bulle doit hugger
     l'image (displayW), point. */
  max-width: min(420px, 80%);
  flex: none;
}
/* Caption typed alongside the file (WhatsApp-style): sits under the card and is
   tightly coupled to it (they're one message), so we pull it up close — only a
   little top padding, no big gap between the file and its text. */
.file-caption {
  padding: 0 12px 8px;
  margin-top: -2px;
  font-size: var(--fs-md, 1rem);
  line-height: 1.4;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  word-break: break-word;
}
/* When there's a preview image directly above the card, the card's own top
   padding already separates them; keep the name close to the image. */
.file-bubble .file-preview + .file-card { padding-top: var(--sp-2); }

/* Type header: a small, centered file-type icon shown above the title when the
   file has no real preview (PDF, archive, doc…). Replaces the old left-of-title
   icon, so the title row keeps its full width for name + kebab. */
.file-typehead {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  padding: var(--sp-3) 0 var(--sp-1);
  margin: 0;
  border: 0;
  background: transparent;
  color: inherit;
  cursor: default;
  -webkit-tap-highlight-color: transparent;
}
button.file-typehead { cursor: pointer; }
button.file-typehead:focus-visible { outline: 2px solid var(--c-accent); outline-offset: -2px; }
.file-typehead-glyph {
  display: grid;
  place-items: center;
  width: 52px;
  height: 52px;
  font-size: 30px;
  line-height: 1;
  border-radius: var(--r-md);
  background: var(--c-bg-3);
}
.file-bubble.mine .file-typehead-glyph { background: rgba(0, 0, 0, 0.18); }
/* The card sits right under the type header — trim its top padding so the icon
   and the name aren't doubly spaced. */
.file-typehead + .file-card { padding-top: 0; }
.file-bubble.mine {
  /* Same opaque background as the text bubble so the chat keeps a uniform
     look (previously the file bubble was painted at ~22 % opacity and read
     as translucent next to opaque text bubbles, which was jarring). */
  background: var(--c-bubble-mine);
  color: var(--c-bubble-mine-text);
  border: 1px solid transparent;
}
/* Inside a "mine" file bubble, override the soft-text/dim colors so the file
   name, meta line and progress track all read clearly on top of the
   user-chosen accent color (instead of the dark theme's grey-on-grey). */
.file-bubble.mine .file-name,
.file-bubble.mine .file-meta,
.file-bubble.mine .file-size,
.file-bubble.mine .file-size::after { color: var(--c-bubble-mine-text); }
.file-bubble.mine .file-kebab-btn { color: var(--c-bubble-mine-text); opacity: 0.85; }
.file-bubble.mine .file-kebab-btn:hover { background: rgba(0,0,0,0.18); opacity: 1; }
.file-bubble.mine .file-progress { background: rgba(0, 0, 0, 0.18); }
.file-bubble.mine .file-progress-bar { background: var(--c-bubble-mine-text); opacity: 0.85; }
.file-bubble.theirs {
  background: var(--c-bubble-theirs);
  color: var(--c-bubble-theirs-text);
  border: 1px solid var(--c-bubble-theirs-border);
}

/* Inline media preview (image thumbnails, video player) above the file card.
   The image preview is a real <button> for accessibility & to avoid window.open
   on installed Android PWAs — these resets erase the default button chrome. */
.file-preview {
  /* Transparent, NOT a dark veil. A dark background here shows through while the
     image fades in (opacity 0→1) and around a transparent image, so the media
     read as "dark → bubble colour" = the last flash the user isolated. With no
     dark fill the bubble's own colour (rose/violet/green) is what's behind the
     image the whole time → the fade is colour-to-image, no dark flash. */
  background: transparent;
  width: 100%;
  padding: 0;
  margin: 0;
  border: 0;
  display: block;
  font: inherit;
  color: inherit;
  text-align: inherit;
  cursor: zoom-in;
  -webkit-tap-highlight-color: transparent;
}
.file-preview:focus-visible { outline: 2px solid var(--c-accent); outline-offset: -2px; }
.tv .file-preview:focus-visible { outline-width: 4px; outline-offset: -4px; }
/* ── UNIFIED image-preview geometry (v191) ────────────────────────────────────
   ONE logic for EVERY image bubble — with or without a thumb, sent or received,
   Electron or PWA. The CONTAINER (`.file-preview-img`, the button) carries the
   aspect-ratio (set inline from the offer's width/height via applyNodeRatio) and
   IS the sized box: height capped, width follows the ratio. The <img>(s) simply
   fill that box. This removes the old split where the container took the full
   ratio height (e.g. 897px) while the <img> was capped at 360px inside it — the
   "tiny image in a huge box" the user hit. */
.file-preview-img {
  display: grid;              /* single cell; a thumb + HD stack in it */
  /* v197 ROOT FIX (measured on S23): pin the grid track to the box, NOT to its
     content. Without this, the implicit `auto` column sizes to the widest child;
     while only the ~40px thumb is loaded (HD not yet decoded) the RENDERED box
     shrinks to the thumb (measured: 168→161→168 as the HD loads), even though
     the CSS/inline width stays 168 — that's the residual zoom + scroll jump.
     `100% / 100%` makes the single cell exactly the box size, content-independent
     → the box can never resize when the HD decodes. */
  grid-template-columns: 100%;
  grid-template-rows: 100%;
  overflow: hidden;          /* clip the blurred thumb flush to the box */
  background: transparent;
}
/* v196: JS (applyNodeRatio) sets EXPLICIT width+height in px on the box (and NO
   aspect-ratio / NO max-width — those three fought and made the box resize on
   load, measured on the S23). So there are NO CSS size rules here that could
   compete: the inline px ARE the size. Legacy messages with no dims (box has no
   inline width) fall back to the rule below. */
.file-preview-img:not([style*="width"]) img {
  width: 100%;
  max-height: 360px;
  object-fit: contain;
}
/* The image(s) fill the sized box exactly. object-fit:cover doesn't crop because
   the box ratio == the image ratio; min-*:0 stops grid items overflowing. */
.file-preview-img > img {
  grid-area: 1 / 1;
  display: block;
  width: 100%;
  height: 100%;
  min-width: 0;
  min-height: 0;
  object-fit: cover;
  background: transparent;
  pointer-events: none;      /* clicks bubble to the parent button */
}
/* INSTANT reveal: the HD <img> is hidden (opacity:0) until decoded, then shown
   at once (no fade — v172). Scoped to NOT match the blurred thumb layer. */
.file-preview-img img:not(.file-preview-thumb) {
  opacity: 0;
}
.file-preview-img img.img-revealed { opacity: 1; }
/* Instant low-res placeholder (LQIP), same cell, behind the HD.
   ⭐ v202 (user insight): NO blur. The `filter: blur(7px)` was the source of the
   "zoom / depth-change" the user disliked: a blurred image reads as "soft/forward"
   and the sharp HD as "back/smaller" → the swap felt like a zoom even though the
   BOX is pixel-stable (measured). A 40px mini-JPEG upscaled to the box is already
   naturally soft (browser interpolation); keeping it SHARP (no added blur) means
   the placeholder and the HD share the exact same framing/scale/edges → the HD
   reveal is an imperceptible sharpness gain, no depth shift, no "zoom" sensation.
   This is closer to what the eye expects than the blur-up trick. */
.file-preview-img .file-preview-thumb {
  opacity: 1;                 /* visible immediately -- it IS the placeholder */
  pointer-events: none;
  border-radius: inherit;
  z-index: 0;
}
/* HD paints above the thumb. */
.file-preview-img.has-thumb img:not(.file-preview-thumb) {
  z-index: 1;
}
/* Receiving shell: the box is already at its FINAL size (aspect-ratio reserved
   from the offer's width/height) while the blob downloads. A neutral dark fill
   plus a thin indeterminate progress bar at the bottom — the real image fills
   this same box when it lands (class removed in setImg), so the box never
   resizes. This is what kills the icon→image "small → large" jump on receive. */
.file-preview-img.file-preview-receiving {
  /* A faint tint DERIVED FROM THE BUBBLE COLOUR (not a dark fill) so the
     downloading box blends with the bubble and the image fades in over the same
     hue — no "dark → bubble colour" flash. Just enough contrast to read the box.
     The mine/theirs overrides below pick the right hue. */
  background: rgba(255, 255, 255, 0.06);
  position: relative;
  overflow: hidden;
}
/* The <img> is already opacity:0 by default (see the reveal rule above); no
   need for visibility:hidden here, which caused the hard hidden→visible flip. */
/* v181: the indeterminate shimmer that used to slide along the image's bottom
   edge (`.file-preview-receiving::after`) was REMOVED. It duplicated the
   determinate `.file-progress` bar (with real %) shown under the image, so a
   receiving image showed TWO progress indicators. User chose to keep the
   informative % bar (consistent with videos and other file kinds, which only
   have that one). The reserved-box tint from `.file-preview-receiving` stays —
   only the redundant animated bar is gone. */
/* Placeholder for an image whose local blob is gone (wiped by "Effacer et
   quitter"). Honest, tappable card instead of a bare filename. Received images
   invite a re-download; our own sent ones just state they're unavailable. */
.file-preview-missing {
  cursor: pointer;
  background: color-mix(in srgb, var(--c-surface, #1b1e24) 85%, transparent);
}
.file-missing-inner {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 6px;
  min-height: 120px;
  padding: 18px 14px;
  text-align: center;
  color: var(--c-text-dim, rgba(255,255,255,0.72));
}
.file-missing-icon { font-size: 40px; line-height: 1; opacity: 0.85; }
.file-missing-label {
  font-size: 13px;
  line-height: 1.35;
  max-width: 90%;
}
/* Empty video box (before the blob resolves) keeps a height so the bubble
   doesn't collapse; the <video> replaces it once we have a valid src. When we
   know the video's aspect ratio (f.width/f.height), JS sets `aspect-ratio` on
   this box AND the <video>, so the box is already at its final size before
   metadata loads — no jump/scroll when the user taps play. */
/* v208 VIDEO = WhatsApp model. At rest the box shows the POSTER (first-frame
   mini-JPEG) + a central play badge + duration; the <video> mounts only on tap.
   The box is a px rectangle (JS sets width/height from computeMediaBox, like an
   image) so it's stable from the first frame — no jump when the <video> mounts,
   no scroll-at-play. Transparent while empty/legacy (no dims); black only once a
   real <video> letterboxes inside. `display:grid` stacks poster + play + duration
   in one cell. */
.file-preview-vid {
  display: grid;
  grid-template-columns: 100%;
  grid-template-rows: 100%;
  min-height: 160px;
  background: transparent;
  overflow: hidden;
  padding: 0;
  border: 0;
}
.file-preview-vid[style*="width"] { min-height: 0; }
.file-preview-vid[style*="aspect-ratio"] { min-height: 0; }

/* Consent shell: a correctly-SIZED neutral box shown while a heavy file awaits
   Accepter/Refuser. Reserves the final box (JS sets width/height px) so accepting
   doesn't resize the bubble, without revealing any media (security v209). Rounded,
   dark, with the neutral glyph centered. Does NOT hide the .file-card (its own class),
   so the Accepter/Refuser CTA in the card stays visible below it. */
.file-preview-consent {
  display: grid;
  place-items: center;
  min-height: 120px;
  border-radius: var(--r-lg, 14px);
  overflow: hidden;
  background:
    linear-gradient(135deg, color-mix(in srgb, var(--c-accent, #4c8dff) 14%, transparent), transparent 70%),
    var(--c-bg-2, #171b24);
}
.file-preview-consent[style*="width"] { min-height: 0; }
.file-preview-consent[style*="aspect-ratio"] { min-height: 0; }
.file-preview-consent .file-vid-poster-glyph { opacity: 0.6; }
/* Cursor: a resting video is a play button (pointer); once the <video> is mounted
   the native control cursors take over. Never the image's zoom-in. */
.file-preview-vid { cursor: pointer; }
.file-preview-vid:has(video.file-vid-player-ready) { cursor: default; }
.file-preview-vid video {
  grid-area: 1 / 1;
  display: block;
  width: 100%;
  height: 100%;
  max-height: 420px;
  object-fit: contain;
}
/* v211 FLASH FIX: the mounted <video> is transparent (poster shows through the
   same grid cell) UNTIL it's actually playing, then it reveals. This kills the
   black/error flash at play — the poster stays put, the video fades in over it.
   Background is transparent until ready so the poster (z-index below) shows; black
   only once the frame is live (letterboxing). */
.file-preview-vid video.file-vid-player {
  opacity: 0;
  background: transparent;
  z-index: 2;                          /* above the poster while transitioning */
  transition: opacity 120ms ease-out;
}
.file-preview-vid video.file-vid-player-ready {
  opacity: 1;
  background: #000;
}
/* The box goes black only once the video is REVEALED (not merely mounted), so a
   mounting-but-not-yet-playing video never flashes a black box over the poster. */
.file-preview-vid:has(video.file-vid-player-ready) { background: #000; min-height: 0; }
/* Poster fills the box (same framing as the image thumb). object-fit:cover because
   the box ratio == the source ratio (computeMediaBox), so no crop. */
.file-vid-poster {
  grid-area: 1 / 1;
  display: block;
  width: 100%;
  height: 100%;
  min-width: 0;
  min-height: 0;
  object-fit: cover;
  z-index: 0;
}
/* Elegant fallback when no first-frame poster could be extracted (Firefox-Android
   wall / Electron-sent). NOT a black box: a soft gradient tinted toward the bubble
   accent + a centered film glyph. Reads as "a video" cleanly. */
.file-vid-poster-fallback {
  display: grid;
  place-items: center;
  background:
    radial-gradient(120% 120% at 50% 30%, rgba(255, 255, 255, 0.10), rgba(0, 0, 0, 0.25) 80%),
    linear-gradient(135deg, color-mix(in srgb, var(--c-accent) 45%, #1b1e24), #12151b);
}
.file-vid-poster-glyph {
  font-size: 40px;
  line-height: 1;
  opacity: 0.9;
  filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.4));
}
/* Once the video is playing (revealed), hide the resting overlays (poster/play/
   duration) so they don't sit on top of the live frame. The video itself (z-index
   2) covers the poster, but the play badge + duration must be explicitly hidden. */
.file-preview-vid:has(video.file-vid-player-ready) .file-vid-play,
.file-preview-vid:has(video.file-vid-player-ready) .file-vid-duration,
.file-preview-vid:has(video.file-vid-player-ready) .file-vid-poster {
  display: none;
}
/* Central play badge over the poster. */
.file-vid-play {
  grid-area: 1 / 1;
  align-self: center;
  justify-self: center;
  z-index: 1;
  width: 56px;
  height: 56px;
  border-radius: 50%;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
  pointer-events: none;              /* the whole button-node handles the tap */
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
}
.file-vid-play-icon {
  color: #fff;
  font-size: 22px;
  line-height: 1;
  margin-left: 3px;                  /* optical centering of the ▶ glyph */
}
/* Duration pill, bottom-left of the poster (WhatsApp position). */
.file-vid-duration {
  grid-area: 1 / 1;
  align-self: end;
  justify-self: start;
  z-index: 1;
  margin: 6px;
  padding: 1px 7px;
  border-radius: var(--r-pill);
  background: rgba(0, 0, 0, 0.55);
  color: #fff;
  font-size: var(--fs-xs);
  pointer-events: none;
}

/* ══ ALBUM (pièces jointes multiples) — étape 2 ═══════════════════════════════
   La grille reçoit sa width/height en PIXELS depuis le JS (computeAlbumBox), qui
   ne les déduit QUE du nombre de médias. Rien ici ne doit rendre la taille
   dépendante du contenu :
     • aucune trace de `aspect-ratio`, `fit-content` ou `auto` sur la boîte ;
     • `minmax(0, 1fr)` et `min-width/min-height: 0` sur les cellules — sans eux,
       la taille INTRINSÈQUE des images gonflerait les pistes de la grille et la
       hauteur bougerait à chaque décodage (la cascade ResizeObserver du bug v141).
   Style arrêté avec l'utilisateur : gouttière 2 px, angles VIFS, badge teinté
   accent SANS flou (pas de backdrop-filter). */
.album-grid {
  --album-gap: 2px;
  display: grid;
  gap: var(--album-gap);
  overflow: hidden;
  border-radius: 0;                    /* angles vifs — choix assumé */
  padding: 0;
  border: 0;
  background: transparent;
  /* La boîte est un rectangle px pur : rien à résoudre, donc rien à redimensionner. */
}
/* La bulle parente porte un border-radius (--r-lg) : sans cette contre-règle,
   elle ROGNE les coins de la grille et les angles vifs demandés réapparaissent
   arrondis en haut. Mesuré à la capture — invisible dans les chiffres. */
.album-bubble {
  padding: 0;
  overflow: hidden;
  border-radius: 0;
}
/* Silhouettes. Les pistes sont TOUJOURS en fractions d'une boîte déjà fixée en
   px — jamais en `auto`, qui laisserait le contenu décider. */
.album-grid.album-2 {
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  grid-template-rows: minmax(0, 1fr);
}
/* n=3 : une grande tuile à gauche (deux rangées), deux empilées à droite. */
.album-grid.album-3 {
  grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
  grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
}
.album-grid.album-3 > .album-tile:first-child { grid-row: 1 / span 2; }
.album-grid.album-4 {
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
}

/* Une tuile ne dimensionne rien : la cellule lui impose sa taille, l'image la
   remplit en `cover`. C'est la clé de l'insensibilité au décodage. */
.album-tile {
  position: relative;
  display: block;
  width: 100%;
  height: 100%;
  min-width: 0;
  min-height: 0;
  padding: 0;
  border: 0;
  border-radius: 0;
  overflow: hidden;
  background: var(--c-bg-2, #171b24);
  cursor: zoom-in;
}
.album-tile-img,
.album-tile-thumb {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
/* La miniature inline (mini-JPEG de l'offre) se peint tout de suite, floutée ;
   l'image nette se révèle par-dessus une fois décodée. Même boîte → aucun reflow. */
.album-tile-thumb {
  filter: blur(8px);
  transform: scale(1.06);              /* masque les bords adoucis par le flou */
  z-index: 0;
}
.album-tile-img {
  z-index: 1;
  opacity: 0;
  transition: opacity 140ms ease-out;
}
.album-tile-img.img-revealed { opacity: 1; }
@media (prefers-reduced-motion: reduce) {
  .album-tile-img { transition: none; }
}

/* Badge « +N » : teinté accent, SANS flou (choix explicite de l'utilisateur).
   Il doit COUVRIR la vignette, pas la teinter : à 42 % d'opacité l'image restait
   lisible dessous et le chiffre s'y noyait (vu à la capture). Un voile dense +
   un chiffre bien posé dit « il y en a d'autres » d'un seul coup d'œil, ce que
   le flou obtiendrait ailleurs — ici, sans flou. */
.album-tile-badge {
  position: absolute;
  inset: 0;
  z-index: 2;
  display: flex;
  align-items: center;
  justify-content: center;
  background: color-mix(in srgb, var(--c-accent, #4c8dff) 30%, rgba(8, 11, 16, 0.82));
  color: #fff;
  font-size: 30px;
  font-weight: 700;
  letter-spacing: 0.5px;
  text-shadow: 0 1px 6px rgba(0, 0, 0, 0.5);
  pointer-events: none;
}
/* Vidéo SANS poster (Electron n'extrait pas de frame) : un cadre sombre lisible
   + le badge ▶, jamais une icône cassée. Le <img> n'est même pas créé pour une
   vidéo — mettre un MP4 dans un <img> était exactement le bug « image cassée ». */
.album-tile-noposter {
  background:
    radial-gradient(120% 120% at 50% 30%, rgba(255, 255, 255, 0.08), rgba(0, 0, 0, 0.30) 80%),
    linear-gradient(135deg, color-mix(in srgb, var(--c-accent, #4c8dff) 35%, #1b1e24), #12151b);
}
/* Badge lecture d'une vidéo dans la grille (aucun <video> n'est monté ici : ce
   serait N lecteurs. La lecture se fait dans la visionneuse, au clic). */
.album-tile-play {
  position: absolute;
  z-index: 2;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  width: 40px;
  height: 40px;
  border-radius: 50%;
  background: rgba(0, 0, 0, 0.5);
  color: #fff;
  font-size: 15px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding-left: 3px;                   /* centrage optique du ▶ */
  pointer-events: none;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
}
/* Tuile verrouillée (consentement PAR MÉDIA en attente) : elle garde sa place
   dans la grille mais ne révèle AUCUN octet — ni miniature, ni première frame. */
.album-tile-locked,
.album-tile-failed {
  display: grid;
  place-items: center;
}
.album-tile-locked {
  background:
    linear-gradient(135deg, color-mix(in srgb, var(--c-accent, #4c8dff) 14%, transparent), transparent 70%),
    var(--c-bg-2, #171b24);
}
.album-tile-failed {
  background: color-mix(in srgb, var(--c-danger, #e5484d) 18%, var(--c-bg-2, #171b24));
}
.album-tile-glyph {
  font-size: 24px;
  line-height: 1;
  opacity: 0.85;
  z-index: 2;
}

/* Ligne d'info sous la grille : nombre de médias + POIDS TOTAL. Le poids total
   est affiché systématiquement (demande explicite) — c'est ce qui permet de
   décider en connaissance de cause quand plusieurs médias sont sous
   consentement, plutôt que d'accepter à l'aveugle. */
.album-meta {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
  padding: 5px 8px 2px;
  font-size: var(--fs-xs);
  color: var(--c-text-dim, rgba(255, 255, 255, 0.72));
}
.album-size { font-variant-numeric: tabular-nums; }

/* Consentement PAR MÉDIA : la CTA groupée n'apparaît que s'il reste des tuiles
   verrouillées, et rappelle leur poids cumulé. */
.album-actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px;
  padding: 2px 8px 6px;
}
.album-actions[hidden] { display: none !important; }
.album-consent-label {
  flex: 1 1 100%;
  font-size: var(--fs-xs);
  color: var(--c-text-dim, rgba(255, 255, 255, 0.72));
}
/* La bulle album est à padding:0 (la grille doit toucher ses bords) — la légende
   reprend donc sa respiration ici, sinon elle colle à l'image. */
.album-bubble .file-caption {
  padding: 2px 10px 8px;
}

.file-card {
  /* Single column now — the default file-type icon was removed. The body holds
     the head row (name + kebab), meta, progress and any inline actions. */
  padding: var(--sp-2) var(--sp-3);
  min-width: 0;
  width: 100%;
  box-sizing: border-box;
}
.file-body {
  display: flex;
  flex-direction: column;
  gap: var(--sp-1);
  min-width: 0;
}
/* Head row: name (left, truncates) + kebab (right, fixed). */
.file-head {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  min-width: 0;
}
.file-name {
  font-weight: 600;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  flex: 1 1 auto;
  min-width: 0;
}
/* Kebab (⋮) trigger + its dropdown. `.topbar-menu` carries the popover look. */
.file-kebab-wrap { position: relative; flex: 0 0 auto; }
.file-kebab-btn {
  width: 28px;
  height: 28px;
  border: 0;
  background: transparent;
  color: var(--c-text-soft);
  font-size: 18px;
  line-height: 1;
  border-radius: 50%;
  cursor: pointer;
}
.file-kebab-btn[hidden] { display: none !important; }
.file-kebab-btn:hover { background: var(--c-bg-3); color: var(--c-text); }
/* Width/position are set inline by openMenu() (body-portaled popover); this
   just keeps the menu readable if those inline styles are ever absent. */
.file-kebab-menu { min-width: 160px; max-width: calc(100vw - 16px); }
.file-meta {
  display: flex;
  gap: var(--sp-2);
  align-items: center;
  font-size: var(--fs-xs);
  color: var(--c-text-soft);
}
.file-size::after { content: '·'; margin-left: var(--sp-2); color: var(--c-text-dim); }
.file-progress {
  height: 4px;
  border-radius: var(--r-pill);
  background: rgba(255,255,255,0.08);
  overflow: hidden;
}
.file-progress-bar {
  height: 100%;
  width: 0%;
  background: var(--c-accent);
  transition: width var(--dur-med) var(--ease-out);
}
.file-progress.done .file-progress-bar { background: var(--c-success); }
/* On MEDIA bubbles (image/video), once the transfer is done the image IS the
   confirmation — a progress bar flipping blue→green (hard, no transition) and a
   "Terminé" label just flash uselessly under the picture (the small flash the
   user isolated). Hide them on completed media cards, WhatsApp-style: only the
   media + filename remain. Non-media file cards keep their bar/status. Ordinary
   transfer states are untouched (the bar shows only while downloading).
   ⭐ v200 SCROLL FIX (MEASURED S23): use `visibility:hidden`, NOT `display:none`.
   The box-diag proved the residual scroll: the file-card oscillated in HEIGHT
   (hCard 107→112→104, hAct 30→35→30) across the transfer while the image box
   stayed rock-stable at 168x360. Cause: this rule REMOVED the progress/status box
   at 'done' (display:none → height drops), FIGHTING the sibling rule below that
   RESERVES that same box during transfer (visibility:hidden). Two opposite space
   models on the same elements = the bubble grew then shrank (499→504→496) →
   pin-to-bottom scroll jump. Making 'done' ALSO visibility:hidden keeps the box
   reserved from first render to end → the media card height NEVER changes → no
   scroll movement. Same v141 lesson (never display:none media children), applied
   to the card this time. Non-media cards are untouched (they want the real bar). */
.file-card.img:has(.file-progress.done) .file-progress,
.file-card.vid:has(.file-progress.done) .file-progress,
.file-card.img:has(.file-progress.done) .file-status,
.file-card.vid:has(.file-progress.done) .file-status {
  visibility: hidden;
}
/* Delayed controls on MEDIA bubbles: the progress bar, the status label and the
   Cancel button stay INVISIBLE during a media transfer by default, and only
   appear when JS adds `.transfer-slow` (transfer still running after ~500ms). A
   quick image never reveals them (clean WhatsApp look); a video / slow image
   reveals them exactly when Cancel becomes useful. Non-media file cards are
   untouched (their bar + Cancel are the whole point).
   IMPORTANT: `visibility:hidden` (NOT display:none) so the space stays RESERVED
   — revealing them never changes the bubble height (which would fire the RO →
   snapToBottom scroll jump we fought in v141). The action row already reserves
   30px on media (below); the bar/status keep their box too. */
.file-bubble:has(.file-preview-img):not(.transfer-slow) .file-progress,
.file-bubble:has(.file-preview-vid):not(.transfer-slow) .file-progress,
.file-bubble:has(.file-preview-img):not(.transfer-slow) .file-status,
.file-bubble:has(.file-preview-vid):not(.transfer-slow) .file-status,
.file-bubble:has(.file-preview-img):not(.transfer-slow) .file-actions,
.file-bubble:has(.file-preview-vid):not(.transfer-slow) .file-actions {
  visibility: hidden;
}
/* When the status is hidden, drop the trailing "·" after the size so it doesn't
   dangle (the size line reads "5.7 Mo" cleanly, not "5.7 Mo ·"). */
.file-card.img:has(.file-progress.done) .file-size::after,
.file-card.vid:has(.file-progress.done) .file-size::after {
  content: none;
}

.file-actions {
  display: flex;
  gap: var(--sp-2);
  flex-wrap: wrap;
  margin-top: var(--sp-1);
}
/* Reserve the action row's height on MEDIA bubbles so the "Annuler" button
   appearing during transfer and disappearing at "Terminé" does NOT change the
   card height (measured on the S23: the file-card went 96px→66px when the button
   left, stretching the whole bubble). With a fixed slot the button comes and
   goes inside a constant-height area → the media bubble never resizes from start
   to end of transfer. Only for image/video previews (non-media file cards keep
   their tight layout). btn-sm ≈ 30px; reserve that plus the row's top margin. */
.file-bubble:has(.file-preview-img) .file-actions,
.file-bubble:has(.file-preview-vid) .file-actions {
  min-height: 30px;
  align-items: center;
}
.file-retry-hint { font-size: var(--fs-xs); color: var(--c-text-dim); }

.file-bubble .bubble-meta {
  padding: 0 var(--sp-3) var(--sp-2);
  opacity: 0.7;
}

/* ═══ v201 IMAGE BUBBLE = WhatsApp model (user decision) ══════════════════════
   ROOT design fix for the residual scroll: on an IMAGE bubble, NOTHING below the
   picture may change height (the file-card's status/bar/Cancel oscillated
   107→112→104 across the transfer → bubble grew/shrank → pin-to-bottom scroll
   jump — proven by box-diag). WhatsApp's answer: the image IS the bubble; the
   time + delivery ticks live as an OVERLAY at the bottom-right OF the image (time
   left, ticks right), on a soft gradient for legibility. No card, no name, no
   size, no bar under the picture → the bubble height == the image height, fixed
   from the first frame → the scroll can never move. A typed caption is the only
   thing allowed below (it's a real message, WhatsApp keeps it too).
   Scope: image AND (v208) video previews. Non-media file cards keep their layout. */
.file-bubble:has(.file-preview-img),
.file-bubble:has(.file-preview-vid) {
  position: relative;                 /* anchor for the meta overlay (bubble is already relative, explicit here) */
}
/* Hide the whole card (name + size + progress + status + actions) on a media
   bubble: the picture/poster + overlay carry everything. Removed from FLOW entirely
   so it contributes ZERO height — no display:none/visibility fight, it's just gone.
   Kept in the DOM (JS still updates it harmlessly) but out of layout. */
.file-bubble:has(.file-preview-img) > .file-card,
.file-bubble:has(.file-preview-vid) > .file-card {
  display: none;
}
/* The time + ticks meta, appended by threadPane after the card, becomes the
   overlay. Absolute bottom-right ON the image; the gradient behind it keeps the
   time/ticks readable over any picture. White text (over the image, not the
   bubble) regardless of mine/theirs. */
/* v205: the time/ticks overlay is now a COMPACT PILL in the bottom-right corner
   of the image — NOT a full-width gradient. User feedback: the full-width gradient
   darkened the caption text on the left / bottom line. A small rounded pill hugging
   only the time + ticks never touches the text: it sits in the corner, the caption
   (if any) stays fully legible to its left and below. */
.file-bubble:has(.file-preview-img) > .bubble-meta,
.file-bubble:has(.file-preview-vid) > .bubble-meta {
  position: absolute;
  right: 6px;
  bottom: 6px;
  left: auto;
  margin: 0;
  padding: 2px 8px;
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 5px;
  opacity: 1;
  color: #fff;
  /* Compact translucent pill — legible over any picture, touches nothing else. */
  background: rgba(0, 0, 0, 0.5);
  border-radius: var(--r-pill);
  pointer-events: none;               /* clicks pass through to the media */
  z-index: 2;
}
.file-bubble:has(.file-preview-img) > .bubble-meta .bubble-time,
.file-bubble:has(.file-preview-vid) > .bubble-meta .bubble-time {
  font-size: var(--fs-xs);
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45);
}
.file-bubble:has(.file-preview-img) > .bubble-meta .tick,
.file-bubble:has(.file-preview-vid) > .bubble-meta .tick {
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45);
}
/* The "seen" (read) tick keeps its bright cyan even over the media. */
.file-bubble:has(.file-preview-img) > .bubble-meta .tick-seen,
.file-bubble:has(.file-preview-vid) > .bubble-meta .tick-seen { color: #34d2ff; }
/* v209 collision fix: once the <video> is mounted (playing), the native control
   bar occupies the bottom of the frame. Hide the time/ticks pill then so it never
   overlaps the scrubber. At rest the box carries `.file-vid-resting`; on mount JS
   removes it → the pill hides only during playback. (Avoid a nested :has() here —
   `:has(.file-preview-vid:has(video))` isn't reliably supported; a plain class on
   the box that toggles is robust everywhere.) */
.file-bubble:has(.file-preview-vid:not(.file-vid-resting)) > .bubble-meta {
  display: none;
}
/* When a CAPTION is present, the picture is followed by a text row (below it), so
   an overlay pill on the image would float above the text and read oddly. WhatsApp
   handles this by moving the time/ticks to sit INLINE at the end of the caption
   text (bottom-right of the message). We do the same purely in CSS: drop the pill
   look and let the meta flow as a normal right-aligned row UNDER the caption — no
   background, so it can never darken the text; the bubble surface already gives
   contrast for a plain-text row. Height is tiny and fixed → no scroll impact. */
.file-bubble:has(.file-preview-img):has(> .file-caption) > .bubble-meta,
.file-bubble:has(.file-preview-vid):has(> .file-caption) > .bubble-meta {
  position: static;
  right: auto;
  bottom: auto;
  margin: 0;
  padding: 0 12px 6px;
  background: transparent;
  color: var(--c-text-soft);
  justify-content: flex-end;
}
.file-bubble:has(.file-preview-img):has(> .file-caption) > .bubble-meta .bubble-time,
.file-bubble:has(.file-preview-img):has(> .file-caption) > .bubble-meta .tick,
.file-bubble:has(.file-preview-vid):has(> .file-caption) > .bubble-meta .bubble-time,
.file-bubble:has(.file-preview-vid):has(> .file-caption) > .bubble-meta .tick {
  text-shadow: none;
}
/* If a caption is present it sits BELOW the media (legitimate, WhatsApp does too).
   Trim its bottom padding since the meta row now follows right under it. */
.file-bubble:has(.file-preview-img) > .file-caption,
.file-bubble:has(.file-preview-vid) > .file-caption {
  padding: 6px 12px 4px;
  margin-top: 0;
}
/* ── Étape B: image transfer overlay (progress + Cancel) ON the image ──────────
   Absolute → showing/hiding it NEVER changes the bubble height (can't revive the
   scroll jump). Sits at the TOP of the image (the bottom is the time/ticks meta
   overlay). Hidden by JS (`hidden`) when not transferring, AND kept invisible for
   fast transfers via the existing `.transfer-slow` gate — a quick LAN image shows
   only the picture; a slow/big one reveals the bar + Annuler exactly when Cancel
   becomes useful. Same time-based rule as the card controls. */
.file-transfer-overlay {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 10px;
  background: linear-gradient(to bottom, rgba(0, 0, 0, 0.45), rgba(0, 0, 0, 0) 100%);
  border-top-left-radius: inherit;
  border-top-right-radius: inherit;
  z-index: 3;
}
.file-transfer-overlay[hidden] { display: none !important; }
/* Fast transfer → keep the overlay invisible (space reserved is moot, it's
   absolute). Revealed only once JS adds `.transfer-slow` on the bubble. */
.file-bubble:has(.file-preview-img):not(.transfer-slow) .file-transfer-overlay,
.file-bubble:has(.file-preview-vid):not(.transfer-slow) .file-transfer-overlay {
  visibility: hidden;
}
.file-transfer-progress {
  flex: 1 1 auto;
  height: 4px;
  border-radius: var(--r-pill);
  background: rgba(255, 255, 255, 0.28);
  overflow: hidden;
}
.file-transfer-progress .file-progress-bar {
  height: 100%;
  width: 0%;
  background: #fff;
  transition: width var(--dur-med) var(--ease-out);
}
.file-transfer-cancel {
  flex: 0 0 auto;
  padding: 3px 10px;
  border: 0;
  border-radius: var(--r-pill);
  background: rgba(0, 0, 0, 0.5);
  color: #fff;
  font-size: var(--fs-xs);
  font-weight: 600;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.file-transfer-cancel:hover { background: rgba(0, 0, 0, 0.68); }
.file-transfer-cancel:active { transform: scale(0.95); }
/* No meta clearance for media bubbles when reactions are pinned: the
   media bubble's width is determined by its preview picture, so padding
   the meta would leave an empty strip next to the image. The chip is
   instead repositioned BELOW the bubble (see `.bubble-reactions`
   override above) where the wrapper has been given extra margin. */

/* Drop overlay */
.drop-overlay {
  position: absolute;
  inset: 0;
  background: rgba(91,140,255,0.08);
  border: 2px dashed var(--c-accent);
  border-radius: var(--r-md);
  margin: var(--sp-3);
  display: grid;
  place-items: center;
  pointer-events: none;
  z-index: 30;
  animation: fade-in 120ms var(--ease-out);
}
.drop-overlay[hidden] { display: none !important; }
.drop-overlay-inner {
  display: grid;
  place-items: center;
  gap: var(--sp-3);
  color: var(--c-accent-soft);
  text-align: center;
}
.drop-icon { font-size: 48px; }

.thread-pane { position: relative; }
.thread-pane.dragging .thread-list { filter: blur(0.5px); }

/* Jump-to-bottom pill. Anchored to .thread-pane (position: relative). The
   bottom offset clears the composer + a small breathing margin. */
.jump-bottom {
  position: absolute;
  bottom: 80px;
  right: 16px;
  padding: 8px 14px;
  border: 0;
  border-radius: 999px;
  background: var(--c-accent);
  color: #fff;
  font-size: var(--fs-sm);
  font-weight: 500;
  box-shadow: 0 4px 12px rgba(0,0,0,0.25);
  cursor: pointer;
  z-index: 5;
}
.jump-bottom:hover { filter: brightness(1.05); }
.jump-bottom:active { transform: translateY(1px); }
.jump-bottom[hidden] { display: none; }

/* Emoji picker — overlay panel anchored above the composer. The composer
   itself is `position: relative`, so the panel docks to its top edge.
   Unicode-only (no images, no external lib). Pair every display: rule with
   a `[hidden]` counter-rule so the `hidden` attribute actually hides it. */
.emoji-panel {
  position: absolute;
  left: var(--sp-3);
  right: var(--sp-3);
  bottom: calc(100% + 6px);
  display: flex;
  flex-direction: column;
  max-height: min(360px, 60vh);
  background: var(--c-bg-1);
  border: 1px solid var(--c-border);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-lg);
  z-index: 10;
  overflow: hidden;
}
.emoji-panel[hidden] { display: none !important; }

.emoji-header {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  padding: var(--sp-2);
  border-bottom: 1px solid var(--c-border);
  background: var(--c-bg-1);
}

.emoji-tabs {
  display: flex;
  gap: 2px;
  flex: 1 1 auto;
  overflow-x: auto;
  scrollbar-width: thin;
}
.emoji-tab {
  flex: 0 0 auto;
  width: 32px;
  height: 32px;
  padding: 0;
  font-size: 18px;
  line-height: 1;
  background: transparent;
  border: 1px solid transparent;
  border-radius: var(--r-sm);
  cursor: pointer;
  color: var(--c-text);
}
.emoji-tab:hover { background: var(--c-bg-3); }
.emoji-tab:focus-visible {
  outline: none;
  border-color: var(--c-accent);
  box-shadow: 0 0 0 2px var(--c-accent-ring);
}

.emoji-close {
  flex: 0 0 auto;
  width: 32px;
  height: 32px;
  padding: 0;
  font-size: 22px;
  line-height: 1;
  background: transparent;
  border: 1px solid transparent;
  border-radius: var(--r-sm);
  color: var(--c-text-soft);
  cursor: pointer;
}
.emoji-close:hover { background: var(--c-bg-3); color: var(--c-text); }
.emoji-close:focus-visible {
  outline: none;
  border-color: var(--c-accent);
  box-shadow: 0 0 0 2px var(--c-accent-ring);
}

.emoji-grid {
  flex: 1 1 auto;
  overflow-y: auto;
  padding: var(--sp-2);
  scrollbar-width: thin;
  /* Disable subpixel font smoothing tricks — emojis already render via the
     OS color font, so we just want a clean grid. */
  scroll-behavior: smooth;
}

.emoji-section {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(calc(36px * var(--app-emoji-scale)), 1fr));
  gap: 2px;
  margin-bottom: var(--sp-2);
}
.emoji-section:last-child { margin-bottom: 0; }

.emoji-cat-label {
  grid-column: 1 / -1;
  font-size: var(--fs-xs);
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--c-text-dim);
  padding: var(--sp-2) 2px var(--sp-1);
  position: sticky;
  top: 0;
  background: var(--c-bg-1);
  z-index: 1;
}

.emoji-cell {
  width: 100%;
  aspect-ratio: 1 / 1;
  padding: 0;
  font-size: calc(22px * var(--app-emoji-scale));
  line-height: 1;
  background: transparent;
  border: 1px solid transparent;
  border-radius: var(--r-sm);
  cursor: pointer;
  color: var(--c-text);
  /* The emoji glyph; keep selection off so taps don't paint highlights. */
  user-select: none;
  -webkit-tap-highlight-color: transparent;
}
.emoji-cell:hover { background: var(--c-bg-3); }
.emoji-cell:focus-visible {
  outline: none;
  border-color: var(--c-accent);
  box-shadow: 0 0 0 2px var(--c-accent-ring);
}
.emoji-cell:active { transform: scale(0.92); }

/* GIF picker — same docking pattern as the emoji panel: anchored to the top
   edge of the (position: relative) composer, scrollable grid below, search
   bar on top. Pair every display: rule with [hidden] per the project's
   counter-rule policy. */
.gif-panel {
  position: absolute;
  left: var(--sp-3);
  right: var(--sp-3);
  bottom: calc(100% + 6px);
  display: flex;
  flex-direction: column;
  max-height: min(420px, 70vh);
  background: var(--c-bg-1);
  border: 1px solid var(--c-border);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-lg);
  z-index: 10;
  overflow: hidden;
}
.gif-panel[hidden] { display: none !important; }
/* Picker GIF/EMOJI DANS l'éditeur de story (plein écran) : bottom-sheet qui monte du bas, AU-DESSUS
   de l'éditeur (.story-editor = 1001) et de la toolbar. Surcharge l'ancrage-au-bouton du composer.
   Sélecteur sur la classe SEULE (pas .gif-panel) pour s'appliquer aussi à .emoji-panel. */
.gif-panel-story {
  position: fixed !important;
  left: 0; right: 0; bottom: 0; top: auto;
  max-height: 60vh;
  border-radius: var(--r-lg) var(--r-lg) 0 0;
  z-index: 1005;
  padding-bottom: env(safe-area-inset-bottom, 0px);
}

.gif-header {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  padding: var(--sp-2);
  border-bottom: 1px solid var(--c-border);
  background: var(--c-bg-1);
}
.gif-brand {
  flex: 0 0 auto;
  font-size: var(--fs-xs);
  font-weight: 700;
  letter-spacing: 0.1em;
  color: var(--c-text-dim);
  padding: 0 var(--sp-1);
}
.gif-search-input {
  flex: 1 1 auto;
  min-width: 0;
  height: 32px;
  padding: 0 var(--sp-3);
  font-family: var(--font-sans);
  font-size: var(--fs-md);
  color: var(--c-text);
  background: var(--c-bg-2);
  border: 1px solid var(--c-border);
  border-radius: var(--r-pill);
  outline: none;
  transition: border-color var(--dur-fast) var(--ease-out),
              box-shadow var(--dur-fast) var(--ease-out);
}
.gif-search-input:focus {
  border-color: var(--c-accent);
  box-shadow: 0 0 0 2px var(--c-accent-ring);
}
.gif-close {
  flex: 0 0 auto;
  width: 32px;
  height: 32px;
  padding: 0;
  font-size: 22px;
  line-height: 1;
  background: transparent;
  border: 1px solid transparent;
  border-radius: var(--r-sm);
  color: var(--c-text-soft);
  cursor: pointer;
}
.gif-close:hover { background: var(--c-bg-3); color: var(--c-text); }
.gif-close:focus-visible {
  outline: none;
  border-color: var(--c-accent);
  box-shadow: 0 0 0 2px var(--c-accent-ring);
}

.gif-status {
  padding: var(--sp-3);
  text-align: center;
  font-size: var(--fs-sm);
  color: var(--c-text-dim);
}
.gif-status[hidden] { display: none !important; }

.gif-grid {
  flex: 1 1 auto;
  overflow-y: auto;
  padding: var(--sp-2);
  scrollbar-width: thin;
  /* Two-column masonry-ish using CSS columns. Cheap, doesn't fight aspect
     ratios — each tile carries its own aspect-ratio so videos load without
     reflow. column-fill: balance gives a more natural fill on the last row. */
  column-count: 2;
  column-gap: var(--sp-2);
  /* Smooth touch scrolling on iOS / Safari. */
  -webkit-overflow-scrolling: touch;
}
@media (min-width: 600px) {
  .gif-grid { column-count: 3; }
}

.gif-cell {
  display: block;
  width: 100%;
  margin: 0 0 var(--sp-2) 0;
  padding: 0;
  background: var(--c-bg-2);
  border: 1px solid transparent;
  border-radius: var(--r-md);
  overflow: hidden;
  cursor: pointer;
  break-inside: avoid;
  -webkit-tap-highlight-color: transparent;
  transition: transform var(--dur-fast) var(--ease-out);
}
.gif-cell:hover { border-color: var(--c-border-strong); }
.gif-cell:focus-visible {
  outline: none;
  border-color: var(--c-accent);
  box-shadow: 0 0 0 2px var(--c-accent-ring);
}
.gif-cell:active { transform: scale(0.97); }

.gif-cell-video {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  background: var(--c-bg-3);
}

/* GIF bubble — replaces the text body. Tight padding so the rounded video
   sits flush to the bubble edge while the mine/theirs tint stays as a thin
   frame around it. The bubble's overflow stays default (visible) so the meta
   row underneath the video isn't clipped. */
.bubble-has-gif {
  padding: 4px;
  max-width: min(280px, 70%);
}
.bubble-gif {
  border-radius: calc(var(--r-lg) - 4px);
  overflow: hidden;
  background: var(--c-bg-3);
  margin-bottom: 4px;
}
.bubble-gif-video {
  display: block;
  width: 100%;
  height: auto;
  max-height: 320px;
  object-fit: cover;
}

/* ══ MENU DES COMMANDES (/ambiance, /wizz…) ══════════════════════════════════
   Se pose AU-DESSUS du composeur — comme le bandeau de pièces jointes, dont on
   reprend la mécanique éprouvée (`bottom:100%`).

   ⚠️ JAMAIS EN DESSOUS : sur mobile, le clavier occupe la moitié basse de
   l'écran ; un menu placé sous le champ serait invisible. */
.cmd-menu {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 100%;
  margin: 0 0 6px;
  padding: 0 var(--sp-5);
  z-index: 7;                       /* au-dessus du bandeau de pièces jointes */
  pointer-events: auto;
  /* La liste peut être longue (8 ambiances) : on la borne et on la fait défiler
     plutôt que de la laisser manger tout l'écran sur un téléphone. */
  max-height: min(46vh, 320px);
  overflow-y: auto;
  overscroll-behavior: contain;     /* ne pas propager le défilement au fil */
  background: var(--c-bg-2);
  border: 1px solid var(--c-border);
  border-radius: var(--r-md);
  box-shadow: 0 -6px 24px rgba(0, 0, 0, 0.35);
}
.cmd-menu[hidden] { display: none !important; }

.cmd-item {
  display: flex;
  align-items: baseline;
  gap: 10px;
  width: 100%;
  padding: 11px 12px;               /* cible tactile confortable (~44 px de haut) */
  background: transparent;
  border: 0;
  border-bottom: 1px solid var(--c-border);
  color: var(--c-text);
  text-align: left;
  cursor: pointer;
  font-size: var(--fs-sm);
}
.cmd-item:last-child { border-bottom: 0; }
.cmd-item:hover,
.cmd-item.is-active {
  background: color-mix(in srgb, var(--c-accent, #4c8dff) 16%, transparent);
}
.cmd-emoji {
  flex: 0 0 auto;
  width: 1.4em;
  text-align: center;
  font-size: 15px;
}
.cmd-label {
  flex: 0 0 auto;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}
.cmd-hint {
  flex: 1 1 auto;
  min-width: 0;
  color: var(--c-text-soft);
  font-size: var(--fs-xs);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* Sur écran étroit, l'explication passe à la ligne plutôt que d'être tronquée. */
@media (max-width: 480px) {
  .cmd-item { flex-wrap: wrap; }
  .cmd-hint { flex-basis: 100%; white-space: normal; padding-left: calc(1.4em + 10px); }
}
