/* ============================================================================
   ASTROPOST — Ce que l'application ajoute au système de design
   ----------------------------------------------------------------------------
   LA RÈGLE DE COHABITATION, et il faut la lire avant de toucher à ce fichier.

   Le système (`design/css/`) est enveloppé dans `@layer astropost`. Une règle
   NON couchée l'emporte donc sur lui, quelle que soit sa spécificité — c'est
   ainsi qu'une barre latérale masquée par le système restait visible, et que
   l'écran affichait à la fois la barre latérale et la barre d'onglets.

   Ce fichier déclare donc l'ordre des couches, puis se range dans la sienne :

       @layer astropost, app;

   `app` passe après `astropost` : ce qu'on écrit ici l'emporte, mais **on le
   sait**, et le système reprend la main dès qu'on retire une règle. Plus
   d'écrasement silencieux.

   On ne trouve donc ici que ce que le système ne porte pas :
     · la carte du ciel et son plan de situation ;
     · l'oculaire de mise au point ;
     · les messages, les listes, les titres d'écran ;
     · quelques réglages d'aération.

   RÈGLE HÉRITÉE DES JETONS : aucune couleur, aucun espacement, aucune durée
   écrits en dur. Tout passe par les variables de `01-jetons.css`.
   ========================================================================= */

@layer astropost, app;

@layer app {

  /* ══ Aération ═════════════════════════════════════════════════════════════
     Le ton du jeu est contemplatif : les écrans respirent plutôt qu'ils ne se
     remplissent. Une seule règle d'espacement vertical, et rien d'autre. */

  /* L'espacement d'un écran se fait en FLUX DE BLOC, et non en flex.
     Les grilles du système portent `margin-inline: auto` pour se centrer
     quand elles sont étroites ; dans un conteneur flex, cette marge
     automatique l'emporte sur l'étirement et la grille se réduit à la
     largeur d'une seule carte. En flux de bloc, elle se résout à zéro et la
     grille occupe toute la largeur — ce qu'on attend d'elle. */
  .ap-screen > * + * { margin-block-start: var(--space-lg); }
  .ap-screen-title { display: flex; flex-direction: column; gap: var(--space-xs); }

  /* ══ La grille régulière d'écran ═════════════════════════════════════════
     UN CHOIX TRANCHÉ PAR LE PROPRIÉTAIRE, ET QUI RENVERSE LE PRÉCÉDENT.

     Ces cartes coulaient en colonnes — `column-width`, le procédé dit de la
     maçonnerie. Il tasse : chaque carte commence où la précédente s'arrête,
     et aucune ne laisse de vide sous elle. C'était l'argument.

     Deux défauts l'ont emporté :

       * LE MOINDRE — les hauteurs sont inégales et les colonnes se décalent.
         « Je préfère avoir de l'espace dans les cartes que d'avoir des cartes
         qui ne sont pas identiques. » C'est dit, et cela tranche.

       * LE PIRE, ET IL SE MESURE — le flux ÉQUILIBRE PAR LA HAUTEUR : il
         vise une hauteur, remplit les premières colonnes jusque-là, et
         abandonne les dernières. Mesuré sur la fiche du catalogue, fenêtre de
         1 600 px : quatre cartes, quatre colonnes possibles, TROIS employées
         — un quart de la largeur perdu, et rien dans le balisage ne dit
         pourquoi. Forcer `column-count` n'y change rien : l'équilibrage
         recommence.

     La grille, elle, ne décide pas : `auto-fit` pose autant de colonnes
     ÉGALES que la place en autorise, et chaque carte en prend une. La largeur
     est employée jusqu'au bout, et deux cartes voisines ont la même laize sur
     tous les écrans du jeu.

     HAUTEUR ÉGALE PAR RANGÉE, et non partout. C'est le comportement naturel
     d'une grille — `align-items` vaut `stretch` par défaut, et les cartes
     d'une même rangée prennent la hauteur de la plus haute. L'égaliser sur
     TOUT l'écran imposerait à une carte de deux lignes la hauteur d'une carte
     qui porte un paragraphe : ce ne serait plus de l'air, ce serait un
     gouffre.

     ET LA CARTE QUI DÉBORDE VRAIMENT n'est ni écourtée ni étalée sur deux
     colonnes : elle fait la hauteur de sa rangée, et ses voisines s'étirent
     avec elle. Écourter perdrait du contenu ; étaler casserait les
     proportions que ce changement existe pour tenir. Un long propos — le fait
     remarquable d'un astre — est d'ailleurs déjà seul dans sa grille, ce qui
     le ramène à la largeur de lecture par la règle ci-dessous. */

  /* `min(…, 100%)` n'est PAS un ornement — DÉFAUT VU À L'ÉCRAN, sur le
     bandeau d'action d'un téléphone. Une piste `minmax(380px, 1fr)` impose
     ses 380 px même quand le conteneur n'en fait que 309 : la grille déborde
     par la droite, les tuiles sont coupées net et le texte du blocage sort de
     l'écran. `min()` autorise la piste à redescendre à la largeur disponible,
     et l'empilement se fait comme prévu. */
  .ap-grille {
    display: grid;
    grid-template-columns:
      repeat(auto-fit, minmax(min(var(--grid-card-min), 100%), 1fr));
    gap: var(--space-md);
  }
  /* Sans `min-width: 0`, un enfant de grille refuse de descendre sous la
     largeur de son contenu le plus large — un mot long, un nombre non
     sécable — et déborde de sa colonne au lieu de la respecter. */
  .ap-grille > * { min-width: 0; }

  /* Le cas d'une SEULE carte, que `auto-fit` étirerait sur toute la largeur :
     1 488 px pour une carte, une ligne de texte qu'on ne lit pas. Elle revient
     à la largeur de lecture, centrée — la même borne que partout ailleurs. */
  .ap-grille:has(> :first-child:last-child) {
    /* `width: 100%` AVANT la borne, pour la même raison que `.ap-card-flow`
       plus bas : dans une pile en colonne, une marge automatique absorbe la
       place libre et fait tomber l'étirement — la carte se réduirait à sa
       largeur intrinsèque, 373 px au lieu de 660. */
    width: 100%;
    max-width: var(--reading-width);
    margin-inline: auto;
  }

  /* Même raison, pour les grilles imbriquées dans une pile : une largeur
     explicite fait tomber les marges automatiques à zéro, et la grille
     s'étale. Sa borne de lecture continue de jouer quand la place manque. */
  .ap-card-flow, .ap-tile-grid { width: 100%; }
  .ap-screen-padding { padding-block: var(--space-lg) var(--space-xl); }

  .ap-section-title {
    font-size: var(--text-footnote);
    font-weight: var(--weight-semibold);
    letter-spacing: .04em;
    text-transform: uppercase;
    color: var(--text-secondary);
  }

  /* La zone défilante doit être LE défilement, et non un bloc qui pousse la
     page : sans cela, `overscroll-behavior: contain` bloque la molette sans
     rien faire défiler. */
  .ap-shell { height: 100vh; height: 100dvh; }
  .ap-shell[data-layout="compact"] { grid-template-columns: minmax(0, 1fr); }
  .ap-content { display: flex; flex-direction: column; min-height: 0; }
  .ap-scroll { flex: 1 1 auto; min-height: 0; }

  /* ══ La tête du profil ════════════════════════════════════════════════════
     Le score est le résumé de la partie : il se lit d'un coup d'œil, à côté
     du nom de l'observatoire, et non serré au fond d'une colonne. */

  .ap-profile-head {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-lg);
  }
  .ap-profile-head__identity {
    display: flex;
    flex-direction: column;
    gap: var(--space-sm);
    min-width: min(100%, 280px);
    flex: 1 1 320px;
  }
  /* L'avatar et le nom sur la même ligne : l'avatar est une identité, pas
     une vignette posée au-dessus du titre. `min-width: 0` sur le titre pour
     qu'un nom long se replie au lieu de pousser l'avatar hors de la carte. */
  .ap-profile-head__title-row {
    display: flex;
    align-items: center;
    gap: var(--space-md);
  }
  .ap-profile-head__title-row > h1 {
    min-width: 0;
    overflow-wrap: anywhere;
  }

  .ap-profile-head__score {
    display: flex;
    flex-direction: column;
    gap: var(--space-xs);
    min-width: 220px;
    flex: 0 1 260px;
  }
  .ap-profile-head__total {
    font-size: var(--text-large-title);
    font-weight: var(--weight-semibold);
    line-height: 1;
  }

  /* Un code se dicte au téléphone : il se lit en grand, en chasse fixe, et
     les caractères sont écartés pour qu'on ne confonde rien. */
  .ap-profile-code {
    /* Un code se DICTE : il doit tenir sur une ligne, faute de quoi on le
       lit en deux morceaux et on se trompe. Depuis que la carte vit dans
       une colonne d'appoint de 287 px, la taille fixe le coupait en deux.
       `clamp` le laisse rétrécir jusqu'à ce qu'il entre, sans jamais
       descendre sous une taille lisible. */
    font-size: clamp(var(--text-headline), 7cqi, var(--text-large-title));
    font-weight: var(--weight-semibold);
    letter-spacing: .12em;
    white-space: nowrap;
  }

  /* ══ Un graphique à barres, servi par le serveur ══════════════════════════
     Aucune bibliothèque : le squelette est calculé côté serveur et le dessin
     tient en trois éléments. C'est le même choix que la carte du ciel et la
     courbe solaire.

     LA COULEUR NE PORTE PAS L'IDENTITÉ. Les teintes par type d'astre ont été
     passées au contrôle de lisibilité : « galaxie » et « nébuleuse » sont à
     ΔE 2,4 en deutéranopie — indiscernables. C'est donc le NOM écrit à côté
     qui identifie, et toutes les barres portent le même ton. La pastille de
     couleur reste accolée au texte, pour le lien avec la carte du ciel.

     La barre est FINE et son extrémité arrondie de 4 px : une barre épaisse
     donne du poids à ce qui n'en a pas. */

  .ap-chart {
    display: grid;
    gap: var(--space-xs);
  }

  .ap-chart__row {
    display: grid;
    grid-template-columns: minmax(0, 8.5rem) minmax(0, 1fr) auto;
    align-items: center;
    gap: var(--space-sm);
  }

  .ap-chart__label {
    display: inline-flex;
    align-items: center;
    gap: var(--space-xs);
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-size: var(--text-caption);
    color: var(--text-secondary);
  }

  .ap-chart__dot {
    flex: none;
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: currentColor;
    color: var(--text-secondary);
  }
  .ap-chart__dot--warning   { color: var(--type-star); }
  .ap-chart__dot--success   { color: var(--type-planet); }
  .ap-chart__dot--secondary { color: var(--type-nebula); }
  .ap-chart__dot--primary   { color: var(--type-galaxy); }
  .ap-chart__dot--glow      { color: var(--type-cluster); }
  .ap-chart__dot--exoplanet { color: var(--type-exoplanet); }

  /* La piste est un repère discret, pas un cadre : elle dit où finit
     l'échelle sans se disputer la lecture avec la barre. */
  .ap-chart__bar {
    position: relative;
    display: block;
    height: 8px;
    border-radius: var(--pill-radius);
    background: color-mix(in srgb, var(--text-secondary) 14%, transparent);
    overflow: hidden;
  }

  .ap-chart__fill {
    display: block;
    height: 100%;
    border-radius: var(--pill-radius);
    background: var(--brand-primary);
    /* Une barre à zéro reste visible : un trait fin vaut mieux qu'un vide
       qu'on prend pour une donnée manquante. */
    min-width: 2px;
  }

  .ap-chart__value {
    font-size: var(--text-caption);
    font-variant-numeric: tabular-nums;
    color: var(--text-primary);
  }

  /* ══ Messages ═════════════════════════════════════════════════════════════
     Le retour d'une action : réussite, ou blocage avec sa cause. */

  .ap-notices { display: flex; flex-direction: column; gap: var(--space-sm); }
  .ap-notice {
    padding: var(--space-sm) var(--space-md);
    border-radius: var(--card-radius);
    font-size: var(--text-subheadline);
    background: var(--app-surface);
  }
  .ap-notice--success { background: var(--status-success-soft); color: var(--status-success); }
  .ap-notice--error   { background: var(--status-error-soft);   color: var(--status-error); }
  .ap-notice--info    { background: var(--brand-primary-soft);  color: var(--brand-primary); }

  /* ══ Liste ════════════════════════════════════════════════════════════════
     Des lignes séparées d'un filet, sans carte autour de chacune : une pile de
     cartes pour une liste de dix éléments fait un escalier. */

  .ap-list { display: flex; flex-direction: column; }
  .ap-list__row {
    display: flex;
    flex-direction: column;
    gap: var(--space-xs);
    padding-block: var(--space-md);
    color: inherit;
    text-decoration: none;
  }
  .ap-list__row + .ap-list__row { border-top: 1px solid var(--hairline); }
  a.ap-list__row:hover { text-decoration: none; color: inherit; }

  .ap-field__error { font-size: var(--text-footnote); color: var(--status-error); }

  /* Un formulaire de coordonnées se lit en colonnes : la latitude, la
     longitude et l'altitude tiennent sur une ligne, et le nom du lieu prend
     toute la largeur. Sur un téléphone, tout se remet à la queue leu leu. */
  .ap-field-grid {
    display: grid;
    gap: var(--space-md);
    grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
  }
  .ap-field-grid .ap-field--wide { grid-column: 1 / -1; }

  /* Un panneau qu'on déplie. Le réglage du lieu vit sur la carte du site,
     mais replié : on regarde son lieu plus souvent qu'on ne le corrige. */
  .ap-details__summary {
    cursor: pointer;
    list-style: none;
    display: flex;
    align-items: center;
    gap: var(--space-sm);
    min-height: 32px;
    font-size: var(--text-subheadline);
    color: var(--brand-primary);
  }
  .ap-details__summary::-webkit-details-marker { display: none; }
  .ap-details__summary::before {
    content: "";
    width: 0;
    height: 0;
    border-left: 5px solid currentColor;
    border-block: 4px solid transparent;
    transition: transform var(--motion-soft);
  }
  .ap-details[open] > .ap-details__summary::before { transform: rotate(90deg); }

  /* ══ La carte du ciel ═════════════════════════════════════════════════════
     Le seul écran qui gagne à occuper tout le cadre. La toile fait 2,2 fois le
     cadre dans les deux sens : le ciel a une étendue à explorer, et les
     repères ne se chevauchent pas. */

  .ap-sky-map {
    position: absolute;
    top: 0;
    left: 0;
    will-change: transform;
  }

  /* La coupole : un carré centré dans la toile, qui porte l'horizon, les
     cercles de hauteur, les points cardinaux et les repères. Tout y est
     positionné en pourcentage, donc la coupole peut changer de taille sans
     qu'aucune coordonnée bouge. */
  .ap-sky-dome { position: absolute; }

  .ap-sky-horizon { position: absolute; inset: 0; width: 100%; height: 100%; }
  .ap-sky-horizon__ring {
    fill: none;
    stroke: var(--hairline);
    stroke-width: 1;
  }
  /* L'horizon est le seul trait qui compte vraiment : au-delà, l'astre est
     sous terre. Il est donc plus marqué que les cercles de hauteur. */
  .ap-sky-horizon__line {
    fill: none;
    stroke: var(--hairline-strong);
    stroke-width: 1.5;
    stroke-dasharray: 6 5;
  }
  .ap-sky-horizon__zenith { stroke: var(--text-secondary); stroke-width: 1.5; opacity: .5; }
  .ap-sky-horizon__degree {
    fill: var(--text-secondary);
    font-size: 15px;
    text-anchor: middle;
    opacity: .55;
  }

  /* Les points cardinaux vivent DANS la toile, qui se réduit à l'échelle
     « Tout voir » : une taille de texte ordinaire y deviendrait illisible.
     Ils sont donc dessinés grands, pour rester lisibles à toutes les
     échelles — ce sont les repères qui tiennent la carte. */
  .ap-sky-cardinal {
    position: absolute;
    transform: translate(-50%, -50%);
    font-size: 2rem;
    font-weight: var(--weight-semibold);
    color: var(--text-secondary);
    letter-spacing: .08em;
    pointer-events: none;
  }

  /* Sous l'horizon : le repère reste, en retrait. Voir qu'un astre existe, et
     de quel côté il reviendra, fait partie du jeu. */
  .ap-marker--below { opacity: .3; }
  .ap-marker--below .ap-marker__name { font-style: italic; }



  /* ── Le menu « Plus », sur téléphone ────────────────────────────────────
     Sept onglets sur une largeur de téléphone font cinquante pixels chacun :
     « Observatoire » et « Exoplanètes » y deviennent illisibles. Quatre
     onglets restent donc visibles — ceux de la boucle de jeu — et le
     cinquième ouvre le reste.

     Il y gagne Profil et Réglages, qui n'existaient QUE dans la barre
     latérale : sur téléphone, ils étaient inaccessibles. */

  .ap-tabbar__more {
    background: none;
    border: 0;
    font: inherit;
    cursor: pointer;
  }

  .ap-more-scrim {
    position: fixed;
    inset: 0;
    z-index: 40;
    background: color-mix(in srgb, var(--sky-deep) 62%, transparent);
    backdrop-filter: blur(2px);
    animation: ap-scrim-in var(--motion-soft) both;
  }
  @keyframes ap-scrim-in { from { opacity: 0 } to { opacity: 1 } }

  .ap-more-sheet {
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 41;
    display: flex;
    flex-direction: column;
    gap: var(--space-sm);
    padding: var(--space-sm) var(--gutter)
             calc(var(--space-lg) + env(safe-area-inset-bottom));
    border-radius: var(--sheet-radius) var(--sheet-radius) 0 0;
    background: color-mix(in srgb, var(--app-surface) 96%, transparent);
    backdrop-filter: blur(24px);
    border-top: 1px solid var(--hairline);
    box-shadow: var(--shadow-sheet);
    /* Le panneau MONTE depuis le bas : c'est le geste qu'on vient de faire
       en touchant la barre, et l'animation le prolonge au lieu de le
       contredire. Sous `prefers-reduced-motion`, le socle la réduit à une
       milliseconde — on ne la désactive pas ici, il le fait déjà. */
    animation: ap-sheet-up var(--motion-soft) cubic-bezier(.22, .9, .3, 1) both;
  }
  @keyframes ap-sheet-up {
    from { transform: translateY(100%); opacity: .4 }
    to   { transform: translateY(0);    opacity: 1 }
  }

  .ap-more-sheet__grip {
    width: 38px;
    height: 4px;
    margin: 2px auto var(--space-xs);
    border-radius: 999px;
    background: var(--hairline);
  }

  .ap-more-sheet__list { display: flex; flex-direction: column; }

  .ap-more-item {
    display: grid;
    grid-template-columns: 26px minmax(0, 1fr);
    align-items: center;
    column-gap: var(--space-md);
    padding: var(--space-sm) var(--space-xs);
    border-top: 1px solid color-mix(in srgb, var(--hairline) 55%, transparent);
    text-decoration: none;
    min-height: 56px;
  }
  .ap-more-item:first-child { border-top: 0; }
  .ap-more-item:hover { text-decoration: none; }

  .ap-more-item__icon { width: 22px; height: 22px; grid-row: span 2; }
  .ap-more-item__label { font-weight: var(--weight-semibold); }
  .ap-more-item__hint {
    grid-column: 2;
    font-size: var(--text-caption);
    color: var(--text-secondary);
  }

  .ap-more-item--current { color: var(--brand-primary); }
  .ap-more-item--current .ap-more-item__hint { color: var(--brand-primary); opacity: .75; }

  /* Au large, la barre latérale porte tout : le panneau n'a plus lieu
     d'être, et le laisser ouvrable créerait deux chemins vers le même
     écran. */
  .ap-shell[data-layout="regular"] ~ .ap-more-sheet,
  .ap-shell[data-layout="regular"] ~ .ap-more-scrim { display: none; }



  /* ── La photo d'un lieu ─────────────────────────────────────────────────
     Elle vient de Wikimedia Commons, sous licence libre. Ces licences
     EXIGENT l'attribution : le crédit n'est pas une politesse, c'est la
     condition du droit d'usage. Un lieu sans attribution n'a pas d'image.

     `referrerpolicy="no-referrer"` sur la balise : sans cela, le serveur de
     Wikimedia apprend quelle page du jeu le joueur regarde. */

  .ap-site-figure {
    margin: 0;
    display: flex;
    flex-direction: column;
    gap: var(--space-xs);
  }

  /* ── Le cadrage de la photo du joueur ───────────────────────────────────
     Le bandeau est plus large que haut : `object-fit: cover` rogne. Centré
     d'office, il coupait une fois sur deux ce qu'on voulait montrer — un
     ciel se cadre en haut, une coupole au milieu, un horizon en bas.

     Le point à garder visible vient du serveur, posé en variables sur la
     figure. Le repli à 50 % n'est pas décoratif : une photo servie sans
     cadrage enregistré doit rester centrée, comme avant. */
  .ap-site-figure--cadrable .ap-site-photo {
    object-position: var(--cadrage-x, 50%) var(--cadrage-y, 50%);
  }

  /* Le cadre prend la FORME de l'image, entre deux bornes.
     Une hauteur fixe réduisait une photo en hauteur à une fente, et l'on
     cadrait un portrait dans une meurtrière. Les bornes restent : un bandeau
     reste un bandeau, et une photo de 3 000 px de haut n'a pas à pousser
     toute la page sous la ligne de flottaison.

     `aspect-ratio` l'emporte sur `max-height` parce que la hauteur devient
     une conséquence de la largeur ; le plafond reste utile pour le cas où le
     rapport manque — une photo déposée avant ce relevé. */
  .ap-site-figure--cadrable .ap-site-photo-frame {
    aspect-ratio: var(--photo-ratio, 2.4);
    max-height: 60vh;
  }
  .ap-site-figure--cadrable .ap-site-photo-frame .ap-site-photo {
    width: 100%;
    height: 100%;
    max-height: none;

    /* `contain` ET NON `cover`, et c'est tout l'enjeu du recul.

       Avec `cover`, l'image est d'abord rognée pour remplir : la réduire
       ensuite ne montre pas plus de photo, elle montre la même chose en
       plus petit. On ne pouvait donc jamais revoir l'image entière.

       Avec `contain`, l'image tient tout entière dans le cadre à 100 %, et
       l'agrandissement la fait déborder au-delà. Reculer découvre les
       bords ; avancer rogne. Le cadre prenant déjà la FORME de l'image, les
       deux coïncident pour la plupart des photos — seules les très hautes
       ou très larges, celles que les bornes ramènent, laissent paraître une
       marge, et le réglage sert précisément à la combler. */
    object-fit: contain;

    /* L'agrandissement part du POINT CADRÉ, pas du centre : on vise d'abord,
       on grossit ensuite, et ce qu'on visait reste sous les yeux. Grossir
       depuis le centre aurait chassé la cible hors du cadre à chaque cran. */
    scale: calc(var(--photo-zoom, 100%) / 100%);
    transform-origin: var(--cadrage-x, 50%) var(--cadrage-y, 50%);
  }
  /* Sans cela, l'image agrandie déborde du cadre et recouvre la page.
     Le fond se voit quand on recule : il vaut mieux qu'il soit celui d'une
     carte qu'un trou noir. */
  .ap-site-figure--cadrable .ap-site-photo-frame {
    overflow: hidden;
    background: var(--app-surface);
    border-radius: var(--card-radius);
  }

  /* On peut l'attraper. `touch-action: none` est ce qui rend le glissement
     possible au doigt : sans lui le navigateur prend le geste pour un
     défilement et coupe la série d'événements en plein milieu. */
  .ap-site-figure--cadrable .ap-site-photo { cursor: grab; touch-action: none; }
  .ap-site-figure--cadrable[data-cadrage="en-cours"] .ap-site-photo { cursor: grabbing; }

  /* Pendant le geste, une croix dit OÙ l'on vise. Sans elle on déplace une
     image sans savoir quel point on désigne, et on règle à tâtons.

     Elle se place sur le CADRE de l'image, et non sur la figure : la légende
     fait partie de la figure, et le point aurait glissé de sa hauteur. */
  .ap-site-photo-frame { position: relative; display: block; }

  /* ── Un relevé solaire, avec ce que son instrument voit ─────────────────
     La liste disait « H-α » en trois caractères. Or l'instrument ne change
     pas une teinte, il change de COUCHE du Soleil — et une couche, cela se
     montre. */
  .ap-releve {
    display: flex;
    align-items: center;
    gap: var(--space-sm);
  }
  .ap-releve__vue {
    flex: none;
    width: 48px;
    height: 48px;
    border-radius: 50%;
    object-fit: cover;
    /* Le disque solaire ne touche pas les bords de l'image publiée : sans ce
       léger agrandissement, la vignette ronde montrerait surtout du noir. */
    object-position: center;
    scale: 1.18;
    background: var(--sky-deep);
    border: 1px solid var(--hairline);
  }
  .ap-releve__corps { min-width: 0; }
  .ap-releve__instrument {
    font-size: var(--text-footnote);
    font-weight: var(--weight-semibold);
  }

  /* ── Une colonne qui ne s'ouvre que si elle a de quoi ───────────────────
     Le tableau du Soleil réserve une colonne de droite. Quand le carnet est
     vide, elle n'y porte qu'une carte de quatre tuiles — et laisse sous elle
     un demi-écran de vide, exactement le défaut déjà corrigé ailleurs.

     `:has()` regarde le contenu avant de décider : une seule carte, et la
     grille reste sur une colonne. Rien à dupliquer dans le gabarit, et la
     colonne revient d'elle-même au premier relevé. */
  @container screen (min-width: 1000px) {
    .ap-dashboard:has(> .ap-card-flow > :first-child:last-child) {
      grid-template-columns: minmax(0, 1fr);
    }
  }

  /* ── Changer la photo, depuis la photo ──────────────────────────────────
     Le seul moyen de la remplacer était un champ de fichier dans la colonne
     de droite, à côté d'un paragraphe. On regarde la photo, on décide
     qu'elle ne convient pas, et il fallait aller chercher ailleurs.

     C'est une `<label>` maquillée en bouton, pas un bouton : un libellé
     ouvre le sélecteur de fichier sans une ligne de script. Elle en a donc
     tous les signes — le pointeur, l'anneau de focus, l'état pressé — parce
     qu'elle n'en a aucun d'origine. */
  .ap-photo-action {
    position: absolute;
    inset-block-start: var(--space-sm);
    inset-inline-end: var(--space-sm);
    display: inline-flex;
    align-items: center;
    gap: 6px;
    padding: 7px 12px;
    border-radius: var(--pill-radius);
    border: 1px solid color-mix(in srgb, var(--text-primary) 22%, transparent);
    /* Le fond est sombre et flouté plutôt qu'opaque : posé sur une photo
       dont on ne sait rien, un aplat clair disparaîtrait sur un ciel clair.
       Le flou garantit le contraste quelle que soit l'image dessous. */
    background: color-mix(in srgb, var(--sky-deep) 72%, transparent);
    backdrop-filter: blur(10px);
    color: var(--text-primary);
    font-size: var(--text-footnote);
    font-weight: var(--weight-semibold);
    cursor: pointer;
    transition: background var(--duration-fast, 120ms) ease;
  }
  .ap-photo-action:hover {
    background: color-mix(in srgb, var(--sky-deep) 88%, transparent);
  }
  .ap-photo-action:active { transform: translateY(1px); }
  /* Le champ qu'elle commande est caché, mais il reste ATTEIGNABLE AU
     CLAVIER : `display: none` l'aurait sorti de l'ordre de tabulation, et
     le libellé n'aurait plus eu de cible à activer. On le rend donc
     invisible sans le retirer, et le libellé porte l'anneau de focus à sa
     place. */
  .ap-photo-action:has(~ :focus-visible),
  .ap-photo-action:focus-visible {
    outline: 2px solid var(--brand-primary);
    outline-offset: 2px;
  }
  .ap-photo-action__icone { width: 16px; height: 16px; flex: none; }
  /* C'est un lien : il ne doit pas porter le soulignement des liens de
     texte, ni leur couleur. */
  a.ap-photo-action { text-decoration: none; }

  /* Les réglages à gauche, ce qu'il advient de l'image à droite.
     L'avertissement occupait toute la largeur SOUS les curseurs : on le
     lisait après avoir décidé, donc trop tard. À côté d'eux, il est sous les
     yeux pendant qu'on règle.

     Deux colonnes seulement quand il y a la place : en dessous, elles se
     remettent l'une sous l'autre, et la commande passe la première. */
  .ap-photo-reglages {
    display: grid;
    gap: var(--space-md);
    grid-template-columns: 1fr;
  }
  @container screen (min-width: 720px) {
    .ap-photo-reglages { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
  }

  /* ── L'éditeur d'avatar ─────────────────────────────────────────────────
     L'aperçu est ROND et CARRÉ, comme l'avatar le sera partout : régler un
     cadrage sur une forme différente de celle où il s'appliquera reviendrait
     à régler autre chose. */
  .ap-avatar-editeur {
    display: grid;
    gap: var(--space-md);
    grid-template-columns: 1fr;
    align-items: start;
  }
  @container screen (min-width: 720px) {
    .ap-avatar-editeur { grid-template-columns: 260px minmax(0, 1fr); }
  }
  .ap-avatar-figure { max-width: 260px; }
  .ap-avatar-figure .ap-site-photo-frame { border-radius: 50%; }
  /* La croix de visée resterait carrée dans un cadre rond. */
  .ap-avatar-figure .ap-photo-action {
    inset-block-start: auto;
    inset-block-end: 6px;
    inset-inline-end: 6px;
  }

  /* La pastille d'appareil photo sur l'avatar du profil : une image sur
     laquelle on peut cliquer doit annoncer qu'elle est cliquable, sinon
     personne ne l'essaie. */
  .ap-avatar--modifiable { position: relative; text-decoration: none; }
  .ap-avatar__badge {
    position: absolute;
    inset-block-end: -2px;
    inset-inline-end: -2px;
    width: 28px;
    height: 28px;
    display: grid;
    place-items: center;
    border-radius: 50%;
    background: var(--app-surface);
    border: 1px solid var(--hairline-strong);
    color: var(--text-primary);
  }
  .ap-avatar__badge svg { width: 15px; height: 15px; }

  /* L'avatar quand il porte une image : le fond dégradé et les initiales
     n'ont plus lieu d'être. */
  .ap-avatar--photo {
    background: var(--app-surface) center / cover no-repeat;
    background-image: var(--avatar-image);
    color: transparent;
  }

  /* Le cadre en attente, quand aucune photo n'a encore été déposée. Un trou
     noir n'aurait rien dit ; celui-ci annonce ce qu'il attend. */
  .ap-photo-vide {
    display: grid;
    place-items: center;
    min-height: 220px;
    padding: var(--space-xl) var(--space-md);
    text-align: center;
    color: var(--text-secondary);
    font-size: var(--text-footnote);
    border: 1px dashed var(--hairline-strong);
    border-radius: var(--card-radius);
    background: color-mix(in srgb, var(--app-surface) 60%, transparent);
  }

  /* Sur un téléphone, le libellé se réduit à son icône : « Modifier la
     photo » posé sur une image de 343 px de large en mangeait le tiers. */
  .ap-shell[data-layout="compact"] .ap-photo-action span {
    position: absolute;
    width: 1px; height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
  .ap-shell[data-layout="compact"] .ap-photo-action { padding: 9px; }
  .ap-site-photo-frame::after {
    content: "";
    position: absolute;
    left: var(--cadrage-x, 50%);
    top: var(--cadrage-y, 50%);
    width: 22px;
    height: 22px;
    margin: -11px 0 0 -11px;
    border: 2px solid var(--brand-primary);
    border-radius: 50%;
    background: color-mix(in srgb, var(--brand-primary) 18%, transparent);
    opacity: 0;
    transition: opacity var(--duration-fast, 120ms) ease;
    pointer-events: none;
  }
  .ap-site-figure--cadrable[data-cadrage="en-cours"] .ap-site-photo-frame::after {
    opacity: 1;
  }

  .ap-site-photo {
    width: 100%;
    max-height: 380px;
    object-fit: cover;
    border-radius: var(--card-radius);
    display: block;
    border: 1px solid var(--hairline);
  }

  /* La photo d'un lieu du catalogue, posée en FOND et non en `<img>`.

     Elle voyage par notre propre serveur (`/sites/<lieu>/photo.jpg`), donc
     lentement la première fois et jamais ensuite. Le bloc a sa forme AVANT
     que l'image arrive : la page ne saute pas, et tant qu'elle n'est pas là
     il reste un aplat au lieu d'un trou. Si elle n'arrive jamais, l'aplat
     reste — ce qui est très préférable au pavé de texte de remplacement
     qu'une balise cassée affiche. */
  .ap-site-photo--distante {
    aspect-ratio: 16 / 9;
    max-height: 380px;
    background-color: var(--app-surface);
    background-position: center;
    background-repeat: no-repeat;
    background-size: cover;
  }

  .ap-site-figure__credit {
    font-size: var(--text-caption);
    color: var(--text-secondary);
    line-height: var(--line-relaxed);
  }

  /* ── La photo du Soleil ─────────────────────────────────────────────────
     Une vraie image, carrée, servie par notre serveur. Le disque dessiné
     garde exactement la même place : basculer de l'un à l'autre ne doit pas
     faire sauter la page. */

  .ap-solar-figure { margin: 0; display: flex; flex-direction: column; gap: var(--space-sm); }

  .ap-solar-photo {
    width: 100%;
    max-width: 420px;
    height: auto;
    aspect-ratio: 1;
    margin-inline: auto;
    display: block;
    /* Un coin arrondi, pas un cercle. Le disque dessiné du jeu est rond
       parce qu'il EST le Soleil ; cette image est une photographie, et le
       noir autour n'est pas du vide de mise en page : c'est le ciel. La
       découper en rond n'en ferait qu'un anneau noir maladroit. */
    border-radius: var(--card-radius);
    /* Le Soleil rayonne : la lueur vient du jeu, pas de la photo. */
    box-shadow:
      0 0 40px color-mix(in srgb, #FFD84A 22%, transparent),
      0 0 120px color-mix(in srgb, #FFD84A 10%, transparent);
  }

  .ap-solar-figure__credit {
    font-size: var(--text-caption);
    color: var(--text-secondary);
    line-height: var(--line-relaxed);
    text-align: center;
  }

  /* ── La couleur par type : quatre familles, pas dix teintes ─────────────
     MESURÉ, pas choisi à l'œil. `outils/verifier_palette.py` confronte
     toutes les paires en CIEDE2000, en vision normale ET en deutéranopie,
     que huit pour cent des hommes ont.

     L'ancienne palette de dix teintes y laissait SIX paires sous le seuil,
     dont quatre indiscernables : amas/comète à ΔE 1,5, nébuleuse/galaxie à
     2,3, étoile/soleil à 3,8, planète/astéroïde à 4,3. Sur la carte du ciel,
     où la couleur est le seul indice de type, deux repères voisins étaient
     donc le même point pour une partie des joueurs.

     Passé quatre couleurs, deux finissent toujours par se toucher. On s'en
     tient donc à quatre FAMILLES, et la forme sépare les types à
     l'intérieur de chacune. Les teintes ne sont pas arbitraires :

       étoiles         la couleur qu'a vraiment une photosphère vers 5 000 K
       système solaire le bleu d'un ciel diffusé
       ciel profond    la raie Hα à 656 nm, celle qui domine les nébuleuses,
                       les bras de galaxie et les nuages où naissent les amas
       exoplanète      un vert qu'on ne voit nulle part dans le ciel — et
                       c'est l'idée : une exoplanète n'est jamais vue, elle
                       se déduit d'une courbe de lumière

     Les jetons sont définis une seule fois, plus bas, hors couche. Ici on ne
     fait que les employer : les trois écrans — carte, catalogue, légende —
     lisaient auparavant cinq couleurs recopiées chacune trois fois, et trois
     copies se contredisent dès que l'une change. */

  .ap-marker.ap-tag--star         { color: var(--type-star); }
  .ap-marker.ap-tag--variableStar { color: var(--type-variable-star); }
  .ap-marker.ap-tag--sun          { color: var(--type-sun); }
  .ap-marker.ap-tag--planet       { color: var(--type-planet); }
  .ap-marker.ap-tag--asteroid     { color: var(--type-asteroid); }
  .ap-marker.ap-tag--comet        { color: var(--type-comet); }
  .ap-marker.ap-tag--cluster      { color: var(--type-cluster); }
  .ap-marker.ap-tag--nebula       { color: var(--type-nebula); }
  .ap-marker.ap-tag--galaxy       { color: var(--type-galaxy); }
  .ap-marker.ap-tag--exoplanet    { color: var(--type-exoplanet); }

  /* Le Soleil reste un disque, et non un point : c'est le seul astre qu'on
     voie comme tel à l'œil nu, et le seul qui interdise de regarder les
     autres. */
  .ap-marker.ap-tag--sun {
    --marker-size: 30px;
    box-shadow:
      0 0 18px color-mix(in srgb, currentColor 75%, transparent),
      0 0 46px color-mix(in srgb, currentColor 40%, transparent);
  }
  .ap-marker.ap-tag--sun .ap-marker__name {
    font-weight: var(--weight-semibold);
  }

  /* ── La légende ─────────────────────────────────────────────────────────
     Une couleur qu'on ne peut pas nommer reste une décoration.

     SUR UN GRAND ÉCRAN, elle est donc TOUJOURS LÀ : le ciel a de la place à
     revendre, et replier ce qui explique la carte oblige à un geste pour
     comprendre ce qu'on regarde. Le bouton disparaît avec elle — il n'aurait
     plus rien à replier.

     SUR UN TÉLÉPHONE, elle reste repliée derrière son bouton : dix pastilles
     et leurs noms mangeraient un tiers d'un écran de 375 px, et c'est
     précisément là que le ciel manque de place.

     La bascule est portée par `data-layout`, que le script pose en mesurant
     la coquille — le même mécanisme que la barre latérale et la barre
     d'onglets, plutôt qu'un second point de rupture à tenir à jour. */

  /* Seul le BOUTON se cache ici. L'état de la légende, lui, est porté par
     l'attribut `hidden` que le script pose : `[hidden]` ne se contourne pas
     par une feuille, `!important` compris — vérifié dans le navigateur. */
  .ap-shell[data-layout="regular"] #ap-sky-legend-toggle { display: none; }


  /* ── Le téléphone ───────────────────────────────────────────────────────
     Trois ajustements demandés à l'écran, et chacun rend de la place. */

  /* Les quatre compteurs se partagent la largeur au lieu de la laisser vide
     à droite : sur un téléphone, une bande morte à côté de quatre pastilles
     serrées à gauche est de la place perdue.

     `data-layout` et non `@container` : le conteneur nommé `screen` est la
     zone défilante, et la carte du ciel n'en a pas — c'est la seule page qui
     occupe tout le cadre. Une requête de conteneur y serait muette. */
  .ap-shell[data-layout="compact"] .ap-resource-bar { flex-wrap: nowrap; }
  .ap-shell[data-layout="compact"] .ap-resource {
    flex: 1;
    justify-content: center;
    min-width: 0;
    padding-inline: 6px;
  }

  /* Les corps sans position modélisée — Cérès, Vesta, la comète de Halley —
     ne paraissent plus sur un téléphone. Ils se posaient au bas du ciel,
     par-dessus les astres, pour trois cibles qu'on atteint aussi bien par le
     catalogue. Ils restent au large, où la place ne manque pas. */
  .ap-shell[data-layout="compact"] .ap-sky-unplaced { display: none; }

  /* ── L'en-tête de la carte : la barre, et la légende ────────────────────
     La légende a vécu à quatre endroits, et trois d'entre eux avaient le
     même défaut : ils la posaient quelque part SUR le ciel, à une distance
     devinée du haut. Or la barre se replie sur deux à quatre rangées selon
     la largeur, et toute position mesurée depuis son bas dérive avec elle.

     Elles sont donc dans un MÊME conteneur, et c'est lui qui décide :

       au large     côte à côte — la légende est une colonne au bord droit
       au téléphone l'une sous l'autre — la légende sous la note, pleine
                    largeur, en rangée repliée

     Plus rien à réserver ni à mesurer : une boîte flexible n'a pas besoin de
     connaître d'avance la largeur de ce qu'elle place. */
  .ap-sky-head {
    position: absolute;
    inset: var(--space-md) var(--space-md) auto;
    z-index: 2;
    display: flex;
    flex-direction: column;
    gap: var(--space-sm);
    /* Le ciel se déplace en glissant : l'en-tête ne prend le pointeur que
       là où il y a quelque chose. */
    pointer-events: none;
  }
  .ap-sky-head > * { pointer-events: auto; }

  .ap-sky-legend {
    display: flex;
    flex-wrap: wrap;
    gap: 6px var(--space-md);
    padding: var(--space-sm) var(--space-md);
    border-radius: var(--card-radius);
    background: color-mix(in srgb, var(--app-surface) 90%, transparent);
    backdrop-filter: blur(16px);
    border: 1px solid var(--hairline);
  }

  .ap-shell[data-layout="regular"] .ap-sky-head {
    flex-direction: row;
    align-items: start;
    /* Le lieu d'observation passe AU-DESSUS de la rangée, et non à côté
       d'elle : c'est lui qui explique toute la carte, il ouvre donc
       l'en-tête au lieu de disputer la place à la barre. */
    flex-wrap: wrap;
  }
  .ap-shell[data-layout="regular"] .ap-sky-site { flex-basis: 100%; }
  .ap-shell[data-layout="regular"] .ap-sky-toolbar { flex: 1; min-width: 0; }
  .ap-shell[data-layout="regular"] .ap-sky-legend {
    flex: none;
    flex-direction: column;
    flex-wrap: nowrap;
    align-items: start;
    gap: 6px;
    /* Elle ne descend jamais jusqu'à la minicarte, au même bord. */
    max-height: min(62vh, 420px);
    overflow-y: auto;
  }

  /* Pas de `color` ici, et c'est tout l'objet de cette règle. Posée sur
     l'élément, elle l'emportait sur `.ap-tag--star` et ses voisins : plus
     spécifique, et dans la couche qui gagne. Cinq pastilles sur dix en
     sortaient grises.

     La pastille prend donc la couleur de son type ; le libellé garde la
     sienne, portée par sa propre classe. */
  .ap-sky-legend__item {
    display: inline-flex;
    align-items: center;
    gap: 7px;
    font-size: var(--text-caption);
    white-space: nowrap;
  }

  .ap-sky-legend__label { color: var(--text-secondary); }

  .ap-sky-legend__dot {
    width: 9px;
    height: 9px;
    border-radius: 50%;
    background: currentColor;
  }

  /* ── La forme, là où la couleur ne suffit pas ───────────────────────────
     Dix pastilles rondes de la même taille ne se distinguent que par la
     teinte, et la mesure dit que la teinte n'y arrive pas : galaxie et
     nébuleuse tombent à ΔE 2,4 en deutéranopie, comète et amas à ΔE 4,1 en
     VISION NORMALE. Invisible pour tout le monde, pas seulement pour les
     daltoniens.

     La forme s'ajoute donc à la couleur, et elle dit quelque chose de
     l'astre : l'anneau d'une variable est sa pulsation, l'ovale d'une
     galaxie son disque vu de biais, le carré d'un astéroïde sa roche sans
     forme, le flou d'une nébuleuse son absence de bord, la traînée d'une
     comète sa queue.

     Les mêmes règles servent la légende du ciel et le catalogue : dans
     l'une la classe est sur le parent, dans l'autre sur la pastille. Sans
     cela les deux écrans se contrediraient. */

  /* Elle bat, et l'amas est un anneau d'étoiles. */
  .ap-tag--variableStar .ap-sky-legend__dot,
  .ap-tag--cluster .ap-sky-legend__dot,
  .ap-object__dot.ap-tag--variableStar,
  .ap-object__dot.ap-tag--cluster,
  .ap-marker.ap-tag--variableStar,
  .ap-marker.ap-tag--cluster {
    background: transparent;
    box-shadow: inset 0 0 0 2px currentColor;
  }

  /* Une roche n'a pas de forme. */
  .ap-tag--asteroid .ap-sky-legend__dot,
  .ap-object__dot.ap-tag--asteroid,
  .ap-marker.ap-tag--asteroid { border-radius: 2px; }

  /* Un disque vu de biais. */
  .ap-tag--galaxy .ap-sky-legend__dot,
  .ap-object__dot.ap-tag--galaxy { transform: scaleX(1.5) scaleY(.65); }
  /* Le repère porte déjà `translate: -50% -50%` pour se centrer : écraser sa
     `transform` le décalerait d'une demi-largeur. On passe donc par
     `scale`, qui est une propriété distincte. */
  .ap-marker.ap-tag--galaxy { scale: 1.5 .65; }

  /* Une nébuleuse n'a pas de bord. */
  .ap-tag--nebula .ap-sky-legend__dot,
  .ap-object__dot.ap-tag--nebula,
  .ap-marker.ap-tag--nebula {
    background: radial-gradient(closest-side,
      currentColor 40%,
      color-mix(in srgb, currentColor 40%, transparent) 68%,
      transparent 100%);
    transform: scale(1.45);
  }
  .ap-marker.ap-tag--nebula { scale: 1.45; }

  /* Le seul astre qu'on voie comme un disque à l'œil nu. */
  .ap-tag--sun .ap-sky-legend__dot,
  .ap-object__dot.ap-tag--sun {
    box-shadow: 0 0 0 2px color-mix(in srgb, currentColor 32%, transparent);
  }

  /* La queue, toujours à l'opposé du Soleil. */
  .ap-tag--comet .ap-sky-legend__dot,
  .ap-object__dot.ap-tag--comet { position: relative; }
  .ap-tag--comet .ap-sky-legend__dot::after,
  .ap-object__dot.ap-tag--comet::after,
  .ap-marker.ap-tag--comet::after {
    content: "";
    position: absolute;
    inset-inline-start: 100%;
    inset-block-start: 50%;
    width: 7px;
    height: 2px;
    transform: translateY(-50%);
    background: linear-gradient(to right, currentColor, transparent);
  }

  /* Le curseur d'heure, posé sous la barre d'outils. */
  .ap-sky-hour {
    position: absolute;
    left: 50%;
    bottom: var(--space-md);
    transform: translateX(-50%);
    z-index: 2;
    display: flex;
    align-items: center;
    gap: var(--space-sm);
    width: min(420px, calc(100% - var(--space-xl)));
    padding: var(--space-sm) var(--space-md);
    border-radius: var(--pill-radius);
    background: color-mix(in srgb, var(--app-surface) 88%, transparent);
    backdrop-filter: blur(16px);
  }
  .ap-sky-hour .ap-focus-slider { flex: 1; }

  /* L'heure regardée. Quand c'est le présent, elle se teinte de l'accent :
     sans ce signe, rien ne distinguerait « maintenant » d'un moment choisi
     au hasard qui tomberait juste. */
  .ap-sky-hour__label--now { color: var(--brand-primary); }

  /* Le bouton « Maintenant » se désactive quand on y est déjà — et sa cause
     est alors écrite par la teinte de l'heure, juste à côté. */
  #ap-sky-now[disabled] { opacity: .45; pointer-events: none; }

  /* Quand le panneau d'une cible est ouvert, il prend la place du curseur.

     PIÈGE VÉRIFIÉ À L'ÉCRAN : c'était écrit avec le combinateur `~`, qui ne
     vise que les frères SUIVANTS. Or le curseur est écrit avant le panneau
     dans le gabarit : la règle ne masquait donc jamais rien, et la barre
     d'heure recouvrait le bouton « Préparer l'observation ». On ne pouvait
     pas lancer l'observation qu'on venait de choisir.

     `:has()` ne dépend pas de l'ordre du document. Et le panneau passe
     au-dessus du curseur, pour que son bouton reste atteignable même si un
     navigateur ignorait `:has()`. */
  .ap-sky-screen:has(.ap-sky-panel:not([hidden])) .ap-sky-hour { display: none; }

  .ap-sky-unplaced {
    position: absolute;
    left: var(--space-md);
    bottom: var(--space-md);
    display: flex;
    align-items: center;
    gap: var(--space-sm);
    flex-wrap: wrap;
    max-width: min(340px, 50%);
  }

  /* Le lieu d'observation ouvre la carte : il est au-dessus de la barre, et
     non en bout de rangée. Il ne reçoit pas le toucher — ce n'est pas une
     commande, et sur la carte tout texte sensible est une bande morte en
     travers du ciel. */
  .ap-sky-site {
    margin: 0 0 var(--space-xs);
    pointer-events: none;
  }

  .ap-sky-toolbar {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-sm);
    pointer-events: none;
  }
  .ap-sky-toolbar > * { pointer-events: auto; }

  /* SAUF LES TEXTES, et c'est ce qui rendait la carte intouchable au doigt.
     La barre s'efface déjà (`pointer-events: none`), mais la règle
     précédente rendait TOUS ses enfants sensibles — y compris la note des
     filtres et le nom du site, qui ne sont pas des commandes.

     Sur un téléphone, ces deux-là prennent chacun une ligne entière : deux
     bandes invisibles en travers du ciel. Mesuré à 375 px : cinq astres sur
     quatorze recevaient le toucher sur du texte au lieu de leur marque, et
     l'on croyait la carte inerte.

     Un libellé n'a rien à recevoir. On ne le rend sensible que lorsqu'il
     porte un lien — ce que `:has(a)` demande. */
  .ap-sky-toolbar > .ap-sky-note,
  .ap-sky-toolbar > .ap-label { pointer-events: none; }

  .ap-sky-toolbar > .ap-sky-note:has(a),
  .ap-sky-toolbar > .ap-label:has(a) { pointer-events: auto; }

  /* ── LES TROIS FILTRES, REGROUPÉS ────────────────────────────────────────
     Ils formaient deux rangées pleines au-dessus d'une carte qui est tout le
     sujet de l'écran. Réunis, ils se replient d'un seul geste derrière leur
     bouton sur téléphone, comme la légende.

     Le groupe s'efface au toucher et ne rend sensibles que ses commandes :
     déplié, il barre la largeur du ciel, et sans cela il intercepterait le
     toucher exactement comme les textes le faisaient. */
  .ap-sky-filters {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-sm);
    pointer-events: none;
  }
  .ap-sky-filters > * { pointer-events: auto; }

  /* Le bouton vaut À TOUTE LARGEUR, et non plus seulement sur téléphone.
     Les trois sélecteurs et leurs deux notes prenaient trois rangées au-dessus
     de la carte sur un écran large aussi — la carte est le sujet, pas eux. */

  /* La pastille d'un filtre actif. Elle est SUR la commande qui l'a causé,
     là où la note est sous la barre. */
  #ap-sky-filters-toggle[data-actif="true"]::after {
    content: "";
    display: inline-block;
    width: 6px;
    height: 6px;
    margin-inline-start: 6px;
    border-radius: 50%;
    background: var(--brand-primary);
    vertical-align: middle;
  }


  /* La note prend sa PROPRE LIGNE dans la barre d'outils. Posée à part, à
     quarante-quatre pixels du haut, elle atterrissait entre deux rangées dès
     que la barre se repliait — quatre lignes sur un téléphone de 375 px.

     `flex-basis: 100%` la fait passer à la ligne quoi qu'il arrive, et la
     marge du bas la décolle de la rangée suivante : sans elle, le texte
     touchait les sélecteurs. */
  .ap-sky-note {
    flex-basis: 100%;
    /* `.ap-tag` ne revient pas à la ligne — c'est juste pour une étiquette
       de deux mots, faux pour une phrase : « 118 cibles à votre portée ·
       154 attendent un palier plus haut » débordait de 19 px sur un écran
       de 375. */
    white-space: normal;
    margin-block-start: calc(var(--space-xs) * -1);
    margin-block-end: var(--space-sm);
  }

  /* ── Le bas de la carte ──────────────────────────────────────────────────
     Sur un téléphone, le curseur d'heure et les astres sans position se
     disputaient le même coin : le premier centré, les seconds à gauche, l'un
     par-dessus l'autre. Rassemblés dans une colonne, ils s'empilent — le
     curseur AU-DESSUS du texte, comme on le lit.

     Sur un grand écran, `display: contents` rend au conteneur son
     inexistence : chacun retrouve sa position d'origine, et rien ne
     change. */
  .ap-sky-bottom { display: contents; }

  .ap-shell[data-layout="compact"] .ap-sky-bottom {
    position: absolute;
    left: var(--space-md);
    right: var(--space-md);
    bottom: var(--space-md);
    z-index: 2;
    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: var(--space-sm);
    /* Elle s'étend sur toute la largeur, et ses intervalles barraient le
       ciel : un astre placé derrière ne recevait plus le toucher. Comme la
       barre du haut, la pile s'efface et seuls ses enfants répondent. */
    pointer-events: none;
  }
  /* ── La barre, sur un téléphone : cinq rangées avant le ciel ────────────
     Mesuré à 375 px : « Tout voir », trois sélecteurs, « Légende », le nom
     du site, puis la note des filtres — la moitié de l'écran prise avant
     qu'on voie un astre, sur l'écran dont le sujet EST le ciel.

     Deux coupes, et la première ne perd rien du tout. */

  /* LE NOM DU SITE EST DÉJÀ DANS LA BARRE DU HAUT, à quarante pixels de là :
     « Niveau 1 — Amateur · Jardin de banlieue ». Le redire ici le faisait
     lire deux fois, et coûtait une rangée entière. Il reste sur grand écran,
     où la place ne manque pas et où la barre du haut peut être loin. */
  .ap-shell[data-layout="compact"] .ap-sky-toolbar > .ap-label,
  .ap-shell[data-layout="compact"] .ap-sky-site { display: none; }

  /* Ce qui reste se resserre : la barre commande le ciel, elle n'est pas le
     sujet. Les cibles tactiles gardent leurs 32 px — on réduit le texte et
     les marges, jamais ce qu'on doit toucher. */
  .ap-shell[data-layout="compact"] .ap-sky-toolbar {
    gap: 6px;
    font-size: var(--text-caption);
  }

  /* ── LE PLAN DE SITUATION QUITTE LE TÉLÉPHONE ───────────────────────────
     Il occupait un carré de 190 px en bas à droite d'un écran de 375 — un
     sixième de la carte, posé précisément là où le pouce vise. Sur un grand
     écran il coûte un coin vide ; ici il coûte du ciel.

     Ce qu'il servait à réparer — se perdre dans une carte plus grande que
     l'écran — a maintenant deux autres réponses au doigt : le PINCEMENT, qui
     n'existait pas, et « Tout voir », qui ramène toujours à l'ensemble d'un
     seul appui. Il reste au large, où la place ne manque pas. */
  .ap-shell[data-layout="compact"] .ap-minimap { display: none; }

  /* Dépliés sur téléphone, les trois filtres prennent leur PROPRE ligne :
     glissés au bout de la rangée des boutons, ils la cassaient au milieu et
     en coûtaient deux au lieu d'une.

     `order: 1` LES ENVOIE EN DERNIER, et ce n'est pas un détail. Dans le
     gabarit, le groupe est ENTRE « Filtres » et « Légende » — sa place
     logique, puisque c'est le bouton « Filtres » qui le commande. Mais en
     prenant toute la largeur, il rejetait « Légende » à la ligne d'après :
     déplier les filtres cassait la rangée des boutons en deux.

     L'ordre du document reste donc celui du sens — un lecteur d'écran et le
     clavier rencontrent le groupe juste après le bouton qui l'ouvre — et
     seul l'affichage le descend. */
  .ap-sky-filters {
    order: 1;
    flex-basis: 100%;
    gap: 6px;
    margin-block-end: 6px;
  }
  .ap-shell[data-layout="compact"] .ap-sky-toolbar .ap-field__input,
  .ap-shell[data-layout="compact"] .ap-sky-toolbar .ap-button {
    font-size: var(--text-caption);
    padding-inline: 10px;
  }
  .ap-shell[data-layout="compact"] .ap-sky-note {
    font-size: var(--text-caption);
    margin-block-end: 6px;
    /* Elle hérite de `.ap-tag` un demi-gras et un fond : la note CRIAIT au
       milieu du ciel alors qu'elle renseigne, elle ne commande pas. Sur un
       petit écran, où elle prend deux lignes, cela suffisait à faire paraître
       la barre plus lourde qu'elle n'est. */
    font-weight: var(--weight-regular, 400);
    color: var(--text-secondary);
    background: none;
    padding: 0;
  }

  .ap-shell[data-layout="compact"] .ap-sky-bottom > * {
    position: static;
    transform: none;
    width: auto;
    max-width: none;
    pointer-events: auto;
  }
  /* Le plan de situation garde sa taille et passe en tête, à droite : il
     regarde le ciel, pas le texte. */
  .ap-shell[data-layout="compact"] .ap-sky-bottom > .ap-minimap {
    order: -1;
    align-self: flex-end;
    width: 96px;
  }

  /* Le panneau de la cible. Il flotte au-dessus du ciel plutôt que de le
     pousser : la carte reste visible pendant qu'on lit la fiche. */
  .ap-sky-panel {
    position: absolute;
    left: 50%;
    bottom: var(--space-md);
    transform: translateX(-50%);
    width: min(var(--reading-width), calc(100% - var(--space-xl)));
    display: flex;
    flex-direction: column;
    gap: var(--space-md);
    padding: var(--card-padding);
    z-index: 3;
    border-radius: var(--sheet-radius);
    background: color-mix(in srgb, var(--app-surface) 92%, transparent);
    backdrop-filter: blur(20px);
    box-shadow: var(--shadow-sheet);
    animation: ap-rise var(--motion-soft) both;
    /* Le panneau s'est enrichi — une famille spectrale, deux tuiles de
       récompense, la distance relevée. Sur un téléphone posé en paysage, il
       finissait par dépasser la carte par le haut, et le bouton
       « Préparer l'observation » sortait de l'écran. Il défile plutôt que de
       déborder : la carte reste visible, et la commande reste atteignable. */
    max-height: calc(100% - var(--space-xl));
    overflow-y: auto;
    overscroll-behavior: contain;
  }
  @keyframes ap-rise {
    from { opacity: 0; transform: translate(-50%, 10px); }
    to   { opacity: 1; transform: translate(-50%, 0); }
  }

  /* Le plan de situation garde la couleur de chaque type : il se lit d'un
     coup d'œil, sans légende. */
  .ap-minimap__dot {
    position: absolute;
    width: 2px;
    height: 2px;
    border-radius: 50%;
    background: currentColor;
  }

  /* ── Le plan de situation se saisit ─────────────────────────────────────
     Il affichait la part visible sans permettre de la déplacer. Le curseur
     dit maintenant qu'on peut l'attraper — sans ce signe, rien ne distingue
     une commande d'une vignette.

     `touch-action: none` est ce qui rend le glissement possible au doigt :
     sans lui, le navigateur prend le geste pour un défilement de page et
     nous coupe la série d'événements en plein milieu.

     Les points du ciel, eux, laissent passer le pointeur : viser une étoile
     large de deux pixels pour déplacer la vue serait un piège. */
  .ap-minimap {
    cursor: grab;
    touch-action: none;
  }
  .ap-minimap--dragging { cursor: grabbing; }
  .ap-minimap__dot { pointer-events: none; }

  /* Le cadre suit le pointeur, il ne le reçoit pas : c'est le PLAN qui écoute,
     pour qu'un clic hors du cadre y amène la vue au lieu de ne rien faire. */
  .ap-minimap__window {
    pointer-events: none;
    background: color-mix(in srgb, var(--brand-primary) 22%, transparent);
  }
  .ap-minimap--dragging .ap-minimap__window {
    background: color-mix(in srgb, var(--brand-primary) 34%, transparent);
  }

  /* ══ L'oculaire ═══════════════════════════════════════════════════════════
     Le seul retour vrai est l'image : un astronome ne lit pas un chiffre, il
     regarde l'étoile. Le halo est donc l'élément principal de l'écran. */

  .ap-eyepiece {
    display: grid;
    place-items: center;
    aspect-ratio: 1;
    width: min(280px, 70vw);
    margin-inline: auto;
    border-radius: 50%;
    background: radial-gradient(circle at 50% 50%, #0E1226, var(--sky-deep) 72%);
    border: 1px solid var(--hairline);
  }
  .ap-eyepiece__star {
    width: 10px;
    height: 10px;
    border-radius: 50%;
    background: var(--star-tint);
    transition: box-shadow var(--motion-soft), filter var(--motion-soft);
  }

  /* Le curseur du porte-oculaire : un vrai geste, pas un champ de saisie. */
  .ap-focus-slider {
    width: 100%;
    accent-color: var(--brand-primary);
  }

  /* ══ Détails ══════════════════════════════════════════════════════════════ */

  /* `display` l'emporte sur l'attribut `hidden` : sans cette règle, un panneau
     marqué `hidden` reste visible, et l'analyse s'ouvrait sur un écran vide. */
  [hidden] { display: none !important; }

  /* Une valeur qui change en place se lit en chasse fixe. */
  .ap-numeric { font-variant-numeric: tabular-nums; }






  /* ── La marque ──────────────────────────────────────────────────────────
     Le logo est un badge circulaire portant déjà le mot « Astropost ». À
     28 px son texte ne se lit pas, et c'est sans importance : c'est la forme
     qu'on reconnaît. Le mot est donc répété à côté, en texte — un lecteur
     d'écran ne lit pas une image, et l'image porte `alt=""` pour ne pas
     annoncer deux fois la même chose. */

  .ap-brand {
    display: inline-flex;
    align-items: center;
    gap: var(--space-xs);
    text-decoration: none;
  }
  .ap-brand:hover { text-decoration: none; }

  .ap-brand__mark {
    width: 28px;
    height: 28px;
    flex: none;
    border-radius: 50%;
  }

  .ap-brand__name {
    font-size: var(--text-caption);
    font-weight: var(--weight-semibold);
    letter-spacing: .12em;
    color: var(--text-secondary);
  }

  .ap-auth-logo {
    width: 96px;
    height: 96px;
    align-self: start;
    border-radius: 50%;
    /* Le badge est sombre sur fond sombre : une lueur le décolle du ciel. */
    box-shadow: 0 0 0 1px color-mix(in srgb, var(--brand-primary) 30%, transparent),
                0 12px 40px color-mix(in srgb, var(--brand-primary) 22%, transparent);
  }

  /* ── L'annuaire des observatoires ───────────────────────────────────────
     Une carte par joueur donnait trois observatoires par écran. L'annuaire
     est une LISTE : une ligne par observatoire, le rang à gauche, les
     chiffres alignés à droite, et le geste au bout. Elle tient autant de
     monde que la communauté en compte. */

  .ap-roster {
    display: flex;
    flex-direction: column;
    border: 1px solid var(--hairline);
    border-radius: var(--card-radius);
    background: var(--surface-1);
    overflow: hidden;
  }

  .ap-observatory {
    display: grid;
    grid-template-columns: 2.6rem minmax(0, 1fr) auto auto;
    align-items: center;
    gap: var(--space-sm) var(--space-md);
    padding: var(--space-sm) var(--space-md);
    border-top: 1px solid color-mix(in srgb, var(--hairline) 55%, transparent);
  }
  .ap-observatory:first-child { border-top: 0; }
  .ap-observatory:hover {
    background: color-mix(in srgb, var(--brand-primary) 6%, transparent);
  }

  .ap-observatory--mine {
    background: color-mix(in srgb, var(--brand-primary) 12%, transparent);
  }

  .ap-observatory__rank {
    font-size: var(--text-footnote);
    color: var(--text-secondary);
    text-align: end;
  }

  .ap-observatory__body { min-width: 0; }

  .ap-observatory__name {
    font-weight: var(--weight-semibold);
    text-decoration: none;
  }
  .ap-observatory__name:hover { text-decoration: underline; }

  .ap-observatory__meta {
    display: block;
    font-size: var(--text-caption);
    color: var(--text-secondary);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  /* La seconde ligne de méta — missions, nuits, arrivée, parrain — ne se
     coupe PAS à la première ellipse : elle porte quatre informations, et
     n'en montrer qu'une reviendrait à n'en montrer aucune. */
  .ap-observatory__meta--libre {
    white-space: normal;
    overflow: visible;
  }

  .ap-observatory__figures {
    display: grid;
    grid-template-columns: auto auto;
    align-items: baseline;
    gap: 0 6px;
    text-align: end;
  }

  .ap-observatory__score {
    font-size: var(--text-headline);
    font-weight: var(--weight-semibold);
  }

  .ap-observatory__unit,
  .ap-observatory__aside {
    font-size: var(--text-caption);
    color: var(--text-secondary);
  }
  .ap-observatory__aside { grid-column: 1 / -1; }

  /* L'écart : vert quand on mène, orangé quand on est derrière. Ce n'est pas
     un jugement, c'est une direction. */
  .ap-observatory__gap {
    grid-column: 1 / -1;
    font-size: var(--text-caption);
    color: var(--status-success);
  }
  .ap-observatory__gap--behind { color: var(--status-warning, var(--brand-primary)); }

  .ap-observatory__action { justify-self: end; }

  /* --- Le classement ------------------------------------------------------
     Il réemploie `.ap-roster` et `.ap-observatory` : c'est la même ligne, et
     un second jeu de classes qui dessine la même chose aurait divergé au
     premier changement. Ce qui lui est propre tient en deux règles. */

  /* La ligne « vous », quand on n'est pas dans la tête du classement. Elle
     est DÉTACHÉE de la liste et posée dessous : la coller à la fin
     laisserait croire qu'on est cinquante-et-unième. */
  .ap-classement-moi {
    margin-top: var(--space-sm);
    border: 1px solid color-mix(in srgb, var(--brand-primary) 45%, transparent);
    border-radius: var(--card-radius);
    background: color-mix(in srgb, var(--brand-primary) 10%, transparent);
  }
  .ap-classement-moi .ap-observatory { border-top: 0; }

  /* Le propos de l'axe : pourquoi ce classement-là récompense ce qu'il
     récompense. Il est écrit à l'écran plutôt que laissé à deviner. */
  .ap-classement-propos {
    font-size: var(--text-footnote);
    color: var(--text-secondary);
    margin-bottom: var(--space-sm);
  }

  /* À l'étroit, la ligne se plie en deux rangées : le NOM d'abord, sur toute
     la largeur, puis les chiffres et le geste. Quatre colonnes sur un
     téléphone coupaient le nom au milieu d'un mot, et le bouton le serrait
     encore davantage. */
  @container screen (max-width: 640px) {
    .ap-observatory {
      grid-template-columns: 2.4rem minmax(0, 1fr) auto;
      row-gap: 6px;
      padding-block: var(--space-sm);
    }
    .ap-observatory__rank { align-self: start; padding-top: 2px; }
    .ap-observatory__body { grid-column: 2 / -1; }
    .ap-observatory__figures {
      grid-column: 2;
      grid-template-columns: auto auto;
      justify-content: start;
      text-align: start;
      column-gap: 8px;
    }
    .ap-observatory__aside,
    .ap-observatory__gap { grid-column: auto; }
    .ap-observatory__score { font-size: var(--text-body); }
    .ap-observatory__action { grid-column: 3; align-self: center; }
  }

  /* ── L'écoute ───────────────────────────────────────────────────────────
     Le bandeau de tête porte trois choses de front : où l'on en est, ce que
     le palier apporte, et ce qui attend d'être tranché. Il s'empile à
     l'étroit et s'aligne au large, sans jamais laisser de bande vide. */

  .ap-listen-head {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr);
    align-items: center;
    gap: var(--space-md);
    padding: var(--space-md);
    margin-bottom: var(--space-lg);
    border: 1px solid var(--hairline);
    border-radius: var(--card-radius);
    background: linear-gradient(135deg,
      color-mix(in srgb, var(--type-exoplanet, var(--brand-primary)) 16%, transparent),
      transparent 70%), var(--surface-1);
  }

  .ap-listen-head__now {
    grid-row: span 1;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    width: 92px; height: 92px;
    border-radius: 50%;
    border: 2px solid var(--brand-primary);
    background: color-mix(in srgb, var(--brand-primary) 12%, transparent);
  }

  .ap-listen-head__stats { grid-column: 1 / -1; }

  @container screen (min-width: 900px) {
    .ap-listen-head { grid-template-columns: auto minmax(0, 1fr) auto; }
    .ap-listen-head__stats { grid-column: auto; min-width: 380px; }
  }

  .ap-card--muted { opacity: .55; }

  /* ── L'échelle des paliers ──────────────────────────────────────────────
     Vingt-huit paliers : ni une grille ni une liste plate. Une échelle, dont
     le fil vertical relie les barreaux et dit d'un coup d'œil où l'on en est.
     Elle se lit du haut (le sommet, encore lointain) vers le bas (ce qui est
     acquis) — comme on regarde une montagne. */

  .ap-ladder-head {
    display: flex;
    align-items: center;
    gap: var(--space-md);
    padding: var(--space-md);
    margin-bottom: var(--space-lg);
    border: 1px solid var(--hairline);
    border-radius: var(--card-radius);
    background: linear-gradient(135deg,
      color-mix(in srgb, var(--brand-primary) 16%, transparent),
      transparent 70%), var(--surface-1);
  }

  .ap-ladder-head__now {
    flex: none;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    width: 92px; height: 92px;
    border-radius: 50%;
    border: 2px solid var(--brand-primary);
    background: color-mix(in srgb, var(--brand-primary) 12%, transparent);
  }

  .ap-ladder-head__level {
    font-size: 2.4rem;
    font-weight: var(--weight-semibold);
    line-height: 1;
    color: var(--brand-primary);
  }

  .ap-ladder-head__of { font-size: var(--text-caption); color: var(--text-secondary); }

  .ap-ladder-head__body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--space-xs); }

  .ap-ladder {
    list-style: none;
    margin: 0;
    padding: 0;
    position: relative;
  }

  /* À l'étroit, on ouvre l'échelle par le bas : le palier 1 en tête, et on
     monte. Sur un téléphone, on ne voit qu'un barreau et demi à la fois —
     arriver sur le palier 28 ne dit rien du chemin parcouru, alors que
     commencer au premier raconte la montée dans l'ordre où elle s'est faite.
     L'ordre du DOM, lui, ne change pas : le large le garde tel quel. */
  @container screen (max-width: 1099.98px) {
    .ap-ladder { display: flex; flex-direction: column-reverse; }
  }

  /* Le raccourci vers le palier courant. Il n'existe qu'à l'étroit : au
     large, les vingt-huit barreaux tiennent en deux colonnes et se voient
     d'un coup d'œil. */
  .ap-ladder__jump {
    display: none;
    align-self: flex-start;
    margin-bottom: var(--space-md);
  }

  @container screen (max-width: 1099.98px) {
    .ap-ladder__jump { display: inline-flex; }
  }

  /* La barre du haut est collée : sans cette marge, un saut d'ancre déposerait
     le barreau visé dessous. */
  .ap-rung { scroll-margin-top: var(--space-xl); }

  /* Le fil qui relie les barreaux. Il s'arrête avant le premier et le dernier
     repère, pour ne pas dépasser. */
  .ap-ladder::before {
    content: "";
    position: absolute;
    inset-block: 18px;
    inset-inline-start: 17px;
    width: 2px;
    background: var(--hairline);
  }

  .ap-rung {
    position: relative;
    display: flex;
    gap: var(--space-md);
    padding-bottom: var(--space-md);
  }

  .ap-rung__mark {
    flex: none;
    z-index: 1;
    display: grid;
    place-items: center;
    width: 36px; height: 36px;
    border-radius: 50%;
    font-size: var(--text-caption);
    font-weight: var(--weight-semibold);
    color: var(--sky-deep);
    background: var(--brand-primary);
    border: 2px solid var(--app-background);
  }

  .ap-rung--ahead .ap-rung__mark {
    color: var(--text-secondary);
    background: var(--surface-2);
    border-color: var(--app-background);
  }

  .ap-rung--current .ap-rung__mark {
    box-shadow: 0 0 0 4px color-mix(in srgb, var(--brand-primary) 30%, transparent);
  }

  .ap-rung__card {
    flex: 1;
    min-width: 0;
    display: flex;
    flex-direction: column;
    gap: 4px;
    padding: var(--space-sm) var(--space-md);
    border: 1px solid var(--hairline);
    border-radius: var(--card-radius);
    background: var(--surface-1);
  }

  .ap-rung--current .ap-rung__card {
    border-color: color-mix(in srgb, var(--brand-primary) 55%, transparent);
    background: color-mix(in srgb, var(--brand-primary) 8%, var(--surface-1));
  }

  /* Un palier à venir reste lisible : il est la raison de continuer. Il est
     estompé, pas effacé. */
  .ap-rung--ahead .ap-rung__card { opacity: .62; }

  .ap-rung__name { font-size: var(--text-headline); }
  .ap-rung__summary { color: var(--text-secondary); line-height: var(--line-relaxed); }

  .ap-rung__done-icon { width: 18px; height: 18px; color: var(--status-success); }

  .ap-rung__unlocks {
    list-style: none;
    margin: 6px 0 0;
    padding: 0;
    display: flex;
    flex-wrap: wrap;
    gap: 6px;
  }

  .ap-unlock {
    font-size: var(--text-caption);
    color: var(--text-secondary);
    padding: 2px 9px;
    border-radius: 999px;
    border: 1px solid var(--hairline);
    background: color-mix(in srgb, var(--surface-2) 70%, transparent);
  }

  .ap-unlock__count {
    font-weight: var(--weight-semibold);
    color: var(--text-primary);
  }

  @container screen (min-width: 1100px) {
    /* Au large, l'échelle se dédouble : deux colonnes de barreaux, et plus de
       bande vide à droite. Le fil n'a alors plus de sens et disparaît. */
    .ap-ladder { columns: 2; column-gap: var(--space-lg); }
    .ap-ladder::before { display: none; }
    .ap-rung { break-inside: avoid; }
  }

  /* ── Ce qui n'est pas encore ouvert ─────────────────────────────────────
     Un onglet verrouillé reste CLIQUABLE : il mène à un écran qui dit quel
     palier l'ouvre et ce qu'il contiendra. Le cacher priverait le joueur de
     la raison de monter d'un niveau. */

  .ap-pane-selector__item--locked { color: var(--text-secondary); }

  .ap-pane-selector__lock {
    width: 14px; height: 14px;
    margin-inline-end: 4px;
    vertical-align: -2px;
    color: var(--status-error);
  }

  /* ── LA VITRINE ──────────────────────────────────────────────────────────
     La seule page que lit quelqu'un qui n'a pas de compte. Elle ne partage
     pas la colonne étroite des écrans de connexion : elle a des cartes à
     aligner, et une colonne de lecture les mettrait toutes l'une sous
     l'autre, même sur un grand écran.

     ELLE NE PEUT PAS S'APPUYER SUR `.ap-card-flow`. Les grilles du système
     passent aux colonnes dans `@container screen`, et ce conteneur, c'est
     `.ap-scroll` — la zone défilante du coquillage du jeu. La vitrine
     n'entre pas dans ce coquillage : la requête de conteneur ne trouvait
     jamais son conteneur, et la page restait sur UNE colonne quelle que soit
     la fenêtre. Elle porte donc sa propre grille, sur des requêtes de MÉDIA,
     qui n'ont besoin de personne au-dessus d'elles. */
  .ap-vitrine {
    padding: 0 var(--gutter) var(--space-xl);
    display: flex;
    flex-direction: column;
    gap: var(--space-lg);
    max-width: var(--page-width);
    margin-inline: auto;
  }

  /* ══ Le bandeau du haut ═══════════════════════════════════════════════ */

  .ap-vitrine__hero {
    position: relative;
    isolation: isolate;          /* le ciel reste sous le texte sans z-index négatif */
    overflow: hidden;            /* les cimes et l'astre débordent par construction */
    border-radius: var(--card-radius);
    border: 1px solid var(--hairline);
    margin-block-start: var(--space-lg);
    padding: var(--space-xl) var(--space-md);
    background: linear-gradient(180deg, var(--sky-deep), var(--app-background));
  }

  .ap-vitrine__hero-texte {
    position: relative;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--space-sm);
    text-align: center;
  }

  .ap-vitrine__logo { width: 96px; height: 96px; }
  .ap-vitrine__logo--pied { width: 64px; height: 64px; opacity: .85; }

  .ap-vitrine__titre {
    font-size: clamp(1.5rem, 4.4vw, 2.4rem);
    line-height: var(--line-tight);
    max-width: 24ch;
  }

  .ap-vitrine__accroche {
    color: var(--text-secondary);
    max-width: var(--reading-width);
    line-height: var(--line-relaxed);
  }

  /* La devise manuscrite de l'affiche, rendue en texte : une image de texte
     ne se lit pas à la loupe, ne se traduit pas, et ne se sélectionne pas.
     L'italique remplace l'écriture inclinée — une police manuscrite
     demanderait un fichier extérieur, que la politique de sécurité refuse. */
  .ap-vitrine__signature {
    color: var(--brand-primary);
    font-style: italic;
    max-width: 34ch;
    line-height: var(--line-normal);
  }

  .ap-vitrine__actions {
    display: flex;
    flex-wrap: wrap;             /* deux boutons côte à côte débordent à 375 px */
    gap: var(--space-sm);
    justify-content: center;
    margin-block-start: var(--space-sm);
  }

  /* ── Le décor du bandeau ────────────────────────────────────────────────
     Un ciel étoilé, un astre et une silhouette d'observateur, entièrement en
     CSS. L'affiche d'origine est une photographie ; le dépôt n'en contient
     aucune, et une image inventée ferait un carré cassé en production. */
  .ap-vitrine__ciel {
    position: absolute;
    inset: 0;
    pointer-events: none;
    background-image:
      radial-gradient(1.5px 1.5px at 12% 22%, var(--star-tint), transparent),
      radial-gradient(1.5px 1.5px at 28% 62%, var(--star-tint), transparent),
      radial-gradient(1px   1px   at 41% 14%, var(--star-tint), transparent),
      radial-gradient(1.5px 1.5px at 58% 38%, var(--star-tint), transparent),
      radial-gradient(1px   1px   at 67% 72%, var(--star-tint), transparent),
      radial-gradient(1.5px 1.5px at 79% 18%, var(--star-tint), transparent),
      radial-gradient(1px   1px   at 88% 52%, var(--star-tint), transparent),
      radial-gradient(1px   1px   at 94% 30%, var(--star-tint), transparent);
    opacity: .55;
  }

  .ap-vitrine__astre {
    position: absolute;
    inset-block-start: var(--space-lg);
    inset-inline-end: var(--space-lg);
    width: 84px;
    height: 84px;
    border-radius: 50%;
    /* Le terminateur d'une lune : un dégradé décentré suffit, et se met à
       l'échelle sans jamais pixelliser. */
    background: radial-gradient(circle at 34% 32%,
                var(--text-primary), var(--rarity-common) 58%, transparent 72%);
    opacity: .45;
  }

  /* Sur un téléphone, l'astre venait toucher le logo centré : deux disques
     qui se chevauchent se lisent comme un défaut d'affichage. */
  @media (max-width: 699px) {
    .ap-vitrine__astre { width: 52px; height: 52px; opacity: .3; }
  }

  .ap-vitrine__horizon {
    position: absolute;
    inset-inline: 0;
    inset-block-end: 0;
    height: 64px;
    background: linear-gradient(180deg, transparent, var(--sky-deep));
  }

  /* La silhouette : un tube incliné sur un trépied. Trois traits, pas un
     dessin — à cette taille, davantage de détail ne se voit pas. */
  .ap-vitrine__observateur {
    position: absolute;
    inset-block-end: var(--space-md);
    inset-inline-start: var(--space-lg);
    width: 56px;
    height: 56px;
    opacity: .55;
  }

  .ap-vitrine__tube,
  .ap-vitrine__trepied {
    position: absolute;
    background: var(--sky-deep);
  }

  .ap-vitrine__tube {
    inset-block-start: 6px;
    inset-inline-start: 4px;
    width: 46px;
    height: 7px;
    border-radius: var(--pill-radius);
    transform: rotate(-28deg);
    transform-origin: 20% 50%;
  }

  .ap-vitrine__trepied {
    inset-block-end: 0;
    inset-inline-start: 22px;
    width: 3px;
    height: 30px;
    box-shadow: -12px 0 0 -1px var(--sky-deep), 12px 0 0 -1px var(--sky-deep);
  }

  /* ══ La grille des douze encarts ══════════════════════════════════════ */

  .ap-vitrine__grille {
    display: grid;
    gap: var(--space-md);
    grid-template-columns: 1fr;   /* mobile d'abord : une colonne, sans exception */
    align-items: start;
  }

  @media (min-width: 700px)  { .ap-vitrine__grille { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
  @media (min-width: 1100px) { .ap-vitrine__grille { grid-template-columns: repeat(4, minmax(0, 1fr)); } }

  .ap-vitrine__carte-titre {
    display: flex;
    align-items: center;
    gap: var(--space-sm);
    font-size: var(--text-headline);
    line-height: var(--line-normal);
  }

  .ap-vitrine__icone {
    width: 22px;
    height: 22px;
    flex: none;                   /* sans cela l'icône s'écrase quand le titre tient sur trois lignes */
    color: var(--brand-primary);
  }

  /* Les puces disent CE QU'ON Y FAIT. Elles restent des listes — un lecteur
     d'écran annonce alors combien il y en a, ce qu'une suite de paragraphes
     ne dit pas. La coche remplace la puce du navigateur : c'est le signe de
     l'affiche, et `list-style: none` sur une liste à icônes ne coûte rien
     puisque le nombre d'éléments reste annoncé. */
  .ap-vitrine__liste {
    display: flex;
    flex-direction: column;
    gap: var(--space-xs);
    margin: 0;
    padding: 0;
    list-style: none;
    color: var(--text-secondary);
    font-size: var(--text-subheadline);
    line-height: var(--line-normal);
  }

  .ap-vitrine__liste li {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr);  /* minmax(0,…) : sans lui un mot long déborde la carte */
    gap: var(--space-sm);
    align-items: start;
  }

  .ap-vitrine__coche {
    width: 14px;
    height: 14px;
    flex: none;
    margin-block-start: 4px;      /* aligné sur la première ligne, pas sur le bloc */
    color: var(--status-success);
  }

  .ap-vitrine__chiffres {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-sm);
  }
  .ap-vitrine__chiffres span {
    padding: var(--space-xs) var(--space-sm);
    border-radius: var(--pill-radius);
    background: var(--brand-primary-soft);
    color: var(--text-primary);
    font-size: var(--text-caption);
  }
  .ap-vitrine__chiffres strong {
    font-size: var(--text-title);
    margin-inline-end: var(--space-xs);
  }

  .ap-vitrine__encadre {
    padding: var(--space-sm);
    border: 1px solid var(--status-warning-soft);
    border-radius: var(--field-radius);
    background: var(--status-warning-soft);
    color: var(--text-primary);
    text-align: center;
    font-size: var(--text-subheadline);
  }

  /* ── Les vignettes d'illustration ───────────────────────────────────────
     Des dégradés, jamais des images. Elles sont décoratives et vides : la
     hauteur doit donc être explicite, sinon un paragraphe sans texte
     s'effondre à zéro et la carte semble trouée. */
  .ap-vitrine__vignette {
    height: 96px;
    border-radius: var(--field-radius);
    border: 1px solid var(--hairline);
  }

  .ap-vitrine__vignette--ciel {
    background-color: var(--sky-deep);
    background-image:
      radial-gradient(1.5px 1.5px at 18% 30%, var(--status-warning), transparent),
      radial-gradient(1.5px 1.5px at 36% 66%, var(--star-tint), transparent),
      radial-gradient(2px   2px   at 52% 24%, var(--brand-primary), transparent),
      radial-gradient(1.5px 1.5px at 68% 58%, var(--brand-secondary), transparent),
      radial-gradient(1.5px 1.5px at 84% 34%, var(--star-tint), transparent),
      radial-gradient(1px   1px   at 26% 82%, var(--star-tint), transparent),
      radial-gradient(60% 50% at 70% 70%, var(--brand-secondary-soft), transparent);
  }

  .ap-vitrine__vignette--soleil {
    background:
      radial-gradient(circle at 50% 50%,
        var(--status-warning) 0 22%,
        var(--status-warning-soft) 23% 34%,
        transparent 35%),
      var(--sky-deep);
  }

  /* Le globe et ses points lumineux : un disque, et des lueurs posées
     dessus. `box-shadow` ferait des points d'une netteté qui jurerait avec
     le reste ; les dégradés radiaux gardent le halo. */
  .ap-vitrine__vignette--globe {
    background:
      radial-gradient(3px 3px at 38% 34%, var(--star-tint), transparent),
      radial-gradient(3px 3px at 58% 46%, var(--status-warning), transparent),
      radial-gradient(3px 3px at 46% 62%, var(--exoplanet), transparent),
      radial-gradient(3px 3px at 66% 26%, var(--brand-primary), transparent),
      radial-gradient(circle at 50% 50%,
        var(--sky-glow) 0 30%, var(--brand-primary-soft) 31% 38%, transparent 39%),
      var(--sky-deep);
  }

  /* ── La vignette météo ─────────────────────────────────────────────────
     Une liste de définitions : « Vent » et « 15 km/h » sont un terme et sa
     valeur. Deux colonnes de texte auraient laissé un lecteur d'écran lire
     les cinq termes puis les cinq valeurs. */
  .ap-vitrine__meteo {
    display: grid;
    gap: var(--space-xs);
    margin: 0;
    padding: var(--space-sm);
    border: 1px solid var(--hairline);
    border-radius: var(--field-radius);
    background: var(--app-background);
    font-size: var(--text-footnote);
  }
  .ap-vitrine__meteo > div {
    display: flex;
    justify-content: space-between;
    gap: var(--space-sm);
  }
  .ap-vitrine__meteo dt { color: var(--text-secondary); }
  .ap-vitrine__meteo dd { margin: 0; color: var(--text-primary); }

  .ap-vitrine__mention {
    color: var(--text-secondary);
    font-size: var(--text-caption);
    line-height: var(--line-normal);
  }

  /* Les onglets imités du jeu. Ce sont des <span> : cliquables, ils
     mèneraient à une porte fermée. */
  .ap-vitrine__onglets {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-xs);
  }
  .ap-vitrine__onglets span {
    padding: var(--space-xs) var(--space-sm);
    border: 1px solid var(--hairline-strong);
    border-radius: var(--pill-radius);
    color: var(--text-secondary);
    font-size: var(--text-caption);
  }

  .ap-vitrine__garde {
    display: flex;
    align-items: start;
    gap: var(--space-sm);
    padding: var(--space-sm);
    border-radius: var(--field-radius);
    background: var(--brand-primary-soft);
    color: var(--text-primary);
    font-size: var(--text-subheadline);
    line-height: var(--line-normal);
  }

  .ap-vitrine__etapes {
    display: flex;
    flex-direction: column;
    gap: var(--space-sm);
    margin: 0;
    padding-inline-start: 1.3em;
    color: var(--text-secondary);
    font-size: var(--text-subheadline);
    line-height: var(--line-relaxed);
  }
  .ap-vitrine__etapes li::marker {
    color: var(--brand-primary);
    font-weight: var(--weight-semibold);
  }

  /* ══ Le bandeau du bas ════════════════════════════════════════════════ */

  .ap-vitrine__pied {
    position: relative;
    isolation: isolate;
    overflow: hidden;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--space-sm);
    text-align: center;
    border: 1px solid var(--hairline);
    border-radius: var(--card-radius);
    padding: var(--space-xl) var(--space-md) 120px;  /* la place des montagnes, sous le texte */
    background: linear-gradient(180deg, var(--app-background), var(--sky-deep));
  }

  /* Le crépuscule de l'affiche : deux plans de relief, faits de dégradés
     coniques. Un `clip-path` aurait donné des arêtes plus franches, mais il
     se déforme dès que la largeur change — ici la page va de 375 px à
     1520 px. */
  .ap-vitrine__montagnes {
    position: absolute;
    inset-inline: 0;
    inset-block-end: 0;
    height: 130px;
    pointer-events: none;
  }

  .ap-vitrine__cime {
    position: absolute;
    inset-inline: 0;
    inset-block-end: 0;
  }

  .ap-vitrine__cime--loin {
    height: 110px;
    background: linear-gradient(180deg, transparent, var(--app-surface) 70%);
    clip-path: polygon(0 100%, 0 62%, 18% 26%, 34% 58%, 52% 22%, 72% 56%, 88% 30%, 100% 54%, 100% 100%);
    opacity: .7;
  }

  .ap-vitrine__cime--pres {
    height: 74px;
    background: var(--sky-deep);
    clip-path: polygon(0 100%, 0 70%, 22% 34%, 44% 72%, 64% 40%, 84% 70%, 100% 46%, 100% 100%);
  }

  .ap-vitrine__observateur--pied {
    inset-inline-start: auto;
    inset-inline-end: var(--space-xl);
    inset-block-end: var(--space-sm);
    opacity: .9;
  }
  .ap-vitrine__observateur--pied .ap-vitrine__tube,
  .ap-vitrine__observateur--pied .ap-vitrine__trepied {
    background: var(--app-background);
  }
  .ap-vitrine__observateur--pied .ap-vitrine__trepied {
    box-shadow: -12px 0 0 -1px var(--app-background), 12px 0 0 -1px var(--app-background);
  }

  .ap-vitrine__devise {
    position: relative;
    font-size: var(--text-title);
    color: var(--text-primary);
  }
  .ap-vitrine__verbes {
    position: relative;
    color: var(--text-secondary);
    letter-spacing: .08em;
    text-transform: uppercase;
    font-size: var(--text-caption);
  }

  /* Les six domaines du pied de l'affiche. Une liste, parce que c'en est
     une, et centrée parce qu'elle ferme la page. */
  .ap-vitrine__domaines {
    position: relative;
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: var(--space-sm) var(--space-lg);
    margin: var(--space-sm) 0 0;
    padding: 0;
    list-style: none;
  }
  .ap-vitrine__domaines li {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--space-xs);
    min-width: 72px;
    color: var(--text-secondary);
    font-size: var(--text-caption);
  }
  .ap-vitrine__domaines .ap-vitrine__icone { width: 26px; height: 26px; }

  .ap-vitrine__pied .ap-vitrine__logo,
  .ap-vitrine__pied .ap-vitrine__signature { position: relative; }
  .ap-vitrine__pied .ap-vitrine__logo { margin-block-start: var(--space-md); }

  /* ── LA FICHE D'UN ÉQUIPEMENT ────────────────────────────────────────────
     La carte ne montre que le palier suivant : bon pour décider d'un achat,
     muet pour décider d'une route. La fiche donne l'échelle entière.

     Le bouton est un « i » dans un cercle, et non une icône : le jeu n'en a
     pas pour « information », et en dessiner une de plus ferait un symbole
     que personne ne reconnaîtrait. Un « i » cerclé se lit partout. */
  .ap-info {
    display: grid;
    place-items: center;
    width: 28px;
    height: 28px;
    flex: none;
    border: 1px solid var(--hairline-strong);
    border-radius: 50%;
    background: transparent;
    color: var(--text-secondary);
    font-size: var(--text-caption);
    font-style: italic;
    font-weight: 600;
    cursor: pointer;
  }
  .ap-info:hover { color: var(--text-primary); border-color: var(--brand-primary); }

  .ap-row--tight { display: flex; align-items: center; gap: 8px; }

  /* Une échelle de six paliers ne tient pas dans la boîte étroite des
     questions oui/non. Elle garde sa marge sur téléphone. */
  .ap-dialog--large {
    width: min(560px, calc(100vw - 2 * var(--gutter)));
    max-height: min(80vh, 720px);
    overflow-y: auto;
  }

  .ap-echelle {
    display: flex;
    flex-direction: column;
    gap: var(--space-sm);
    margin: 0;
    padding: 0;
    list-style: none;
  }

  .ap-echelon {
    padding: var(--space-sm);
    border: 1px solid var(--hairline);
    border-radius: var(--card-radius);
  }

  /* TROIS ÉTATS QUI SE DISTINGUENT SANS COULEUR : un acquis porte sa coche,
     le palier courant porte un trait épais à gauche. La couleur seule
     exclurait un joueur sur douze. */
  .ap-echelon--acquis { opacity: .62; }
  .ap-echelon--courant {
    border-color: var(--brand-primary);
    border-inline-start-width: 3px;
    background: var(--brand-primary-soft);
  }

  .ap-echelon__nom {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    font-weight: 600;
  }
  .ap-echelon__coche { width: 14px; height: 14px; color: var(--status-success); }
  .ap-echelon__etat { color: var(--text-secondary); font-size: var(--text-caption); }

  /* LE POT DE MIEL. Caché à l'œil sans l'être au robot : `display: none`
     est ce que le plus grossier des remplisseurs automatiques sait éviter.
     On le sort donc du flux par la position, ce qui le rend invisible sans
     l'annoncer. `aria-hidden` et `tabindex="-1"`, posés dans le gabarit,
     l'écartent des lecteurs d'écran et du clavier : un piège qui attrape un
     humain n'est pas un piège, c'est un défaut. */
  .ap-pot-de-miel {
    position: absolute;
    left: -9999px;
    width: 1px;
    height: 1px;
    overflow: hidden;
  }

  .ap-gate {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--space-sm);
    text-align: center;
    padding: var(--space-lg) var(--gutter);
    border: 1px solid var(--hairline);
    border-radius: var(--card-radius);
    background: var(--surface-1);
  }

  .ap-gate__seal {
    display: grid;
    place-items: center;
    width: 64px; height: 64px;
    border-radius: 50%;
    background: color-mix(in srgb, var(--status-error) 14%, transparent);
    border: 1px solid color-mix(in srgb, var(--status-error) 35%, transparent);
  }

  .ap-gate__icon { width: 30px; height: 30px; color: var(--status-error); }

  .ap-gate__title { font-size: var(--text-title); }

  .ap-gate__body {
    max-width: var(--reading-width);
    color: var(--text-secondary);
    line-height: var(--line-relaxed);
  }

  /* Quand le sceau EST la page — le Soleil sous son palier — il se place au
     milieu plutôt qu'en haut, et grossit. Une carte de la taille d'un
     avertissement, seule sur un écran vide, ressemble à une erreur ; un
     sceau centré ressemble à une porte. */
  .ap-gate--pleine-page {
    min-height: min(58vh, 520px);
    justify-content: center;
  }
  .ap-gate--pleine-page .ap-gate__seal { width: 96px; height: 96px; }
  .ap-gate--pleine-page .ap-gate__icon { width: 46px; height: 46px; }

  .ap-gate__list { width: 100%; max-width: var(--reading-width); text-align: start; }

  /* ── Catalogue ──────────────────────────────────────────────────────────
     Deux cent soixante-douze objets ne tiennent pas en cartes : une carte par
     objet donnerait quatre écrans de défilement pour une seule constellation.
     Le catalogue est donc une LISTE dense, groupée par constellation, où
     chaque ligne tient sur une seule hauteur de texte. */

  .ap-filter-bar {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-sm);
    align-items: end;
    margin-bottom: var(--space-md);
  }

  .ap-filter { display: flex; flex-direction: column; gap: 2px; }

  .ap-filter__label {
    font-size: var(--text-caption);
    color: var(--text-secondary);
    padding-inline-start: 2px;
  }

  .ap-catalog-note { margin-bottom: var(--space-md); display: block; }

  /* Les constellations se rangent en colonnes : une constellation courte ne
     laisse pas un demi-écran vide à côté d'une longue. */
  .ap-catalog {
    columns: 3 300px;
    column-gap: var(--space-md);
  }

  .ap-constellation {
    break-inside: avoid;
    margin-bottom: var(--space-md);
    border: 1px solid var(--hairline);
    border-radius: var(--card-radius);
    background: var(--surface-1);
    overflow: hidden;
  }

  .ap-constellation__name {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--space-sm);
    margin: 0;
    padding: var(--space-sm) var(--space-md);
    font-size: var(--text-subheadline);
    border-bottom: 1px solid var(--hairline);
    background: color-mix(in srgb, var(--brand-primary) 8%, transparent);
  }

  .ap-constellation__count {
    font-size: var(--text-caption);
    color: var(--text-secondary);
    font-weight: var(--weight-regular);
  }

  .ap-object {
    display: flex;
    align-items: center;
    gap: var(--space-sm);
    padding: 6px var(--space-md);
    border-top: 1px solid color-mix(in srgb, var(--hairline) 55%, transparent);
    text-decoration: none;
  }
  .ap-object:first-child { border-top: 0; }
  .ap-object:hover {
    background: color-mix(in srgb, var(--brand-primary) 10%, transparent);
    text-decoration: none;
  }

  /* La pastille prend la couleur du type : `.ap-tag--galaxy` et consorts ne
     posent qu'une `color`, dont on se sert ici comme fond. */
  .ap-object__dot {
    flex: none;
    width: 8px; height: 8px;
    border-radius: 50%;
    background: currentColor;
  }

  .ap-object__body { flex: 1; min-width: 0; display: flex; flex-direction: column; }

  .ap-object__name {
    font-size: var(--text-footnote);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  .ap-object__meta {
    font-size: var(--text-caption);
    color: var(--text-secondary);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  .ap-object__quality {
    flex: none;
    font-size: var(--text-caption);
    color: var(--status-success);
  }

  /* Un objet hors de portée reste VISIBLE : il montre ce que le prochain
     palier apportera. Il est estompé, et son palier est écrit. */
  .ap-object--locked { opacity: .5; }

  .ap-object__gate {
    flex: none;
    font-size: var(--text-caption);
    color: var(--text-secondary);
    border: 1px solid var(--hairline);
    border-radius: 999px;
    padding: 1px 7px;
  }

  .ap-object--found .ap-object__name { font-weight: var(--weight-semibold); }

  /* ══ Sites — une suite de LIGNES qui se comparent ══════════════════════════
     La question de cet écran est « ce lieu est-il meilleur que le mien, et
     puis-je m'y installer ? ». Tout ce qui ne sert pas cette question passe
     derrière.

     La mosaïque la desservait : des cartes hautes et étroites, du texte cassé
     sur deux mots, et quatre tuiles de chiffres pleine largeur qui mangeaient
     la moitié de la hauteur. On ne compare pas deux lieux qu'on ne voit pas
     côte à côte — et à quatre colonnes de 330 px, la dernière était coupée.

     D'où une pile d'une seule colonne, qui devient une LIGNE par lieu dès
     qu'il y a la place : quatre zones toujours aux mêmes abscisses, donc
     quatre nombres qu'on lit verticalement d'un lieu à l'autre. C'est ce
     qu'un tableau fait, et qu'une mosaïque ne peut pas faire. Une seule
     colonne ne laisse aucun trou : le flux en colonnes n'a plus lieu d'être
     ici. */

  .ap-comparatif {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    gap: var(--space-md);
    align-items: start;
  }

  .ap-lieu {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    gap: var(--space-md);
  }

  /* Le lieu où l'on est déjà est la RÉFÉRENCE de tous les écarts : il se lit
     avant eux, sinon on compare à un repère qu'on n'a pas encore vu. Le
     catalogue est trié par palier, et les règles du jeu ne se touchent pas :
     c'est donc la disposition qui le remonte.

     Il ne se distingue pas par la seule couleur — il porte « Vous êtes ici »
     en toutes lettres. La bordure ne fait que le répéter. */
  .ap-lieu--ici {
    order: -1;
    border-color: var(--brand-primary-soft);
    background: linear-gradient(160deg,
                rgba(89, 140, 242, .10), rgba(179, 153, 242, .05)),
                var(--app-surface);
  }

  .ap-lieu__identite,
  .ap-lieu__mesures,
  .ap-lieu__meteo {
    display: flex;
    flex-direction: column;
    gap: var(--space-sm);
    align-items: flex-start;
    min-width: 0;
  }

  .ap-lieu__geste {
    display: flex;
    flex-direction: column;
    gap: var(--space-sm);
    align-items: stretch;
    min-width: 0;
  }

  .ap-lieu__identite > .ap-row { width: 100%; }

  /* Une cible tactile se touche : le lien vers la fiche est court, il faut
     donc lui donner la hauteur qu'il n'a pas. */
  .ap-lieu__identite .ap-button {
    min-height: 32px;
    display: inline-flex;
    align-items: center;
  }

  /* Vingt-six lieux, chacun avec son paragraphe déplié, et l'écran ne servait
     plus à décider où aller. Deux lignes suffisent à situer ; le reste de
     l'histoire est sur la fiche, à un clic. */
  .ap-lieu__resume {
    font-size: var(--text-subheadline);
    line-height: var(--line-normal);
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 2;
    line-clamp: 2;
    overflow: hidden;
  }

  /* ── Les quatre mesures, deux sur deux ────────────────────────────────────
     Elles étaient quatre tuiles encadrées sur toute la largeur de la carte :
     quatre nombres pour la moitié de la hauteur. Sans cadre ni fond, rangées
     deux par deux, elles disent la même chose en un quart de la place — et
     restent une LISTE, parce que c'en est une. */
  .ap-mesures {
    list-style: none;
    margin: 0;
    padding: 0;
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: var(--space-sm) var(--space-md);
    width: 100%;
  }

  .ap-mesure {
    display: flex;
    flex-direction: column;
    gap: var(--space-xs);
    min-width: 0;
  }

  .ap-mesure__valeur {
    font-size: var(--text-title);
    font-weight: var(--weight-semibold);
    line-height: var(--line-tight);
    font-variant-numeric: tabular-nums;
  }

  /* La couleur ne fait que RÉPÉTER le signe, qui est déjà écrit — « +11 % »,
     « −0,2 », « égal ». Un joueur sur douze ne la distingue pas, et il lit
     quand même l'écran. */
  .ap-mesure__valeur--mieux { color: var(--status-success); }
  .ap-mesure__valeur--pire  { color: var(--status-error); }

  .ap-mesure__nom {
    font-size: var(--text-caption);
    font-weight: var(--weight-medium);
    line-height: var(--line-tight);
    color: var(--text-secondary);
  }

  /* La valeur sur place, en second : elle renseigne, elle ne décide pas. */
  .ap-mesure__absolu {
    font-size: var(--text-caption-2);
    line-height: var(--line-tight);
    color: var(--text-secondary);
  }

  .ap-lieu__catalogue { margin-block-start: var(--space-xs); }

  .ap-lieu__reglage { width: 100%; }

  /* ── Deux paliers, et non un seul ─────────────────────────────────────────
     Les seuils du jeu raisonnent en largeur DISPONIBLE, et la zone
     défilante n'a que 980 px dans une fenêtre de 1 280 : le seuil habituel
     de 1 000 px ne se serait jamais déclenché là où le propriétaire
     regarde. D'où un palier intermédiaire, qui range les quatre zones deux
     par deux, et le palier plein qui les met en ligne.

     `minmax(0, …)` partout : sans le minimum à zéro, une pastille de
     prévision ou un nom long refuse de rétrécir et pousse la dernière
     colonne hors de l'écran — c'était le débordement horizontal.

     Le réglage du lieu personnel passe sous la ligne, sur toute la largeur :
     huit champs ne tiennent pas dans un quart de page. */
  @container screen (min-width: 700px) {
    .ap-lieu {
      grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
      gap: var(--space-md) var(--space-lg);
      align-items: start;
    }
    .ap-lieu__reglage { grid-column: 1 / -1; }
  }

  /* Une ligne par lieu : les quatre zones gardent la même abscisse d'un lieu
     à l'autre, et les nombres se lisent alors en colonne. C'est ce qu'un
     tableau fait, et c'est pour cela que l'écran existe. */
  @container screen (min-width: 1060px) {
    .ap-lieu {
      grid-template-columns:
        minmax(0, 1.6fr) minmax(0, 1.4fr) minmax(0, 1.1fr) minmax(0, 1fr);
      gap: var(--space-lg);
    }
  }


  /* ══ La demande d'accès ═══════════════════════════════════════════════════
     Le formulaire public, et son accusé de réception.

     IL NE REPREND PLUS LA COQUILLE DE CONNEXION. Celle-ci centre une carte
     unique au milieu d'un écran vide : elle convient à qui tape un mot de
     passe, pas à qui hésite encore. Le visiteur arrive de la présentation, et
     doit retrouver le même ciel, la même largeur et le même souffle — sinon
     l'enchaînement fait un mur.

     LA HIÉRARCHIE EST REMISE À L'ENDROIT. Le libellé était en `--text-caption`
     (12 px) et son texte d'aide en corps de page (17 px) : l'aide se lisait
     donc AVANT le champ qu'elle explique. Le libellé passe en `--text-headline`
     semi-gras, l'aide en `--text-footnote` gris — et elle remonte au-dessus du
     champ, parce qu'une explication lue après la saisie arrive trop tard. */

  .ap-demande {
    display: flex;
    flex-direction: column;
    gap: var(--space-lg);
    padding: 0 var(--gutter) var(--space-xl);
    /* Pas `--page-width` : deux colonnes étalées sur 1 520 px éloignent le
       bouton d'envoi de la dernière chose lue. */
    max-width: var(--side-by-side);
    margin-inline: auto;
  }

  /* ── Le bandeau ─────────────────────────────────────────────────────────
     Le même ciel que la présentation, en plus bas : ici le sujet est le
     formulaire, pas l'affiche. */
  .ap-demande__hero {
    position: relative;
    isolation: isolate;
    overflow: hidden;
    border-radius: var(--card-radius);
    border: 1px solid var(--hairline);
    margin-block-start: var(--space-lg);
    padding: var(--space-xl) var(--space-md);
    background: linear-gradient(180deg, var(--sky-deep), var(--app-background));
  }

  .ap-demande__ciel {
    position: absolute;
    inset: 0;
    pointer-events: none;
    background-image:
      radial-gradient(1.5px 1.5px at 14% 26%, var(--star-tint), transparent),
      radial-gradient(1px   1px   at 33% 68%, var(--star-tint), transparent),
      radial-gradient(1.5px 1.5px at 52% 18%, var(--star-tint), transparent),
      radial-gradient(1px   1px   at 71% 54%, var(--star-tint), transparent),
      radial-gradient(1.5px 1.5px at 86% 28%, var(--star-tint), transparent),
      radial-gradient(1px   1px   at 94% 74%, var(--star-tint), transparent);
    opacity: .5;
  }

  .ap-demande__hero-texte {
    position: relative;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--space-sm);
    text-align: center;
  }

  .ap-demande__logo {
    width: 96px;
    height: 96px;
    border-radius: 50%;
    /* Le badge est sombre sur fond sombre : une lueur le décolle du ciel. */
    box-shadow: 0 0 0 1px color-mix(in srgb, var(--brand-primary) 30%, transparent),
                0 12px 40px color-mix(in srgb, var(--brand-primary) 22%, transparent);
  }

  .ap-demande__titre {
    font-size: clamp(1.5rem, 4.4vw, 2.125rem);
    line-height: var(--line-tight);
  }

  .ap-demande__accroche {
    color: var(--text-secondary);
    max-width: 46ch;
    line-height: var(--line-relaxed);
  }

  /* Le sceau de l'accusé de réception. Une coche, pas une phrase de plus :
     c'est le seul écran du parcours où il n'y a rien à faire. */
  .ap-demande__sceau {
    display: grid;
    place-items: center;
    width: 64px;
    height: 64px;
    border-radius: 50%;
    background: var(--status-success-soft);
  }
  .ap-demande__sceau-icone {
    width: 32px;
    height: 32px;
    color: var(--status-success);
  }

  /* ── Les deux colonnes ──────────────────────────────────────────────────
     Empilées par défaut, côte à côte dès qu'il y a la place. Le formulaire
     reste la colonne large : l'aparté l'accompagne, il ne le concurrence
     pas. */
  .ap-demande__colonnes {
    display: grid;
    gap: var(--space-lg);
    align-items: start;
  }

  @media (min-width: 900px) {
    .ap-demande__colonnes:not(.ap-demande__colonnes--seule) {
      grid-template-columns: minmax(0, 1.15fr) minmax(0, .85fr);
    }
  }

  /* L'accusé de réception n'a qu'une carte : centrée, et bornée à la largeur
     de lecture — étirée sur 1 000 px, une liste de trois lignes se perd. */
  .ap-demande__colonnes--seule {
    max-width: var(--reading-width);
    width: 100%;
    margin-inline: auto;
  }

  .ap-demande__formulaire {
    display: flex;
    flex-direction: column;
    /* Plus d'air qu'une pile ordinaire : les champs étaient collés les uns
       aux autres, et le formulaire se lisait comme un bloc unique. */
    gap: var(--space-lg);
    padding: var(--space-lg);
  }

  .ap-demande__aparte {
    display: flex;
    flex-direction: column;
    gap: var(--space-md);
  }

  /* ── Un champ ───────────────────────────────────────────────────────────  */

  .ap-demande__champ {
    display: flex;
    flex-direction: column;
    gap: var(--space-xs);
  }

  .ap-demande__libelle {
    font-size: var(--text-headline);
    font-weight: var(--weight-semibold);
    color: var(--text-primary);
    line-height: var(--line-normal);
  }

  /* « facultatif » est écrit, pas seulement suggéré par l'absence d'étoile :
     une convention typographique ne s'annonce nulle part. */
  .ap-demande__mention {
    margin-inline-start: var(--space-sm);
    font-size: var(--text-caption);
    font-weight: var(--weight-regular);
    color: var(--text-secondary);
  }

  .ap-demande__aide {
    font-size: var(--text-footnote);
    line-height: var(--line-normal);
    color: var(--text-secondary);
  }

  /* Les champs eux-mêmes. Ils sont désignés par leur élément et non par une
     classe : le gabarit rend `{{ champ }}` tel que Django l'écrit, et poser
     une classe dessus demanderait de toucher au formulaire Python — qui porte
     les deux pièges à robots, et qu'on ne rouvre pas pour une question de
     style. */
  .ap-demande__champ input,
  .ap-demande__champ textarea {
    width: 100%;
    /* 44 px : la cible tactile confortable. Les champs faisaient 36 px, et on
       les manquait au pouce. */
    min-height: 44px;
    padding: var(--space-sm) var(--space-md);
    border: 1px solid var(--hairline-strong);
    border-radius: var(--field-radius);
    background: var(--sky-deep);
    font-family: inherit;
    font-size: var(--text-body);
    line-height: var(--line-normal);
    color: var(--text-primary);
    transition: border-color var(--motion-soft), background var(--motion-soft);
  }

  .ap-demande__champ input:focus-visible,
  .ap-demande__champ textarea:focus-visible {
    border-color: var(--brand-primary);
    background: var(--app-background);
    outline: none;
  }

  /* « Deux lignes » demandées dans quatre lignes hautes comme une seule : on
     n'écrivait pas, on remplissait une case. */
  .ap-demande__champ textarea {
    min-height: 140px;
    resize: vertical;
    line-height: var(--line-relaxed);
  }

  .ap-demande__champ--fautif input,
  .ap-demande__champ--fautif textarea {
    border-color: var(--status-error);
  }

  /* L'erreur porte une icône EN PLUS de sa couleur : rouge seul, elle
     n'existe pas pour qui ne distingue pas le rouge du gris. */
  .ap-demande__erreur,
  .ap-demande__note {
    display: flex;
    align-items: flex-start;
    gap: var(--space-sm);
    font-size: var(--text-footnote);
    line-height: var(--line-normal);
  }
  .ap-demande__erreur { color: var(--status-error); }
  .ap-demande__note   { color: var(--text-secondary); }

  .ap-demande__icone {
    width: 18px;
    height: 18px;
    flex: none;
    margin-block-start: 1px;
  }
  .ap-demande__erreur .ap-demande__icone { color: var(--status-error); }
  .ap-notice .ap-demande__icone { vertical-align: -4px; margin-inline-end: var(--space-xs); }

  /* ── Les deux gestes ────────────────────────────────────────────────────
     Ils n'ont pas le même poids : l'un envoie, l'autre renonce. Deux boutons
     pleine largeur empilés les donnaient pour équivalents, et le second se
     cliquait par erreur. */
  .ap-demande__gestes {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-sm) var(--space-md);
    margin-block-start: var(--space-xs);
  }

  .ap-demande__gestes .ap-button--primary { flex: 1 1 14rem; }
  .ap-demande__gestes .ap-button { min-height: 44px; }

  /* ── L'aparté ───────────────────────────────────────────────────────────  */

  .ap-demande__etapes {
    display: flex;
    flex-direction: column;
    gap: var(--space-sm);
    margin: 0;
    padding-inline-start: 1.5em;
    color: var(--text-secondary);
    font-size: var(--text-subheadline);
    line-height: var(--line-relaxed);
  }
  .ap-demande__etapes li::marker {
    color: var(--brand-primary);
    font-weight: var(--weight-semibold);
  }
  .ap-demande__etapes strong { color: var(--text-primary); }

  .ap-demande__garanties {
    display: flex;
    flex-direction: column;
    gap: var(--space-md);
    margin: 0;
    padding: 0;
    list-style: none;
    color: var(--text-secondary);
    font-size: var(--text-subheadline);
    line-height: var(--line-relaxed);
  }
  .ap-demande__garanties li {
    display: flex;
    align-items: flex-start;
    gap: var(--space-sm);
  }
  .ap-demande__garanties .ap-demande__icone { color: var(--brand-primary); margin-block-start: 4px; }
}

/* ============================================================================
   HORS COUCHE — et il y a une raison, écrite ici pour qu'on puisse la retirer
   ----------------------------------------------------------------------------
   `02-socle.css` pose `a { color: var(--brand-primary) }` SANS l'envelopper
   dans `@layer astropost`, contrairement aux feuilles 03 et 04. Une règle non
   couchée l'emporte sur toutes les règles couchées, quelle que soit leur
   spécificité : le texte de tout bouton qui est un lien prenait donc la
   couleur d'accent — et le bouton principal, bleu sur bleu, s'affichait vide.

   Ces règles sont donc volontairement hors couche, et ne portent que sur la
   couleur du texte des composants qui sont des liens.

   À RETIRER dès que `02-socle.css` enveloppe son sélecteur `a` dans la
   couche : la cascade suffira alors toute seule.
   ========================================================================= */

a.ap-button                       { color: var(--text-primary); }
a.ap-button--primary              { color: var(--sky-deep); }
a.ap-button--ghost,
a.ap-button--plain                { color: var(--brand-primary); }
a.ap-button--destructive,
a.ap-button--danger               { color: var(--status-error); }

a.ap-sidebar__item,
a.ap-tabbar__item,
a.ap-pane-selector__item,
a.ap-list__row,
a.ap-card--interactive,
a.ap-marker                       { color: var(--text-primary); }

a.ap-tabbar__item                 { color: var(--text-secondary); }
a.ap-tabbar__item[aria-selected="true"],
a.ap-sidebar__item[aria-selected="true"] { color: var(--brand-primary); }

a.ap-progress__label              { color: var(--text-secondary); }
a.ap-object                       { color: var(--text-primary); }
a.ap-more-item                    { color: var(--text-primary); }
a.ap-more-item--current           { color: var(--brand-primary); }


/* ============================================================================
   LES COULEURS DE TYPE — hors couche, et pour une raison précise
   ----------------------------------------------------------------------------
   `design/css/01-jetons.css` n'est PAS enveloppé dans `@layer astropost`,
   contrairement aux feuilles 03 et 04, ET il se charge APRÈS cette feuille.
   Une redéfinition posée dans `@layer app` perdrait donc deux fois : le non
   couché l'emporte sur le couché, et à égalité c'est le dernier chargé qui
   gagne.

   D'où ce bloc hors couche et le sélecteur doublé `:root:root`, qui ajoute
   un cran de spécificité. Le ranger « proprement » dans `@layer app` le
   ferait taire SANS RIEN CASSER D'APPARENT : les anciennes couleurs
   reviendraient simplement, et personne ne verrait l'erreur.

   Les quatre familles sont mesurées — voir `outils/verifier_palette.py`, et
   l'épreuve qui l'exécute. Les dix types s'y rattachent ; la FORME les
   sépare à l'intérieur d'une famille.
   ========================================================================= */

:root:root {
  /* La couleur d'une photosphère vers 5 000 K. */
  --famille-etoile:     #E49900;
  /* Le bleu d'un ciel diffusé. */
  --famille-solaire:    #00C2E9;
  /* La raie Hα, à 656 nm. */
  --famille-profond:    #F47DB9;
  /* Un vert qu'on ne voit nulle part dans le ciel : une exoplanète ne se
     voit pas, elle se déduit d'une courbe de lumière. */
  --famille-exoplanete: #51C672;

  --type-star:          var(--famille-etoile);
  --type-variable-star: var(--famille-etoile);
  --type-sun:           var(--famille-etoile);

  --type-planet:        var(--famille-solaire);
  --type-asteroid:      var(--famille-solaire);
  --type-comet:         var(--famille-solaire);

  --type-cluster:       var(--famille-profond);
  --type-nebula:        var(--famille-profond);
  --type-galaxy:        var(--famille-profond);

  --type-exoplanet:     var(--famille-exoplanete);
}

/* Les quatre vues du Soleil. Une grille qui se remplit toute seule : deux
   colonnes dès qu'il y a la place, une seule sinon. Aucun point de rupture à
   écrire — c'est la largeur disponible qui décide, pas la taille de l'écran,
   et la carte vit aussi bien dans une colonne étroite. */
.ap-solar-gallery {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
  gap: var(--space-sm);
}

.ap-solar-view {
  margin: 0;
  display: flex;
  flex-direction: column;
  gap: var(--space-2xs);
}

.ap-solar-view__image {
  width: 100%;
  height: auto;
  border-radius: var(--card-radius);
  /* Le disque est déjà sur fond noir dans l'image : le cadre le prolonge au
     lieu de le trancher. */
  background: #000;
  display: block;
}

.ap-solar-view__caption {
  display: flex;
  flex-direction: column;
  gap: 2px;
  font-size: var(--font-size-sm);
}

.ap-source {
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.ap-source__name {
  font-weight: 600;
}

/* ============================================================================
   L'ÉCRAN DES RÉGLAGES
   ----------------------------------------------------------------------------
   Il vivait sur `.ap-dashboard` : une colonne de lecture à gauche, une grille
   à droite. Avec UNE seule carte à droite, la grille tenait toute la hauteur
   de l'écran pour un bloc de six lignes — un mur d'un côté, un demi-écran gris
   de l'autre.

   Ici, le lieu prend la pleine largeur parce que c'est le sujet, et les deux
   autres familles — les préférences, puis ce qui est gardé et le compte — se
   rangent en deux piles ÉCRITES, qui se remplissent chacune à son rythme.

   LE SEUIL EST À 760 px, ET NON À 1000. Ces requêtes mesurent `.ap-scroll`,
   pas la fenêtre : avec la barre latérale, une fenêtre de 1280 px ne donne
   que 980 px de conteneur, et un seuil à 1000 ne se déclenche jamais. C'est
   exactement le piège qui laissait la grille en une colonne sur un écran
   large. Le seuil est écrit en clair et non en jeton : une condition de
   `@container` n'accepte pas `var()`.
   ========================================================================= */

@layer app {

  /* Le lieu, puis les deux autres familles. Une simple pile : c'est
     `.ap-reglages__familles` qui porte les colonnes. */
  .ap-reglages {
    display: flex;
    flex-direction: column;
    gap: var(--space-md);
  }
  /* Le sujet de l'écran garde la pleine largeur : son formulaire compte six
     champs, et une colonne de moitié les aurait remis en file. */
  .ap-reglages__sujet { width: 100%; }

  /* Le champ de tête vit dans une carte pleine largeur, mais un champ de
     texte d'un mètre de long ne se lit pas mieux qu'un autre : on le borne à
     la mesure de lecture du système. */
  .ap-reglages__vedette .ap-field__input { max-width: var(--reading-width); }

  .ap-reglages__familles {
    display: grid;
    gap: var(--space-md);
    grid-template-columns: minmax(0, 1fr);
  }

  @container screen (min-width: 760px) {
    .ap-reglages__familles {
      /* `minmax(0, …)` DES DEUX CÔTÉS : sans la borne basse à zéro, un mot
         long — une adresse électronique, un nom de lieu — impose sa largeur à
         la colonne et fait déborder la page entière. */
      grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
      align-items: start;
    }
  }

  /* Chaque colonne se remplit à son rythme. Une grille de cartes aurait
     aligné les hauteurs de rangée, et creusé sous la carte courte un vide de
     la hauteur de la longue. */
  .ap-reglages__colonne {
    display: flex;
    flex-direction: column;
    gap: var(--space-md);
    min-width: 0;
  }

  /* ── Le volet, et le résumé qui évite de l'ouvrir ────────────────────────
     Un volet nommé « Coordonnées » n'apprend rien : il faut l'ouvrir pour
     savoir ce qu'il contient, donc on l'ouvre. Avec la position écrite à
     côté du titre, il ne s'ouvre que pour être corrigé.

     Le résumé passe à la ligne quand la carte est étroite : sur un téléphone,
     « 47,54° hémisphère nord · −1,75° · 120 m » ne tient pas après un titre. */
  .ap-reglages__volet > .ap-details__summary { flex-wrap: wrap; }
  .ap-reglages__volet-titre { color: var(--brand-primary); }
  .ap-reglages__resume {
    min-width: 0;
    overflow-wrap: anywhere;
    color: var(--text-secondary);
    font-size: var(--text-footnote);
    font-variant-numeric: tabular-nums;
  }

  /* Le corps d'un volet respire sous son libellé : `.ap-stack` ne l'atteint
     pas, un `<details>` n'étant pas la pile mais son enfant. */
  .ap-reglages__volet-corps { margin-block-start: var(--space-md); }

  /* Une suite de trois constats EST une liste, et s'annonce comme telle :
     « liste de trois éléments » dit d'emblée qu'il n'y en a pas un quatrième
     plus bas. Les puces, elles, n'apportent rien devant une icône. */
  .ap-reglages__liste {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: var(--space-sm);
  }
}

/* ============================================================================
   L'ÉCRAN D'INSCRIPTION
   ----------------------------------------------------------------------------
   Le formulaire comptait quatre champs et se lisait d'un trait. Il en compte
   huit, et deux d'entre eux ne se remplissent pas comme les autres : le mot
   de passe a un bouton qui le propose, le lieu un bouton qui le relève.

   D'où ces deux BLOCS. Sans eux, le bouton « Proposer un mot de passe »
   flottait entre deux champs sans qu'on voie lequel il concernait, et
   « Facultatif » sur le lieu se lisait comme une remarque sur le champ du
   dessus. Un encadré discret suffit à dire « ceci va ensemble » — il n'est
   pas une carte : une carte dans une carte creuse un puits.
   ========================================================================= */

@layer app {

  .ap-inscription__bloc {
    display: flex;
    flex-direction: column;
    gap: var(--space-sm);
    padding: var(--space-md);
    border: 1px solid var(--hairline);
    border-radius: var(--field-radius);
  }

  .ap-inscription__titre {
    margin: 0;
    display: flex;
    align-items: center;
    /* Le libellé passe sous le titre quand la carte est étroite : sur un
       téléphone, « D'où observez-vous ? » et « Facultatif » ne tiennent pas
       sur la même ligne. */
    flex-wrap: wrap;
    gap: var(--space-sm);
    color: var(--brand-primary);
    font-size: var(--text-headline);
    font-weight: var(--weight-semibold);
  }

  /* « Facultatif » EST ÉCRIT, et pas seulement gris : une information portée
     par la seule couleur n'existe pas pour qui ne la distingue pas. Le cadre
     et le texte disent la même chose deux fois, exprès. */
  .ap-inscription__facultatif {
    padding: 2px var(--space-sm);
    border: 1px solid var(--hairline-strong);
    border-radius: var(--pill-radius);
    color: var(--text-secondary);
    font-size: var(--text-caption);
    font-weight: var(--weight-regular);
    text-transform: none;
  }

  /* La ville et le code postal se mettent côte à côte dès qu'il y a la
     place, et le code postal reste étroit : un champ de quarante caractères
     pour en accueillir cinq invite à y écrire autre chose. */
  .ap-inscription__lieu {
    display: grid;
    gap: var(--space-md);
    grid-template-columns: minmax(0, 1fr);
  }

  @media (min-width: 480px) {
    .ap-inscription__lieu {
      grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
      align-items: start;
    }
  }

  /* L'avertissement de conserver le mot de passe. Il se détache du reste
     parce qu'il n'est pas une aide de plus : c'est la seule phrase de
     l'écran dont l'oubli coûte le compte. */
  .ap-inscription__garde {
    margin: 0;
    padding: var(--space-sm) var(--space-md);
    border-inline-start: 3px solid var(--status-warning);
    border-radius: var(--field-radius);
    background: var(--status-warning-soft);
    color: var(--text-primary);
    font-size: var(--text-subheadline);
    line-height: var(--line-normal);
  }

  /* Les deux boutons du mot de passe doivent rester atteignables au pouce :
     `--space-sm` de part et d'autre d'une ligne de texte ne suffit pas à
     faire 32 px sur `.ap-button--plain`, qui n'a pas de fond. */
  .ap-inscription__bloc .ap-button {
    min-height: 32px;
  }

  /* ── L'indicateur de robustesse et les coches (§ 7) ──────────────────────
     Elles ne remplacent RIEN : la validation serveur reste seule juge. Leur
     travail est de dire, pendant la saisie, ce qui manque encore — sans quoi
     l'on découvre la règle en se faisant refuser, ce qui est le pire moment
     pour l'apprendre.

     Chaque critère porte SON MOT à côté de sa coche. Une pastille verte
     seule n'existe pas pour qui ne distingue pas le vert, et une liste de
     cinq pastilles ne dit pas laquelle est laquelle. */
  .ap-mdp-criteres {
    margin: 0;
    padding: 0;
    list-style: none;
    display: grid;
    gap: 2px;
    font-size: var(--text-caption);
    color: var(--text-secondary);
  }

  .ap-mdp-critere::before {
    content: "○";
    display: inline-block;
    width: 1.4em;
    color: var(--text-secondary);
  }

  .ap-mdp-critere[data-satisfait="oui"] {
    color: var(--text-primary);
  }

  .ap-mdp-critere[data-satisfait="oui"]::before {
    content: "✓";
    color: var(--status-success);
  }

  .ap-mdp-jauge {
    height: 6px;
    border-radius: var(--pill-radius);
    background: var(--hairline);
    overflow: hidden;
  }

  /* La largeur est posée par le script, en pourcentage. La couleur, elle,
     vient des trois paliers : un dégradé continu ne se lit pas. */
  .ap-mdp-jauge__fill {
    height: 100%;
    width: 0;
    border-radius: inherit;
    background: var(--status-danger, #E5484D);
    transition: width .18s ease, background-color .18s ease;
  }

  .ap-mdp-jauge__fill[data-force="moyen"] { background: var(--status-warning); }
  .ap-mdp-jauge__fill[data-force="fort"]  { background: var(--status-success); }

  /* La correspondance des deux mots de passe. Le mot ET le signe, pour la
     même raison que les coches ci-dessus. */
  .ap-mdp-accord[data-accord="non"] { color: var(--status-danger, #E5484D); }
  .ap-mdp-accord[data-accord="oui"] { color: var(--status-success); }

  /* Les deux cases d'acceptation. Elles ne sont PAS des interrupteurs : un
     consentement se coche, il ne se bascule pas — et une case native part
     avec le formulaire sans une ligne de script. */
  .ap-consentement {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr);
    align-items: start;
    gap: var(--space-sm);
    font-size: var(--text-subheadline);
  }

  .ap-consentement input {
    /* 20 px : la case native d'un navigateur de bureau fait 13 px, ce qui
       est sous le seuil de ce qu'un doigt vise sans se reprendre. */
    width: 20px;
    height: 20px;
    margin: 2px 0 0;
    accent-color: var(--brand-primary);
  }
}

/* Le volume de la musique, sous l'interrupteur qu'il nuance. Un léger retrait
   à gauche dit qu'il DÉPEND de lui : posé au même bord, il se lirait comme un
   sixième réglage indépendant. */
.ap-volume {
  display: flex;
  flex-direction: column;
  gap: var(--space-2xs);
  padding-inline-start: var(--space-sm);
  margin-block-start: calc(var(--space-2xs) * -1);
}

.ap-volume .ap-row {
  align-items: center;
  gap: var(--space-sm);
}

.ap-volume input[type="range"] { flex: 1; min-width: 0; }

/* Le chiffre ne doit pas faire sauter le curseur en passant de 5 à 100 % :
   une largeur réservée vaut mieux qu'une mise en page qui tressaute. */
.ap-volume [data-volume-valeur] {
  flex: none;
  min-width: 4.5ch;
  text-align: end;
}

/* LE VOLUME DISPARAÎT AVEC LA MUSIQUE. Régler le niveau de ce qui ne joue
   pas est un geste sans effet, et un réglage sans effet est un réglage qui
   ment — le même défaut que la case qui ne commandait rien.

   Aucune ligne de script pour cela : `data-musique` est déjà posé sur le
   corps et l'interrupteur le met à jour en direct. La règle suit toute
   seule. `display: none` retire aussi le curseur du parcours au clavier,
   ce qu'un simple `opacity` n'aurait pas fait. */
body[data-musique="false"] .ap-volume { display: none; }

/* L'ACCUSÉ DE RÉCEPTION DES PRÉFÉRENCES, et le bouton qu'il remplace.

   En retirant le bouton « Enregistrer », on retire le seul signe qu'il se
   passe quelque chose : un réglage qui change sans aucun retour laisse
   croire qu'on n'a rien fait, et l'on rebascule deux fois. D'où cette
   ligne — discrète, jamais bloquante, et SANS bandeau : un message qui
   pousse la page ferait sauter sous les doigts les réglages voisins.

   La hauteur du pied est réservée : l'ardoise apparaît et s'efface sans
   rien déplacer, et le bouton peut revenir en cas d'échec sans que la
   carte change de taille. */
.ap-auto__pied {
  display: flex;
  align-items: center;
  gap: var(--space-sm);
  min-block-size: var(--space-lg);
}

/* Le fondu dit « quelque chose vient d'arriver » sans clignoter. Le socle
   réduit déjà les transitions sous `prefers-reduced-motion` : l'accusé y
   devient franc, ce qui reste juste. */
.ap-auto__etat {
  opacity: 0;
  transition: opacity var(--motion-soft);
}

.ap-auto__etat[data-etat="ok"] { opacity: 1; color: var(--status-success); }
.ap-auto__etat[data-etat="echec"] { opacity: 1; color: var(--status-error); }

/* LE BOUTON N'EST MASQUÉ QUE QUAND LE SCRIPT A PRIS LA MAIN — c'est lui qui
   pose `ap-auto--pris`, jamais le gabarit. Sans JavaScript, la classe
   n'arrive pas, le bouton reste, et la carte s'enregistre par le chemin
   ordinaire du navigateur. Il revient aussi quand un envoi échoue : c'est
   alors le seul moyen de reprendre. */
.ap-auto--pris .ap-auto__bouton { display: none; }

/* ══ Le geste de déconnexion, dans la barre du haut ══════════════════════════
   Partir est un POST : le geste est donc un BOUTON, quand ses voisins sont
   des liens. Le navigateur lui donne d'office un fond, une bordure et une
   police à lui — sans ce dégrossissage, il se verrait comme une pièce
   rapportée à côté de la roue crantée. Le formulaire, lui, ne doit pas
   introduire de hauteur : c'est le bouton qui porte la taille. */
@layer app {
  .ap-topbar__logout {
    display: flex;
    margin: 0;
  }

  /* LA COULEUR EST COPIÉE SUR LE VOISIN, PAS SUR LE SYSTÈME. Le système veut
     ces actions en `--text-secondary`, mais `02-socle.css` pose `a { color }`
     hors couche : la roue crantée, qui est un lien, sort donc en bleu de
     marque. Un bouton gris à côté d'une roue bleue se lirait comme un geste
     d'une autre nature. Le jour où cette règle `a` rentrera dans sa couche,
     les deux lignes ci-dessous seront à retirer ensemble. */
  button.ap-topbar__action {
    padding: 0;
    border: 0;
    background: none;
    font: inherit;
    color: var(--brand-primary);
    cursor: pointer;
  }

  /* Le survol du système perdrait de toute façon : `app` passe après
     `astropost`, et une couche l'emporte sur la spécificité. */
  button.ap-topbar__action:hover { color: var(--text-primary); }
}

/* ══ La boîte de déconnexion ════════════════════════════════════════════════
   `ap-dialog` ne prévoyait qu'un titre, un message et des actions : la case
   « ne plus me demander » s'intercale entre les deux derniers, et sans cette
   respiration elle se collait au message, où elle se lisait comme une
   troisième phrase plutôt que comme un réglage. */
@layer app {
  .ap-deconnexion__memoire { margin-top: var(--space-md); }
}

/* ══ Les filtres du catalogue, repliés au téléphone ══════════════════════════
   Onze filtres empilés mangeaient toute la hauteur d'un écran de téléphone :
   on arrivait au catalogue sans avoir vu un seul astre. Ils passent donc
   derrière un bouton — MAIS SEULEMENT À L'ÉTROIT. Sur grand écran la barre
   ne bouge pas d'un pixel : le panneau est en `display: contents`, donc ses
   enfants restent les enfants directs du `flex` de `.ap-filter-bar`, dans le
   même ordre et avec le même alignement qu'avant.

   Le repli est porté par `data-filtres-etroit`, posé par `filtres.js` quand
   la zone défilante mesure 560 px ou moins — la même bascule que les
   `@container screen (max-width: 560px)` du système de design. Sans script,
   l'attribut n'existe pas et la page est exactement celle d'hier.
   ══════════════════════════════════════════════════════════════════════════ */
@layer app {
  .ap-filter-bar__panneau { display: contents; }

  /* `[hidden]` vient de la feuille du navigateur, que tout `display`
     d'auteur écrase — sans cette ligne le bouton resterait visible sur
     grand écran, où il n'ouvre rien. */
  .ap-filtres-bascule[hidden] { display: none; }

  /* `align-self` et non `width` : la barre est un `flex` en colonne dont
     l'`align-items: stretch` tirerait le bouton sur toute la largeur. Le
     propriétaire veut un bouton à sa taille, pas à celle de l'écran. */
  .ap-filtres-bascule {
    display: inline-flex;
    align-items: center;
    align-self: flex-start;
    gap: var(--space-sm);
  }

  /* Le compte dit ce que le repli cache : « Filtres · 2 » se lit replié,
     « Filtres » seul dirait que le catalogue est entier alors qu'il ne l'est
     pas. C'est le serveur qui compte — la même liste que la note sous la
     barre, jamais un second calcul qui pourrait diverger. */
  .ap-filtres-bascule__compte {
    min-width: 20px;
    padding: 1px 7px;
    border-radius: 999px;
    background: var(--brand-primary);
    color: var(--sky-deep);
    font-size: var(--text-caption);
    font-variant-numeric: tabular-nums;
  }

  .ap-filtres-raz { display: none; }

  .ap-filter-bar[data-filtres-etroit] {
    flex-direction: column;
    align-items: stretch;
  }

  .ap-filter-bar[data-filtres-etroit] .ap-filter-bar__panneau {
    display: flex;
    flex-direction: column;
    gap: var(--space-sm);
    padding: var(--space-sm);
    border: 1px solid var(--hairline);
    border-radius: var(--card-radius);
    background: color-mix(in srgb, var(--app-surface) 55%, transparent);
  }

  /* Replié : retiré de l'arbre, donc hors du parcours du clavier et muet
     pour les lecteurs d'écran. Un panneau seulement transparent garderait
     ses onze menus tabulables sous le doigt. */
  .ap-filter-bar[data-filtres-etroit][data-filtres-etat="ferme"]
  .ap-filter-bar__panneau { display: none; }

  .ap-filter-bar[data-filtres-etroit] .ap-filtres-raz { display: inline-flex; }

  .ap-filter-bar[data-filtres-etroit] .ap-filter { width: 100%; }
}

/* HORS COUCHE, et pour la raison écrite plus haut dans ce fichier :
   `02-socle.css` pose `.ap-compact-target { min-height: 32px }` SANS couche,
   et une règle non couchée bat toute règle couchée. Rangée dans `@layer app`,
   la cible de 44 px serait silencieusement ignorée et les menus resteraient
   à 32 px — trop bas pour un pouce, et sans rien d'apparent qui le dise. */
.ap-filter-bar[data-filtres-etroit] .ap-compact-target,
.ap-filter-bar[data-filtres-etroit] .ap-button,
button.ap-filtres-bascule { min-height: 44px; }

/* ┌───────────────────────────────────────────────────────────────────────────
   │ LE FORUM DE LA COMMUNAUTÉ
   │
   │ Deux règles portent tout le reste, et ce sont des règles de SÛRETÉ
   │ autant que de dessin :
   │
   │   · `white-space: pre-wrap` sur le corps d'un message. C'est ce qui
   │     permet de rendre les retours à la ligne SANS fabriquer de HTML —
   │     le gabarit affiche la variable nue, échappée par Django, et le CSS
   │     fait le reste. Aucun `|linebreaksbr`, aucun `|safe`.
   │   · `overflow-wrap: anywhere` au même endroit. Un joueur peut coller
   │     cinq mille signes sans une espace ; sans cette ligne, sa colonne
   │     pousserait la page entière hors de l'écran, et sur téléphone plus
   │     rien ne serait lisible. C'est un défaut de forum classique.
   └────────────────────────────────────────────────────────────────────── */

@layer app {
  .ap-forum-rubrique {
    display: block;
    text-decoration: none;
    color: inherit;
  }

  .ap-forum-rubrique:hover,
  .ap-forum-rubrique:focus-visible {
    border-color: var(--accent-primary);
  }

  .ap-forum-rubrique .ap-card__title {
    margin-bottom: var(--space-xs);
  }

  /* `.ap-observatory` est une grille à QUATRE colonnes : un rang, un corps,
     des chiffres, une action. Une ligne de forum n'a ni rang ni action, et
     la colonne du corps se réduisait alors à la largeur d'un mot — un
     intitulé tombait en colonne, une lettre par ligne. La ligne de forum
     redéclare donc sa grille : le corps prend la place, les chiffres
     suivent s'il y en a. */
  .ap-observatory.ap-forum-ligne {
    grid-template-columns: minmax(0, 1fr) auto;
  }

  .ap-forum-ligne .ap-observatory__name {
    text-decoration: none;
  }

  .ap-forum-message {
    display: flex;
    flex-direction: column;
    gap: var(--space-sm);
  }

  .ap-forum-message--mien {
    border-color: var(--accent-primary);
  }

  .ap-forum-message__auteur {
    font-weight: 600;
    text-decoration: none;
  }

  .ap-forum-message__corps {
    margin: 0;
    /* Voir l'encadré ci-dessus : ces deux lignes remplacent un filtre de
       gabarit qui aurait produit du HTML. */
    white-space: pre-wrap;
    overflow-wrap: anywhere;
    line-height: 1.55;
  }

  .ap-forum-message__signalement > summary {
    cursor: pointer;
    list-style: none;
  }

  .ap-forum-message__signalement > summary::-webkit-details-marker {
    display: none;
  }

  .ap-forum-message__signalement[open] > summary {
    margin-bottom: var(--space-xs);
  }

  /* Le menu des motifs n'a pas à prendre toute la largeur d'un message :
     ses intitulés sont courts, et un champ démesuré suggère qu'on y écrit. */
  .ap-forum-message__signalement select {
    max-width: 22rem;
  }

  .ap-forum__saisie {
    width: 100%;
    min-height: 8rem;
    resize: vertical;
    font-family: inherit;
    line-height: 1.5;
  }

  .ap-pagination {
    align-items: center;
    gap: var(--space-sm);
    margin-top: var(--space-md);
  }
}

@media (max-width: 640px) {
  /* La règle étroite de `.ap-observatory` renvoie le corps en colonne 2,
     qui n'existe pas ici. On la défait pour la ligne de forum. */
  @layer app {
    .ap-observatory.ap-forum-ligne {
      grid-template-columns: minmax(0, 1fr);
    }
    .ap-forum-ligne .ap-observatory__body { grid-column: 1 / -1; }
    .ap-forum-ligne .ap-observatory__figures {
      grid-column: 1 / -1;
      grid-template-columns: auto auto auto;
      justify-content: start;
      gap: var(--space-xs) var(--space-sm);
    }
  }
}
