/* ==========================================================================
   OMNIA — Composants
   --------------------------------------------------------------------------
   Deux inventions portent l'identité et reviennent partout :

   1. La PLAQUE (.plate) — une surface qui se comporte comme un objet posé :
      filet d'encre à 1 px, biseau clair en haut, double ombre très basse.
      C'est ce trio, et non le rayon des coins, qui fait « fabriqué ».

   2. L'AURA (--progress) — tout ce qui progresse émet de la lumière.
      Un thème à 8 % est graphite ; à 90 % il rayonne. La progression n'est
      jamais un simple chiffre : elle se voit du coin de l'œil.
   ========================================================================== */

/* ==========================================================================
   Plaque
   ========================================================================== */

.plate {
  position: relative;
  background: var(--surface);
  border-radius: var(--r-lg);
  box-shadow:
    inset 0 0 0 1px var(--hairline),
    inset 0 1px 0 0 var(--bevel),
    var(--shadow-2);
  transition:
    box-shadow var(--dur-3) var(--ease),
    transform var(--dur-3) var(--ease),
    background-color var(--dur-3) var(--ease);
}

.plate--flat { box-shadow: inset 0 0 0 1px var(--hairline), inset 0 1px 0 0 var(--bevel); }
.plate--inset { background: var(--surface-inset); box-shadow: inset 0 0 0 1px var(--hairline); }
.plate--pad { padding: var(--s6); }

/* Sur téléphone, `--s6` n'éloigne le contenu des bords sans rien apporter :
   il n'y a pas de place à donner. La règle vit ici et non dans views.css,
   qui n'est pas chargé par les écrans d'exercice. */
@media (max-width: 47.99rem) { .plate--pad { padding: var(--s4); } }
@media (max-width: 22.5rem)  { .plate--pad { padding: var(--s3); } }
.plate--pad-lg { padding: clamp(1.5rem, 1rem + 2vw, 2.5rem); }
.plate--tight { padding: var(--s4); }
/* Surface interactive : la lévitation est minuscule (2 px) et l'ombre fait le
   reste du travail. Au-delà, l'effet devient un jouet. */
/* Pas de `will-change` ici, et c'est délibéré. Cette classe habille des
   listes entières : vingt-trois thèmes dans la bibliothèque, autant d'objets
   à la boutique. Un `will-change` permanent promeut chacun d'eux en calque
   GPU dès l'affichage, qu'on le survole ou non, et sur un téléphone de
   quelques années la mémoire graphique s'épuise avant que l'animation ait
   servi une seule fois. On promeut au survol, c'est-à-dire au moment où le
   navigateur a encore le temps de le faire, et jamais sur un écran tactile
   où le survol n'existe pas. */
.plate--interactive {
  cursor: pointer;
}

@media (hover: hover) {
  /* Le survol prévient le compositeur juste avant qu'il ait à travailler.
     C'est le cycle de vie correct de `will-change` : on l'annonce au dernier
     moment utile, et il retombe de lui-même dès que le curseur s'en va. */
  .plate--interactive:hover { will-change: transform; }

  .plate--interactive:hover {
    transform: translate3d(0, -2px, 0);
    box-shadow:
      inset 0 0 0 1px var(--hairline-strong),
      inset 0 1px 0 0 var(--bevel),
      var(--shadow-3);
  }
}

.plate--interactive:active { transform: scale(0.97); transition-duration: 90ms; }

/* ==========================================================================
   Aura de progression
   --------------------------------------------------------------------------
   S'applique à n'importe quel conteneur portant --progress (0 → 1).
   ========================================================================== */

.aura { position: relative; isolation: isolate; }

.aura::after {
  content: "";
  position: absolute;
  inset: -30%;
  z-index: -1;
  pointer-events: none;
  border-radius: inherit;
  background: radial-gradient(
    closest-side circle at 50% 46%,
    var(--accent) 0%,
    color-mix(in oklab, var(--accent) 40%, transparent) 42%,
    transparent 72%
  );
  opacity: calc(var(--progress, 0) * 0.3);
  filter: blur(calc(14px + var(--progress, 0) * 22px));
  transform: scale(calc(0.72 + var(--progress, 0) * 0.28));
  transition:
    opacity var(--dur-5) var(--ease),
    filter var(--dur-5) var(--ease),
    transform var(--dur-5) var(--ease);
}

.aura--strong::after { opacity: calc(var(--progress, 0) * 0.46); }

/* ==========================================================================
   Boutons
   ========================================================================== */

.btn {
  --btn-h: 2.75rem;
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--s2);
  height: var(--btn-h);
  /* Un bouton ne descend jamais sous sa propre hauteur.
   *
   * `height` ne suffit pas : dans un conteneur flex en colonne dont la
   * hauteur est bornée, un enfant se fait comprimer sur l'axe principal —
   * `flex-shrink` vaut 1 par défaut et `min-height: auto` ne protège que les
   * boîtes à contenu intrinsèque, pas une hauteur déclarée. Le bouton, qui
   * est presque toujours le dernier élément d'une carte, est celui qui cède.
   *
   * Mesuré sur la fenêtre de succès à 316 × 564, neuf médailles : la carte
   * plafonne à sa hauteur maximale et « Continuer » passe de quarante-quatre
   * pixels à vingt-trois. Moitié moins qu'une cible tactile, sur l'unique
   * action de la fenêtre.
   *
   * `min-block-size` reprend la même variable que `height` : chaque variante
   * garde donc sa propre taille — 34 px pour `btn--sm`, 52 pour `btn--lg` —
   * et rien ne change là où le bouton n'était pas comprimé, c'est-à-dire
   * partout ailleurs. C'est ce qui doit céder autour de lui, jamais lui.
   */
  min-block-size: var(--btn-h);
  padding-inline: var(--s5);
  border-radius: var(--r-full);
  font-size: var(--text-md);
  font-weight: 550;
  letter-spacing: -0.006em;
  white-space: nowrap;
  cursor: pointer;
  user-select: none;
  -webkit-tap-highlight-color: transparent;
  transition:
    background-color var(--dur-2) var(--ease),
    color var(--dur-2) var(--ease),
    box-shadow var(--dur-2) var(--ease),
    transform var(--dur-1) var(--ease-soft),
    opacity var(--dur-2) var(--ease);
}

.btn:active:not(:disabled) { transform: scale(0.97); transition-duration: 90ms; }

.btn:disabled,
.btn[aria-disabled="true"] {
  opacity: 0.42;
  cursor: not-allowed;
  transform: none;
}

.btn .icon { flex: 0 0 auto; }

/* Action principale : encre pleine. L'ambre est réservé à ce qui *progresse*,
   pas à ce qui se clique — sinon l'accent perd son sens. */
.btn--primary {
  background: var(--ink);
  color: var(--paper);
  box-shadow: var(--shadow-2);
}

@media (hover: hover) {
  .btn--primary:hover:not(:disabled) {
    background: color-mix(in oklab, var(--ink) 88%, var(--accent));
    box-shadow: var(--shadow-3);
  }
}

/* Action lumineuse : l'unique bouton ambre de l'écran — « apprendre maintenant ». */
.btn--lumen {
  background: var(--accent);
  color: #1a1409;
  box-shadow:
    inset 0 1px 0 0 rgba(255, 255, 255, 0.28),
    0 2px 10px -2px color-mix(in oklab, var(--accent) 55%, transparent),
    var(--shadow-2);
}

@media (hover: hover) {
  .btn--lumen:hover:not(:disabled) {
    background: var(--accent-bright);
    box-shadow:
      inset 0 1px 0 0 rgba(255, 255, 255, 0.34),
      0 4px 22px -4px color-mix(in oklab, var(--accent) 70%, transparent),
      var(--shadow-3);
  }
}

.btn--outline {
  background: var(--surface);
  color: var(--ink);
  box-shadow: inset 0 0 0 1px var(--hairline-strong), inset 0 1px 0 0 var(--bevel), var(--shadow-1);
}

@media (hover: hover) {
  .btn--outline:hover:not(:disabled) {
    background: var(--surface-2);
    box-shadow: inset 0 0 0 1px var(--hairline-strong), var(--shadow-2);
  }
}

.btn--ghost { background: transparent; color: var(--ink-2); }

@media (hover: hover) {
  .btn--ghost:hover:not(:disabled) { background: var(--surface-2); color: var(--ink); }
}

.btn--quiet {
  background: var(--surface-2);
  color: var(--ink);
}

@media (hover: hover) {
  .btn--quiet:hover:not(:disabled) { background: var(--surface-3); }
}

.btn--danger { background: var(--wrong-wash); color: var(--wrong-ink); }

@media (hover: hover) {
  .btn--danger:hover:not(:disabled) { background: var(--wrong); color: var(--paper); }
}

.btn--sm { --btn-h: 2.125rem; padding-inline: var(--s4); font-size: var(--text-sm); }
.btn--lg { --btn-h: 3.25rem; padding-inline: var(--s7); font-size: var(--text-lg); }
.btn--block { width: 100%; }

.btn--icon {
  width: var(--btn-h);
  padding-inline: 0;
  border-radius: var(--r-full);
}

/* Le libellé glisse pour laisser entrer la flèche : micro-interaction de la
   carte-action principale. */
.btn__arrow {
  transition: transform var(--dur-3) var(--ease);
}

@media (hover: hover) {
  .btn:hover .btn__arrow { transform: translateX(3px); }
}

/* ==========================================================================
   Icônes
   ========================================================================== */

.icon {
  width: 1.25em;
  height: 1.25em;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-linejoin: round;
  flex: none;
}

.icon--sm { width: 1em; height: 1em; }
.icon--lg { width: 1.75em; height: 1.75em; stroke-width: 1.4; }
.icon--xl { width: 2.5rem; height: 2.5rem; stroke-width: 1.2; }

/* ==========================================================================
   Puces, étiquettes, jetons
   ========================================================================== */

.chip {
  display: inline-flex;
  align-items: center;
  gap: var(--s2);
  height: 1.875rem;
  padding-inline: var(--s3);
  border-radius: var(--r-full);
  background: var(--surface-2);
  color: var(--ink-2);
  font-size: var(--text-xs);
  font-weight: 550;
  letter-spacing: -0.004em;
  white-space: nowrap;
}

.chip--accent {
  background: var(--accent-wash);
  color: var(--accent-ink);
}

.chip--outline {
  background: transparent;
  box-shadow: inset 0 0 0 1px var(--hairline);
}

/* Filtre cliquable du catalogue.
 *
 * Un fond, plus un contour. La barre de la bibliothèque en aligne douze —
 * quatre catégories, trois niveaux, cinq tris — sur deux rangées, et chacun
 * portait son trait. Douze rectangles cerclés côte à côte au-dessus d'une
 * grille de cartes elles-mêmes cerclées : c'est beaucoup de lignes pour dire
 * « ceci est cliquable », et c'est exactement ce qui donne à un écran l'air
 * d'avoir été rempli par un gabarit.
 *
 * Un aplat très légèrement plus clair que la page dit la même chose sans
 * ajouter un seul trait. Et il rend le choix *plus* lisible, pas moins : le
 * filtre actif est un aplat d'encre pleine, et un aplat au milieu d'aplats
 * ressort davantage qu'un aplat au milieu de contours. La hiérarchie vient de
 * la différence, jamais de l'accumulation. */
.chip--toggle {
  cursor: pointer;
  height: 2.25rem;
  padding-inline: var(--s4);
  background: var(--surface-2);
  box-shadow: none;
  transition:
    background-color var(--dur-2) var(--ease),
    box-shadow var(--dur-2) var(--ease),
    color var(--dur-2) var(--ease);
}

@media (hover: hover) {
  /* Le survol éclaircit d'un cran de plus : c'est le seul retour dont il a
     besoin maintenant que le repos est déjà un aplat. */
  .chip--toggle:hover { background: var(--surface-3, var(--surface)); color: var(--ink); }
}

.chip--toggle[aria-pressed="true"] {
  background: var(--ink);
  color: var(--paper);
  box-shadow: none;
}

.tag {
  font-size: var(--text-2xs);
  font-weight: 600;
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--ink-3);
}

/* Point de niveau : trois traits croissants, plus lisible qu'un mot. */
.level-dots {
  display: inline-flex;
  align-items: flex-end;
  gap: 2px;
  height: 0.75rem;
}

.level-dots span {
  width: 3px;
  border-radius: 1px;
  background: var(--ink-4);
  transition: background-color var(--dur-2) var(--ease);
}

.level-dots span:nth-child(1) { height: 40%; }
.level-dots span:nth-child(2) { height: 70%; }
.level-dots span:nth-child(3) { height: 100%; }
.level-dots[data-level="1"] span:nth-child(1),
.level-dots[data-level="2"] span:nth-child(-n+2),
.level-dots[data-level="3"] span { background: var(--accent); }

/* ==========================================================================
   Progression : barre, anneau, jauge segmentée
   ========================================================================== */

.bar {
  position: relative;
  height: 6px;
  border-radius: var(--r-full);
  background: color-mix(in oklab, var(--ink) 8%, transparent);
  overflow: hidden;
}

.bar__fill {
  position: absolute;
  inset-block: 0;
  inset-inline-start: 0;
  /* `scaleX` et non `width`.

     Le rendu est identique au pixel : un dégradé posé sur un élément large de
     la moitié, ou le même dégradé comprimé de moitié, donnent la même image.
     Ce qui change est le coût — animer `width` demande au navigateur de
     refaire la mise en page à chaque image, animer une transformation ne
     touche qu'à la composition. Cette barre bouge à chaque réponse, soit des
     centaines de fois par séance. */
  width: 100%;
  transform: scaleX(var(--progress, 0));
  transform-origin: left center;
  border-radius: inherit;
  background: linear-gradient(90deg, var(--accent-deep), var(--accent-bright));
  box-shadow: 0 0 12px -2px color-mix(in oklab, var(--accent) 60%, transparent);
  transition: transform var(--dur-5) var(--ease);
}

.bar--thin { height: 3px; }
.bar--ink .bar__fill { background: var(--ink); box-shadow: none; }

/* Anneau : le compteur de maîtrise. Le trait se remplit dans le sens horaire
   depuis midi, et la lueur suit la valeur. */
.ring {
  --size: 3.5rem;
  position: relative;
  width: var(--size);
  height: var(--size);
  flex: none;
}

/* `overflow: visible`, et c'est la correction d'un défaut qu'on voyait sans
 * savoir le nommer.
 *
 * `.ring__value` porte un `drop-shadow` : la lueur qui suit l'arc. Un SVG
 * rogne par défaut tout ce qui dépasse de sa fenêtre — et l'arc, à
 * `stroke-width: 3` sur un `viewBox` de 36, touche déjà le bord. La lueur
 * était donc coupée net aux quatre points cardinaux, ce qui, à l'œil, ne
 * donne pas un halo tronqué : cela donne **un carré lumineux autour du
 * cercle**. On croyait à une ombre carrée dessinée exprès ; c'était le
 * cadre du dessin qui découpait une ombre ronde.
 *
 * Un mot sur le risque : `overflow: visible` laisse sortir tout ce que le
 * SVG contient. Ici il ne contient que deux cercles concentriques inscrits
 * dans la fenêtre — rien qui puisse déborder, hormis précisément la lueur
 * qu'on veut voir en entier. */
.ring svg { width: 100%; height: 100%; transform: rotate(-90deg); overflow: visible; }

.ring__track {
  fill: none;
  stroke: color-mix(in oklab, var(--ink) 9%, transparent);
  stroke-width: var(--ring-w, 3.5);
}

.ring__value {
  fill: none;
  stroke: var(--accent);
  stroke-width: var(--ring-w, 3.5);
  stroke-linecap: round;
  stroke-dasharray: var(--circumference, 100);
  stroke-dashoffset: calc(var(--circumference, 100) * (1 - var(--progress, 0)));
  filter: drop-shadow(0 0 4px color-mix(in oklab, var(--accent) 55%, transparent));
  transition: stroke-dashoffset var(--dur-5) var(--ease);
}

.ring__label {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  font-weight: 600;
  letter-spacing: -0.03em;
  color: var(--ink);
}

.ring--lg { --size: 7.5rem; --ring-w: 3; }
.ring--lg .ring__label { font-size: var(--text-xl); }
.ring--sm { --size: 2.25rem; --ring-w: 5; }
.ring--sm .ring__label { font-size: var(--text-2xs); }

/* Jauge segmentée : une case par connaissance. Sert quand le nombre d'items
   est petit — on lit alors « 7 sur 12 » sans aucun chiffre. */
.meter {
  display: flex;
  gap: 3px;
  align-items: stretch;
  height: 8px;
}

.meter__cell {
  flex: 1 1 0;
  border-radius: 2px;
  background: color-mix(in oklab, var(--ink) 8%, transparent);
  transition: background-color var(--dur-3) var(--ease), box-shadow var(--dur-3) var(--ease);
}

.meter__cell.is-on {
  background: var(--accent);
  box-shadow: 0 0 8px -2px color-mix(in oklab, var(--accent) 70%, transparent);
}

.meter__cell.is-wrong { background: var(--wrong); }

/* ==========================================================================
   Couverture de thème
   ========================================================================== */

.cover {
  position: relative;
  aspect-ratio: 4 / 3;
  overflow: hidden;
  border-radius: var(--r-md);
  background: var(--cover-ink);
  isolation: isolate;
  /* Le dessin reste toujours lisible — une couverture illisible n'est pas une
     couverture. C'est la LUEUR, elle seule, qui traduit la maîtrise. */
  --cover-lift: calc(0.72 + var(--progress, 0) * 0.28);
}

.cover__art {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  opacity: var(--cover-lift);
  transition: opacity var(--dur-5) var(--ease), transform var(--dur-5) var(--ease);
}

/* De graphite froid (0 %) à halo chaud (100 %). */
.cover__halo {
  opacity: calc(0.06 + var(--progress, 0) * 0.9);
  transition: opacity var(--dur-5) var(--ease);
}

.cover__photo {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: calc(0.55 + var(--progress, 0) * 0.45);
  transition: opacity var(--dur-5) var(--ease), transform var(--dur-5) var(--ease);
}

.cover__lines { transform-origin: center; }

/* Vignette de liste : dans une rangée flex, une couverture sans largeur
   explicite se réduit à zéro. */
.cover--thumb {
  width: var(--thumb, 3.25rem);
  flex: none;
}

.cover--square { aspect-ratio: 1; }

@media (hover: hover) {
  .plate--interactive:hover .cover__art,
  .plate--interactive:hover .cover__photo {
    transform: scale(1.045);
    opacity: calc(var(--cover-lift) + 0.1);
  }
}

/* ==========================================================================
   Carte de thème
   ========================================================================== */

/* La bibliothèque compte vingt-trois thèmes, chacun coiffé d'une couverture
   SVG dessinée à la main. Sur un téléphone, la page fait douze mille pixels de
   haut et une seule carte est visible au chargement : les vingt-deux autres
   étaient malgré tout mises en page et peintes, avec leurs 91 <svg> et leurs
   152 <path>. Un recalcul complet de style et de mise en page coûtait 35,8 ms
   sur un ordinateur de bureau, donc plusieurs images perdues sur le téléphone
   de quelques années qu'on cherche à ménager.

   `content-visibility: auto` laisse le navigateur ignorer tout ce qui est hors
   de l'écran jusqu'à ce qu'on s'en approche. Même mesure après : 3,6 ms.

   La taille intrinsèque n'est pas décorative : sans elle, une carte non rendue
   compterait pour zéro et la barre de défilement mentirait. 436 px est la
   hauteur moyenne mesurée d'une carte en colonne unique, et le mot-clé `auto`
   fait retenir au navigateur la hauteur réelle de chaque carte dès qu'il l'a
   vue une fois — l'estimation ne sert donc qu'au tout premier affichage. */
/* La couverture est la face de la carte, pas un tableau accroché dedans.
 *
 * La carte avait douze pixels de rembourrage sur ses quatre côtés, et
 * l'illustration à l'intérieur portait son propre rayon : un cadre dans un
 * cadre, répété vingt-trois fois sur la grille de la bibliothèque. C'est la
 * signature la plus reconnaissable d'une interface assemblée plutôt que
 * dessinée — chaque élément reçoit sa boîte, et les boîtes s'emboîtent.
 *
 * L'illustration touche maintenant les bords et épouse les coins hauts de la
 * carte. Une seule surface, un seul contour. Le texte garde son rembourrage :
 * c'est lui qui a besoin d'une marge, pas l'image. */
.theme-card {
  display: flex;
  flex-direction: column;
  overflow: hidden;
  border-radius: var(--r-lg);
  padding: 0;
  content-visibility: auto;
  contain-intrinsic-size: auto 436px;
}

/* Les coins hauts seulement : la carte est déjà `overflow: hidden`, mais un
   rayon explicite évite que l'image ne dépasse d'un pixel pendant qu'elle
   grandit au survol. */
.theme-card__cover { border-radius: var(--r-lg) var(--r-lg) 0 0; }

/* Le rembourrage passe de la carte au texte : c'est le texte qui a besoin
   d'une marge, l'image a besoin du bord. */
.theme-card__body {
  display: flex;
  flex-direction: column;
  gap: var(--s2);
  padding: var(--s5) var(--s5) var(--s4);
  flex: 1 1 auto;
}

.theme-card__foot { padding-inline: var(--s5); padding-block-end: var(--s5); }

/* L'état par défaut d'une carte : une mention, pas une pastille.
   Voir le commentaire du gabarit — un signe posé sur toutes les cartes cesse
   d'être un signe. Il garde la place et la ligne de base de la puce qu'il
   remplace, si bien que les cartes signalées et les autres restent alignées. */
.theme-card__etat {
  display: inline-flex;
  align-items: center;
  min-block-size: 1.875rem;
  font-size: var(--text-xs);
  font-weight: 550;
  color: var(--ink-3);
}

.theme-card__title {
  font-family: var(--font-display);
  font-size: var(--text-xl);
  font-weight: 400;
  letter-spacing: var(--track-tight);
  line-height: 1.2;
}

.theme-card__meta {
  display: flex;
  align-items: center;
  gap: var(--s2);
  font-size: var(--text-xs);
  color: var(--ink-3);
}

.theme-card__meta > * + *::before {
  content: "·";
  margin-inline-end: var(--s2);
  color: var(--ink-4);
}

.theme-card__foot {
  display: flex;
  align-items: center;
  gap: var(--s3);
  margin-block-start: auto;
  padding-block-start: var(--s3);
}

/* Variante horizontale, utilisée par « Continuer ». */
.theme-card--row {
  flex-direction: row;
  align-items: stretch;
  gap: var(--s4);
}

.theme-card--row .theme-card__cover {
  width: clamp(6rem, 18vw, 9.5rem);
  aspect-ratio: 1;
  flex: none;
}

.theme-card--row .theme-card__body { padding: var(--s2) var(--s3) var(--s2) 0; }

/* — Variante compacte : une vignette, un titre ————————————————————
   Utilisée là où l'on reconnaît un thème au lieu de s'informer sur lui —
   le sélecteur de défi. La vignette carrée et le titre court en font une
   tuile de bibliothèque de jeux : on en voit plusieurs d'un coup d'œil,
   ce qui est précisément ce qu'une carte complète empêchait.

   `contain-intrinsic-size` est ramené à la hauteur réelle de la tuile. La
   valeur héritée — 436 px, mesurée sur la carte complète — aurait fait
   mentir la barre de défilement d'un facteur trois avant que les tuiles
   soient rendues. */
/* Les deux sélecteurs sont volontairement redondants — `.theme-card` répété,
   `.cover` ajouté — et ce n'est pas de la superstition.
 *
 * La carte de thème est habillée à trois endroits : ici, dans la requête de
 * conteneur `fiche` de views.css, et dans le bloc mobile de la même feuille.
 * `views.css` est chargée après celle-ci ; à spécificité égale, elle gagne.
 * Deux de ses règles reprenaient donc la main sur la variante compacte :
 * `aspect-ratio: 3/2` sur la couverture, et surtout
 * `contain-intrinsic-size: auto 321px`, qui faisait déclarer 337 pixels de
 * haut à une tuile qui n'en occupe que 146 — la barre de défilement mentait
 * d'un facteur deux et la grille réservait la place de l'ancienne carte.
 *
 * Un cran de spécificité suffit, et vaut mieux qu'un `!important` : la
 * variante reste surchargeable par un contexte plus précis. */
.theme-card.theme-card--compact {
  padding: 0;
  gap: 0;
  overflow: hidden;
  /* `content-visibility: auto` est neutralisé ici.

     Il est hérité de `.theme-card`, où il évite de peindre vingt-quatre
     cartes de 436 px hors écran. Sur la tuile compacte il produisait
     l'inverse de ce qu'on lui demande : le titre est en position absolue
     sur l'image, donc hors du flux que `contain: size` mesure, et une tuile
     dont le rendu est sauté puis repris apparaissait vide ou sans titre le
     temps d'un cadre. Une tuile de 160 px ne coûte rien à peindre. */
  /* Aucune optimisation de rendu sur cette tuile, et c'est définitif.

     `content-visibility: auto` est hérité de `.theme-card`, où il évite de
     peindre vingt-quatre cartes de 436 px hors écran. Sur la tuile compacte
     il produit l'inverse : le titre est en position absolue sur l'image,
     donc hors du flux que `contain` mesure, et une tuile dont le rendu est
     sauté puis repris apparaît vide le temps d'un cadre. `contain` est remis
     à zéro avec lui — le laisser sans `content-visibility` isolerait encore
     la mise en page de la tuile de celle de son titre flottant. */
  content-visibility: visible;
  contain-intrinsic-size: auto none;
  contain: none;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--hairline), var(--shadow-1);
  transition:
    transform var(--dur-3) var(--ease-spring),
    box-shadow var(--dur-3) var(--ease);
}

/* La vignette occupe toute la largeur, sans marge : l'image est le sujet,
   pas une illustration posée dans une carte. */
/* Le même cadrage que la bibliothèque, et pas un carré.
 *
 * Les couvertures d'Omnia sont dessinées en 400 × 300 et posées en
 * `preserveAspectRatio="…slice"` : elles remplissent leur boîte et ce qui
 * dépasse est coupé. Dans la bibliothèque la boîte est en 3/2, soit un
 * rognage discret en haut et en bas. Ici elle était carrée : le dessin,
 * quatre tiers plus large que haut, devait être agrandi d'un tiers pour
 * couvrir la hauteur, et l'on perdait un quart de l'image de chaque côté.
 * D'où des vignettes visiblement plus zoomées que partout ailleurs, sur
 * lesquelles on ne reconnaissait plus le thème — ce qui est précisément le
 * seul travail de cette tuile.
 *
 * `3 / 2` la remet au cadrage du produit. La tuile y gagne aussi en
 * hauteur : à largeur égale, un carré coûtait trente pixels de plus par
 * rangée pour montrer moins. */
.theme-card--compact .theme-card__cover.cover {
  aspect-ratio: 3 / 2;
  width: 100%;
  border-radius: 0;
  transition: transform var(--dur-5) var(--ease-soft);
}

/* Le titre est posé *sur* l'image, en bas, sous un dégradé. On gagne la
   hauteur du bandeau et l'image respire sur toute la tuile. */
.theme-card--compact .theme-card__body {
  position: absolute;
  inset-block-end: 0;
  inset-inline: 0;
  z-index: 2;
  /* Le voile part de plus bas depuis que la tuile est en 3/2. Il montait à
     trente-deux pixels du bord haut, ce qui, sur une vignette de soixante-
     douze, couvrait l'illustration presque entière : on choisissait un
     thème sur une liste de titres blancs, et le dessin ne servait plus à
     rien. Seize suffisent à décoller le texte du fond. */
  /* Le titre respire enfin dans sa vignette.
   *
   * Douze pixels sur les côtés et huit sous la ligne de base : sur une tuile
   * de cent soixante pixels, un titre de deux lignes venait toucher les trois
   * bords à la fois. Un texte posé sur une image a besoin de plus de marge
   * qu'un texte sur un aplat, pas de moins — c'est ce vide qui le décolle du
   * dessin et le rend lisible. Le voile, lui, part toujours d'aussi bas :
   * c'est le rembourrage qui change, pas la surface assombrie. */
  padding: var(--s5) var(--s4) var(--s3);
  gap: 0;
  flex: 0 0 auto;
  background: linear-gradient(to top,
    rgba(8, 7, 5, 0.94) 0%,
    rgba(8, 7, 5, 0.66) 55%,
    rgba(8, 7, 5, 0) 100%);
  pointer-events: none;
}

.theme-card--compact .theme-card__title { color: #fff; text-shadow: 0 1px 3px rgba(0,0,0,.5); }

/* La tuile garde une hauteur même si la couverture ne se dessine pas : sans
   cela, une carte dont l'illustration échoue s'effondre à la hauteur du seul
   titre et disparaît de la grille.
 *
 * Quatre rem suffisent, et non huit et demie. La valeur haute datait de la
 * couverture carrée : depuis qu'elle est en 3/2, elle laissait soixante et
 * un pixels de surface vide sous l'image, sur lesquels tombait le titre —
 * censé être posé *sur* l'illustration. C'est `aspect-ratio` qui donne
 * désormais sa hauteur à la tuile ; cette ligne n'est plus qu'un filet. */
.theme-card--compact { min-block-size: 4rem; }
.theme-card--compact .theme-card__cover.cover { background: var(--surface-2); }

.theme-card--compact { position: relative; }

@media (hover: hover) {
  .theme-card--compact:hover { transform: translate3d(0, -3px, 0); box-shadow: inset 0 0 0 1px var(--hairline-strong), var(--shadow-3, var(--shadow-2)); }
  /* `will-change` posé au survol seulement : le déclarer en permanence sur
     vingt-quatre tuiles réserverait vingt-quatre couches de composition pour
     un effet qui ne joue qu'une fois. */
  .theme-card--compact:hover .theme-card__cover.cover { transform: scale(1.07); will-change: transform; }
}

.theme-card--compact:active { transform: scale(0.97); transition-duration: 90ms; }

@media (prefers-reduced-motion: reduce) {
  .theme-card--compact,
  .theme-card--compact:hover,
  .theme-card--compact:active { transform: none; }
  .theme-card--compact .theme-card__cover.cover,
  .theme-card--compact:hover .theme-card__cover.cover { transform: none; }
}

.theme-card--compact .theme-card__title {
  font-size: var(--text-sm);
  font-weight: 600;
  font-family: var(--font-ui);
  line-height: 1.3;
  letter-spacing: var(--track-normal);
}

/* ==========================================================================
   Avatar
   ========================================================================== */

.avatar {
  position: relative;
  display: grid;
  place-items: center;
  width: var(--size, 2.5rem);
  height: var(--size, 2.5rem);
  border-radius: var(--r-full);
  background: var(--surface-2);
  color: var(--ink-3);
  box-shadow: inset 0 0 0 1px var(--hairline);
  overflow: hidden;
  flex: none;
}

.avatar__art { width: 100%; height: 100%; }
.avatar--lg { --size: 5rem; }
.avatar--xl { --size: 7rem; }
.avatar--sm { --size: 2rem; }

/* ==========================================================================
   Médaille de succès
   ========================================================================== */

.medal {
  --medal-size: 3.25rem;
  position: relative;
  display: grid;
  place-items: center;
  width: var(--medal-size);
  height: var(--medal-size);
  flex: none;
  border-radius: var(--r-full);
  background: var(--surface-2);
  color: var(--ink-4);
  box-shadow: inset 0 0 0 1px var(--hairline);
  transition: color var(--dur-3) var(--ease), box-shadow var(--dur-3) var(--ease);
}

.medal .icon { width: 45%; height: 45%; stroke-width: 1.5; }

.medal.is-unlocked {
  color: var(--accent);
  background: var(--accent-wash);
  box-shadow:
    inset 0 0 0 1px var(--accent-line),
    0 0 16px -4px color-mix(in oklab, var(--accent) 60%, transparent);
}

.medal[data-tier="argent"].is-unlocked { --accent-c: 0.03; --accent-l: 72%; }
.medal[data-tier="or"].is-unlocked {
  box-shadow:
    inset 0 0 0 1px var(--accent),
    0 0 26px -4px color-mix(in oklab, var(--accent) 75%, transparent);
}

.medal--lg { --medal-size: 4.5rem; }

/* Anneau de progression du succès, dessiné autour de la médaille. */
.medal__ring {
  position: absolute;
  inset: -5px;
  pointer-events: none;
}

.medal__ring circle {
  fill: none;
  stroke: var(--accent-line);
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-dasharray: var(--circumference, 100);
  stroke-dashoffset: calc(var(--circumference, 100) * (1 - var(--progress, 0)));
  transform: rotate(-90deg);
  transform-origin: center;
}

/* ==========================================================================
   Tuile de statistique
   ========================================================================== */

.stat {
  display: flex;
  flex-direction: column;
  gap: var(--s1);
  padding: var(--s5);
  border-radius: var(--r-lg);
}

/* Le porte-icône d'une tuile.

   Les trois copies de cette tuile portaient `class="row row--tight faint"`
   plus un `font-size` en attribut — quatre décisions de dessin réparties
   entre une classe de disposition, une classe de couleur et un style en
   ligne, pour poser une icône. Une seule règle nommée fait la même chose et
   se retrouve. */
.stat__mark {
  display: inline-flex;
  align-items: center;
  color: var(--ink-3);
}

.stat__value {
  font-family: var(--font-display);
  font-size: clamp(1.5rem, 1.2rem + 1.2vw, 2.125rem);
  line-height: 1;
  letter-spacing: var(--track-display);
  font-variant-numeric: tabular-nums;
}

.stat__label {
  font-size: var(--text-xs);
  color: var(--ink-3);
}

.stat__unit {
  font-family: var(--font-ui);
  font-size: var(--text-md);
  color: var(--ink-3);
  letter-spacing: 0;
}

/* ==========================================================================
   Champs de formulaire
   ========================================================================== */

.field { display: flex; flex-direction: column; gap: var(--s2); }

.field__label {
  font-size: var(--text-xs);
  font-weight: 600;
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--ink-3);
}

.field__hint { font-size: var(--text-xs); color: var(--ink-3); }
.field__error { font-size: var(--text-xs); color: var(--wrong); }

.input,
.textarea,
.select {
  width: 100%;
  min-height: 2.875rem;
  padding: var(--s3) var(--s4);
  border: 0;
  border-radius: var(--r-md);
  background: var(--surface-inset);
  color: var(--ink);
  font-size: var(--text-base);
  box-shadow: inset 0 0 0 1px var(--hairline);
  transition: box-shadow var(--dur-2) var(--ease), background-color var(--dur-2) var(--ease);
}

.input::placeholder,
.textarea::placeholder { color: var(--ink-4); }

.input:hover,
.textarea:hover,
.select:hover { box-shadow: inset 0 0 0 1px var(--hairline-strong); }

.input:focus,
.textarea:focus,
.select:focus {
  outline: none;
  background: var(--surface);
  box-shadow:
    inset 0 0 0 1px var(--accent),
    0 0 0 4px var(--accent-wash);
}

.textarea {
  min-height: 7rem;
  line-height: var(--leading-body);
  resize: vertical;
}

.textarea--code {
  font-family: var(--font-mono);
  font-size: var(--text-sm);
  min-height: 14rem;
}

.select {
  appearance: none;
  padding-inline-end: var(--s8);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23978f7f' stroke-width='1.6' stroke-linecap='round'%3E%3Cpath d='M4 6.5 8 10.5 12 6.5'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right var(--s4) center;
  background-size: 1rem;
  cursor: pointer;
}

.field--invalid .input,
.field--invalid .textarea,
.field--invalid .select { box-shadow: inset 0 0 0 1px var(--wrong); }

/* Champ de recherche avec icône intégrée. */
.search {
  position: relative;
  display: flex;
  align-items: center;
}

.search .icon {
  position: absolute;
  inset-inline-start: var(--s4);
  color: var(--ink-3);
  pointer-events: none;
}

.search .input {
  padding-inline-start: calc(var(--s4) * 2 + 1rem);
  height: 3rem;
  border-radius: var(--r-full);
}

.search__clear {
  position: absolute;
  inset-inline-end: var(--s2);
  display: grid;
  place-items: center;
  width: 2rem;
  height: 2rem;
  border-radius: var(--r-full);
  color: var(--ink-3);
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--dur-2) var(--ease), background-color var(--dur-2) var(--ease);
}

.search.has-value .search__clear { opacity: 1; pointer-events: auto; }
.search__clear:hover { background: var(--surface-2); color: var(--ink); }

/* Bascule à deux ou trois positions. */
.segmented {
  display: inline-flex;
  padding: 3px;
  gap: 2px;
  border-radius: var(--r-full);
  background: var(--surface-2);
  box-shadow: inset 0 0 0 1px var(--hairline);
}

/* Le tri de la bibliothèque compte cinq entrées et mesure 459 pixels. Sur un
   téléphone de 390, il en dépassait donc largement, et comme `body` porte
   `overflow-x: hidden`, ce dépassement n'était pas seulement invisible : il
   était inatteignable. La dernière option de tri ne pouvait pas être
   sélectionnée du tout. Ce n'était pas un défaut d'aspect mais une commande
   absente.

   La barre défile donc latéralement, comme une barre d'onglets d'application
   native, avec accroche pour s'arrêter sur une option entière. La barre de
   défilement est masquée : la rangée coupée par le bord suffit à annoncer la
   suite.

   Deux précisions sur la portée de la règle.

   Elle ne vise que le tri de la bibliothèque, et non toutes les barres
   segmentées. Celles des réglages mesurent 145, 143 et 282 pixels : elles
   tiennent partout, jusqu'à 320. Leur appliquer le même traitement les faisait
   se resserrer et défiler sans raison, puisque `min-width: 0` les autorisait
   alors à devenir plus étroites que leur contenu là où rien ne l'exigeait.

   `min-width: 0` est indispensable ici, en revanche, et sur la rangée autant
   que sur la barre : un élément flexible vaut `min-width: auto` par défaut, ce
   qui lui interdit de devenir plus étroit que son contenu. La barre restait
   donc à 459 pixels quoi qu'on lui demande, et son `overflow-x` ne se
   déclenchait jamais faute d'être jamais trop petite. */
@media (max-width: 47.99rem) {
  .library__controls .row:has(> .segmented) { min-width: 0; }

  .library__controls .segmented {
    display: flex;
    min-width: 0;
    max-width: 100%;
    overflow-x: auto;
    scrollbar-width: none;
    scroll-snap-type: x proximity;
    overscroll-behavior-x: contain;
  }

  .library__controls .segmented::-webkit-scrollbar { display: none; }
  .library__controls .segmented__option { flex: none; scroll-snap-align: center; }
}

.segmented__option {
  padding: var(--s2) var(--s4);
  border-radius: var(--r-full);
  font-size: var(--text-sm);
  font-weight: 550;
  color: var(--ink-2);
  cursor: pointer;
  /* Un libellé ne se coupe jamais en deux. Sans cela, « Pour vous » passait à
     la ligne et « A → Z » sur trois, ce qui portait la barre entière de 37 à
     79 pixels de haut pour cinq mots. */
  white-space: nowrap;
  transition: background-color var(--dur-2) var(--ease), color var(--dur-2) var(--ease);
}

.segmented__option[aria-checked="true"],
.segmented__option[aria-pressed="true"] {
  background: var(--surface);
  color: var(--ink);
  box-shadow: var(--shadow-1);
}

.switch {
  position: relative;
  width: 2.875rem;
  height: 1.625rem;
  flex: none;
  border-radius: var(--r-full);
  background: var(--surface-3);
  box-shadow: inset 0 0 0 1px var(--hairline);
  cursor: pointer;
  transition: background-color var(--dur-3) var(--ease);
}

.switch::after {
  content: "";
  position: absolute;
  inset-block-start: 3px;
  inset-inline-start: 3px;
  width: 1.125rem;
  height: 1.125rem;
  border-radius: var(--r-full);
  background: var(--surface);
  box-shadow: var(--shadow-1);
  transition: transform var(--dur-3) var(--ease-spring);
}

.switch[aria-checked="true"] { background: var(--accent); }
.switch[aria-checked="true"]::after { transform: translateX(1.25rem); }

.kbd {
  display: inline-grid;
  place-items: center;
  min-width: 1.5rem;
  height: 1.5rem;
  padding-inline: var(--s2);
  border-radius: var(--r-xs);
  background: var(--surface-2);
  box-shadow: inset 0 0 0 1px var(--hairline), 0 1px 0 var(--hairline);
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  color: var(--ink-2);
}

/* ==========================================================================
   Navigation
   ========================================================================== */

.topbar {
  position: sticky;
  inset-block-start: 0;
  z-index: var(--z-nav);
  background: color-mix(in oklab, var(--paper) 82%, transparent);
  backdrop-filter: saturate(180%) blur(20px);
  -webkit-backdrop-filter: saturate(180%) blur(20px);
  border-block-end: 1px solid transparent;
  transition: border-color var(--dur-3) var(--ease), background-color var(--dur-3) var(--ease);
}

.topbar.is-stuck { border-block-end-color: var(--hairline); }

.topbar__inner {
  display: flex;
  align-items: center;
  gap: var(--s5);
  height: 4rem;
}

.brand {
  display: inline-flex;
  align-items: center;
  gap: var(--s3);
  font-family: var(--font-display);
  font-size: var(--text-xl);
  letter-spacing: -0.02em;
}

/* La marque hérite du gabarit des icônes ; seules la taille et la couleur
   changent. Le trait vient du sprite, la teinte du contexte. */
.brand__mark {
  width: 1.75rem;
  height: 1.75rem;
  flex: none;
  color: var(--accent);
  stroke-width: 1.5;
}

.nav {
  display: flex;
  align-items: center;
  gap: var(--s1);
}

.nav__link {
  position: relative;
  /* Le libellé et sa pastille sur une seule ligne, toujours.

     Le lien était un bloc et la pastille une puce détournée : à trois
     chiffres — « Révisions 131 » — elle passait à la ligne, le lien montait
     à soixante et un pixels contre trente-neuf pour ses voisins, et toute la
     barre se déformait autour de lui. Un élément de navigation dont la
     hauteur dépend du nombre qu'il affiche n'est pas un élément stable. */
  display: inline-flex;
  align-items: center;
  gap: var(--s2);
  white-space: nowrap;
  padding: var(--s2) var(--s4);
  border-radius: var(--r-full);
  font-size: var(--text-md);
  font-weight: 500;
  color: var(--ink-2);
  transition: color var(--dur-2) var(--ease), background-color var(--dur-2) var(--ease);
}

@media (hover: hover) {
  .nav__link:hover { color: var(--ink); background: var(--surface-2); }
}

.nav__link[aria-current="page"] { color: var(--ink); }

/* La pastille de la navigation.

   Elle n'emprunte plus `.chip`, qu'il fallait corriger de trois valeurs en
   ligne pour l'y faire tenir. Une puce sert à porter un mot dans une liste
   de filtres ; un compteur de barre a d'autres contraintes — une hauteur
   fixe qui ne dépend pas du nombre de chiffres, et des chiffres tabulaires
   pour que la largeur ne saute pas entre 9 et 10. */
.nav__badge {
  display: inline-grid;
  place-items: center;
  min-width: 1.25rem;
  height: 1.25rem;
  padding-inline: 0.375rem;
  border-radius: var(--r-full);
  background: var(--accent-wash);
  color: var(--accent-ink);
  font-size: var(--text-2xs);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  line-height: 1;
}

/* Le repère de page active : un point ambre, pas un fond ni un soulignement. */
.nav__link[aria-current="page"]::after {
  content: "";
  position: absolute;
  inset-block-end: 0.1rem;
  inset-inline: 0;
  margin-inline: auto;
  width: 4px;
  height: 4px;
  border-radius: var(--r-full);
  background: var(--accent);
  box-shadow: 0 0 8px var(--accent);
}

/* Barre d'onglets mobile : les destinations principales, à portée de pouce.

   Elle flotte au lieu d'être soudée au bas de l'écran. Ce n'est pas un
   ornement : une barre collée au bord se confond avec le châssis du téléphone
   et avec la barre de gestes du système, si bien qu'on ne sait plus ce qui
   appartient à l'application. Détachée de quelques pixels, arrondie, elle
   redevient un objet posé sur le contenu — ce que toutes les applications
   natives récentes ont adopté, et pour cette raison.

   Le verre dépoli reste possible parce que la surface a diminué : une barre
   flottante floute deux fois moins de pixels qu'une bande pleine largeur, et
   le budget de composition établi dans base.css continue de s'appliquer
   (débranchement sous `prefers-reduced-transparency` et sur appareil modeste). */
.tabbar {
  position: fixed;
  inset-block-end: calc(var(--s3) + env(safe-area-inset-bottom));
  inset-inline: calc(var(--s4) + env(safe-area-inset-left))
                calc(var(--s4) + env(safe-area-inset-right));
  z-index: var(--z-nav);
  /* Une grille explicite, et non `grid-auto-columns` : la pastille est un
     enfant de la barre, et en flux automatique elle occuperait une colonne à
     elle seule, décalant les cinq onglets. Elle est donc posée dans la même
     cellule que le premier, en position absolue. */
  display: grid;
  grid-template-columns: repeat(var(--onglets, 5), minmax(0, 1fr));
  padding: var(--s2);
  border-radius: var(--r-2xl);
  /* Le verre : peu de fond, beaucoup de flou, une saturation forte. C'est ce
     rapport qui donne la matière iOS : le fond doit rester lisible à travers,
     pas seulement teinté. La saturation compense ce que le flou éteint.

     Le voile clair par-dessus est ce qui distingue un verre d'un simple
     aplat translucide : la lumière frappe une vitre par le haut, et la face
     supérieure est donc toujours plus claire que la face inférieure. Un
     aplat uniforme, si bien flouté soit-il, se lit comme une pellicule de
     couleur ; le dégradé, même de trois pour cent, lui donne une orientation
     — et une orientation, c'est une épaisseur. Il est en `background-image`,
     donc écrasé d'un bloc par les replis opaques de `base.css`, qui reposent
     tous sur le raccourci `background`. */
  background:
    linear-gradient(
      to bottom,
      color-mix(in oklab, var(--bevel) 55%, transparent),
      transparent 42%
    ),
    color-mix(in oklab, var(--paper) 58%, transparent);
  backdrop-filter: saturate(200%) blur(28px);
  -webkit-backdrop-filter: saturate(200%) blur(28px);
  /* Cinq couches, et chacune fait une chose.
     Le filet extérieur, plus fin que le trait de bordure du produit, dessine
     l'arête du verre. Le biseau haut lui donne son épaisseur : sans lui, le
     flou paraît sale plutôt que translucide. Le contact sombre, très court,
     décolle la barre de ce qui passe dessous — c'est lui qui manquait pour
     que le contenu ne semble pas coller au verre. Les deux ombres, enfin, la
     portent au-dessus de la page : une courte qui dit à quelle distance elle
     flotte, une longue qui dit qu'elle flotte. Une seule ombre ne peut pas
     dire les deux — c'est le rapport entre elles que l'œil lit comme une
     hauteur. */
  box-shadow:
    inset 0 0 0 0.5px var(--hairline-strong),
    inset 0 1px 0 0 var(--bevel),
    inset 0 -1px 0 0 color-mix(in oklab, var(--ink) 5%, transparent),
    0 1px 3px -1px rgba(0, 0, 0, .22),
    0 10px 24px -12px rgba(0, 0, 0, .35),
    var(--shadow-4);
  /* Le compositeur garde la barre sur sa propre couche : sans cela, le flou
     est recalculé à chaque pixel de défilement de la page en dessous.
     `translateZ(0)` suffit à la promouvoir. `will-change` a été retiré : il
     n'ajoutait rien à la promotion déjà obtenue et gardait l'indice vivant
     en permanence, sur un élément présent à chaque page. */
  transform: translateZ(0);
  /* Aucune animation d'entrée, et c'est ce qui la rend native.
   *
   * Elle montait de 120 % de sa hauteur en 220 ms — à chaque chargement de
   * page, donc à chaque changement d'onglet. Une barre d'onglets est le seul
   * élément d'une application qui ne bouge jamais : c'est même à cela qu'on
   * la reconnaît. La voir se remettre en place quatre fois de suite pendant
   * qu'on passe d'Accueil à Thèmes à Cercle est le signal le plus net qu'on
   * change de page et non d'écran. Immobile, elle donne l'illusion inverse :
   * seul le contenu au-dessus a changé. */
}

.tabbar__link {
  /* `relative` sans `z-index` ne suffit pas : la pastille est un frère
     antérieur en position absolue, donc peinte après les enfants en flux
     mais avant les positionnés. Un rang explicite la garde derrière les
     icônes et les libellés, sans quoi elle les voilerait. */
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 3px;
  padding-block: var(--s2);
  border-radius: var(--r-lg);
  font-size: var(--text-2xs);
  font-weight: 550;
  color: var(--ink-3);
  -webkit-tap-highlight-color: transparent;
  /* `transform` transitionne aussi : c'est le relâchement du doigt. Sans
     lui, l'onglet se creuse instantanément à la pression puis ressort
     instantanément — deux sauts au lieu d'un appui. */
  transition:
    color var(--dur-2) var(--ease),
    transform 180ms var(--ease-spring);
}

/* La pastille de l'onglet actif : une seule, qui glisse.
 *
 * Elle occupe une colonne de la grille et se déplace de son propre multiple.
 * Aucune mesure : les colonnes sont en `1fr`, donc la n-ième position vaut
 * exactement n fois cette largeur. Le déplacement n'anime que `transform` —
 * le compositeur s'en charge seul, sans repasser par la mise en page, et la
 * barre n'a jamais besoin de connaître sa propre géométrie.
 *
 * Elle remplace cinq pastilles qui s'allumaient et s'éteignaient chacune de
 * leur côté : deux fondus croisés ne racontent pas un déplacement, ils
 * racontent une disparition suivie d'une apparition. */
.tabbar__pastille {
  position: absolute;
  inset-block: var(--s2);
  inset-inline-start: var(--s2);
  width: calc((100% - var(--s2) * 2) / var(--onglets, 5));
  border-radius: var(--r-lg);
  /* La matière de la pastille : une arête ambre, une lumière rasante en
     haut, une ombre courte en bas. Elle cesse d'être un aplat de couleur
     pour devenir un objet posé sur le verre, et c'est ce relief qui la fait
     lire comme un curseur physique quand elle se déplace.

     Le fond n'est plus un aplat mais un dégradé très court, du haut vers le
     bas. C'est le même raisonnement que pour la barre elle-même, à l'échelle
     en dessous : une pastille uniforme se lit comme une zone coloriée, une
     pastille orientée se lit comme une pièce posée dessus. Deux objets de
     verre superposés doivent recevoir la lumière du même côté, sans quoi la
     scène cesse d'être crédible et l'on ne sait plus lequel est au-dessus. */
  background:
    linear-gradient(
      to bottom,
      color-mix(in oklab, var(--accent) 14%, transparent),
      color-mix(in oklab, var(--accent) 6%, transparent)
    );
  /* La lueur portée, à l'extérieur : c'est elle qui décolle la pastille du
     verre au lieu de l'y imprimer. Très large, très faible — assez pour que
     l'ambre déborde de quelques pixels sur ses voisins, jamais assez pour
     qu'on la voie comme une ombre. */
  box-shadow:
    inset 0 0 0 1px var(--accent-line),
    inset 0 1px 0 0 color-mix(in oklab, #fff 22%, transparent),
    inset 0 -1px 0 0 color-mix(in oklab, var(--accent) 18%, transparent),
    0 1px 2px -1px color-mix(in oklab, var(--accent) 45%, transparent),
    0 0 14px -4px color-mix(in oklab, var(--accent) 40%, transparent);
  transform: translate3d(calc(var(--onglet, 0) * 100%), 0, 0);
  /* `scale` est déclaré à part de `transform` : les deux se composent, ce
     qui laisse la translation piloter la position pendant que l'échelle
     gère l'apparition. Mélangés dans une même valeur, l'un écraserait
     l'autre à chaque changement d'onglet.
     240 ms pour le trajet : au-delà, la pastille traîne derrière le doigt. */
  scale: 1;
  transition:
    transform 240ms var(--ease-spring),
    scale 200ms var(--ease-spring),
    opacity 140ms var(--ease);
  pointer-events: none;
}

/* Aucun onglet ne correspond à la page : la boutique, par exemple, qui n'a
   pas d'onglet à elle. La pastille se retire sur place, en se rétractant,
   plutôt que de filer vers une position qui ne veut rien dire ou de
   disparaître d'un coup. */
.tabbar--sans-actif .tabbar__pastille { opacity: 0; scale: .84; }

/* Le poids de la pastille pendant qu'elle se déplace.
 *
 * Elle s'étire dans le sens de la marche et s'aplatit un peu : cinq pour
 * cent de large, huit de haut, le temps du trajet. C'est l'écart entre un
 * rectangle qu'on téléporte et un objet qui a de la masse — le même
 * principe que la déformation d'une goutte, et il suffit d'une classe posée
 * par le script pendant les 240 ms du voyage.
 *
 * Deux axes séparés dans `scale` : le premier pour la largeur, le second
 * pour la hauteur. La translation, elle, reste dans `transform` et n'est
 * jamais touchée. */
.tabbar.is-glisse .tabbar__pastille { scale: 1.05 0.92; }

/* Le premier rendu ne glisse pas.
   Sans cette garde, arriver sur Profil ferait traverser la barre à la
   pastille depuis Accueil, à chaque chargement de page. Le script pose
   `is-pret` à la première image suivante : la position initiale est donc
   posée sans transition, et tous les déplacements ultérieurs en ont une. */
.tabbar:not(.is-pret) .tabbar__pastille { transition: none; }

/* Le point de l'onglet actif : le repère le plus court à lire, et le seul
   qui ne dépende pas de la couleur pour être vu.
 *
 * Il est porté par la pastille et non par l'onglet : il voyage donc avec
 * elle. Posé sur l'onglet, il se serait éteint d'un côté pour se rallumer de
 * l'autre pendant que la pastille glissait entre les deux — un second
 * indicateur qui contredit le premier au moment précis où on le regarde. */
.tabbar__pastille::after {
  content: "";
  position: absolute;
  inset-block-end: 3px;
  inset-inline-start: 50%;
  width: 4px;
  height: 4px;
  margin-inline-start: -2px;
  border-radius: var(--r-full);
  background: var(--accent);
  pointer-events: none;
}

/* L'onglet choisi se distingue par trois choses à la fois, et il en faut
   trois : la couleur seule ne se voit pas d'un daltonien, la graisse seule ne
   se voit pas d'assez loin, et la pastille seule ne dit pas *lequel* des cinq
   libellés lui appartient quand elle est encore en train de glisser. */
.tabbar__link[aria-current="page"] { color: var(--accent-ink); }
.tabbar__link[aria-current="page"] .icon { color: var(--accent); }
.tabbar__link[aria-current="page"] .tabbar__label { font-weight: 650; }

/* Le libellé, et le seul endroit du produit où l'on coupe un mot d'interface.
 *
 * À trois cent vingt pixels, cinq colonnes valent cinquante-trois pixels ;
 * « Révisions » en demande cinquante-huit à onze pixels de corps. Il passait
 * sous ses voisins, et la barre entière semblait mal alignée pour un seul
 * mot. Trois réglages, du plus doux au plus brutal : la chasse se resserre,
 * le corps descend d'un demi-point sur les écrans étroits, et la coupe reste
 * en dernier recours — elle n'intervient qu'au-delà, et vaut mieux qu'un
 * chevauchement. */
.tabbar__label {
  max-inline-size: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  letter-spacing: -0.012em;
}

/* L'icône choisie monte, se met en avant, puis se pose.
 *
 * Trois temps en 200 ms : elle décolle de trois pixels, dépasse d'un
 * dixième, et revient à son repos légèrement au-dessus des autres. C'est le
 * seul mouvement de la barre, et il ne se déclenche qu'au changement
 * d'onglet — jamais en boucle, jamais au défilement.
 *
 * Le repos n'est pas zéro : l'onglet actif garde un pixel de hauteur et
 * douze pour cent de volume. C'est ce qui le distingue encore une fois
 * l'animation finie, sans rien qui bouge. */
/* L'icône est dimensionnée en `rem` et non en `em`.
 *
 * `.icon--lg` vaut 1,75 em, et l'em d'un onglet est celui de son libellé —
 * onze pixels. L'icône mesurait donc dix-neuf pixels : sous la barre des
 * vingt-quatre que toutes les barres d'onglets natives tiennent, et surtout
 * plus petite que celle des mêmes destinations dans le panneau de compte.
 * Le libellé et l'icône n'ont aucune raison d'être solidaires : l'un se lit,
 * l'autre se reconnaît, et ils ne se reconnaissent pas à la même taille. */
.tabbar__link .icon {
  width: 1.375rem;
  height: 1.375rem;
  transition: transform 180ms var(--ease-spring);
}

.tabbar__link[aria-current="page"] .icon {
  transform: translate3d(0, -1px, 0) scale(1.12);
  animation: tabbar-icone 200ms var(--ease-spring) both;
}

@keyframes tabbar-icone {
  0%   { transform: translate3d(0, 0, 0) scale(0.96); }
  55%  { transform: translate3d(0, -4px, 0) scale(1.24); }
  100% { transform: translate3d(0, -1px, 0) scale(1.12); }
}

/* Les trois temps du doigt.
 *
 * Pression : l'onglet s'enfonce en 90 ms, assez vite pour paraître
 * simultané. Son icône ne suit pas le mouvement, elle s'y oppose légèrement
 * — c'est ce décalage qui donne l'impression d'un bouton qui résiste plutôt
 * que d'une image qu'on réduit.
 *
 * Relâchement : la transition de `.tabbar__link` reprend la main, avec sa
 * courbe à ressort. Le retour est donc plus long que l'aller et dépasse un
 * peu, comme un vrai bouton.
 *
 * Activation : la pastille part vers le nouvel onglet et l'icône joue ses
 * trois temps. Rien de tout cela n'attend le réseau. */
.tabbar__link:active {
  /* L'échelle enfonce, l'opacité éloigne. Les deux ensemble donnent la
     profondeur qu'une réduction seule ne donne pas : un bouton qu'on pousse
     s'écarte de la lumière autant qu'il rétrécit. */
  transform: scale(0.93);
  opacity: .78;
  transition-duration: 90ms;
}

.tabbar__link:active .icon {
  transform: translate3d(0, 1px, 0) scale(1.04);
  transition-duration: 90ms;
}

.tabbar__link:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }

@media (prefers-reduced-motion: reduce) {
  /* La pastille change de place sans traverser : le repère reste juste,
     seul le trajet disparaît. C'est ce que demande le réglage — pas de
     mouvement, pas moins d'information. */
  .tabbar__pastille,
  .tabbar.is-glisse .tabbar__pastille { transition: none; scale: 1; }
  .tabbar--sans-actif .tabbar__pastille { scale: 1; }
  .tabbar__link .icon,
  .tabbar__link[aria-current="page"] .icon,
  .tabbar__link:active,
  .tabbar__link:active .icon { animation: none; transform: none; }
}

/* Le porte-icône d'un onglet.

   Il n'existe que pour ancrer la pastille : sans repère positionné, elle se
   placerait par rapport à la barre entière. Il portait ses trois propriétés
   en attribut `style`, ce qui en faisait une règle de mise en page invisible
   au CSS — impossible à retrouver, impossible à ajuster en média-requête. */
.tabbar__icone {
  position: relative;
  display: grid;
  place-items: center;
}

/* Une pastille qui naît sous les yeux.
 *
 * Elle est posée par le relevé, jamais par un geste : quelqu'un vous a
 * demandé en ami pendant que vous lisiez autre chose. Elle apparaissait d'un
 * coup, à l'image près, et une apparition instantanée dans une barre par
 * ailleurs immobile se lit comme un défaut d'affichage plutôt que comme une
 * nouvelle. Deux dixièmes de seconde et un léger dépassement suffisent à
 * dire qu'elle vient d'arriver.
 *
 * `both` retient l'état final : sans lui la pastille reviendrait à son
 * échelle de départ à la fin de l'animation. La classe n'est posée qu'à la
 * création, donc l'éclosion ne se rejoue jamais sur une pastille qui ne fait
 * que changer de chiffre. */
.tabbar__badge.is-neuve,
.nav__badge.is-neuve {
  animation: pastille-eclot 220ms var(--ease-spring) both;
}

@keyframes pastille-eclot {
  0%   { opacity: 0; scale: 0.4; }
  60%  { opacity: 1; scale: 1.18; }
  100% { opacity: 1; scale: 1; }
}

@media (prefers-reduced-motion: reduce) {
  .tabbar__badge.is-neuve,
  .nav__badge.is-neuve { animation: none; }
}

.tabbar__badge {
  position: absolute;
  transform: translate(0.75rem, -0.35rem);
  min-width: 1rem;
  height: 1rem;
  padding-inline: 3px;
  display: grid;
  place-items: center;
  border-radius: var(--r-full);
  background: var(--accent);
  color: #1a1409;
  font-size: 0.625rem;
  font-weight: 700;
  font-family: var(--font-mono);
}

@media (min-width: 48rem) {
  .tabbar { display: none; }
}

@media (max-width: 47.99rem) {
  /* Cinq rem couvrent la barre elle-même, mesurée à 73 px. L'inset s'y ajoute
     et ne s'y fond pas : `body` reçoit bien `env(safe-area-inset-bottom)` par
     le raccourci `padding` de base.css, mais cette règle-ci écrase le bord
     bas du raccourci, et l'inset disparaissait avec lui. Sur un iPhone à
     barre de gestes, la barre monte à 107 px quand le corps n'en réservait
     que 80 : les vingt-sept derniers pixels de chaque page passaient
     définitivement sous la barre d'onglets, hors d'atteinte du défilement.
     Le calc() rend l'addition explicite, et le défaut impossible à rouvrir
     en modifiant l'une des deux valeurs. */
  body.has-tabbar { padding-block-end: calc(5.75rem + env(safe-area-inset-bottom)); }
}

/* Les téléphones étroits, où cinq colonnes ne sont plus gratuites.
 *
 * Trois cent vingt pixels, moins deux fois seize de marge et deux fois huit
 * de rembourrage interne, laissent deux cent soixante-douze pixels pour cinq
 * colonnes : cinquante-quatre chacune. C'est assez pour l'icône et pour
 * quatre des cinq libellés ; « Révisions » est le seul à dépasser, et il
 * dépasse de peu. On resserre donc la barre elle-même plutôt que de rogner
 * ce qui se lit — la marge extérieure rend six pixels par côté, le
 * rembourrage quatre, et le corps du libellé descend d'un demi-point. Les
 * cinquante-quatre pixels deviennent soixante-deux, et le mot tient.
 *
 * 375 et 390 pixels ne sont pas concernés : ils offrent respectivement
 * soixante-quatre et soixante-sept pixels par colonne, où tout tient déjà. */
@media (max-width: 22.5rem) {
  .tabbar {
    inset-inline: calc(var(--s3) + env(safe-area-inset-left))
                  calc(var(--s3) + env(safe-area-inset-right));
    padding: var(--s1) var(--s1) 6px;
  }

  .tabbar__pastille {
    inset-block: var(--s1) 6px;
    inset-inline-start: var(--s1);
    width: calc((100% - var(--s1) * 2) / var(--onglets, 5));
  }

  .tabbar__link {
    gap: 2px;
    padding-block: 6px;
  }

  .tabbar__label { font-size: 0.625rem; }
}

/* ==========================================================================
   Panneau modal (feuille)
   ========================================================================== */

.sheet {
  position: fixed;
  inset: 0;
  z-index: var(--z-sheet);
  display: grid;
  place-items: center;
  padding: var(--gutter);
  background: var(--scrim);
  /* Le voile des fenêtres d'Omnia : huit pixels de flou, cent vingt
     millisecondes de fondu. La même valeur pour la feuille, la célébration
     et la recherche : trois fenêtres qui s'ouvraient avec trois flous et
     trois durées différentes, ce qui donnait à chacune un poids que rien ne
     justifiait. Le voile couvre tout l'écran, c'est le flou le plus cher du
     produit ; sur un appareil modeste il est remplacé par un voile opaque,
     plus bas. */
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  animation: fade 120ms var(--ease) both;
}

.sheet[hidden] { display: none; }

.sheet__panel {
  width: 100%;
  max-width: 32rem;
  /* `dvh` : une feuille contient souvent un champ, et le clavier qui s'ouvre
     doit la faire se resserrer plutôt que la pousser sous lui. */
  max-height: min(85dvh, 46rem);
  overflow-y: auto;
  padding: clamp(1.5rem, 1rem + 2vw, 2.25rem);
  border-radius: var(--r-2xl);
  background: var(--surface);
  box-shadow: inset 0 1px 0 0 var(--bevel), var(--shadow-4);
  /* 130 ms, et rien d'autre qu'opacité et échelle.

     Une feuille qui glissait de seize pixels sur 520 ms retardait l'action
     d'un demi-tiers de seconde à chaque ouverture — sur un panneau qu'on
     ouvre pour agir, pas pour le regarder arriver. Le mouvement subsiste,
     assez pour dire d'où vient la fenêtre, trop court pour être attendu. */
  animation: sheet-in 140ms var(--ease) both;
}

/* L'entrée d'un panneau, pour toutes les fenêtres du produit.
   140 ms et quatre centièmes d'échelle : assez pour dire d'où vient la
   fenêtre, trop court pour qu'on l'attende. */
@keyframes sheet-in {
  from { opacity: 0; transform: scale(0.96); }
  to   { opacity: 1; transform: none; }
}

@media (max-width: 34rem) {
  /* — La feuille qu'on tire ————————————————————————————————————————
     Le cas par défaut : un panneau qu'on a demandé, qui monte du bas et
     prolonge le doigt qui l'a ouvert. Le `:not()` n'est pas un détail de
     style — c'est ce qui laisse les fenêtres surgissantes (plus bas) garder
     leurs quatre coins et leur propre remplissage, qu'une règle de bord bas
     à deux niveaux de spécificité leur reprendrait sinon. */
  .sheet:not(.sheet--centre) { place-items: end center; padding: 0; }
  .sheet:not(.sheet--centre) .sheet__panel {
    max-width: none;
    border-end-start-radius: 0;
    border-end-end-radius: 0;
    padding-block-end: calc(1.5rem + env(safe-area-inset-bottom));
  }
  /* Sur téléphone la feuille monte du bas — c'est le geste attendu — mais en
     150 ms au lieu de 520. */
  @keyframes sheet-in {
    from { opacity: 0; transform: translate3d(0, 14%, 0); }
    to   { opacity: 1; transform: none; }
  }

  /* — La fenêtre qui surgit ————————————————————————————————————————
     Une invitation n'a pas de geste d'origine : elle arrive pendant qu'on
     fait autre chose, elle a soixante secondes, et elle demande une
     décision. La coller au bord bas la fait ressembler à un tiroir ouvert
     par mégarde, et l'installe justement là où l'on ne regardait pas. Elle
     se pose au centre, à la largeur qu'elle demande. */
  .sheet--centre .sheet__panel { animation-name: sheet-in-centre; }

  @keyframes sheet-in-centre {
    from { opacity: 0; transform: scale(0.96); }
    to   { opacity: 1; transform: none; }
  }
}

/* ==========================================================================
   Notifications éphémères
   ========================================================================== */

.toasts {
  position: fixed;
  inset-block-end: var(--s6);
  inset-inline: 0;
  z-index: var(--z-toast);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--s2);
  padding-inline: var(--gutter);
  pointer-events: none;
}

@media (max-width: 47.99rem) {
  body.has-tabbar .toasts { inset-block-end: calc(6.25rem + env(safe-area-inset-bottom)); }
}

.toast {
  display: flex;
  align-items: center;
  gap: var(--s3);
  max-width: 26rem;
  padding: var(--s3) var(--s5);
  border-radius: var(--r-full);
  background: var(--ink);
  color: var(--paper);
  font-size: var(--text-md);
  font-weight: 500;
  box-shadow: var(--shadow-4);
  pointer-events: auto;
  animation: toast-in 220ms var(--ease-spring) both;
}

.toast.is-leaving { animation: toast-out var(--dur-3) var(--ease) both; }
.toast--right .icon { color: oklch(75% 0.13 158); }
.toast--wrong .icon { color: oklch(72% 0.15 28); }
.toast--lumen .icon { color: var(--accent-bright); }

/* — Le message qui conduit quelque part ————————————————————————————
   Il se distingue de celui qui informe par trois riens : le curseur, le
   chevron, et le fait qu'il s'enfonce sous le doigt. Aucun n'est décoratif —
   un message cliquable qui ne se donne pas pour tel n'est pas cliqué. */
.toast--action {
  cursor: pointer;
  color: var(--paper);
  text-decoration: none;
  transition: transform 140ms var(--ease-spring), box-shadow var(--dur-2) var(--ease);
}

.toast--action:active { transform: scale(0.97); transition-duration: 90ms; }
.toast--action:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }

@media (hover: hover) {
  .toast--action:hover { box-shadow: var(--shadow-4), 0 0 0 1px var(--accent-line); }
}

/* Le chevron est repoussé au bout et rendu discret : il indique la direction,
   il ne se dispute pas la lecture avec le texte. */
.toast__go {
  flex: none;
  margin-inline-start: auto;
  opacity: .6;
}

@media (prefers-reduced-motion: reduce) {
  .toast--action:active { transform: none; }
}

/* ==========================================================================
   L'endroit qu'on vient de rejoindre
   --------------------------------------------------------------------------
   Une notification conduit à une section précise — les demandes d'ami dans le
   Cercle. Le défilement seul ne suffit pas à le dire : arriver en haut d'une
   section ressemble exactement à un défilement qu'on aurait fait soi-même, et
   c'est encore plus vrai quand on était déjà sur la page. Un halo de deux
   secondes, qui s'éteint tout seul, désigne la section sans rien exiger.

   Aucune marge de défilement n'est déclarée ici, et c'est délibéré :
   `base.css` pose déjà `scroll-padding-top: 6rem` sur la racine, ce qui
   réserve la hauteur de la barre pour *toute* cible d'ancre du produit. Une
   `scroll-margin` sur la section s'y serait ajoutée au lieu de la remplacer
   — mesuré : 96 plus 84, soit 180 pixels de vide au-dessus d'un titre qu'on
   voulait justement mettre sous les yeux. Le réglage existait, il était bon,
   et il n'avait besoin de personne.
   ========================================================================== */

/* Le repère se pose sur le bord, plus sur le fond.
 *
 * Il remplissait la section d'un aplat ambre pendant une seconde et demie.
 * Sur un bloc de quelques centaines de pixels, cela ne se lit pas comme « voici
 * ce que vous cherchiez » mais comme un grand rectangle jaune posé sur la
 * page — d'autant que l'aplat couvre le texte qu'il est censé désigner et en
 * abaisse le contraste au moment précis où on le lit.
 *
 * Ce qui reste : une arête lumineuse et un halo, tous deux dans le
 * `box-shadow`, donc sans un pixel de mise en page et sans toucher au fond.
 * L'arête bat deux fois — deux pulsations valent mieux qu'une, la première
 * arrive souvent pendant que le défilement se termine encore — puis s'éteint.
 * Le halo la suit à distance, plus large et plus faible, ce qui donne la
 * lumière sans donner de second contour.
 *
 * `--r-lg` reste posé : le repère épouse le rayon des cartes du produit, et
 * une arête à angle droit sur un bloc arrondi se verrait comme un défaut. */
.is-pointed {
  animation: pointe 2.2s var(--ease-inout) both;
  border-radius: var(--r-lg);
}

@keyframes pointe {
  0% {
    box-shadow:
      inset 0 0 0 1px transparent,
      0 0 0 0 color-mix(in oklab, var(--accent) 45%, transparent),
      0 0 0 0 transparent;
  }
  18% {
    box-shadow:
      inset 0 0 0 1px var(--accent),
      0 0 0 2px color-mix(in oklab, var(--accent) 14%, transparent),
      0 0 24px -6px color-mix(in oklab, var(--accent) 45%, transparent);
  }
  42% {
    box-shadow:
      inset 0 0 0 1px var(--accent-line),
      0 0 0 1px transparent,
      0 0 18px -8px color-mix(in oklab, var(--accent) 40%, transparent);
  }
  64% {
    box-shadow:
      inset 0 0 0 1px color-mix(in oklab, var(--accent) 80%, transparent),
      0 0 0 2px color-mix(in oklab, var(--accent) 10%, transparent),
      0 0 22px -7px color-mix(in oklab, var(--accent) 38%, transparent);
  }
  100% {
    box-shadow:
      inset 0 0 0 1px transparent,
      0 0 0 0 transparent,
      0 0 0 0 transparent;
  }
}

@media (prefers-reduced-motion: reduce) {
  /* Le repère reste, il ne bat plus : une arête posée deux secondes puis
     retirée désigne aussi bien qu'une arête qui pulse, et c'est le fait de
     désigner qui est l'information. */
  .is-pointed {
    animation: pointe-fixe 2.2s steps(1, end) both;
  }

  @keyframes pointe-fixe {
    0%   { box-shadow: inset 0 0 0 1px var(--accent); }
    100% { box-shadow: inset 0 0 0 1px transparent; }
  }
}

@keyframes toast-in {
  from { opacity: 0; transform: translate3d(0, 20px, 0) scale(0.94); }
  to   { opacity: 1; transform: none; }
}

@keyframes toast-out {
  to { opacity: 0; transform: translate3d(0, 10px, 0) scale(0.96); }
}

/* ==========================================================================
   États vides et chargement
   ========================================================================== */

.empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--s4);
  padding: var(--s10) var(--s6);
  text-align: center;
}

.empty__mark {
  display: grid;
  place-items: center;
  width: 4.5rem;
  height: 4.5rem;
  border-radius: var(--r-full);
  background: var(--surface-2);
  color: var(--ink-4);
}

.empty__title {
  font-family: var(--font-display);
  font-size: var(--display-4);
}

.empty__text {
  max-width: 30rem;
  color: var(--ink-2);
  font-size: var(--text-md);
}

/* La variante compacte, pour un vide qui vit *dans* une carte.

   Le composant plein prend `--s10` de rembourrage : c'est juste pour un écran
   entier qui n'a rien à montrer, et absurde pour une colonne de tableau de
   bord. Faute de cette variante, dix écrans avaient renoncé au composant et
   posé un paragraphe gris à la place — d'où trois façons différentes de dire
   « il n'y a rien », dont deux sans repère visuel ni sortie.

   Ce sont les mêmes éléments, aux mêmes rôles : seuls le rembourrage et le
   diamètre du repère changent. Un vide reste reconnaissable comme un vide,
   qu'il occupe l'écran ou un coin de carte. */
.empty--compact {
  gap: var(--s3);
  padding: var(--s6) var(--s4);
}

.empty--compact .empty__mark { width: 3rem; height: 3rem; }
.empty--compact .empty__title { font-size: var(--text-lg); }
.empty--compact .empty__text { font-size: var(--text-sm); }

@media (max-width: 47.99rem) {
  .empty { padding: var(--s8) var(--s4); gap: var(--s3); }
  .empty__mark { width: 3.5rem; height: 3.5rem; }
  .empty--compact { padding: var(--s5) var(--s3); }
}

.skeleton {
  border-radius: var(--r-sm);
  background: linear-gradient(
    90deg,
    var(--surface-2) 25%,
    var(--surface-3) 50%,
    var(--surface-2) 75%
  );
  background-size: 300% 100%;
  animation: shimmer 1.6s var(--ease-inout) infinite;
}

@keyframes shimmer {
  from { background-position: 150% 0; }
  to   { background-position: -50% 0; }
}

/* Barre de progression de navigation : discrète, en haut de l'écran. */
.route-progress {
  position: fixed;
  inset-block-start: 0;
  inset-inline: 0;
  z-index: var(--z-toast);
  height: 2px;
  background: var(--accent);
  transform-origin: 0 50%;
  transform: scaleX(0);
  opacity: 0;
  box-shadow: 0 0 10px var(--accent);
  transition: transform var(--dur-4) var(--ease), opacity var(--dur-2) var(--ease);
}

.route-progress.is-active { opacity: 1; }

/* ==========================================================================
   Fil d'Ariane et retour
   ========================================================================== */

.breadcrumb {
  display: flex;
  align-items: center;
  gap: var(--s2);
  font-size: var(--text-sm);
  color: var(--ink-3);
}

.breadcrumb a { transition: color var(--dur-2) var(--ease); }
.breadcrumb a:hover { color: var(--ink); }

.back-link {
  display: inline-flex;
  align-items: center;
  gap: var(--s2);
  font-size: var(--text-md);
  font-weight: 500;
  color: var(--ink-2);
  transition: color var(--dur-2) var(--ease);
}

.back-link:hover { color: var(--ink); }
.back-link .icon { transition: transform var(--dur-2) var(--ease); }
.back-link:hover .icon { transform: translateX(-3px); }

/* ==========================================================================
   Messages système
   ========================================================================== */

.notice {
  display: flex;
  align-items: flex-start;
  gap: var(--s3);
  padding: var(--s4) var(--s5);
  border-radius: var(--r-md);
  background: var(--surface-2);
  font-size: var(--text-md);
  color: var(--ink-2);
}

.notice .icon { flex: none; margin-block-start: 0.15em; }
.notice--wrong { background: var(--wrong-wash); color: var(--wrong); }
.notice--lumen { background: var(--accent-wash); color: var(--accent-ink); }

/* ==========================================================================
   Célébration d'un succès
   --------------------------------------------------------------------------
   Affichée aussi bien en fin de séance que sur l'accueil (pour un succès
   débloqué hors connexion) : elle appartient donc aux composants globaux.
   ========================================================================== */

.celebration {
  position: fixed;
  inset: 0;
  z-index: var(--z-celebration);
  display: grid;
  place-items: center;
  padding: var(--gutter);
  background: var(--scrim);
  /* Mêmes voile et fondu que la feuille : voir `.sheet`. */
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  animation: fade 120ms var(--ease) both;
}

.celebration__card {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--s5);
  max-width: 24rem;
  padding: var(--s9) var(--s7) var(--s7);
  border-radius: var(--r-2xl);
  background: var(--surface);
  text-align: center;
  /* Même arête, même ombre et même entrée que tous les panneaux du produit.
     Le filet intérieur manquait ici : la carte de célébration flottait sans
     bord là où la feuille et la recherche en ont un. */
  box-shadow: inset 0 0 0 1px var(--hairline-strong), inset 0 1px 0 0 var(--bevel), var(--shadow-4);
  animation: sheet-in 140ms var(--ease) both;
}

/* Halo qui respire derrière la médaille : la lumière de la maîtrise, une fois
   par déblocage, et pas ailleurs. */
.celebration__halo {
  position: absolute;
  inset-block-start: -4rem;
  width: 18rem;
  height: 18rem;
  border-radius: var(--r-full);
  background: radial-gradient(closest-side, var(--accent) 0%, transparent 70%);
  opacity: .28;
  filter: blur(18px);
  /* Une éclosion, pas une respiration.
   *
   * Le halo battait toutes les trois secondes tant que la fenêtre restait
   * ouverte — c'est-à-dire jusqu'à ce qu'on la ferme, parfois une minute.
   * Après le premier cycle il n'apprenait plus rien : la médaille est déjà
   * là, le titre aussi. Il s'ouvre une fois, au moment où la carte arrive,
   * et se pose. C'est le geste qui célèbre ; sa répétition ne célébrait
   * plus, elle attendait. */
  animation: eclosion 240ms var(--ease) both;
  pointer-events: none;
}

@keyframes eclosion {
  from { transform: scale(0.86); opacity: 0; }
  to   { transform: scale(1);    opacity: .28; }
}

@media (prefers-reduced-motion: reduce) {
  .celebration__halo { animation: none; opacity: .28; }
  .celebration__card { animation: fade var(--dur-3) linear both; }
}

.celebration__title { font-family: var(--font-display); font-size: var(--display-3); font-weight: 400; }
.celebration__text { color: var(--ink-2); font-size: var(--text-md); }

/* — Plusieurs succès à la fois ——————————————————————————————————
   Ils arrivent ensemble bien plus souvent qu'on ne le croit : une bonne fin
   de séance franchit le même jour un palier de série, un palier de thème et
   un palier de questions. La fenêtre s'élargit alors, passe en liste, et
   défile si la grappe est vraiment longue — jamais la fenêtre entière, dont
   le bouton doit rester atteignable sans chercher. */

.celebration__card--groupe {
  max-width: 30rem;
  gap: var(--s5);
  padding: var(--s7) var(--s6) var(--s6);
  /* La carte ne dépasse jamais l'écran : c'est la liste qui cède, pas le
     cadre. Sans cette borne, cinq succès sur un petit téléphone poussaient
     le bouton « Continuer » hors du champ. */
  max-height: min(84dvh, 40rem);
}

.celebration__liste {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: var(--s3);
  width: 100%;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  text-align: start;
}

.celebration__succes {
  display: flex;
  align-items: center;
  gap: var(--s4);
  padding: var(--s3) var(--s4);
  border-radius: var(--r-lg);
  background: var(--surface-2);
  box-shadow: inset 0 0 0 1px var(--hairline);
}

.celebration__corps {
  display: grid;
  gap: 2px;
  min-width: 0;
}

.celebration__nom { font-size: var(--text-md); font-weight: 600; }
.celebration__detail { font-size: var(--text-sm); color: var(--ink-3); }

/* Le halo est dessiné pour une médaille unique centrée. Au-dessus d'une
   liste il éclairerait le premier succès et laisserait les autres dans
   l'ombre, ce qui les hiérarchiserait sans raison — ils ont tous été
   débloqués de la même façon. */
.celebration__card--groupe .celebration__halo { display: none; }

/* ==========================================================================
   ÉCLATS — la monnaie
   --------------------------------------------------------------------------
   Un montant d'Éclats s'écrit toujours de la même façon : le fragment, puis le
   nombre en chiffres tabulaires. Il n'a pas de couleur propre — il prend
   l'ambre du contexte, comme tout ce qui, dans Omnia, signale un acquis.
   ========================================================================== */

.eclats {
  display: inline-flex;
  align-items: center;
  gap: 0.3em;
  color: var(--accent-ink);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.eclats__mark { flex: none; color: var(--accent); }
.eclats--quiet { color: var(--ink-2); font-weight: 550; }
.eclats--quiet .eclats__mark { color: var(--ink-3); }
.eclats--xl { font-size: var(--display-3); font-family: var(--font-display); font-weight: 400; }
.eclats--xl .eclats__mark { width: 1.5rem; height: 1.5rem; }

.chip--eclat { background: var(--accent-wash); }

/* — Pseudonyme —————————————————————————————————————————————————
   La couleur de pseudo est le seul endroit du produit où une teinte autre que
   Lumen touche du texte. Elle reste confinée au nom : l'accent du produit,
   lui, ne bouge pas — sans quoi deux ambres se disputeraient l'écran. */

.pseudo { display: inline-flex; align-items: baseline; gap: 0.35em; }

/* La teinte suit le thème, la nuance reste celle qu'on a achetée.
 *
 * Elle était bâtie sur `--accent-l`, la clarté des *aplats* : 66 % dans les
 * deux thèmes. Sur fond sombre, un nom clair se lit ; sur Papier, mesuré,
 * les cinq teintes tombaient entre 2,05 et 2,22 pour un contre 4,5 requis.
 * On achetait une couleur qu'on ne pouvait pas lire la moitié du temps.
 *
 * `--accent-ink-l` est la clarté du *texte* accentué, et elle, s'inverse
 * d'un thème à l'autre — 46 % sur Papier, 80 % de nuit. Seule la clarté
 * change : la teinte, qui est l'objet acheté, ne bouge pas d'un degré. */
.pseudo--tinted { color: oklch(var(--accent-ink-l) 0.1 var(--pseudo-h, 66deg)); }
.pseudo__seal {
  align-self: center;
  display: inline-flex;
  color: oklch(var(--accent-ink-l) var(--accent-ink-c) var(--sceau-h, 66deg));
  opacity: .9;
}

/* — Cadres d'avatar ————————————————————————————————————————————
   Chacun n'ajoute qu'un anneau : le dessin de l'avatar est intouché, et un
   cadre retiré ne laisse rien derrière lui. */

.avatar--filet    { box-shadow: inset 0 0 0 1.5px var(--hairline-strong); }
.avatar--biseau   { box-shadow: inset 0 0 0 1px var(--hairline-strong), inset 0 1.5px 0 0 var(--bevel); }
.avatar--double   { box-shadow: inset 0 0 0 1px var(--accent-line), inset 0 0 0 4px var(--paper), inset 0 0 0 5px var(--hairline-strong); }
.avatar--aura     { box-shadow: inset 0 0 0 1.5px var(--accent-line), 0 0 0 3px var(--accent-wash), 0 0 18px -2px var(--accent); }
.avatar--couronne {
  box-shadow: inset 0 0 0 1.5px var(--accent-line), 0 0 0 2px var(--accent-wash);
}
.avatar--couronne::after {
  content: '';
  position: absolute;
  inset: -6px;
  border-radius: var(--r-full);
  background: conic-gradient(from 0deg, var(--accent) 0 2deg, transparent 2deg 30deg);
  opacity: .55;
  pointer-events: none;
}

/* La lueur d'avatar.
   Elle pulsait autrefois en animant `box-shadow`, ce qui oblige le navigateur
   à repeindre l'avatar et son voisinage soixante fois par seconde, pour un
   effet qui ne bouge pas d'un pixel. La même lueur est ici peinte une fois
   dans un calque à part, dont seule l'opacité varie : le compositeur s'en
   charge sans jamais rappeler le peintre. C'est le même effet à l'œil, et il
   cesse d'être visible dans le profileur — ce qui compte, un classement
   pouvant afficher vingt avatars animés en même temps. */
.avatar--lueur { box-shadow: inset 0 0 0 1px var(--hairline); }

.avatar--lueur::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow: inset 0 0 0 1px var(--accent-line), 0 0 16px -4px var(--accent);
  opacity: 0;
  animation: avatar-lueur 4.5s var(--ease-inout) infinite;
  pointer-events: none;
}

@keyframes avatar-lueur {
  0%, 100% { opacity: 0; }
  50%      { opacity: 1; }
}
.avatar--poussiere::before {
  content: '';
  position: absolute;
  inset: 0;
  background:
    radial-gradient(1.5px 1.5px at 22% 78%, var(--accent) 0, transparent 100%),
    radial-gradient(1.5px 1.5px at 68% 42%, var(--accent) 0, transparent 100%),
    radial-gradient(1px 1px at 46% 88%, var(--accent) 0, transparent 100%);
  opacity: .7;
  animation: avatar-poussiere 6s linear infinite;
  pointer-events: none;
}
@keyframes avatar-poussiere {
  from { transform: translateY(18%); opacity: 0; }
  35%  { opacity: .7; }
  to   { transform: translateY(-26%); opacity: 0; }
}

@media (prefers-reduced-motion: reduce) {
  .avatar--lueur, .avatar--poussiere::before, .avatar--couronne::after { animation: none; }
}

/* ==========================================================================
   RÉCOMPENSES
   --------------------------------------------------------------------------
   Une récompense doit se voir arriver. Un nombre qui change d'un coup n'est
   pas une récompense, c'est une mise à jour de compteur.

   Tout ce qui suit n'anime que `transform` et `opacity` : le compositeur s'en
   charge seul, sans repasser par la mise en page. C'est la condition pour que
   ces effets tiennent soixante images par seconde sur un téléphone, y compris
   au bout de la centième question.
   ========================================================================== */

/* L'éclat en vol. Il est en `position: fixed` et ne survit pas à son
   animation : la scène reste toujours propre. */
.reward-shard {
  position: fixed;
  z-index: var(--z-toast);
  width: 1.15rem;
  height: 1.15rem;
  margin: 0;
  color: var(--accent);
  pointer-events: none;
  filter: drop-shadow(0 0 7px color-mix(in oklab, var(--accent) 65%, transparent));
  will-change: transform, opacity;
}

.reward-shard svg { width: 100%; height: 100%; fill: none; stroke: currentColor; stroke-width: 1.6; }

/* Le compteur qui vient de se remplir marque un temps, une seule fois.
   Une pulsation, pas un clignotement : l'œil doit revenir dessus, pas sursauter. */
/* L'atterrissage du compteur.
   Une seule pulsation d'échelle ne suffisait pas : à 1,16 fois, sur un nombre
   de treize pixels, le mouvement était réel et imperceptible. La pulsation
   monte à 1,28, s'accompagne d'une brève lueur ambre, et dure un peu plus
   longtemps — assez pour être vue, trop peu pour être attendue. */
.is-landed {
  animation: reward-landed var(--dur-4) var(--ease-spring);
  will-change: transform;
}

@keyframes reward-landed {
  0%   { transform: scale(1); text-shadow: none; }
  32%  { transform: scale(1.28); text-shadow: 0 0 14px color-mix(in oklab, var(--accent) 70%, transparent); }
  58%  { transform: scale(0.97); }
  100% { transform: scale(1); text-shadow: none; }
}

/* Une ligne d'annonce se pose au lieu d'apparaître. */
.reward-line {
  opacity: 0;
  transform: translate3d(0, 10px, 0);
  transition: opacity var(--dur-3) var(--ease), transform var(--dur-3) var(--ease);
  will-change: opacity, transform;
}

.reward-line.is-revealed {
  opacity: 1;
  transform: translate3d(0, 0, 0);
  will-change: auto;
}

/* La bourse de la barre de navigation : destination par défaut de tout gain. */
.chip--eclat { transition: box-shadow var(--dur-3) var(--ease); }
/* La bourse elle-même s'allume au moment où son compteur se remplit. La
   transition de sortie est longue : l'éclat s'installe vite et se retire
   lentement, ce qui laisse une trace au coin de l'œil sans clignoter. */
.chip--eclat { transition: box-shadow 900ms var(--ease); }

.chip--eclat:has(.is-landed) {
  box-shadow:
    0 0 0 1px var(--accent),
    0 0 26px -4px color-mix(in oklab, var(--accent) 75%, transparent);
  transition-duration: 160ms;
}

@media (prefers-reduced-motion: reduce) {
  .is-landed, .reward-line { animation: none; transition-duration: 120ms; }
  .reward-shard { display: none; }
}

/* Le titre porté s'écrit avec son encre : la teinte fait partie de ce qui a
   été acquis, au même titre que le mot. Elle est déclarée ici, au point
   d'usage, et non héritée d'un jeton dérivé (voir tokens.css, teinte locale). */
.titre-porte { font-weight: 550; }
.titre-porte--teinte {
  color: oklch(var(--accent-ink-l) var(--accent-ink-c) var(--titre-h, 66deg));
}

/* ==========================================================================
   PALETTE DE RECHERCHE
   --------------------------------------------------------------------------
   Une barre, depuis n'importe quel écran. Elle se pose au tiers supérieur et
   non au centre : c'est là que l'œil cherche un champ de saisie, et cela
   laisse la liste des résultats grandir vers le bas sans jamais repousser le
   champ lui-même.
   ========================================================================== */

.palette {
  position: fixed;
  inset: 0;
  z-index: var(--z-sheet);
  display: grid;
  justify-items: center;
  align-content: start;
  padding: clamp(3rem, 12dvh, 8rem) var(--gutter) var(--s6);
  background: var(--scrim);
  /* Mêmes voile et fondu que la feuille : voir `.sheet`. */
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  opacity: 0;
  transition: opacity 120ms var(--ease);
}

.palette.is-open { opacity: 1; }

.palette__panel {
  display: grid;
  grid-template-rows: auto minmax(0, 1fr);
  width: min(100%, 40rem);
  /* `dvh` et non `vh`, et c'est toute la différence sur un téléphone : `vh`
     décrit la fenêtre clavier fermé et ne bouge jamais. Le panneau gardait
     donc sa pleine hauteur au moment précis où le clavier venait d'en manger
     la moitié, et les résultats de recherche s'affichaient dessous, hors de
     vue, pendant qu'on tapait. `dvh` suit la fenêtre réellement visible : la
     liste se resserre au-dessus du clavier au lieu de se cacher derrière. */
  max-height: min(70dvh, 34rem);
  /* Le rayon des panneaux, et non celui d'une carte : `--r-2xl` comme la
     feuille et la célébration. La recherche était le seul panneau à `--r-xl`,
     un cran plus serré, et cela se voyait dès qu'on ouvrait les deux à la
     suite. */
  border-radius: var(--r-2xl);
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--hairline-strong), inset 0 1px 0 0 var(--bevel), var(--shadow-4);
  overflow: hidden;
  transform: scale(0.96);
  transition: transform 140ms var(--ease);
}

.palette.is-open .palette__panel { transform: scale(1); }

.palette__field {
  display: flex;
  align-items: center;
  gap: var(--s3);
  padding: var(--s4) var(--s5);
  border-block-end: 1px solid var(--hairline);
  color: var(--ink-3);
}

.palette__input {
  flex: 1 1 auto;
  min-width: 0;
  border: 0;
  background: none;
  color: var(--ink);
  font-family: var(--font-ui);
  font-size: var(--text-lg);
}

.palette__input:focus { outline: none; }
.palette__input::-webkit-search-cancel-button { display: none; }

.palette__results {
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: var(--s2);
  /* Le défilement de la liste n'entraîne jamais celui de la page derrière. */
  -webkit-overflow-scrolling: touch;
}

.palette__group {
  padding: var(--s3) var(--s3) var(--s2);
  font-size: var(--text-2xs);
  font-weight: 650;
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--ink-4);
}

.palette__row {
  display: flex;
  align-items: center;
  gap: var(--s3);
  padding: var(--s3);
  border-radius: var(--r-md);
  color: var(--ink);
  /* La couleur seule change au survol : aucune transformation, donc aucun
     décalage de la liste sous le curseur. */
  transition: background-color var(--dur-1) var(--ease);
}

.palette__row:hover,
.palette__row.is-active { background: var(--surface-2); }
.palette__row.is-active { box-shadow: inset 0 0 0 1px var(--accent-line); }

.palette__icon {
  display: grid;
  place-items: center;
  width: 2rem;
  height: 2rem;
  flex: none;
  border-radius: var(--r-sm);
  background: var(--surface-2);
  color: var(--ink-3);
}

.palette__body { display: grid; gap: 1px; min-width: 0; flex: 1 1 auto; }
.palette__title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.palette__aside { color: var(--ink-3); font-size: var(--text-sm); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.palette__badge {
  flex: none;
  padding: 2px var(--s2);
  border-radius: var(--r-full);
  font-size: var(--text-2xs);
  color: var(--paper);
  white-space: nowrap;
}

.palette__badge[data-palier="neuve"],
.palette__badge[data-palier="decouverte"] { color: var(--ink-2); }

.palette__empty {
  padding: var(--s6) var(--s5) var(--s7);
  color: var(--ink-3);
  font-size: var(--text-sm);
  text-align: center;
}

.palette.is-searching .palette__field { color: var(--accent); }

/* Le déclencheur dans la barre. */
.topbar__search {
  display: inline-flex;
  align-items: center;
  gap: var(--s2);
  height: 2.25rem;
  padding-inline: var(--s3);
  border-radius: var(--r-full);
  background: var(--surface-2);
  color: var(--ink-3);
  font-size: var(--text-sm);
  box-shadow: inset 0 0 0 1px var(--hairline);
  transition: color var(--dur-2) var(--ease), box-shadow var(--dur-2) var(--ease);
}

.topbar__search:hover { color: var(--ink); box-shadow: inset 0 0 0 1px var(--hairline-strong); }

@media (max-width: 48rem) {
  /* — Ce qui ne se comprime jamais ————————————————————————————————
   *
   * Les cinq commandes de la barre demandent 282 pixels et les écarts en
   * consomment cent de plus : à 390, la barre en réclamait 382 pour 354
   * disponibles. Flexbox ne déborde pas pour autant — il rétrécit —, et il
   * rétrécit ce qui n'a rien demandé : la cloche et le menu tombaient à
   * vingt-neuf pixels de large, en gélules aplaties, pendant que la
   * recherche et la bourse gardaient leur pleine largeur. Un défaut
   * invisible à la mesure, puisqu'il n'y a aucun débordement à constater.
   *
   * On rend donc les vingt pixels d'écart qui n'avaient pas de raison
   * d'être — la barre du haut est serrée, pas aérée, sur un téléphone — et
   * l'on interdit la compression aux quatre commandes rondes. Elles font
   * quarante-quatre pixels ou elles ne sont pas là ; il n'y a pas d'état
   * intermédiaire acceptable pour une cible tactile. */
  .topbar__inner { gap: var(--s3); }

  .topbar__cloche,
  .topbar__me,
  .topbar__inner > .btn--icon { flex: none; }

  /* — La marque ————————————————————————————————————————————————
     Elle mesurait 28 à 31 pixels de haut, sous les 44 requis. La surface se
     prend en marge interne et se rend en marge externe : rien ne bouge à
     l'écran, seule la zone touchable grandit. Ce compromis lui convient —
     c'est un mot, pas un bouton, et l'agrandir pour de bon donnerait à la
     barre un titre deux fois trop lourd. */
  a.brand {
    height: auto;
    min-block-size: 2.75rem;
    padding-block: var(--s2);
    margin-block: calc(var(--s2) * -1);
  }

  .palette__panel { max-height: 78dvh; }
  .palette { padding-block-start: var(--s6); }

  /* — Recherche et bourse : deux commandes, une seule géométrie ————
   *
   * Elles sont voisines dans la barre, elles se touchent du même pouce, et
   * elles n'avaient rien en commun. La recherche était un bouton de 36
   * pixels rembourré de 8 ; la bourse une puce de 30, que la règle tactile
   * de `pointer: coarse` relevait à 44 en lui imposant au passage un
   * rembourrage de 16 — que la règle d'ici tentait de ramener à 8, sans y
   * parvenir : `a.chip` l'emporte sur `.topbar__bourse` à la spécificité.
   * On voyait donc, côte à côte, deux commandes de hauteurs différentes aux
   * rembourrages contradictoires, dont l'une seulement atteignait la cible
   * tactile.
   *
   * Une seule déclaration pour les deux, qualifiée par la balise pour
   * reprendre la main sur `a.chip`. Quarante-quatre pixels dans les deux
   * sens — la hauteur *et* la largeur, sans quoi un bouton d'icône seule
   * reste une cible de 28 de large —, le même rembourrage, le contenu
   * centré, et la certitude de ne pas déborder : `flex: none` les empêche
   * d'être comprimées par le reste de la barre, `nowrap` interdit le retour
   * à la ligne qui, à quatre chiffres, poussait la cloche hors de l'écran.
   *
   * `box-sizing` est déjà global dans `base.css` ; il est rappelé ici parce
   * que la garantie des 44 pixels en dépend entièrement et qu'elle ne doit
   * pas pouvoir se perdre en silence. */
  button.topbar__search,
  a.topbar__bourse {
    box-sizing: border-box;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: none;
    gap: var(--s2);
    height: auto;
    min-block-size: 2.75rem;
    min-inline-size: 2.75rem;
    padding-block: 0;
    padding-inline: var(--s3);
    margin-block: 0;
    border-radius: var(--r-full);
    text-align: center;
    white-space: nowrap;
  }

  /* `tabular-nums` et une largeur minimale gardent le chiffre stable pendant
     qu'il monte — sans quoi la barre entière se réorganise à chaque unité
     comptée par la fanfare de fin de défi. Le montant est centré comme le
     reste : c'est la largeur minimale, et non un alignement à droite, qui
     empêche le saut. */
  .topbar__bourse .numeral {
    min-inline-size: 2ch;
    font-variant-numeric: tabular-nums;
    text-align: center;
  }
}

/* Le mot « Omnia » s'efface au profit de sa marque.
 *
 * Elle mène au même endroit et se reconnaît sans être lue : c'est le seul
 * élément de la barre dont on peut retirer la moitié sans rien perdre. Le
 * mot pèse soixante-huit pixels, presque autant que deux commandes.
 *
 * Le seuil était à 360 pixels et il était trop bas d'un cran. Les quatre
 * commandes rondes ne se comprimant plus, la barre réclame 274 pixels à
 * 375 comme à 390, pour 337 et 354 disponibles : cela tient, mais de
 * justesse, et un solde à cinq chiffres suffisait à reprendre la marge. À
 * 480, le mot revient, et il y a alors cent pixels de reste. */
@media (max-width: 30rem) {
  .brand span { display: none; }
}

/* Les très petits écrans, où la bourse ne peut plus être gratuite. */
@media (max-width: 22.5rem) {
  .topbar__inner { gap: var(--s2); }

  /* Le rembourrage se resserre, la cible ne bouge pas : `min-inline-size`
     tient les quarante-quatre pixels quoi qu'il arrive, et c'est bien pour
     cela qu'on peut retirer du rembourrage sans rien perdre. Mesuré à
     320 pixels avec un solde à cinq chiffres — le pire cas — la barre
     demande 270 des 284 disponibles. */
  button.topbar__search,
  a.topbar__bourse { padding-inline: var(--s2); }
  a.topbar__bourse { gap: var(--s1); }
}

@media (prefers-reduced-motion: reduce) {
  .palette, .palette__panel { transition-duration: 100ms; }
}

/* ==========================================================================
   Surfaces tactiles
   --------------------------------------------------------------------------
   Une règle, mesurée sur l'appareil et non estimée à l'œil : rien de ce qu'on
   touche ne doit offrir moins de 44 pixels de côté. C'est le seuil en dessous
   duquel un pouce vise sans certitude, et l'incertitude coûte plus cher que
   tout le reste du dessin, parce qu'elle se paie à chaque geste.

   La difficulté est qu'agrandir les commandes abîmerait la mise en page :
   une étoile de favori de 44 pixels dans une ligne de liste écrase la ligne,
   et un chevron « Boutique › » aussi haut qu'un bouton cesse d'être un
   discret renvoi. On sépare donc ce qui se voit de ce qui se touche. Le
   dessin ne bouge pas d'un pixel ; une zone invisible, centrée sur la
   commande, porte la surface à 44 pixels.

   Trois conséquences à connaître avant d'y toucher :

   — la zone est en `::after` avec `position: absolute`, donc l'élément doit
     être positionné ; les règles ci-dessous s'en assurent ;
   — elle sort de la boîte, mais `pointer-events` reste sur la commande, si
     bien qu'aucun élément voisin n'est recouvert visuellement ;
   — elle n'existe que sous `pointer: coarse`. À la souris, la précision est
     au pixel et des zones débordantes se voleraient les clics entre voisins.
   ========================================================================== */

@media (pointer: coarse) {
  .favorite,
  .search__clear,
  .topbar__me,
  .topbar__level,
  .topbar__search,
  .topbar__bourse,
  .topbar__cloche,
  .brand,
  .segmented__option,
  .btn,
  .joker,
  a.section__aside,
  a.faint,
  .home__goal-link {
    position: relative;
  }

  .favorite::after,
  .search__clear::after,
  .topbar__me::after,
  .topbar__level::after,
  .topbar__search::after,
  .topbar__bourse::after,
  .topbar__cloche::after,
  .brand::after,
  .segmented__option::after,
  .btn::after,
  .joker::after,
  a.section__aside::after,
  a.faint::after,
  .home__goal-link::after {
    content: '';
    position: absolute;
    inset-block-start: 50%;
    inset-inline-start: 50%;
    translate: -50% -50%;
    /* Au moins 44, jamais moins que la commande elle-même : une zone plus
       petite que ce qu'elle recouvre créerait un bord mort. */
    min-width: 44px;
    min-height: 44px;
    width: 100%;
    height: 100%;
  }

  /* Les options d'un sélecteur segmenté se touchent : une zone débordante
     mordrait sur sa voisine et ferait choisir l'autre. On élargit en hauteur
     seulement, la largeur étant déjà suffisante. */
  .segmented__option::after { min-width: 0; }

  /* La puce, elle, ne reçoit pas de zone invisible : elle grandit pour de bon.
     Une puce cliquable mesurait trente pixels de haut, et une zone débordante
     aurait mordu sur la rangée suivante — les rangées de puces s'écartent de
     huit à douze pixels selon l'écran, jamais des quatorze qu'il aurait
     fallu. Chercher à rattraper cela par les gaps revenait à poursuivre la
     cascade de règle en règle, et à laisser le prochain écran rouvrir le
     défaut.

     En lui donnant sa vraie hauteur, la mise en page redevient honnête : la
     puce occupe ce qu'elle prétend occuper, les gaps existants suffisent, et
     aucun chevauchement n'est plus possible où que la puce soit posée. C'est
     aussi ce que font les applications natives, dont les filtres se touchent
     à quarante-quatre points sans que personne les trouve épais.

     Seules les puces cliquables sont concernées. Une puce qui n'est qu'une
     étiquette ne se touche pas, et l'épaissir n'aurait fait que peser.

     La bourse de la barre est exclue : elle est déjà tenue à quarante-quatre
     pixels dans les deux sens par les règles de la barre, avec le
     rembourrage de sa voisine la recherche. C'est cette règle-ci qui le lui
     reprenait — même spécificité, écrite plus bas, donc gagnante — et les
     deux commandes voisines finissaient avec des rembourrages différents. */
  a.chip:not(.topbar__bourse),
  button.chip {
    min-height: 44px;
    padding-inline: var(--s4);
  }
}

/* L'anneau d'impact d'une récompense.
   -------------------------------------------------------------------------
   Il s'ouvre là où les éclats se posent, dure six dixièmes de seconde et ne
   laisse rien. Aucune bordure pleine : un dégradé radial qui s'éteint vers
   l'extérieur, sans quoi l'anneau se lit comme un élément d'interface au lieu
   d'une onde. `pointer-events: none` est indispensable — il naît par-dessus
   la barre de navigation, dont il ne doit jamais voler un clic. */
.reward-halo {
  position: fixed;
  z-index: var(--z-celebration);
  width: 5.5rem;
  height: 5.5rem;
  border-radius: var(--r-full);
  pointer-events: none;
  background: radial-gradient(
    circle,
    transparent 46%,
    color-mix(in oklab, var(--accent) 55%, transparent) 62%,
    transparent 78%
  );
}

@media (prefers-reduced-motion: reduce) {
  .reward-halo { display: none; }
}

/* Le montant gagné, en grand.
   -------------------------------------------------------------------------
   Il naît là où le gain se produit, s'élève et s'efface. Chasse fixe et
   chiffres tabulaires : un nombre qui grandit ne doit pas changer de largeur
   à chaque dizaine. `pointer-events: none` parce qu'il traverse l'écran
   au-dessus de tout, y compris de boutons qu'il ne doit jamais intercepter. */
.reward-amount {
  position: fixed;
  z-index: var(--z-celebration);
  display: inline-flex;
  align-items: center;
  gap: var(--s2);
  padding: var(--s2) var(--s4);
  border-radius: var(--r-full);
  background: color-mix(in oklab, var(--paper) 82%, transparent);
  box-shadow:
    inset 0 0 0 1px var(--accent-line),
    0 0 32px -8px color-mix(in oklab, var(--accent) 80%, transparent);
  color: var(--accent-ink);
  font-family: var(--font-mono);
  font-size: var(--text-xl);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  letter-spacing: -0.02em;
  white-space: nowrap;
  pointer-events: none;
}

.reward-amount svg {
  width: 1.1em;
  height: 1.1em;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.6;
}

/* Le flash des gains importants : une nappe, pas un éclair. Elle part des
   bords et s'éteint vers le centre, ce qui la rend perceptible sans jamais
   masquer ce qu'on regarde. */
.reward-flash {
  position: fixed;
  inset: 0;
  z-index: var(--z-celebration);
  pointer-events: none;
  opacity: 0;
  background: radial-gradient(
    circle at 50% 55%,
    transparent 35%,
    color-mix(in oklab, var(--accent) 22%, transparent) 100%
  );
}

@media (prefers-reduced-motion: reduce) {
  .reward-amount, .reward-flash { display: none; }
}

/* Le pseudonyme en dégradé.
   -------------------------------------------------------------------------
   Deux teintes, la clarté du thème courant. `--accent-l` vaut plus haut en
   thème Nuit qu'en thème Papier : le dégradé s'adapte donc tout seul, sans
   qu'aucune valeur soit écrite deux fois, et sans jamais devenir illisible
   sur son fond.

   `background-clip: text` exige que la couleur du texte s'efface. Un
   navigateur qui ne le prendrait pas en charge afficherait donc un nom
   invisible : la règle entière est enfermée dans un `@supports`, et sans lui
   le pseudonyme retombe simplement sur sa première teinte, en aplat. */
@supports ((background-clip: text) or (-webkit-background-clip: text)) {
  .pseudo--degrade {
    background-image: linear-gradient(
      100deg,
      oklch(var(--accent-ink-l) 0.13 var(--pseudo-h, 66deg)) 15%,
      oklch(var(--accent-ink-l) 0.11 var(--pseudo-h2, 205deg)) 85%
    );
    background-clip: text;
    -webkit-background-clip: text;
    color: transparent;
  }

  /* Le sceau ne se découpe pas : il garde sa propre teinte et son propre
     tracé, sinon il disparaîtrait avec la couleur du texte. */
  .pseudo--degrade .pseudo__seal {
    background: none;
    -webkit-background-clip: border-box;
    background-clip: border-box;
    color: oklch(var(--accent-ink-l) 0.12 var(--sceau-h, var(--pseudo-h, 66deg)));
  }
}

/* ==========================================================================
   Le centre de notifications
   --------------------------------------------------------------------------
   Il réutilise la feuille du produit — même fond, même ombre, même entrée —
   et n'ajoute que ce qu'une liste de messages demande : une tête qui reste
   en vue, des entrées séparées par un filet, et une marque discrète pour ce
   qui n'a pas été lu.

   Le non-lu se dit par un point et par un fond à peine plus dense, jamais par
   du gras : dans une liste où presque tout est non lu, le gras devient la
   norme et cesse de distinguer quoi que ce soit.
   ========================================================================== */

/* — Où le panneau se pose ————————————————————————————————————————

   Il se posait au centre de l'écran, derrière un voile flouté plein cadre,
   comme une boîte de dialogue. C'est le traitement d'une décision — « voulez-
   vous vraiment supprimer ? » — appliqué à une liste qu'on ouvre pour jeter
   un œil et qu'on referme aussitôt. Deux conséquences visibles : le panneau
   n'avait aucun lien avec la cloche qui venait de l'ouvrir, et flouter toute
   l'application pour consulter trois lignes donnait à un geste anodin le
   poids d'une interruption.

   Il tombe désormais sous la cloche, à droite, comme tout menu ouvert par un
   bouton. Le voile reste — c'est lui qui reçoit le clic à l'extérieur, et
   sans lui il faudrait un second mécanisme de fermeture — mais il redevient
   ce qu'il aurait toujours dû être ici : presque invisible, et sans flou.

   Sur téléphone, rien ne change : la feuille montante reste la bonne réponse
   quand la cloche est à trois centimètres du bord et le panneau large comme
   l'écran. */

.sheet#sheet-notifs {
  place-items: start end;
  /* 3,5 rem : la barre en fait 4, le panneau la mord d'un demi-rem pour se
     rattacher visuellement à la cloche plutôt que de flotter sous elle. */
  padding-block-start: 3.5rem;
  /* Aligné sur la cloche, et non sur le bord de l'écran.
   *
   * La barre du haut vit dans une coquille large centrée : sur un écran de
   * 2 000 px, la cloche est à quelque six cents pixels du bord droit. Un
   * panneau simplement collé à droite s'ouvrait donc très loin du bouton qui
   * venait de le produire — mesuré : 906 px pour la cloche, 44 px pour le
   * panneau. On ne voyait plus le lien entre les deux.
   *
   * La marge reprend le calcul de `.shell` : la moitié de ce qui dépasse de
   * la coquille, plus la gouttière. `max()` la ramène à la gouttière seule
   * dès que l'écran est plus étroit que la coquille, cas où celle-ci occupe
   * déjà toute la largeur. */
  padding-inline: max(var(--gutter), calc((100vw - var(--w-wide)) / 2 + var(--gutter)));
  background: color-mix(in oklab, var(--scrim) 35%, transparent);
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
}

.notifs__panneau {
  width: min(27rem, 100%);
  max-width: none;
  padding: 0;
  display: grid;
  grid-template-rows: auto auto minmax(0, 1fr) auto;
  max-height: min(72dvh, 34rem);
  /* La liste défile, jamais le panneau : sans cela les deux défilaient l'un
     dans l'autre et la tête du panneau s'échappait vers le haut. */
  overflow: hidden;
  transform-origin: 100% 0;
  /* 130 ms comme les autres feuilles : le panneau s'ouvre pour être lu, pas
     pour être vu arriver. */
  animation: notifs-in 130ms var(--ease) both;
}

@keyframes notifs-in {
  from { opacity: 0; transform: scale(0.98); }
  to   { opacity: 1; transform: none; }
}

@media (max-width: 34rem) {
  .sheet#sheet-notifs {
    place-items: end center;
    padding: 0;
    background: var(--scrim);
  }

  .notifs__panneau {
    width: 100%;
    max-height: min(85dvh, 44rem);
    transform-origin: 50% 100%;
    animation-name: sheet-in;
    /* `.sheet__panel` réserve la barre de gestes en bas de l'écran ; le
       `padding: 0` ci-dessus, écrit plus loin dans la feuille, l'effaçait à
       spécificité égale. Les derniers pixels du panneau — le lien vers les
       réglages — passaient donc sous l'indicateur d'accueil de l'iPhone.
       La réserve est reposée ici, sur le seul bord concerné. */
    padding-block-end: env(safe-area-inset-bottom);
  }
}

/* Le mouvement réduit n'était honoré par aucune feuille : `sheet-in` glissait
   de seize pixels, et de toute la hauteur de l'écran sur téléphone, quel que
   soit le réglage du système. L'apparition reste marquée, mais par la seule
   opacité — ce qui gêne dans un panneau qui monte, c'est le déplacement, pas
   le fait qu'il apparaisse.

   La règle vient après la bascule mobile, et non avant : `animation-name:
   sheet-in` y est réaffecté, et à spécificité égale c'est le dernier écrit
   qui l'emporte. Placée plus haut, elle aurait été sans effet exactement là
   où le mouvement est le plus ample. */
@media (prefers-reduced-motion: reduce) {
  .sheet__panel,
  .notifs__panneau { animation: fade var(--dur-2) linear both; }
}

.notifs__tete {
  display: flex;
  align-items: center;
  gap: var(--s2);
  padding: var(--s4) var(--s3) var(--s3) var(--s5);
}

.notifs__titre {
  font-family: var(--font-ui);
  font-size: var(--text-md);
  font-weight: 650;
  letter-spacing: var(--track-tight);
}

/* Le compte, à côté du titre. Discret : c'est un repère, pas une alarme —
   l'alarme, c'est la pastille de la cloche, et elle a déjà servi. */
.notifs__compte {
  min-width: 1.25rem;
  padding-inline: 6px;
  border-radius: var(--r-full);
  background: var(--accent-wash);
  color: var(--accent-ink);
  font-size: var(--text-2xs);
  font-weight: 700;
  line-height: 1.25rem;
  text-align: center;
  font-variant-numeric: tabular-nums;
}

.notifs__compte[hidden] { display: none; }

/* Fermer : une cible de trente-six pixels, sans l'habillage d'un `btn`.
   Un bouton fantôme de la taille des autres aurait pesé autant que le
   titre, dans une tête qui doit rester légère. */
.notifs__fermer {
  display: grid;
  place-items: center;
  width: 2.25rem;
  height: 2.25rem;
  border: 0;
  border-radius: var(--r-full);
  background: transparent;
  color: var(--ink-3);
  cursor: pointer;
  transition: background-color var(--dur-2) var(--ease), color var(--dur-2) var(--ease);
}

.notifs__barre {
  display: flex;
  gap: var(--s1);
  padding: 0 var(--s3) var(--s3) var(--s3);
  border-block-end: 1px solid var(--hairline);
}

/* Deux actions de service, écrites en petit. Elles étaient posées en
   `btn--ghost btn--sm` : deux gélules de trente-quatre pixels qui pesaient
   visuellement autant que les notifications elles-mêmes, alors qu'on ne les
   touche qu'exceptionnellement. */
.notifs__action {
  display: inline-flex;
  align-items: center;
  gap: var(--s1);
  padding: var(--s2) var(--s3);
  border: 0;
  border-radius: var(--r-md);
  background: transparent;
  color: var(--ink-3);
  font-size: var(--text-xs);
  font-weight: 550;
  cursor: pointer;
  transition: background-color var(--dur-2) var(--ease), color var(--dur-2) var(--ease);
}

@media (hover: hover) {
  .notifs__fermer:hover { background: var(--surface-2); color: var(--ink); }
  .notifs__action:hover { background: var(--surface-2); color: var(--ink); }
}

.notifs__liste {
  list-style: none;
  margin: 0;
  padding: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
}

.notif {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  align-items: center;
  border-block-end: 1px solid var(--hairline);
  transition: background-color var(--dur-2) var(--ease);
  animation: notif-pose 200ms var(--ease) both;
  animation-delay: calc(var(--i, 0) * 24ms);
}

.notif:last-child { border-block-end: 0; }

@keyframes notif-pose {
  from { opacity: 0; transform: translate3d(0, 6px, 0) scale(.99); }
  to   { opacity: 1; transform: none; }
}

@media (prefers-reduced-motion: reduce) {
  .notif { animation: fade var(--dur-2) linear both; animation-delay: 0s; }
}

/* Le non-lu se dit deux fois, et c'est voulu : un fond à peine teinté, qui
   se voit du coin de l'œil en parcourant la liste, et un point net à
   gauche, qui répond à la question « laquelle exactement ». Le fond seul ne
   survit pas au thème sombre, où les écarts de luminosité sont plus
   serrés ; le point, lui, se lit partout. Jamais de gras : dans une liste
   où presque tout est non lu, le gras devient la norme et ne distingue
   plus rien. */
.notif.is-unread { background: var(--accent-wash); }

.notif__corps {
  display: grid;
  grid-template-columns: auto auto minmax(0, 1fr);
  align-items: center;
  gap: var(--s3);
  padding: var(--s3) var(--s2) var(--s3) var(--s4);
  color: inherit;
  text-decoration: none;
  min-width: 0;
}

/* Le point de non-lu occupe sa colonne même quand il est éteint : sinon la
   liste se décale de six pixels à chaque entrée lue, et l'alignement des
   avatars part en escalier. */
.notif__point {
  width: 7px;
  height: 7px;
  border-radius: var(--r-full);
  background: transparent;
  transition: background-color var(--dur-2) var(--ease);
}

.notif.is-unread .notif__point {
  background: var(--accent);
  box-shadow: 0 0 8px -1px var(--accent);
}

.notif__marque {
  position: relative;
  display: grid;
  place-items: center;
  width: 2.5rem;
  height: 2.5rem;
  border-radius: var(--r-full);
  background: var(--surface-2);
  color: var(--ink-3);
  flex: none;
  box-shadow: inset 0 0 0 1px var(--hairline);
  transition: transform var(--dur-2) var(--ease-spring);
}

.notif__corps:hover .notif__marque { transform: scale(1.06); }

.notif.is-unread .notif__marque { background: var(--accent); color: var(--on-accent, #fff); }

/* Un visage occupe la case entière : pas de fond, pas de teinte de non-lu
   par-dessous. Sans cette remise à zéro, l'avatar d'un ami se retrouvait
   posé sur une pastille ambre qui dépassait tout autour — deux ronds
   concentriques de couleurs différentes là où il n'en faut qu'un. */
.notif__marque--visage {
  background: none;
  padding: 0;
  box-shadow: none;
}

.notif__marque--visage .avatar {
  width: 2.5rem;
  height: 2.5rem;
  box-shadow: inset 0 0 0 1px var(--hairline), 0 2px 8px -3px rgba(0,0,0,.3);
}

.notif.is-unread .notif__marque--visage .avatar {
  box-shadow: 0 0 0 2px var(--accent), 0 3px 12px -4px color-mix(in oklab, var(--accent) 70%, transparent);
}

.notif__texte { display: grid; gap: 1px; min-width: 0; }

/* Deux lignes au plus pour le titre : il contient un nom de compte, donc
   du texte écrit par quelqu'un d'autre, dont on ne maîtrise pas la
   longueur. Trois lignes de titre feraient une entrée haute comme deux. */
.notif__titre {
  font-size: var(--text-sm);
  font-weight: 600;
  line-height: 1.35;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* Deux lignes au plus : une notification qui demande à être dépliée n'est
   plus une notification, c'est une page. */
.notif__detail {
  font-size: var(--text-xs);
  color: var(--ink-3);
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.notif__quand { font-size: var(--text-2xs); color: var(--ink-4); margin-block-start: 2px; }

/* Le survol d'une entrée : un glissement de deux pixels vers l'intérieur,
   qui suffit à dire « celle-ci est cliquable » sans déplacer la liste. */
/* Le glissement au survol se fait en `transform`, jamais en `padding` :
   animer une marge intérieure remet en page l'entrée à chaque image, et sur
   une liste de douze cela se voit. */
@media (hover: hover) {
  .notif__corps { transition: background-color var(--dur-2) var(--ease), transform var(--dur-2) var(--ease); }
  .notif__corps:hover { transform: translate3d(3px, 0, 0); }
}

.notif__oubli {
  align-self: center;
  display: grid;
  place-items: center;
  width: 2.75rem;
  height: 2.75rem;
  margin-inline-end: var(--s2);
  border: 0;
  background: transparent;
  color: var(--ink-4);
  border-radius: var(--r-full);
  cursor: pointer;
  transition: color var(--dur-2) var(--ease), background-color var(--dur-2) var(--ease);
}

@media (hover: hover) {
  .notif__corps:hover { background: var(--surface-2); }
  .notif__oubli:hover { color: var(--ink); background: var(--surface-2); }
}

.notifs__vide {
  padding: var(--s8) var(--s5);
  text-align: center;
  color: var(--ink-3);
  font-size: var(--text-sm);
}

.notifs__reglages {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--s2);
  padding: var(--s4);
  border-block-start: 1px solid var(--hairline);
  font-size: var(--text-sm);
  color: var(--ink-3);
  text-decoration: none;
}

/* — La cloche de la barre ————————————————————————————————————————
   --------------------------------------------------------------------------
   Elle était le seul élément de la barre sans surface : trente-six pixels,
   fond transparent, encre secondaire. À côté d'une pastille de recherche,
   d'une bourse, d'un anneau de niveau et d'un avatar — tous posés sur un
   fond — elle se lisait comme un détail décoratif plutôt que comme un
   bouton. C'est la raison pour laquelle personne ne la voyait.

   Elle reçoit donc le même traitement que ses voisines : une surface, un
   filet, quarante-quatre pixels de côté — la taille tactile, qui se trouve
   être aussi celle de l'avatar juste à côté, ce qui aligne enfin la fin de
   la barre.

   Trois états, et un seul mouvement.

   Au repos, elle est sobre. Dès qu'il y a du non-lu, `is-news` allume un
   halo ambre derrière elle et la cloche sonne une fois — un balancement de
   quelques degrés, joué une seule fois par arrivée, jamais en boucle. Une
   icône qui s'agite en permanence devient un bruit de fond qu'on cesse de
   voir, ce qui est l'inverse du but.
   ========================================================================== */

.topbar__cloche {
  position: relative;
  display: grid;
  place-items: center;
  width: 2.75rem;
  height: 2.75rem;
  border: 0;
  border-radius: var(--r-full);
  background: var(--surface-2);
  box-shadow: inset 0 0 0 1px var(--hairline);
  color: var(--ink);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition:
    background-color var(--dur-2) var(--ease),
    box-shadow var(--dur-2) var(--ease),
    color var(--dur-2) var(--ease),
    transform var(--dur-1) var(--ease-soft);
}

.topbar__cloche-icone {
  position: relative;
  z-index: 1;
  transform-origin: 50% 15%;   /* la cloche pivote depuis son attache */
}

@media (hover: hover) {
  .topbar__cloche:hover {
    background: var(--surface-3, var(--surface));
    box-shadow: inset 0 0 0 1px var(--hairline-strong, var(--hairline));
  }
}

.topbar__cloche:active { transform: scale(0.94); transition-duration: 90ms; }

.topbar__cloche:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* — Il y a du neuf ————————————————————————————————————————————— */

/* L'état « il y a du neuf » se dit par le halo, le liseré et la pastille —
   jamais en recolorant l'icône.
 *
 * La première version passait la cloche en `--accent-ink`. Mesuré : 1,71:1
 * sur sa propre surface en thème clair, contre 8,92 en thème sombre. Le
 * jeton `--accent-ink` se résout à la même valeur dans les deux thèmes —
 * un défaut plus ancien et plus large, qui touche aussi les pastilles
 * accentuées — et il n'a pas sa place sur un fond clair.
 *
 * L'icône reste donc à pleine encre, lisible partout, et l'accent vit
 * autour d'elle. C'est de toute façon plus juste : ce qui a changé n'est
 * pas la nature du bouton, c'est ce qu'il y a derrière. */
.topbar__cloche.is-news {
  background: var(--accent-wash);
  box-shadow: inset 0 0 0 1px var(--accent-line), 0 2px 12px -6px var(--accent);
}

/* Le halo est un calque à part, et non une ombre portée sur le bouton :
   une `box-shadow` diffuse aurait grossi la cible et poussé ses voisines.
   Celui-ci déborde sans occuper de place. */
.topbar__cloche-halo {
  position: absolute;
  inset: -25%;
  border-radius: var(--r-full);
  background: radial-gradient(closest-side, var(--accent) 0%, transparent 72%);
  opacity: 0;
  filter: blur(6px);
  pointer-events: none;
  transition: opacity var(--dur-4) var(--ease);
}

/* Le halo s'allume, il ne bat pas.
 *
 * Il pulsait toutes les trois secondes, sans fin, tant qu'il restait une
 * notification non lue — sur chaque écran, dans le coin de l'œil, pendant
 * toute la session. Une pastille chiffrée dit déjà combien il y en a ; un
 * halo qui respire par-dessus ne l'apprend pas deux fois, il rappelle
 * seulement qu'on ne l'a pas encore fait taire. C'est la mécanique des jeux
 * qui réclament l'attention, et Omnia ne la pratique pas.
 *
 * Il reste : allumé, fixe, comme une lampe. Le balancement de la cloche,
 * lui, se joue une fois à l'arrivée d'une nouvelle, et c'est ce qui signale
 * l'événement. */
.topbar__cloche.is-news .topbar__cloche-halo { opacity: .38; }

/* Le balancement, une seule fois. `is-ring` est posée puis retirée par le
   script à la fin de l'animation : c'est ce qui garantit qu'elle rejoue à
   la notification suivante sans jamais tourner en continu. */
.topbar__cloche.is-ring .topbar__cloche-icone {
  animation: cloche-sonne 820ms var(--ease) both;
}

@keyframes cloche-sonne {
  0%   { transform: rotate(0deg); }
  12%  { transform: rotate(-13deg); }
  26%  { transform: rotate(10deg); }
  40%  { transform: rotate(-7deg); }
  54%  { transform: rotate(5deg); }
  68%  { transform: rotate(-3deg); }
  82%  { transform: rotate(1.5deg); }
  100% { transform: rotate(0deg); }
}

/* La pastille est posée sur la cloche et ne la déplace pas : une pastille qui
   apparaît en poussant son icône ferait sauter toute la barre au moment
   précis où l'on cherche à cliquer dessus.

   L'anneau couleur papier la détache de l'icône. Sans lui, un « 3 » ambre
   sur une cloche ambre en thème sombre se fondait dans le trait du dessin —
   le compteur était présent et illisible. */
.topbar__pastille {
  position: absolute;
  inset-block-start: 1px;
  inset-inline-end: 1px;
  z-index: 2;
  min-width: 1.125rem;
  height: 1.125rem;
  padding-inline: 4px;
  border-radius: var(--r-full);
  background: var(--accent);
  color: var(--on-accent, #fff);
  box-shadow: 0 0 0 2px var(--paper);
  font-size: 0.625rem;
  font-weight: 700;
  line-height: 1.125rem;
  text-align: center;
  font-variant-numeric: tabular-nums;
  letter-spacing: 0;
  animation: pastille-pose 200ms var(--ease-spring) both;
}

@keyframes pastille-pose {
  from { opacity: 0; transform: scale(0.4); }
  to   { opacity: 1; transform: none; }
}

.topbar__pastille[hidden] { display: none; }

@media (prefers-reduced-motion: reduce) {
  .topbar__cloche.is-ring .topbar__cloche-icone { animation: none; }
  .topbar__pastille { animation: none; }
  .topbar__cloche:active { transform: none; }
  .notif__corps:hover .notif__marque { transform: none; }
}

/* ==========================================================================
   La navigation ne bouge pas
   --------------------------------------------------------------------------
   Une barre de navigation n'est pas du contenu : c'est le cadre dans lequel
   le contenu apparaît. Un cadre qui glisse, s'estompe ou rebondit se fait
   remarquer au moment précis où l'on cherchait autre chose — et pire, il
   déplace des cibles pendant qu'un doigt s'approche d'elles.

   Trois choses sont donc figées ici, et pour trois raisons différentes.

   **Le menu de compte.** Il empruntait l'animation générique des feuilles :
   un fondu du voile, un panneau qui monte de cent pour cent en bas d'écran.
   C'est juste pour une fenêtre qui interrompt — un défi reçu, une
   célébration — parce que le mouvement dit alors « ceci arrive maintenant ».
   Un menu, lui, n'arrive pas : il était déjà là, on l'ouvre. Il apparaît
   donc d'un coup.

   **Le filet de la barre du haut.** Il se fondait à l'entrée du défilement.
   Une barre qui change d'apparence pendant qu'on fait défiler donne
   l'impression que la page réagit à autre chose qu'au défilement. Le filet
   apparaît toujours, mais franchement.

   **La barre d'onglets.** Elle ne conserve que la teinte de l'onglet actif,
   qui est le seul changement d'état que l'on ait besoin de suivre du regard.

   Ce qui n'est pas figé, et pourquoi : les couleurs au survol et à l'état
   actif. Elles ne déplacent rien, ne changent aucune dimension, et sont ce
   qui rend un lien vivant sous le doigt. Les supprimer rendrait la barre
   inerte, pas stable.
   ========================================================================== */

#sheet-menu,
#sheet-menu .sheet__panel {
  animation: none !important;
}

.topbar { transition: none; }

/* Rien ne s'anime dans la barre du haut, ni dans le cadre de celle du bas.
 *
 * Le garde couvrait aussi les enfants de la barre d'onglets — `.tabbar *` —
 * et il éteignait donc la seule animation qu'on veuille y voir : les trois
 * temps de l'icône choisie, qui répondent au doigt. Un `!important` posé
 * pour empêcher les entrées de page empêchait aussi les réactions.
 *
 * La distinction tient en une phrase : la barre elle-même ne bouge jamais,
 * ce qu'elle contient réagit. Le cadre reste figé, les onglets répondent. */
.topbar,
.topbar *,
.tabbar {
  animation: none !important;
}

.nav__link {
  transition: color var(--dur-2) var(--ease), background-color var(--dur-2) var(--ease);
}

/* ==========================================================================
   La case à cocher
   --------------------------------------------------------------------------
   C'était le seul contrôle du produit resté au dessin du navigateur : treize
   pixels de côté, bleu système, coins carrés, au milieu d'une interface où
   tout le reste porte les rayons, les teintes et les durées d'Omnia. Rien ne
   trahit plus sûrement un travail inachevé qu'un élément natif oublié parmi
   des éléments dessinés — on ne sait pas nommer ce qui cloche, mais on le voit.

   `appearance: none` retire le dessin natif sans toucher au comportement :
   c'est toujours une case à cocher pour le clavier, pour un lecteur d'écran
   et pour le formulaire. Seule sa peau change.

   La coche est un trait SVG en masque plutôt qu'un caractère : un « ✓ »
   typographique n'a pas la même graisse d'une police à l'autre, et se
   déplacerait d'un demi-pixel selon la plateforme.
   ========================================================================== */

.check {
  appearance: none;
  -webkit-appearance: none;
  flex: none;
  width: 1.375rem;
  height: 1.375rem;
  margin: 0;
  border-radius: var(--r-sm, 0.375rem);
  background: var(--surface-2);
  box-shadow: inset 0 0 0 1px var(--hairline-strong);
  cursor: pointer;
  display: grid;
  place-items: center;
  transition:
    background-color var(--dur-2) var(--ease),
    box-shadow var(--dur-2) var(--ease);
}

.check::after {
  content: "";
  width: 0.75rem;
  height: 0.75rem;
  background: var(--on-accent, #1a1409);
  -webkit-mask: var(--coche) center / contain no-repeat;
  mask: var(--coche) center / contain no-repeat;
  transform: scale(0);
  transition: transform var(--dur-2) var(--ease);
}

.check {
  --coche: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E");
}

.check:checked {
  background: var(--accent);
  box-shadow: inset 0 0 0 1px var(--accent);
}

.check:checked::after { transform: scale(1); }

.check:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

@media (hover: hover) {
  .check:not(:checked):hover { box-shadow: inset 0 0 0 1px var(--accent); }
}

@media (prefers-reduced-motion: reduce) {
  .check, .check::after { transition: none; }
}

/* ==========================================================================
   Les animations perpétuelles, sous « animations réduites »
   --------------------------------------------------------------------------
   Une durée écrite en dur échappe à la préférence. Les jetons `--dur-*`
   tombent bien à quelques millisecondes quand on demande moins de mouvement,
   mais une animation qui déclare « 1,6s infinite » ne les consulte jamais.

   Deux y échappaient, et ce sont les deux qui comptent le plus : elles ne
   s'arrêtent pas. Le miroitement d'un squelette tourne tant qu'une page
   charge ; la lueur d'un avatar tourne tant que la page est ouverte. Pour
   quelqu'un que le mouvement perturbe, une animation brève est un
   désagrément, une animation perpétuelle est une raison de fermer.

   On ne supprime pas l'élément, seulement son mouvement : le squelette garde
   sa teinte et continue d'annoncer qu'il se passe quelque chose ; l'avatar
   garde sa lueur, fixe.
   ========================================================================== */

@media (prefers-reduced-motion: reduce) {
  .skeleton { animation: none; }
  .avatar--lueur::after { animation: none; }
}


/* ==========================================================================
   Une pièce qui change
   --------------------------------------------------------------------------
   Quand une action ne modifie qu'un composant — une personne, un article,
   un défi — c'est ce composant seul qui est échangé. Deux transitions
   suffisent, et elles n'animent que `opacity` et `transform` : rien qui
   remette la page en page.

   `is-muant` couvre l'échange du contenu. La pièce ne disparaît pas : elle
   perd un souffle d'opacité le temps que le nouveau balisage prenne place,
   ce qui masque le remplacement sans jamais laisser de trou.

   `is-partant` est le cas où la pièce n'existe plus — un ami retiré, une
   invitation déclinée. Elle s'efface et se rétracte, plutôt que de
   disparaître d'un coup en faisant remonter tout ce qui suit.
   ========================================================================== */

[data-piece] {
  transition: opacity var(--dur-3) var(--ease), transform var(--dur-3) var(--ease);
}

[data-piece].is-muant {
  animation: piece-mue 180ms var(--ease) both;
}

@keyframes piece-mue {
  0%   { opacity: .45; transform: scale(0.994); }
  100% { opacity: 1;   transform: none; }
}

[data-piece].is-partant {
  opacity: 0;
  transform: translate3d(0, -6px, 0) scale(0.97);
  pointer-events: none;
}

/* Le formulaire en cours : le geste est parti, on le montre sans bloquer la
   lecture du reste. */
form.is-busy { opacity: .62; transition: opacity var(--dur-2) var(--ease); }

@media (prefers-reduced-motion: reduce) {
  [data-piece], [data-piece].is-muant, [data-piece].is-partant {
    animation: none;
    transition: opacity var(--dur-2) linear;
    transform: none;
  }
}


/* ==========================================================================
   L'état pressé, partout et de la même façon
   --------------------------------------------------------------------------
   Le produit avait trois réponses différentes au même geste : les boutons
   s'enfonçaient de 2,8 %, les onglets de 8 %, les cartes d'amis de 7 %, et
   les cartes de thème ou de défi ne réagissaient pas du tout. Un doigt qui
   appuie sur deux objets de la même page recevait deux réponses distinctes,
   et parfois aucune.

   Une seule valeur, un seul temps : 0,97 en 90 ms. C'est assez pour être
   senti, trop court pour être vu comme une animation, et cela n'anime que
   `transform` — donc rien ne se remet en page.

   `:active` ne dure que le temps du contact : il n'y a rien à retirer, rien
   à nettoyer, et aucun état ne peut rester coincé.
   ========================================================================== */

.plate--interactive:active,
.theme-card--choix:active,
.duel-card:active,
.notif__corps:active,
.shop-item:active {
  transform: scale(0.97);
  transition-duration: 90ms;
}

/* Les surfaces qui portent déjà un déplacement au survol le conservent :
   l'enfoncement s'ajoute au lieu de le remplacer, sinon la carte remonte
   sous le doigt au moment où elle devrait descendre. */
@media (hover: hover) {
  .plate--interactive:hover:active,
  .duel-card:hover:active { transform: translate3d(0, -1px, 0) scale(0.97); }
}

@media (prefers-reduced-motion: reduce) {
  .plate--interactive:active,
  .theme-card--choix:active,
  .duel-card:active,
  .notif__corps:active,
  .shop-item:active { transform: none; }
}


/* ==========================================================================
   Les petits liens, au pouce
   --------------------------------------------------------------------------
   « Voir tout », « Statistiques détaillées », « Boutique » : des liens de
   navigation posés en fin de titre de section, hauts de vingt et un pixels.
   Ils sont lisibles, mais on les rate au doigt — la moitié de la cible
   recommandée.

   La zone touchable s'étend sans que rien ne bouge à l'écran : les marges
   intérieures sont compensées par des marges négatives, si bien que le texte
   reste exactement où il était. C'est la seule façon d'agrandir une cible
   sans redessiner la mise en page autour d'elle.

   Uniquement sur tactile : à la souris, vingt et un pixels se visent très
   bien, et l'espacement de la section n'a pas à s'en trouver changé.
   ========================================================================== */

@media (max-width: 47.99rem) {
  a.section__aside,
  .home__goal-link,
  .notifs__reglages {
    display: inline-flex;
    align-items: center;
    min-block-size: 2.75rem;
    padding-inline: var(--s2);
    margin-inline: calc(var(--s2) * -1);
  }

  /* Les pastilles de filtre — période, cercle, catégorie — sont des boutons
     à part entière : on les touche pour changer ce qu'on regarde. Trente
     pixels de haut, c'est la taille d'une étiquette, pas d'une commande.
     Elles gardent leur corps de texte et leur silhouette ; seule la hauteur
     de la zone change. */
  a.chip, button.chip { min-block-size: 2.75rem; padding-block: var(--s2); }

  /* Les actions de carte — « Défier », « Acquérir », « Porter » — sont
     posées en `btn--sm`, une taille pensée pour la souris. Elles étaient
     déjà relevées dans les défis ; la règle vaut partout où l'on agit au
     doigt sur une carte. */
  /* Les options d'un segmenté — « Pour vous », « Progression », « A → Z » —
     changent ce qu'on regarde : ce sont des commandes, pas des étiquettes.
     Elles tenaient trente-sept pixels, et le bouton d'effacement de la
     recherche trente-deux. */
  .segmented__option { min-block-size: 2.75rem; }
  .search__clear { width: 2.75rem; height: 2.75rem; }

  /* Le lien de retour en tête des sous-pages : vingt-trois pixels, et c'est
     la première chose qu'on touche pour revenir en arrière. Comme ailleurs,
     la surface se prend en marge interne et se rend en marge externe : le
     texte ne bouge pas d'un pixel. */
  .back-link {
    min-block-size: 2.75rem;
    padding-block: var(--s2);
    margin-block: calc(var(--s2) * -1);
  }

  .personne__actions .btn,
  .shop-item .btn,
  .plate .btn--sm,
  /* Le pied du profil : « Exporter mes données » et « Réglages », deux
     `btn--sm` posés hors carte, à trente-quatre pixels. */
  .row--center > .btn--sm,
  /* Les feuilles aussi : le menu de compte y loge « Réglages » et
     « Exporter mes données » en `btn--sm`, à trente-quatre pixels. Un
     panneau qu'on ouvre au pouce n'a aucune raison d'être plus avare que
     les cartes qu'il recouvre. */
  .sheet__panel .btn--sm { min-block-size: 2.75rem; }
}
