/* =========================================================================
   Spinner — primitive du design system (kindle-shelf)

   L'indicateur de chargement de l'app : <span class="spin"></span>, injecté
   5 fois en JS (frontend/index.html) dans les états de chargement :
     l.574  #status        — recherche  : <span class="spin"></span> Recherche…
     l.679  bouton         — sendToReader : spinner + libellé de progression
     l.721  #dbody .dnote  — détail     : <div class="dnote"><span class="spin"></span></div>
     l.765  #hist-status   — historique : <span class="spin"></span> Chargement…
     l.796  bouton         — renvoi hist : btn.innerHTML = spinner seul

   CONTRAT : la classe .spin et la keyframe rot doivent survivre TELS QUELS —
   les 5 injections JS réécrivent le DOM en innerHTML. La primitive les
   style ; le markup JS est inchangé.

   C'est un INDICATEUR, pas un contrôle : pas de tabindex, pas d'action, pas
   de fermeture. Comme le Skeleton, le Toast et le cadre de l'Empty :
     - « repos »  = sa rotation (l'app ne le rend jamais statique)
     - « chargement » = sa fonction première : il EST l'indicateur
     - survol / focus clavier / désactivé : ABSENTS PAR DÉCISION — le spinner
       n'est pas interactif (aria-hidden : il ne capte ni la souris ni le
       clavier ; l'anneau est réservé aux contrôles focusables)
     - erreur : ABSENT PAR DÉCISION — l'échec est porté par l'ÉTAT qui remplace
       le spinner (Empty avec --borders-error + message, le toast .err, ou le
       message de l'app), jamais par le spinner lui-même
   Un auditeur qui cherche .spin:hover / .spin:focus-visible / .spin--error
   doit comprendre que c'est une décision, pas une omission.

   Règles (02-ANTI-SLOP.md + skill primitive) :
   - AUCUNE valeur en dur, et PAS D'EXCEPTION DE STATUT. Le spinner est
     NEUTRE : background: currentColor — il hérite de la couleur du texte
     parent (sand-11 / sand-12 en sombre FIXE, max(r,g,b)−min < 12 → neutre).
     Il ne consomme ni --success-9 ni --danger-9 : l'exception de statut de
     la carte est CONDITIONNELLE (« si la primitive en a besoin ») et un
     spinner d'état n'est pas un accent — comme les spinners de button / chip /
     empty / toast, tous en currentColor neutre. Zéro hex en dur dans ce
     fichier.
   - Arc en MASQUE (radial + conic), PAS de `border` : le fond est
     currentColor, 3 couches en intersection — donut (trou central), disque
     plein (coupe les coins), conic 270° (l'arc ; sans le tiers manquant la
     rotation d'un anneau complet ne serait pas visible). Le masque ne lit
     que le canal alpha : `black` y est structurel, pas une couleur. Les
     angles / pourcentages sont structurels (forme de l'arc), pas des valeurs
     d'échelle. border-radius: 0 → zéro rayon ajouté (le masque fait le
     cercle).
   - OMBRES : le plafond « 3 ombres distinctes » porte sur la page specimen
     ENTIÈRE et est SATURÉ par la primitive Button (borders-base /
     borders-base+borders-focus / borders-error). Le spinner en introduit
     AUCUNE : il est en masque, SANS box-shadow → zéro ombre. Il ne réutilise
     rien non plus (il n'en a pas besoin).
   - FONDS NON NEUTRES : plafond 3, saturé par .btn.primary (bronze-9/10) +
     le point .dot.no (red-9). Le spinner n'en ajoute AUCUN : sa
     backgroundColor (currentColor) se résout en NEUTRE (sand-11/12) dans
     TOUS les contextes du specimen (statut, bouton data-busy, ligne .kv,
     compacte) → hors du compteur « fonds non neutres » (l'audit ne lit que
     backgroundColor, jamais currentColor d'une couleur de texte).
   - TAILLE : 16 px au repos (--space-5, la taille du spinner partout dans le
     design system : button / kv / chip / empty / toast) ; 12 px en compacte
     (--space-4) pour les contextes serrés. Les 13 px de l'app (l.263) sont
     hors de l'échelle fermée --space-* → absorbés par --space-5. Trait
     4 px au repos (--space-2), 2 px en compacte (--space-1) : le rapport
     anneau/trou reste lisible. Tout est sur la grille 4 px.
   - DURÉE DU SPIN : 0,75 s (la durée continue du design system, comme
     button / kv / chip / empty / toast) ; 2 s sous prefers-reduced-motion
     (le mouvement reste — l'information « chargement » doit passer). Seuls
     littéraux du fichier, comme les autres primitives : tokens.css ne
     définit aucune durée d'animation continue, et en ajouter un se demande,
     ne se décide pas.
   - ACCESSIBILITÉ — CONTRAT : aria-label SEULEMENT en chargement. Le spinner
     décoratif (à côté d'un texte visible, ou dans un bouton déjà nommé) est
     aria-hidden. Quand il est le SEUL indicateur d'un chargement (pas de
     texte visible), c'est le CONTENEUR qui porte le nom (role="status" +
     aria-label) — jamais le spinner lui-même, jamais hors chargement.
   ========================================================================= */

/* --- Spinner (repos = sa rotation) ---------------------------------------
   Remplace .spin de l'app (l.262–266). display: inline-block : l'app le
   pose inline devant un libellé (statut, bouton) ; le flex (align-items)
   des conteneurs le centre aussi. currentColor : il suit la couleur du
   parent (neutre partout dans le specimen). vertical-align: middle —
   mot-clé, pas une valeur : l'alignement inline devant un texte, sans px. */

.spin {
  display: inline-block;
  width: var(--space-5);    /* 16 px — sur la grille (l'app : 13px, hors échelle) */
  height: var(--space-5);
  flex: none;
  vertical-align: middle;

  background: currentColor; /* NEUTRE : hérite du texte parent (sand-11/12) */

  -webkit-mask-image:
    radial-gradient(farthest-side, transparent calc(100% - var(--space-2)), black 0),
    radial-gradient(closest-side, black 99%, transparent 100%),
    conic-gradient(black 270deg, transparent 0deg);
  -webkit-mask-composite: source-in, source-in;
  mask-image:
    radial-gradient(farthest-side, transparent calc(100% - var(--space-2)), black 0),
    radial-gradient(closest-side, black 99%, transparent 100%),
    conic-gradient(black 270deg, transparent 0deg);
  mask-composite: intersect, intersect;

  animation: rot 0.75s linear infinite;
}

@keyframes rot {
  to { transform: rotate(360deg); }
}

/* --- Taille compacte -------------------------------------------------------
   12 px (--space-4) pour les contextes serrés, trait 2 px (--space-1) : le
   rapport anneau/trou reste lisible (4 px de trait sur 12 px serait un
   disque presque plein). Seule la 1re couche du masque change (l'épaisseur
   de l'anneau) ; le disque et l'arc sont inchangés. Sur la grille. */

.spin.sm {
  width: var(--space-4);    /* 12 px */
  height: var(--space-4);
  -webkit-mask-image:
    radial-gradient(farthest-side, transparent calc(100% - var(--space-1)), black 0),
    radial-gradient(closest-side, black 99%, transparent 100%),
    conic-gradient(black 270deg, transparent 0deg);
  mask-image:
    radial-gradient(farthest-side, transparent calc(100% - var(--space-1)), black 0),
    radial-gradient(closest-side, black 99%, transparent 100%),
    conic-gradient(black 270deg, transparent 0deg);
}

/* --- prefers-reduced-motion ------------------------------------------------
   Le spin ralentit mais reste : l'information « chargement » doit passer.
   (Le spinner n'a pas de transition — son animation est ici ; les durées de
   transition des autres primitives tombent déjà à 0 via tokens.css.) */

@media (prefers-reduced-motion: reduce) {
  .spin {
    animation-duration: 2s;
  }
}
