/* ============================================================================
   ASTROPOST — Composants
   ----------------------------------------------------------------------------
   Chaque composant du jeu, avec la raison de sa forme quand elle n'est pas
   évidente. Les noms reprennent ceux du code iOS (DesignSystem/Components.swift)
   pour qu'on puisse passer de l'un à l'autre.
   ========================================================================= */

/* ┌───────────────────────────────────────────────────────────────────────────
   │ COUCHE DE CASCADE — à lire avant de s'étonner d'une priorité
   │
   │ Ce fichier est enveloppé dans `@layer astropost`. Conséquence : toute
   │ règle NON couchée l'emporte sur celles d'ici, quelle que soit sa
   │ spécificité et quel que soit l'ordre de chargement.
   │
   │ C'est voulu. Une application qui définit déjà `.ap-button` dans sa propre
   │ feuille garde SA version, sans avoir à surenchérir en spécificité ni à
   │ surveiller l'ordre des <link>. Le système de design fournit le socle ;
   │ l'application tranche.
   │
   │ Sans cette couche, ce fichier écrasait silencieusement 32 classes de
   │ l'application web — dont son bouton principal, qui perdait son fond.
   │ Le défaut a été vu à l'écran, pas deviné.
   └────────────────────────────────────────────────────────────────────────── */

@layer astropost {

  /* ══ Carte ═════════════════════════════════════════════════════════════════
     Le composant de base. Pas de bordure, pas d'ombre : le contraste entre
     `app-surface` et `app-background` suffit. Un filet très léger est ajouté
     parce qu'un écran de PC a un contraste plus faible qu'une dalle OLED, et
     que les cartes s'y confondaient avec le fond. */

  .ap-card {
    padding: var(--card-padding);
    border: 1px solid var(--hairline);
    border-radius: var(--card-radius);
    background: var(--app-surface);
    width: 100%;
  }

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

  /* Une carte cliquable — une ligne de courrier, un site, un objet du
     catalogue. Elle doit être un <button> ou un <a>, pas une <div> cliquable. */
  .ap-card--interactive {
    display: block;
    text-align: left;
    font: inherit;
    color: inherit;
    cursor: pointer;
    transition: border-color var(--motion-soft), background-color var(--motion-soft);
  }
  .ap-card--interactive:hover {
    border-color: var(--hairline-strong);
    background: color-mix(in srgb, var(--app-surface) 92%, var(--brand-primary));
  }
  .ap-card--interactive:active { background: var(--pressed-fill); }

  /* Le passage mis en avant : un bandeau, pour ce qui vient d'arriver ou ce qui
     attend le joueur. */
  .ap-card--highlight {
    background: linear-gradient(160deg,
                rgba(89, 140, 242, .14), rgba(179, 153, 242, .08));
    border-color: rgba(115, 184, 255, .22);
    border-radius: var(--sheet-radius);
  }

  /* ══ Boutons ══════════════════════════════════════════════════════════════
     Quatre variantes, et elles ont un sens :
       — plein      : l'action principale de l'écran, une seule par écran
       — contour    : une action secondaire assumée
       — discret    : une sortie, un « plus tard », un lien
       — destructif : ce qui efface
     `.ap-button` est une classe ; l'élément doit rester <button> ou <a>. */

  .ap-button {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-sm);
    padding: 10px var(--space-md);
    border: 1px solid transparent;
    border-radius: var(--pill-radius);
    background: none;
    font-family: inherit;
    font-size: var(--text-subheadline);
    font-weight: var(--weight-medium);
    color: var(--text-primary);
    cursor: pointer;
    text-decoration: none;
    transition: background-color var(--motion-soft),
                border-color var(--motion-soft),
                transform 200ms ease;
  }

  .ap-button--large { padding: 14px var(--space-lg); font-size: var(--text-body); }
  .ap-button--small { padding: 6px var(--space-sm); font-size: var(--text-footnote);
                      min-height: 32px; }
  .ap-button--block { width: 100%; }

  .ap-button--filled {
    background: var(--brand-primary);
    /* Un bleu clair avec du texte clair ne passerait aucun contrôle de
       contraste : le texte est donc très sombre, comme sur iOS. */
    color: #08131F;
  }
  .ap-button--filled:hover  { background: #8FC6FF; transform: translateY(-1px); }
  .ap-button--filled:active { transform: none; background: #5FA9F5; }

  .ap-button--bordered { border-color: var(--hairline-strong); }
  .ap-button--bordered:hover { border-color: var(--brand-primary); color: var(--brand-primary); }

  .ap-button--plain { color: var(--brand-primary); padding-inline: var(--space-sm); }
  .ap-button--plain:hover { background: var(--brand-primary-soft); }

  .ap-button--destructive { color: var(--status-error); }
  .ap-button--destructive:hover { background: var(--status-error-soft); }

  /* Les mêmes variantes, sous les noms que l'application web emploie déjà.
     Définies ici pour que les deux vocabulaires marchent, plutôt que
     d'imposer une réécriture des gabarits. */
  .ap-button--primary { background: var(--brand-primary); color: #08131F; }
  .ap-button--primary:hover { background: #8FC6FF; }
  .ap-button--ghost { background: none; color: var(--brand-primary); }
  .ap-button--ghost:hover { background: var(--brand-primary-soft); }
  .ap-button--danger { background: var(--status-error-soft); color: var(--status-error); }
  .ap-button--danger:hover { background: var(--status-error); color: var(--sky-deep); }

  /* Un bouton désactivé reste LISIBLE : il n'est pas à demi effacé. Et il est
     TOUJOURS accompagné de son blocage écrit (voir .ap-blocker) — un bouton
     grisé sans explication se lit comme une panne. */
  .ap-button:disabled,
  .ap-button[aria-disabled="true"] {
    cursor: not-allowed;
    opacity: .45;
    transform: none;
  }

  @media (prefers-reduced-motion: reduce) {
    .ap-button:hover { transform: none; }
  }

  /* ══ Le blocage écrit ═════════════════════════════════════════════════════
     Règle transversale du jeu : TOUT BLOCAGE ANNONCE SA CAUSE, CHIFFRÉE. Le
     message porte ce qui MANQUE, pas ce que coûte l'action — savoir qu'il reste
     135 crédits à gagner dit au joueur s'il en a pour une observation ou pour
     la soirée. Le prix, lui, est déjà affiché juste à côté. */

  .ap-blocker {
    display: flex;
    align-items: flex-start;
    gap: var(--space-sm);
    font-size: var(--text-footnote);
    color: var(--status-warning);
  }
  .ap-blocker__icon { flex: none; width: 16px; height: 16px; margin-top: 2px; }

  /* Un indice donné AVANT l'envoi, sur un formulaire : plus doux qu'un refus. */
  .ap-hint { font-size: var(--text-footnote); color: var(--text-secondary); }

  /* ══ Libellé à icône ══════════════════════════════════════════════════════
     L'équivalent du `Label` de SwiftUI : une icône et un texte, alignés. */

  .ap-label {
    display: inline-flex;
    align-items: center;
    gap: var(--space-sm);
  }
  .ap-label__icon { flex: none; width: 1.1em; height: 1.1em; }
  .ap-label--top { align-items: flex-start; }
  .ap-label--top .ap-label__icon { margin-top: .15em; }

  /* ══ Pastille de ressource ════════════════════════════════════════════════
     La barre du haut : crédits, données, réputation, énergie. Quatre pastilles
     au fond teinté de leur couleur. */

  .ap-resource {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    padding: 5px 10px;
    border-radius: var(--pill-radius);
    font-size: var(--text-subheadline);
    font-weight: var(--weight-medium);
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
  }
  .ap-resource__icon { width: 1.05em; height: 1.05em; flex: none; }

  .ap-resource--credits    { background: var(--status-warning-soft);  color: var(--status-warning); }
  .ap-resource--data       { background: var(--brand-primary-soft);   color: var(--brand-primary); }
  .ap-resource--reputation { background: var(--brand-secondary-soft); color: var(--brand-secondary); }
  .ap-resource--energy     { background: var(--status-success-soft);  color: var(--status-success); }

  .ap-resource-bar {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-sm);
    padding-block: var(--space-sm);
  }

  /* ══ La barre du haut ═════════════════════════════════════════════════════
     Le nom de l'observatoire, son palier et son lieu ne vivaient que dans la
     barre latérale — donc NULLE PART sur un téléphone, où ce sont pourtant
     les trois premières choses qu'on veut voir. La barre du haut les porte
     sur les deux dispositions, et la latérale cesse de les redire.

     Elle traverse toute la largeur, au-dessus de la latérale comme du
     contenu : c'est l'identité de la partie, pas un en-tête d'écran. */

  .ap-topbar {
    /* Son PROPRE conteneur : la barre vit hors de `.ap-scroll`, donc le
       conteneur `screen` ne la mesure pas. Une requête écrite dessus ne
       s'appliquait jamais, et le nom de l'observatoire restait tronqué au
       profit du mot « ASTROPOST » — exactement l'inverse de ce qu'on veut. */
    container-type: inline-size;
    container-name: topbar;
    display: flex;
    align-items: center;
    gap: var(--space-sm);
    min-height: 52px;
    padding-inline: var(--gutter);
    border-bottom: 1px solid var(--hairline);
    background: color-mix(in srgb, var(--app-surface) 82%, transparent);
    backdrop-filter: blur(12px);
  }

  /* `min-width: 0` est ce qui autorise la troncature : sans lui, un élément
     de flex ne descend pas sous sa largeur de contenu et pousse la barre. */
  .ap-topbar__identity {
    display: flex;
    flex-direction: column;
    justify-content: center;
    min-width: 0;
  }

  .ap-topbar__name,
  .ap-topbar__detail {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

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

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

  .ap-topbar__actions {
    display: flex;
    align-items: center;
    gap: var(--space-xs);
    margin-inline-start: auto;
  }

  .ap-topbar__action {
    display: inline-grid;
    place-items: center;
    width: 36px;
    height: 36px;
    border-radius: var(--pill-radius);
    color: var(--text-secondary);
  }
  .ap-topbar__action:hover { background: var(--selected-fill); color: var(--text-primary); }
  .ap-topbar__action svg { width: 20px; height: 20px; }

  /* Le trait vertical qui sépare la marque de l'identité. Décoratif, donc
     `aria-hidden` dans le balisage. */
  .ap-topbar__rule {
    align-self: stretch;
    width: 1px;
    margin-block: 12px;
    background: var(--hairline);
  }

  /* Sur un téléphone, le mot « ASTROPOST » prend la place du nom de
     l'observatoire, qui est le renseignement utile. La marque reste dite par
     son logo, qui ne bouge pas. */
  @container topbar (max-width: 560px) {
    .ap-topbar__wordmark,
    .ap-topbar__rule { display: none; }
  }

  /* ══ L'avatar ═════════════════════════════════════════════════════════════
     Rien à téléverser : il est DÉDUIT du code d'observatoire, comme le reste
     du jeu se déduit au lieu de se retenir. Le code est tiré au hasard et ne
     dérive d'aucun nom (§ 5.22) — la couleur n'apprend donc rien du joueur.

     Les deux teintes viennent du style en ligne, posé par `engine/avatar.py`.
     Un aplat parfaitement plat se lit comme un trou : d'où le dégradé. */

  .ap-avatar {
    display: inline-grid;
    place-items: center;
    flex: none;
    width: 34px;
    height: 34px;
    border-radius: 50%;
    background: linear-gradient(145deg,
                hsl(var(--avatar-hue, 220) 62% 58%),
                hsl(var(--avatar-hue-2, 260) 58% 44%));
    /* Texte SOMBRE sur une couleur claire, pour la même raison que la
       pastille : du blanc sur ces teintes ne passe aucun contrôle. */
    color: var(--sky-deep);
    font-size: var(--text-caption);
    font-weight: var(--weight-semibold);
    letter-spacing: .02em;
    line-height: 1;
    user-select: none;
  }

  .ap-avatar--large {
    width: 72px;
    height: 72px;
    font-size: var(--text-title);
  }

  /* ══ Pastille de compte ═══════════════════════════════════════════════════
     ROUGE, et non bleue : une pastille discrète est une pastille qu'on n'a pas
     vue. Elle ne compte que ce qui est réclamable SUR-LE-CHAMP — une pastille
     qui s'allume pour rien cesse d'être regardée.

     Le nombre RÉTRÉCIT plutôt que de passer à la ligne (UI_UX.md). */

  .ap-badge {
    display: inline-grid;
    place-items: center;
    min-width: 18px;
    height: 18px;
    padding-inline: 5px;
    border-radius: var(--pill-radius);
    background: var(--status-error);
    /* Texte SOMBRE sur le rouge, et non blanc : #FA736B avec du blanc donne
       un contraste de 2,4:1, qui ne passe aucun contrôle. Avec le fond du
       ciel, il monte à 8:1. */
    color: var(--sky-deep);
    font-size: var(--text-caption-2);
    font-weight: var(--weight-semibold);
    font-variant-numeric: tabular-nums;
    line-height: 1;
    white-space: nowrap;
  }

  /* Un point sans chiffre : « il y a quelque chose », sans dire combien.
     Employé pour « non lu » sur une ligne de courrier. */
  .ap-dot {
    display: inline-block;
    width: 8px; height: 8px;
    border-radius: 50%;
    background: var(--brand-primary);
    flex: none;
  }

  /* Le même point, en ROUGE : « celui-ci vous attend ».

     La pastille de l'onglet dit qu'il y a trois choses à récupérer ; elle ne
     dit pas LESQUELLES. Dans une liste de vingt cartes, le joueur ouvre
     l'onglet puis cherche — et le tri seul ne suffit pas, parce qu'une carte
     en tête de liste ne se distingue d'une autre par rien.

     Rouge comme `.ap-badge`, et pour la même raison : ce point ne s'allume
     que sur ce qui est réclamable SUR-LE-CHAMP. Bleu, il se confondrait avec
     « non lu », qui ne demande rien. */
  .ap-dot--attention { background: var(--status-error); }

  /* ══ Sélecteur de volet ═══════════════════════════════════════════════════
     Le sélecteur segmenté du système ne sait pas porter de pastille. Or la
     pastille de l'onglet dit qu'il y a quelque chose à récupérer SANS DIRE DE
     QUEL CÔTÉ : le joueur ouvre l'onglet, puis cherche. Celui-ci répond à la
     question au moment où elle se pose.

     Il doit être un vrai groupe d'onglets : role="tablist" et des boutons
     role="tab", pour que le clavier et les lecteurs d'écran s'y retrouvent. */

  .ap-pane-selector {
    display: flex;
    flex-wrap: wrap;
    gap: 4px;
    padding: 3px;
    border-radius: 11px;
    background: color-mix(in srgb, var(--app-surface) 55%, transparent);
  }

  /* QUATRE volets sur une largeur de téléphone ne tiennent pas : les items
     portent `white-space: nowrap` et `flex: 1 1 0`, donc ils ne rétrécissent
     pas — ils débordent, et le dernier est coupé net. C'est arrivé le jour
     où le laboratoire a pris son volet à côté de Missions, Contrats et
     Recherche.

     Deux par ligne plutôt qu'un libellé tronqué : un volet dont on ne lit
     pas le nom n'est pas un volet. La règle ne s'applique qu'à partir de
     QUATRE — à trois, tout tient sur une ligne et rien ne bouge. */
  @container screen (max-width: 560px) {
    .ap-pane-selector:has(> :nth-child(4)) > .ap-pane-selector__item {
      flex-basis: calc(50% - 4px);
    }
  }

  .ap-pane-selector__item {
    flex: 1 1 0;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-sm);
    min-height: 34px;
    padding: 7px var(--space-sm);
    border: 0;
    border-radius: 8px;
    background: none;
    font-family: inherit;
    font-size: var(--text-subheadline);
    font-weight: var(--weight-regular);
    color: var(--text-primary);
    cursor: pointer;
    white-space: nowrap;
    transition: background-color var(--motion-soft);
  }
  .ap-pane-selector__item:hover { background: var(--pressed-fill); }

  .ap-pane-selector__item[aria-selected="true"] {
    background: var(--selected-fill);
    font-weight: var(--weight-semibold);
  }

  /* ══ Barre d'onglets — mobile ═════════════════════════════════════════════
     Cinq onglets, en bas. `env(safe-area-inset-bottom)` : sur un iPhone en
     navigateur, la barre système mange le bas de l'écran. */

  .ap-tabbar {
    z-index: var(--z-tabbar);
    display: flex;
    justify-content: space-around;
    gap: 2px;
    padding: 6px var(--space-sm) calc(6px + env(safe-area-inset-bottom, 0px));
    background: color-mix(in srgb, var(--app-background) 86%, transparent);
    backdrop-filter: blur(20px);
    border-top: 1px solid var(--hairline);
  }

  .ap-tabbar__item {
    position: relative;
    flex: 1 1 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 3px;
    padding: 6px 2px;
    border: 0;
    border-radius: 10px;
    background: none;
    font-family: inherit;
    font-size: var(--text-caption-2);
    color: var(--text-secondary);
    cursor: pointer;
  }
  .ap-tabbar__item[aria-selected="true"] { color: var(--brand-primary); }
  .ap-tabbar__icon { width: 24px; height: 24px; }

  /* La pastille se pose sur l'icône, décalée en haut à droite. */
  .ap-tabbar__badge {
    position: absolute;
    top: 2px;
    left: 50%;
    transform: translateX(6px);
  }

  /* ══ Barre latérale — tablette et desktop ═════════════════════════════════
     Elle reste VISIBLE, porte l'identité de l'observatoire, dit ce que contient
     chaque section, et affiche les rappels. Une barre d'onglets en bas d'un
     écran de treize pouces gaspille la hauteur et oblige à viser loin de ce
     qu'on regarde. */

  .ap-sidebar {
    z-index: var(--z-sidebar);
    display: flex;
    flex-direction: column;
    gap: var(--space-lg);
    padding: var(--space-lg) var(--space-md);
    border-right: 1px solid var(--hairline);
    /* LE DÉFILEMENT EST DESCENDU D'UN CRAN, et c'est ce qui rend le guide
       trouvable. Mesuré à 1440 × 720 — une fenêtre de portable très
       ordinaire : les neuf sections du jeu occupent 658 px des 668 px
       disponibles. La barre était donc pleine à dix pixels près AVANT qu'on
       lui ajoute quoi que ce soit, et une dixième entrée n'y tient par aucun
       réglage d'espacement.
       Elle sortait par le bas, en dernière position — exactement l'entrée
       qu'on venait d'ajouter pour qu'on la trouve. Le groupe d'aide est donc
       ancré en pied, hors du flux défilant : ce sont les sections du jeu qui
       défilent derrière lui. */
    overflow: hidden;
  }

  .ap-sidebar__identity { display: flex; flex-direction: column; gap: var(--space-xs); }
  .ap-sidebar__name  { font-size: var(--text-title); font-weight: var(--weight-semibold); }
  .ap-sidebar__title { font-size: var(--text-footnote); color: var(--brand-secondary); }

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

  /* LE GROUPE D'AIDE, SÉPARÉ D'UN FILET. « Guide et aide » n'est pas une
     section du jeu : le filet dit qu'on change de nature, et non qu'on
     descend d'un cran dans la même liste. La barre laisse déjà `space-lg`
     entre ses groupes ; le filet se pose donc dans la marge, par un
     pseudo-élément, sans rien ajouter à la hauteur d'une barre qui compte
     désormais dix entrées. */
  /* Les sections du jeu prennent la hauteur restante et défilent seules.
     `min-height: 0` est indispensable : sans lui, un enfant de flex refuse de
     descendre sous la hauteur de son contenu et pousse le pied dehors. */
  .ap-sidebar__nav--sections { flex: 1 1 auto; min-height: 0; overflow-y: auto; }

  .ap-sidebar__nav--aide { position: relative; flex: 0 0 auto; margin-top: auto; }
  .ap-sidebar__nav--aide::before {
    content: "";
    position: absolute;
    inset-inline: var(--space-sm);
    top: calc(var(--space-lg) / -2);
    border-top: 1px solid var(--hairline);
  }

  .ap-sidebar__item {
    display: grid;
    grid-template-columns: 24px 1fr auto;
    align-items: center;
    gap: var(--space-sm) var(--space-md);
    padding: 10px var(--space-sm);
    border: 0;
    border-radius: 10px;
    background: none;
    font-family: inherit;
    text-align: left;
    color: var(--text-primary);
    cursor: pointer;
    transition: background-color var(--motion-soft);
  }
  .ap-sidebar__item:hover { background: var(--pressed-fill); }
  .ap-sidebar__item[aria-selected="true"] { background: var(--selected-fill); }

  .ap-sidebar__icon { width: 22px; height: 22px; color: var(--brand-primary); }
  .ap-sidebar__label { font-size: var(--text-subheadline); font-weight: var(--weight-medium); }

  /* Le résumé de section : « Ressources, équipement et site ». Le téléphone n'a
     pas la place de l'afficher ; la barre latérale, si. */
  .ap-sidebar__summary {
    grid-column: 2 / -1;
    font-size: var(--text-caption);
    color: var(--text-secondary);
    line-height: var(--line-tight);
  }

  /* ══ Tuile de chiffre ═════════════════════════════════════════════════════ */

  .ap-stat-tile {
    display: flex;
    flex-direction: column;
    gap: var(--space-xs);
    padding: var(--space-md);
    border: 1px solid var(--hairline);
    border-radius: var(--card-radius);
    background: var(--app-surface);
  }
  .ap-stat-tile__value {
    font-size: var(--text-title);
    font-weight: var(--weight-semibold);
    font-variant-numeric: tabular-nums;
  }
  .ap-stat-tile__label { font-size: var(--text-caption); color: var(--text-secondary); }

  /* ══ Badges de rareté et de type ══════════════════════════════════════════ */

  .ap-tag {
    display: inline-flex;
    align-items: center;
    gap: 5px;
    padding: 3px 9px;
    border-radius: var(--pill-radius);
    font-size: var(--text-caption);
    font-weight: var(--weight-semibold);
    letter-spacing: .03em;
    white-space: nowrap;
  }

  .ap-tag--common    { background: rgba(166, 176, 194, .16); color: var(--rarity-common); }
  .ap-tag--uncommon  { background: rgba( 92, 204, 168, .16); color: var(--rarity-uncommon); }
  .ap-tag--rare      { background: rgba(122, 179, 255, .16); color: var(--rarity-rare); }
  .ap-tag--very-rare { background: rgba(204, 148, 250, .16); color: var(--rarity-very-rare); }

  /* Par type d'objet. `currentColor` évite d'écrire dix fois la même règle :
     le fond se déduit de la couleur du texte. */
  .ap-tag--type {
    background: color-mix(in srgb, currentColor 16%, transparent);
  }
  .ap-tag--star, .ap-tag--variableStar, .ap-tag--sun { color: var(--type-star); }
  .ap-tag--planet    { color: var(--type-planet); }
  .ap-tag--nebula    { color: var(--type-nebula); }
  .ap-tag--galaxy    { color: var(--type-galaxy); }
  .ap-tag--cluster   { color: var(--type-cluster); }
  .ap-tag--comet, .ap-tag--asteroid { color: var(--type-comet); }
  .ap-tag--exoplanet { color: var(--type-exoplanet); }

  /* ══ Barre de progression ═════════════════════════════════════════════════ */

  .ap-progress { display: flex; flex-direction: column; gap: 6px; }

  .ap-progress__head {
    display: flex;
    justify-content: space-between;
    gap: var(--space-sm);
    font-size: var(--text-footnote);
  }
  .ap-progress__label { color: var(--text-secondary); }
  .ap-progress__value { color: var(--text-primary); font-variant-numeric: tabular-nums; }

  .ap-progress__track {
    height: 6px;
    border-radius: var(--pill-radius);
    background: var(--hairline);
    overflow: hidden;
  }
  .ap-progress__fill {
    height: 100%;
    border-radius: inherit;
    background: var(--brand-primary);
    transition: width var(--motion-soft);
  }
  .ap-progress--success .ap-progress__fill { background: var(--status-success); }
  .ap-progress--warning .ap-progress__fill { background: var(--status-warning); }

  /* ══ Ligne de mesure ══════════════════════════════════════════════════════
     Icône, libellé, valeur alignée à droite. Employée partout : mesures d'une
     fiche, récompenses d'une observation, compte rendu d'une intégration. */

  .ap-measurement {
    display: grid;
    grid-template-columns: 24px 1fr auto;
    align-items: center;
    gap: var(--space-md);
  }
  .ap-measurement__icon  { width: 20px; height: 20px; color: var(--brand-primary); }
  .ap-measurement__label { color: var(--text-secondary); font-size: var(--text-subheadline); }
  .ap-measurement__value {
    font-size: var(--text-subheadline);
    font-weight: var(--weight-medium);
    font-variant-numeric: tabular-nums;
  }

  /* Une mesure NON OBTENUE : son nom est affiché, sa valeur non. Savoir ce
     qu'il reste à relever fait partie du jeu ; connaître la réponse d'avance,
     non. */
  .ap-measurement--missing .ap-measurement__icon  { color: var(--text-secondary); opacity: .5; }
  .ap-measurement--missing .ap-measurement__value { color: var(--text-secondary); }

  /* Une mesure REÇUE d'un autre observatoire. En astronomie, une mesure se cite
     toujours avec son auteur. */
  .ap-measurement--shared .ap-measurement__icon { color: var(--brand-secondary); }
  .ap-measurement__credit {
    grid-column: 2 / -1;
    font-size: var(--text-caption);
    color: var(--brand-secondary);
  }

  /* ══ État vide ════════════════════════════════════════════════════════════
     Il dit TOUJOURS quoi faire ensuite. Un écran vide sans explication ne se
     distingue pas d'une panne. */

  .ap-empty {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: var(--space-sm);
    padding: var(--card-padding);
    border: 1px solid var(--hairline);
    border-radius: var(--card-radius);
    background: var(--app-surface);
  }
  .ap-empty__icon  { width: 28px; height: 28px; color: var(--text-secondary); }
  .ap-empty__title { font-size: var(--text-headline); font-weight: var(--weight-semibold); }
  .ap-empty__body  { font-size: var(--text-subheadline); color: var(--text-secondary); }

  /* ══ Champs ═══════════════════════════════════════════════════════════════
     Libellé AU-DESSUS, et un exemple de saisie en gris. L'exemple ne doit
     jamais prendre la couleur du texte saisi : il se lirait comme une valeur
     déjà remplie — défaut vu et corrigé sur l'application. */

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

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

  .ap-field__input {
    width: 100%;
    padding: 10px var(--space-sm);
    border: 1px solid transparent;
    border-radius: var(--field-radius);
    background: var(--app-background);
    font-family: inherit;
    font-size: var(--text-body);
    color: var(--text-primary);
  }
  .ap-field__input::placeholder { color: var(--text-secondary); opacity: 1; }
  .ap-field__input:focus-visible { border-color: var(--brand-primary); outline: none; }
  .ap-field__input[aria-invalid="true"] { border-color: var(--status-error); }

  .ap-field__input--code {
    font-size: var(--text-title);
    font-variant-numeric: tabular-nums;
    letter-spacing: .25em;
  }

  /* ══ Interrupteur ═════════════════════════════════════════════════════════ */

  .ap-toggle {
    display: grid;
    grid-template-columns: 1fr auto;
    align-items: center;
    gap: var(--space-md);
  }
  .ap-toggle__text  { display: flex; flex-direction: column; gap: var(--space-xs); }
  .ap-toggle__label { font-size: var(--text-subheadline); }
  .ap-toggle__hint  { font-size: var(--text-caption); color: var(--text-secondary); }

  /* L'interrupteur est un TRANSIT : un disque — la planète — et une lune
     blanche qui passe devant lui. Ce n'est pas une décoration gratuite : le
     transit est la méthode par laquelle le jeu lui-même détecte les
     exoplanètes, et l'objet dit donc quelque chose de son sujet.

     Mais un interrupteur se lit d'abord à la COURSE de sa pastille, et un
     cercle du diamètre de celle-ci n'en laisserait aucune. D'où la géométrie,
     qui est mesurée et non trouvée à l'œil :

       * la boîte fait 62 × 44 — 44 étant la cible tactile minimale ;
       * la planète est un disque de 44, calé à DROITE de la boîte ;
       * la lune fait 18, et voyage de 40 px, soit le double des 20 px
         d'autrefois ;
       * éteinte, la lune est tangente au limbe gauche, ENTIÈREMENT hors du
         disque : le transit n'a pas commencé ;
       * allumée, elle est tout entière DEVANT la planète, côté droit — son
         centre est à 9 px du centre du disque, pour 22 px de rayon — elle reste
         à 4 px du limbe droit, dedans et non à cheval.

     Les deux états diffèrent donc par trois choses à la fois : la position de
     la lune, sa présence ou non sur le disque, et la couleur de la planète.

     Cette couleur n'est plus le vert de `--status-success` mais l'ocre de
     `--planet-surface`. On quitte là une convention universelle — le vert
     pour « actif » — et on l'assume : l'écart mesuré avec le gris éteint
     reste très au-dessus du seuil du jeu, en vision normale comme en
     deutéranopie, et la position de la lune porte de toute façon le signal
     principal. */

  .ap-toggle__switch {
    position: relative;
    flex: none;
    width: 62px;
    height: 44px;
    border: 0;
    /* Le fond quitte l'élément pour la planète, mais le rayon reste : c'est
       lui qui donne sa forme à l'anneau de focus. */
    border-radius: var(--pill-radius);
    background: transparent;
    cursor: pointer;
  }

  /* La planète, et ses cratères.

     Trois creux seulement, très doux, et dessinés en dégradés sans bord net :
     à 44 px, une dizaine de cratères contrastés ne feraient pas une texture
     mais du bruit, et six interrupteurs empilés dans la carte Préférences
     s'agiteraient. Trois se sentent sans se voir.

     Ils RESTENT dans les deux états : une planète ne perd pas son relief en
     s'éteignant, et le creux est un assombrissement neutre, qui va aussi
     bien sur l'ocre que sur le gris. C'est aussi pourquoi la couleur est
     posée à part, en `background-color` : seule elle change d'un état à
     l'autre, et la transition ne porte que sur elle. */
  .ap-toggle__switch::before {
    content: "";
    position: absolute;
    top: 0; right: 0;
    width: 44px; height: 44px;
    border-radius: 50%;
    background-color: var(--hairline-strong);
    background-image:
      radial-gradient(circle 7px at 13px 15px, var(--planet-crater), transparent),
      radial-gradient(circle 5px at 31px 11px, var(--planet-crater), transparent),
      radial-gradient(circle 6px at 25px 33px, var(--planet-crater), transparent);
    transition: background-color var(--motion-soft);
  }

  /* La lune. */
  .ap-toggle__switch::after {
    content: "";
    position: absolute;
    top: 13px; left: 0;
    width: 18px; height: 18px;
    border-radius: 50%;
    background: #fff;
    transition: transform var(--motion-soft);
  }

  .ap-toggle__switch[aria-checked="true"]::before { background-color: var(--planet-surface); }
  .ap-toggle__switch[aria-checked="true"]::after  { transform: translateX(40px); }

  /* Le transit se TAIT quand on demande moins de mouvement : la lune saute
     d'un état à l'autre au lieu de traverser. Le socle abaisse déjà toutes
     les durées, ici on le dit sur le composant lui-même pour que la règle
     survive à une transition qu'on rallongerait un jour. */
  @media (prefers-reduced-motion: reduce) {
    .ap-toggle__switch::before,
    .ap-toggle__switch::after { transition: none; }
  }
  html[data-reduced-motion="true"] .ap-toggle__switch::before,
  html[data-reduced-motion="true"] .ap-toggle__switch::after { transition: none; }

  /* La PLACE de chacun, posée explicitement.

     La grille est « 1fr auto » : sans cette règle, c'est l'ordre du balisage
     qui décide, et un interrupteur écrit AVANT son texte atterrit dans la
     colonne large — le libellé se retrouvait poussé au bord droit de la
     carte. Or une case à cocher se place volontiers en premier, parce
     qu'elle est ce que le `<label>` enveloppe. */
  .ap-toggle__text   { grid-column: 1; grid-row: 1; }
  .ap-toggle__switch { grid-column: 2; grid-row: 1; }

  /* ── Le même interrupteur, porté par une CASE À COCHER ───────────────────
     Le système le dessine comme un `<button role="switch">` : correct, mais
     il demande du script pour changer d'état. Dans un FORMULAIRE, c'est une
     case qu'il faut — elle part avec le reste, et fonctionne sans une ligne
     de JavaScript.

     Les deux doivent donc se ressembler, et ne le faisaient pas :
     `[aria-checked="true"]` ne s'applique JAMAIS à une case, si bien que
     l'état allumé n'existait pas ; et ni `::before` ni `::after` ne se
     dessinent sur un élément remplacé, donc ni la planète ni sa lune ne
     paraissaient. On obtenait un rectangle blanc, identique allumé ou
     éteint. */

  input.ap-toggle__switch {
    appearance: none;
    -webkit-appearance: none;
    margin: 0;
  }
  input.ap-toggle__switch:checked::before { background-color: var(--planet-surface); }
  input.ap-toggle__switch:checked::after  { transform: translateX(40px); }
  input.ap-toggle__switch:disabled { opacity: .45; cursor: not-allowed; }

  /* Le clavier doit voir où il est : une case sans `appearance` perd son
     anneau par défaut. */
  .ap-toggle__switch:focus-visible {
    outline: 2px solid var(--brand-primary);
    outline-offset: 2px;
  }

  /* ══ Jauge de paramètre planétaire ════════════════════════════════════════
     Les cinq jauges de la colonie. Elles doivent montrer trois choses en même
     temps : la valeur, la CIBLE terrestre, et la TOLÉRANCE autour d'elle.
     Une barre nue ne dirait pas si l'on est « presque bon » ou « très loin ». */

  .ap-gauge { display: flex; flex-direction: column; gap: 6px; }

  .ap-gauge__head {
    display: grid;
    grid-template-columns: 22px 1fr auto;
    align-items: center;
    gap: var(--space-sm);
    font-size: var(--text-subheadline);
  }
  .ap-gauge__icon  { width: 18px; height: 18px; color: var(--brand-primary); }
  .ap-gauge__value { font-weight: var(--weight-medium); font-variant-numeric: tabular-nums; }

  .ap-gauge__track {
    position: relative;
    height: 10px;
    border-radius: var(--pill-radius);
    background: var(--hairline);
    overflow: hidden;
  }

  /* La bande de tolérance : l'endroit où le paramètre « compte comme atteint ».
     Positionnée par le code via --tolerance-start et --tolerance-width, en %. */
  .ap-gauge__tolerance {
    position: absolute;
    inset-block: 0;
    left: var(--tolerance-start, 40%);
    width: var(--tolerance-width, 20%);
    background: var(--status-success-soft);
  }

  .ap-gauge__fill {
    position: absolute;
    inset-block: 0;
    left: 0;
    width: var(--value, 0%);
    border-radius: inherit;
    background: var(--brand-primary);
    transition: width var(--motion-soft);
  }
  .ap-gauge--reached .ap-gauge__fill { background: var(--status-success); }

  /* Le repère de la valeur terrestre. Un trait, pas une flèche : il doit se
     lire sans légende. */
  .ap-gauge__target {
    position: absolute;
    inset-block: -3px;
    left: var(--target, 50%);
    width: 2px;
    background: var(--text-primary);
    border-radius: 1px;
  }

  .ap-gauge__footer {
    display: flex;
    justify-content: space-between;
    font-size: var(--text-caption);
    color: var(--text-secondary);
  }

  /* ══ Cadran d'observation ═════════════════════════════════════════════════
     Le temps restant, en anneau. `--progress` va de 0 à 1.
     `conic-gradient` évite un SVG et se met à jour sans redessiner. */

  .ap-dial {
    position: relative;
    display: grid;
    place-items: center;
    width: var(--dial-size, 132px);
    aspect-ratio: 1;
    border-radius: 50%;
    background:
      conic-gradient(var(--brand-primary) calc(var(--progress, 0) * 360deg),
                     var(--hairline) 0);
    transition: background var(--motion-soft);
  }
  /* Le trou du milieu. Un masque serait plus propre, mais un disque posé
     dessus marche partout. */
  .ap-dial::before {
    content: "";
    position: absolute;
    inset: 9px;
    border-radius: 50%;
    background: var(--app-surface);
  }
  .ap-dial__content {
    position: relative;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 2px;
  }
  .ap-dial__time {
    font-size: var(--text-title);
    font-weight: var(--weight-semibold);
    font-variant-numeric: tabular-nums;
  }
  .ap-dial__label { font-size: var(--text-caption); color: var(--text-secondary); }

  /* ══ Créneau météo ════════════════════════════════════════════════════════
     La prévision : 1, 3 ou 6 créneaux selon la station météo possédée. */

  .ap-slot {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--space-xs);
    min-width: 64px;
    padding: var(--space-sm);
    border: 1px solid var(--hairline);
    border-radius: 10px;
    font-size: var(--text-caption);
  }
  .ap-slot__icon { width: 22px; height: 22px; }
  .ap-slot__hour { color: var(--text-secondary); font-variant-numeric: tabular-nums; }

  .ap-slot--clear    { color: var(--brand-primary); }
  .ap-slot--hazy     { color: var(--brand-secondary); }
  .ap-slot--cloudy   { color: var(--text-secondary); }
  /* Un créneau couvert REFUSE l'observation : il doit se voir comme un refus,
     pas comme une nuance. */
  .ap-slot--overcast { color: var(--status-error); border-color: var(--status-error-soft); }

  .ap-forecast { display: flex; gap: var(--space-sm); flex-wrap: wrap; }

  /* ══ Repère de la carte du ciel ═══════════════════════════════════════════
     Positionné par le code via --sky-x et --sky-y (0 à 1). La taille suit la
     magnitude : un objet brillant est un point plus gros. */

  /* PIÈGE VÉRIFIÉ À L'ÉCRAN : la carte entière porte un `scale(zoom)`, et sur
     un téléphone le zoom d'ajustement vaut environ 0,43. Une taille écrite en
     pixels est donc MULTIPLIÉE par ce zoom : le point de 14 px arrivait à 6 px
     et sa zone tactile de 44 px à 19 px — invisible, et intouchable au doigt.
     On divise donc par le zoom, que `applyTransform` publie dans `--sky-zoom`,
     pour que le repère garde la MÊME taille à l'écran quel que soit le zoom. */
  .ap-marker {
    position: absolute;
    left: calc(var(--sky-x) * 100%);
    top: calc(var(--sky-y) * 100%);
    translate: -50% -50%;
    display: grid;
    place-items: center;
    width: calc(var(--marker-size, 14px) / var(--sky-zoom, 1));
    aspect-ratio: 1;
    border: 0;
    border-radius: 50%;
    background: currentColor;
    color: var(--type-star);
    cursor: pointer;
    box-shadow: 0 0 calc(var(--marker-size, 14px) * .9 / var(--sky-zoom, 1)) color-mix(in srgb, currentColor 55%, transparent);
    transition: scale var(--motion-soft);
  }
  .ap-marker:hover { scale: 1.25; }

  /* La cible tactile, invisible et généreuse. Un point de 14 px ne se touche
     pas au doigt ; l'agrandir le rendrait illisible sur une carte chargée. La
     zone est donc séparée du dessin — c'est ce que `contentShape` fait sur
     iOS. */
  .ap-marker::after {
    content: "";
    position: absolute;
    top: 50%;
    left: 50%;
    translate: -50% -50%;
    width: calc(44px / var(--sky-zoom, 1));
    height: calc(44px / var(--sky-zoom, 1));
    border-radius: 50%;
  }

  /* Un objet hors de portée reste AFFICHÉ, mais grisé. Voir ce qui manque
     motive l'amélioration suivante ; le masquer ne dit rien. */
  .ap-marker--unavailable {
    color: var(--text-secondary);
    opacity: .38;
    box-shadow: none;
  }

  .ap-marker--selected {
    outline: 2px solid var(--text-primary);
    outline-offset: 3px;
  }

  /* L'étiquette du repère, montrée au survol ou à la sélection. */
  .ap-marker__name {
    position: absolute;
    top: calc(100% + 6px);
    left: 50%;
    translate: -50% 0;
    padding: 2px 7px;
    border-radius: var(--pill-radius);
    background: color-mix(in srgb, var(--app-background) 88%, transparent);
    color: var(--text-primary);
    font-size: var(--text-caption);
    white-space: nowrap;
    pointer-events: none;
  }

  /* Le plan de situation, en bas à droite : il montre le ciel entier et encadre
     la part visible. OBLIGATOIRE — une carte plus grande que l'écran rend le
     fait de se perdre possible. */
  .ap-minimap {
    position: absolute;
    right: var(--space-md);
    bottom: var(--space-md);
    width: 116px;
    aspect-ratio: 1;
    border: 1px solid var(--hairline-strong);
    border-radius: 10px;
    background: color-mix(in srgb, var(--sky-deep) 78%, transparent);
    backdrop-filter: blur(8px);
  }
  .ap-minimap__window {
    position: absolute;
    left: calc(var(--win-x, 0) * 100%);
    top: calc(var(--win-y, 0) * 100%);
    width: calc(var(--win-w, 1) * 100%);
    height: calc(var(--win-h, 1) * 100%);
    border: 1px solid var(--brand-primary);
    border-radius: 3px;
    background: var(--brand-primary-soft);
  }

  /* ══ Disque solaire ═══════════════════════════════════════════════════════
     Les régions actives sont posées par --disc-x et --disc-y, de -1 à 1, DÉJÀ
     raccourcies par la perspective (voir la spécification § 5.9). */

  .ap-sun {
    position: relative;
    width: var(--sun-size, 280px);
    aspect-ratio: 1;
    border-radius: 50%;
    background: radial-gradient(circle at 42% 38%, #FFE9B8, #FAC24A 62%, #E09A18);
    box-shadow: 0 0 60px rgba(250, 194, 74, .35);
  }
  .ap-sun__region {
    position: absolute;
    left: calc(50% + var(--disc-x) * 50%);
    top:  calc(50% + var(--disc-y) * 50%);
    translate: -50% -50%;
    width: var(--region-size, 12px);
    aspect-ratio: 1;
    border-radius: 50%;
    background: #4A2C06;
    opacity: .82;
    cursor: pointer;
  }
  .ap-sun__region--tracked { outline: 2px solid var(--app-background); outline-offset: 1px; }

  /* ══ Panneau ══════════════════════════════════════════════════════════════
     Pour l'analyse, la mise au point, une fiche. RÈGLE DU JEU : deux panneaux
     ne peuvent PAS s'ouvrir l'un sur l'autre. S'il y en a plusieurs à montrer,
     ils défilent un par un. */

  .ap-overlay {
    position: fixed;
    inset: 0;
    z-index: var(--z-overlay);
    background: var(--overlay);
    backdrop-filter: blur(4px);
  }

  .ap-sheet {
    position: fixed;
    z-index: var(--z-sheet);
    inset-inline: 0;
    bottom: 0;
    max-height: 92dvh;
    display: flex;
    flex-direction: column;
    border-radius: var(--sheet-radius) var(--sheet-radius) 0 0;
    background: var(--app-background);
    box-shadow: var(--shadow-sheet);
  }

  /* Sur écran large, le panneau devient une fenêtre centrée : un bandeau
     collé en bas d'un écran de 27 pouces n'a plus de sens. */
  @media (min-width: 1000px) {
    .ap-sheet {
      inset: 50% auto auto 50%;
      translate: -50% -50%;
      width: min(680px, 92vw);
      max-height: 86dvh;
      border-radius: var(--sheet-radius);
    }
  }

  .ap-sheet__head {
    display: flex;
    align-items: center;
    gap: var(--space-sm);
    padding: var(--space-md) var(--gutter);
    border-bottom: 1px solid var(--hairline);
  }
  .ap-sheet__title { flex: 1; text-align: center; font-size: var(--text-headline);
                     font-weight: var(--weight-semibold); }
  .ap-sheet__body  { padding: var(--space-md) var(--gutter) var(--space-xl);
                     overflow-y: auto; }

  /* ══ Boîte de confirmation ════════════════════════════════════════════════
     Elle dit TOUJOURS ce qui est perdu. « Annuler cette observation » sans
     préciser que l'énergie dépensée ne revient pas serait un piège. */

  .ap-dialog {
    position: fixed;
    z-index: var(--z-sheet);
    inset: 50% auto auto 50%;
    translate: -50% -50%;
    width: min(420px, 92vw);
    padding: var(--space-lg);
    border: 1px solid var(--hairline-strong);
    border-radius: var(--sheet-radius);
    background: var(--app-surface);
    box-shadow: var(--shadow-popover);
  }
  .ap-dialog__title   { font-size: var(--text-headline); font-weight: var(--weight-semibold); }
  .ap-dialog__message { margin-top: var(--space-sm); font-size: var(--text-subheadline);
                        color: var(--text-secondary); }
  .ap-dialog__actions { margin-top: var(--space-lg); display: flex; flex-direction: column;
                        gap: var(--space-sm); }

  /* ══ Séparateur ═══════════════════════════════════════════════════════════ */

  .ap-divider { height: 1px; border: 0; margin: 0; background: var(--hairline); }

}

  /* ══ Le manuel ════════════════════════════════════════════════════════════
     Le guide a d'abord été un écran de seize cartes. Il était juste et court,
     et c'est ce qu'on lui a reproché : une carte de quatre lignes répond à
     « qu'est-ce que c'est » et jamais à « je suis bloqué, qu'est-ce que je
     fais ». Un chapitre par page, donc — et un manuel n'a pas la disposition
     d'un tableau de bord.

     TROIS RÉGIMES, pour la même raison que les écrans de l'astre : un plan
     qu'on garde sous les yeux, une colonne de prose BORNÉE — une ligne de
     1 400 px se perd d'une fin de ligne à l'autre — et une marge qui porte
     les chiffres et les pièges.

     LE VIDE EST TRAITÉ PAR LA COMPOSITION, PAS PAR L'ÉTIREMENT. Sur 2 000 px,
     borner la prose à 660 px et laisser le reste blanc donnerait un ruban
     perdu au bord gauche. Le bloc « texte + marge » est donc CENTRÉ dans la
     place restante : ce qui dépasse devient deux marges égales, ce qu'on lit
     comme une mise en page et non comme un trou. */

  .ap-manuel { display: grid; gap: var(--space-xl); align-items: start; }

  /* LE PLAN DISPARAÎT SOUS 1 100 px, et c'est mesuré : sur un téléphone de
     375 px, ses onze entrées poussaient le chapitre 400 px plus bas. On venait
     de CHOISIR ce chapitre ; le faire chercher sous sa propre table des
     matières est le contraire du service rendu.

     Rien n'est perdu pour autant, et c'est la condition pour se permettre de
     masquer : le sommaire entier est à une touche, par le lien de l'en-tête,
     l'en-tête dit « chapitre 8 sur 11 », et le pied porte le précédent et le
     suivant. Trois chemins restent, tous visibles.

     Masqué plutôt que replacé en bas : un plan mis après l'article se lirait
     avec les touches dans un ordre et avec les yeux dans un autre. */
  @media (max-width: 1099px) {
    .ap-manuel__plan { display: none; }
  }

  @media (min-width: 1100px) {
    .ap-manuel { grid-template-columns: minmax(190px, 230px) minmax(0, 1fr); }
    .ap-manuel__plan {
      position: sticky;
      /* Sous la barre du haut, qui est fixe : collé à 0, le plan passerait
         dessous en défilant. */
      top: var(--space-lg);
      max-height: calc(100vh - var(--space-xl) * 2);
      overflow-y: auto;
    }
  }

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

  .ap-manuel__plan-item {
    display: grid;
    grid-template-columns: 1.6em 1fr;
    gap: var(--space-sm);
    padding: 6px var(--space-sm);
    border-radius: 8px;
    color: var(--text-secondary);
    font-size: var(--text-footnote);
    line-height: var(--line-tight);
  }
  .ap-manuel__plan-item:hover { background: var(--pressed-fill); text-decoration: none; }
  .ap-manuel__plan-item[aria-current="page"] {
    background: var(--selected-fill);
    color: var(--text-primary);
    font-weight: var(--weight-medium);
  }
  .ap-manuel__plan-num { font-variant-numeric: tabular-nums; color: var(--brand-secondary); }

  /* Le corps du chapitre : la prose et sa marge. Le bloc entier est centré,
     jamais étiré — voir l'en-tête de ce bloc. */
  .ap-manuel__corps {
    display: flex;
    flex-direction: column;
    gap: var(--space-xl);
    width: 100%;
    max-width: calc(var(--reading-width) + 26rem + var(--space-lg));
    margin-inline: auto;
  }

  .ap-chapitre__section { display: grid; gap: var(--space-lg); align-items: start; }

  @media (min-width: 900px) {
    .ap-chapitre__section {
      grid-template-columns: minmax(0, var(--reading-width)) minmax(0, 26rem);
    }
    /* LES DEUX COLONNES NE BOUGENT JAMAIS, même quand la marge est vide.
       Laisser une section sans encadré reprendre toute la largeur avait
       deux défauts : sa prose passait à 1 100 px, la largeur même qu'on
       s'interdit ; et la colonne de texte changeait de largeur d'une
       section à l'autre, ce qui fait osciller le bord droit d'un
       paragraphe au suivant. Une marge parfois vide est ce qu'est une
       marge dans un livre imprimé. */
  }

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

  /* L'encadré : un cas chiffré, un piège, une issue quand on est bloqué.
     Le filet de gauche le distingue d'une carte ordinaire sans lui donner
     le poids d'une section — il accompagne le texte, il ne le remplace pas. */
  .ap-encadre {
    border-inline-start: 2px solid var(--hairline);
    padding: var(--space-sm) 0 var(--space-sm) var(--space-md);
    display: flex;
    flex-direction: column;
    gap: var(--space-xs);
  }
  .ap-encadre__titre {
    font-size: var(--text-caption);
    font-weight: var(--weight-semibold);
    letter-spacing: .06em;
    text-transform: uppercase;
    color: var(--brand-secondary);
  }
  .ap-encadre p { color: var(--text-secondary); line-height: var(--line-relaxed); }
  /* Le piège se signale par la couleur de l'avertissement, et jamais par
     elle seule : son intitulé le dit aussi. Une information portée par la
     seule teinte se perd pour un daltonien et dans un cache d'impression. */
  .ap-encadre--piege { border-inline-start-color: var(--status-warning); }
  .ap-encadre--piege .ap-encadre__titre { color: var(--status-warning); }

  /* Le pied de chapitre : précédent / suivant. Un manuel se lit aussi dans
     l'ordre, et refermer pour rouvrir le sommaire à chaque chapitre est une
     façon sûre de n'en lire qu'un. */
  .ap-chapitre__pied {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-md);
    justify-content: space-between;
    padding-top: var(--space-lg);
    border-top: 1px solid var(--hairline);
  }
  .ap-chapitre__saut { display: flex; flex-direction: column; gap: 2px; max-width: 22rem; }
  .ap-chapitre__saut--suivant { text-align: end; margin-inline-start: auto; }
  .ap-chapitre__saut-sens {
    font-size: var(--text-caption);
    color: var(--text-secondary);
  }

  /* --- Le spectre radio ---------------------------------------------------
     Le SVG est calculé par le serveur (`engine/radio.tracer`) et ne porte
     aucune couleur : elles sont posées ici, à partir des jetons, pour que
     le thème sombre et le thème clair suivent sans que le moteur en sache
     rien. */
  .ap-spectre {
    margin: var(--space-lg) 0 0;
    padding: var(--space-md);
    background: var(--app-surface);
    border: 1px solid var(--hairline);
    border-radius: var(--card-radius);
  }
  .ap-spectre__svg { display: block; width: 100%; height: auto; }
  .ap-spectre__trace {
    fill: none;
    stroke: var(--brand-primary);
    stroke-width: 1.4;
    stroke-linejoin: round;
  }
  /* Le plancher de bruit en pointillé : c'est une référence, pas une
     mesure, et il ne doit pas se lire comme une seconde courbe. */
  .ap-spectre__plancher {
    stroke: var(--text-secondary);
    stroke-width: 1;
    stroke-dasharray: 4 4;
    opacity: .6;
  }
  .ap-spectre__pic { fill: none; stroke: var(--status-warning); stroke-width: 2; }
  .ap-spectre__axe {
    fill: var(--text-secondary);
    font-size: 10px;
    font-variant-numeric: tabular-nums;
  }
  .ap-spectre__legende {
    margin-top: var(--space-sm);
    font-size: var(--text-caption);
    color: var(--text-secondary);
  }
