/* workbench.css v0.27.0 — copie, ne pas modifier ici.
   Toute correction remonte à ~/projects/perso/workbench. */
/* ==========================================================================
   WORKBENCH — design system des apps perso
   Copié dans chaque app, jamais modifié sur place : une correction remonte au
   dépôt ~/projects/perso/workbench puis redescend par `scripts/install_css.py`.

   Pas de numéro de version ici : `install_css.py` l'estampille lui-même en
   tête du fichier copié, en la lisant dans `workbench/__init__.py` — c'est ce
   qui permet à `--check` de dire à une app qu'elle est en retard. En écrire
   une seconde ici créerait deux mentions que rien ne compare, et qui
   divergeraient silencieusement au premier oubli.

   Workbench ne définit AUCUNE teinte d'accent : c'est ce qui distingue une app des
   autres. Chaque app pose sa paire --accent / --accent-tx / --accent-soft /
   --accent-line / --ring, pour les DEUX thèmes, vérifiée au contraste :
   `python3 css/check_contrasts.py "#8c2f39" "#ffffff"`.

   Cette vérification n'est pas une formalité : l'ancien accent de recipes
   (#FEF991) tenait sur fond noir et tombait à 1,2:1 sur fond blanc.
   ========================================================================== */

/* --- Jetons : thème clair (défaut) --------------------------------------- */
:root {
  --bg: #f7f7f6;
  --surface: #ffffff;
  --surface-2: #f2f2f0;   /* zone en retrait, ligne survolée */
  --surface-hi: #ffffff;  /* élément surélevé : menu, popover */

  /* `--line` et `--line-strong` sont décoratifs (séparateurs, bord de carte).
     `--line-controle` est le seul qui identifie un contrôle : il tient 3:1
     comme l'exige WCAG 1.4.11, les deux autres n'y sont pas soumis. */
  --line: #e4e3df;
  --line-strong: #d3d2cd;
  --line-controle: #8a8880;

  --tx: #16150f;
  --tx-2: #5f5d55;
  --tx-3: #6f6d64;        /* gris de service : libellés, unités, inactifs */

  --ok-bg: #e7f4e7;   --ok-tx: #1e5320;   --ok-line: #bfe0c0;
  --warn-bg: #fdf1dd; --warn-tx: #6b4200; --warn-line: #f0dcb0;
  --err-bg: #fdecec;  --err-tx: #8c1d1d;  --err-line: #f3c9c9;
  --info-bg: #e8f0fd; --info-tx: #1a4a8c; --info-line: #c5d9f5;

  /* --danger : la SEULE teinte que le socle pose lui-même, contrairement à
     --accent qu'il ne définit jamais. Un geste destructeur n'a pas de marque
     à qui appartenir : sa couleur ne peut donc pas venir de l'app, sous peine
     que deux apps montrent deux rouges différents pour le même risque. Vérifiée
     aux quatre ambiances : `python3 css/check_contrasts.py "#b3261e" "#ffffff"
     "#ff6659" "#3a0a06"`. */
  --danger: #b3261e; --danger-tx: #ffffff;

  --shadow-1: 0 1px 2px rgba(20,18,10,.05), 0 1px 1px rgba(20,18,10,.04);
  --shadow-2: 0 6px 16px -6px rgba(20,18,10,.14), 0 2px 5px -2px rgba(20,18,10,.08);
  --shadow-3: 0 1px 2px rgba(20,18,10,.04), 0 12px 32px -12px rgba(20,18,10,.16);

  color-scheme: light;
}

/* --- Jetons : thème sombre ----------------------------------------------- */
/* Le sombre n'est pas une inversion : les surfaces s'ÉCLAIRCISSENT à mesure
   qu'elles s'élèvent, et l'accent se désature.
   `:root:not([data-theme="light"])` est ce qui permet à un choix « clair »
   explicite de gagner contre un système en sombre. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg: #0d0d0e;
    --surface: #161618;
    --surface-2: #202023;
    --surface-hi: #242428;

    --line: #2a2a2e;
    --line-strong: #35353b;
    --line-controle: #66666f;

    --tx: #f2f1ee;
    --tx-2: #a3a29c;
    --tx-3: #8a8982;

    --ok-bg: #16290f;   --ok-tx: #9bd79b;   --ok-line: #274a20;
    --warn-bg: #2a1e10; --warn-tx: #f0c07a; --warn-line: #4a3616;
    --err-bg: #2c1414;  --err-tx: #f5a9a9;  --err-line: #4d2222;
    --info-bg: #101f33; --info-tx: #9dc0f0; --info-line: #1e3a5c;

    --danger: #ff6659; --danger-tx: #3a0a06;

    --shadow-1: 0 1px 2px rgba(0,0,0,.5);
    --shadow-2: 0 10px 26px -10px rgba(0,0,0,.75), 0 2px 6px -2px rgba(0,0,0,.5);
    --shadow-3: 0 1px 2px rgba(0,0,0,.5), 0 14px 36px -14px rgba(0,0,0,.8);

    color-scheme: dark;
  }
}

/* Choix explicite. Duplique le bloc ci-dessus : c'est le prix à payer pour
   qu'une préférence stockée l'emporte sur le réglage du système. */
:root[data-theme="dark"] {
  --bg: #0d0d0e;
  --surface: #161618;
  --surface-2: #202023;
  --surface-hi: #242428;

  --line: #2a2a2e;
  --line-strong: #35353b;
  --line-controle: #66666f;

  --tx: #f2f1ee;
  --tx-2: #a3a29c;
  --tx-3: #8a8982;

  --ok-bg: #16290f;   --ok-tx: #9bd79b;   --ok-line: #274a20;
  --warn-bg: #2a1e10; --warn-tx: #f0c07a; --warn-line: #4a3616;
  --err-bg: #2c1414;  --err-tx: #f5a9a9;  --err-line: #4d2222;
  --info-bg: #101f33; --info-tx: #9dc0f0; --info-line: #1e3a5c;

  --danger: #ff6659; --danger-tx: #3a0a06;

  --shadow-1: 0 1px 2px rgba(0,0,0,.5);
  --shadow-2: 0 10px 26px -10px rgba(0,0,0,.75), 0 2px 6px -2px rgba(0,0,0,.5);
  --shadow-3: 0 1px 2px rgba(0,0,0,.5), 0 14px 36px -14px rgba(0,0,0,.8);

  color-scheme: dark;
}
:root[data-theme="light"] { color-scheme: light; }

/* --- Formes, espacement, mouvement, typographie --------------------------- */
:root {
  --r-s: 6px; --r-m: 10px; --r-l: 14px; --r-xl: 18px;

  /* Base de 4. Une valeur d'espacement écrite en dur est un jeton manquant. */
  --e-1: 4px; --e-2: 8px; --e-3: 12px; --e-4: 16px;
  --e-5: 24px; --e-6: 32px; --e-7: 48px;

  /* Le rythme vertical d'une PAGE, distinct des espacements de composant.
     `--e-section` sépare deux sections, `--e-bloc` sépare un titre de section
     de son contenu. Ce sont eux que la densité resserre — une app qui pose
     ses propres marges continue de marcher, mais ne suivra pas le compact. */
  --e-section: 32px;
  --e-bloc: 12px;

  /* La taille de BASE du corps de texte, distincte des 16px d'un champ de
     saisie (`--e-4` ni l'un ni l'autre : `font-size: 16px` reste écrit en
     dur sur les champs, jamais en dessous, jamais paramétré par la densité —
     c'est un plancher d'usage, pas un jeton de style). Le corps, lui, PEUT
     descendre : c'est ce que redéfinit la densité compacte. */
  --taille-corps: 15px;

  /* L'ÉCHELLE TYPOGRAPHIQUE, jetonnée comme l'espacement l'est déjà.
     Avant ce lot : 31 `font-size` en dur pour 15 valeurs distinctes
     (.7 / .75 / .78 / .8 / .85 / .9 / .92 / 1 / 1.05 / 1.1 / 1.15 / 1.5 / 2rem,
     plus 16px et 22px) — dont `.75`, `.78` et `.8` à trois endroits sans
     qu'aucune différence soit voulue. C'est le signe d'une échelle absente,
     pas d'une intention.

     Cinq valeurs, pas quinze : au-delà, l'échelle cesse d'être une échelle.
     La densité compacte les resserre — c'est ce qui manquait, `--taille-corps`
     n'atteignant que `body`.

     LES JETONS ONT ÉTÉ DÉFINIS SANS ÊTRE LUS. Pendant un lot entier, la
     feuille les déclarait, la densité compacte les redéfinissait, et DEUX
     règles seulement les lisaient sur trente-deux `font-size` : `h1` restait
     à `1.5rem` en dur, exactement la valeur de `--t-xl`, donc le compact
     changeait le jeton et rien ne bougeait à l'écran. Les deux tests qui
     portent leur nom vérifiaient la DÉFINITION (« `--t-xs:` est dans la
     feuille »), jamais la lecture — verts pendant tout ce temps. C'est le
     même défaut que la vue grille jamais rendue : ce qui est écrit n'est pas
     ce qui est vérifié.

     LA SUBSTITUTION SUIT LE RÔLE, PAS LE NOMBRE — les commentaires par jeton
     ci-dessous sont cette table, et ils tranchent les collisions. `.8rem`
     devient `--t-s` sur `.badge` et `.tableau th` (le jeton les NOMME), et
     `--t-xs` sur `.bulle.outil`, qui est en mono et va avec les deux autres
     blocs mono à `.75`. Suivre le nombre aurait mis les trois au même
     endroit, ce qui est précisément l'absence d'échelle qu'on corrige.

     QUATRE FAMILLES RESTENT EN DUR, ET AUCUNE N'EST UNE TAILLE DE TEXTE :

     1. Le PLANCHER de champ de saisie (`16px` sur `input/select/textarea` et
        sur `.menu-reglage select`) : un contrôle sous 16px fait zoomer Safari
        iOS. C'est un plancher d'usage, jamais paramétré par la densité — dit
        plus haut, à `--taille-corps`. `.menu-reglage select` portait `1rem`,
        égal à 16px seulement tant que la racine garde sa taille par défaut :
        écrit en px, il tient aussi quand elle rétrécit.
     2. Les GLYPHES d'icône (`.nav-basse .ico`, `.recherche .ico`,
        `.collection-vide .ico`, `.vide .ico`, `.pastille-assistant .robot`,
        et l'`::after` de l'ambiance terminal). Un emoji ou un caractère
        d'icône se dimensionne par rapport à la boîte qu'il remplit, pas par
        rapport au corps du texte : les faire suivre l'échelle ferait
        rétrécir les pictogrammes en compact, où c'est la DENSITÉ qu'on
        resserre, pas la lisibilité.
     3. `.num` en `.92em` — relatif au PARENT, à dessein : le même composant
        sert dans un titre et dans un paragraphe, et doit suivre celui qui le
        contient. Le passer en `rem` le figerait à une taille absolue.
     4. `h3` à `1rem` : le seul site de la feuille à ce cran. Un cran
        d'échelle avec un unique usager n'est pas un cran, c'est une valeur
        avec une raison — h3 doit rester au-dessus du corps (15px) là où
        `--t-m` (.9rem, 14,4px) passerait dessous. Sixième jeton refusé pour
        cette seule règle ; à reconsidérer au deuxième usager. */
  --t-xs: .75rem;   /* raccourci clavier, intertitre de menu */
  --t-s:  .85rem;   /* libellé, métadonnée, badge, en-tête de tableau */
  --t-m:  .9rem;    /* texte secondaire, corps d'alerte */
  --t-l:  1.1rem;   /* h2 */
  --t-xl: 1.5rem;   /* h1 */

  /* Deux graisses nommées. Quatre valeurs coexistaient (500, 600, 640, 700)
     sans qu'un lecteur puisse dire laquelle veut dire quoi. */
  --gr-normal: 500;
  --gr-fort: 600;

  /* Hauteur d'une ligne de collection en vue liste. Une ligne de hauteur
     variable oblige l'œil à chercher où retomber : mesuré chez roms, 124px
     quand la ligne casse contre ~60 sinon.

     56px datait d'avant la correction du padding hérité de `> *` (voir plus
     bas, `.collection[data-view="list"] .ligne`) : avec ces 24px en trop,
     56 semblait généreux. Une fois le padding neutralisé, ce que la ligne
     contient RÉELLEMENT au repos, c'est un bouton de 44px (plancher de cible
     tactile, verrouillé par tests) OU deux lignes de texte empilées (titre +
     `.sous-titre`, ~42px). 56px laissait donc 12px de vide qui ne protégeait
     rien. 48px garde une respiration visible au-dessus du bouton (4px) sans
     revenir coller pile sur le plancher tactile — mesuré à la baseline
     1500×643 : 8 résultats au-dessus du pli au lieu de 5. Ne descend pas
     sous 44px : le plancher de cible tactile reste `--h-ligne` en densité
     compacte, juste en dessous. */
  --h-ligne: 48px;

  /* Ratio par défaut d'une vignette. `.collection img` gravait `4 / 3`, ce qui
     rogne tout objet en portrait — c'est-à-dire tout objet réel dont on ne
     choisit pas la forme. En jeton, une app peut poser le sien ; en `natif`,
     la macro le calcule par élément (voir `collection`). */
  --ratio-vignette: 4 / 3;

  --lg-lecture: 720px;  /* contenu linéaire */
  --lg-large: 1240px;   /* grilles et tableaux */
  --lg-formulaire: 30rem; /* saisies linéaires : nom, email, mot de passe */
  /* Entre les deux : une page dont le contenu principal est un tableau avec
     une action par ligne (l'administration, au premier chef). `--lg-lecture`
     (720px) coupait les boutons « Réinitialiser » du tableau des comptes ;
     `--lg-large` (1240px) est pensé pour des grilles denses et écarte les
     colonnes au point qu'on ne relie plus un nom à son rôle. 880px laisse
     respirer quatre colonnes (Nom, Email, Rôle + bouton, Mot de passe +
     bouton) plus une colonne d'action étroite, sans dilater le reste de la
     page. */
  --lg-tableau: 880px;

  --ease: cubic-bezier(.2,.7,.3,1);
  --t: 140ms var(--ease);

  --sans: ui-sans-serif, -apple-system, BlinkMacSystemFont, "SF Pro Text",
          "Segoe UI Variable Text", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  --serif: Georgia, "Iowan Old Style", "Times New Roman", serif;
  --mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono",
          "Cascadia Mono", Menlo, Consolas, monospace;

  /* --face-titre, --graisse-titre et --lettrage-titre n'ont VOLONTAIREMENT
     aucun défaut ici. `.titre-affiche` et `.bandeau .nom-app` n'ont pas la
     même allure aujourd'hui (serif/500/-.01em contre sans/640/-.02em) : une
     seule valeur par défaut ne peut pas reproduire les deux. Chacun des deux
     lit donc `var(--x, SON-PROPRE-REPLI)` — le même motif que
     `outline: var(--accent, var(--tx))` plus bas — et une ambiance qui pose
     l'une de ces variables l'impose aux deux d'un coup, sans rien dupliquer.
     Une ambiance qui ne la pose PAS (ex. `moderne` et `--face-titre`) doit le
     faire explicitement : sans déclaration nulle part, c'est le repli de
     CHAQUE élément qui s'applique, pas une valeur partagée. */
}

/* --- Halo ---------------------------------------------------------------- */
/* Ombre teintée de l'accent, posée sur ce qui doit accrocher l'œil. Dérivée de
   `--accent` : une app qui pose sa teinte reçoit son halo sans rien déclarer.

   Dosé plus bas que sur le site vitrine, et volontairement : une app consultée
   dix fois par jour n'est pas une page d'accueil. En clair le halo descend
   (une ombre portée), en sombre il rayonne (il n'y a pas de lumière au-dessus
   d'un fond noir). */
:root {
  --halo: 0 2px 12px color-mix(in srgb, var(--accent, transparent) 20%, transparent);
  --halo-2: 0 2px 12px color-mix(in srgb, var(--accent-2, transparent) 20%, transparent);
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --halo: 0 0 16px color-mix(in srgb, var(--accent, transparent) 30%, transparent);
    --halo-2: 0 0 16px color-mix(in srgb, var(--accent-2, transparent) 30%, transparent);
  }
}
:root[data-theme="dark"] {
  --halo: 0 0 16px color-mix(in srgb, var(--accent, transparent) 30%, transparent);
  --halo-2: 0 0 16px color-mix(in srgb, var(--accent-2, transparent) 30%, transparent);
}

/* --- L'ACCENT est fourni par l'application ------------------------------- */
/* Socle ne définit aucune teinte : c'est ce qui distingue une app des autres.
   Chaque app pose, dans sa propre feuille et POUR LES DEUX THÈMES :

     --accent        la teinte
     --accent-tx     le texte qui se pose dessus  (≥ 4,5:1 sur --accent)
     --accent-soft   fond discret teinté
     --accent-line   filet teinté
     --ring          l'accent à ~30 % d'opacité, pour l'anneau de focus

   L'accent est une PAIRE, jamais une couleur seule. Un jaune pâle qui tient
   sur fond noir tombe à 1,2:1 dès qu'on pose un fond blanc — c'est la leçon
   qui a coûté le plus cher dans recipes. Vérifier avant de commiter :
   `python3 check_contrasts.py`.

   UN SECOND ACCENT, facultatif :

     --accent-2       la teinte secondaire
     --accent-2-tx    le texte qui se pose dessus
     --accent-2-soft  fond discret teinté
     --accent-2-line  filet teinté

   À quoi il sert : distinguer « c'est ici que vous êtes » de « ceci mérite
   votre attention ». Avec une seule teinte, l'onglet actif et le badge urgent
   ont la même couleur, et chaque app finit par bricoler la seconde dans son
   coin. Non déclaré, il retombe sur l'accent principal — une app qui n'en veut
   pas n'a rien à faire.

   Des paires prêtes et vérifiées vivent dans `css/accents/`. */
:root {
  --accent-2: var(--accent);
  --accent-2-tx: var(--accent-tx);
  --accent-2-soft: var(--accent-soft);
  --accent-2-line: var(--accent-line);
}

/* ==========================================================================
   LES AMBIANCES
   La personnalité visuelle, choisie par l'utilisateur et POSÉE PAR LE SERVEUR
   sur `:root` — jamais par un script après coup, sinon la page part dans une
   ambiance et saute dans l'autre.

   `countryside` n'a PAS de bloc ici : c'est l'existant, il vit dans `:root`.
   L'absence de l'attribut rend donc exactement le CSS d'avant, et c'est ce
   qui permet à chaque app de prendre cette version sans changer d'allure.

   AUCUNE ambiance ne redéfinit l'accent ni les couleurs d'état. L'accent
   appartient à l'app — c'est lui qui dit dans laquelle on se trouve — et un
   message d'erreur reste rouge partout, sous peine de ne plus se lire comme
   une erreur. Verrouillé par tests/test_css.py.
   ========================================================================== */

/* --- terminal : chasse fixe, angles vifs, filets francs, zéro ombre ------- */
:root[data-ambiance="terminal"] {
  /* BLANC PUR, ENCRE NOIRE. Pas de papier crème, pas de gris de surface : un
     terminal n'a qu'un fond et des caractères dessus. Les quatre surfaces
     valent donc la même chose, et TOUTE la structure passe par les filets —
     c'est ce qui rend l'ambiance radicale au lieu de décorative. */
  --bg: #ffffff; --surface: #ffffff; --surface-2: #ffffff; --surface-hi: #ffffff;
  --line: #000000; --line-strong: #000000; --line-controle: #000000;
  /* Le secondaire et le tertiaire restent des gris : du noir pur partout
     supprimerait toute hiérarchie de lecture. 10,4:1 et 7,0:1 sur blanc. */
  --tx: #000000; --tx-2: #3a3a3a; --tx-3: #565656;
  --r-s: 0; --r-m: 0; --r-l: 0; --r-xl: 0;
  --shadow-1: none; --shadow-2: none; --shadow-3: none;
  /* Redéfinit --sans, pas une règle sur `body` : les CINQ apps écrivent
     `font-family: var(--sans)` dans leur PROPRE feuille, en dehors de celle-
     ci. Une règle posée ici sur `body` ne les atteindrait jamais — le
     terminal se serait arrêté au bord du CSS de l'app. --mono garde sa
     valeur : le monospace doit rester du monospace là où il est demandé
     explicitement (`.num`, `.raccourci`…). */
  --sans: var(--mono);
  --face-titre: var(--mono);
  --graisse-titre: 700; --lettrage-titre: .01em;
}
/* Le sombre tire les gris vers le vert — l'esprit phosphore — sans toucher
   l'accent, qui reste celui de l'app. */
:root[data-ambiance="terminal"][data-theme="dark"] {
  /* NOIR PUR, symétrique du blanc pur en clair. Aucune surface surélevée :
     sur un terminal, une « carte » est un cadre, pas un aplat plus clair. */
  --bg: #000000; --surface: #000000; --surface-2: #000000; --surface-hi: #000000;
  --line: #4a4a4a; --line-strong: #6e6e6e; --line-controle: #8a8a8a;
  /* L'encre reste NEUTRE, pas verte : le vert phosphore serait un accent, et
     l'accent appartient à l'app — c'est lui qui dit dans laquelle on est.
     Un terminal ambré pour recipes, un vert pour fitness, chacun sur ce noir. */
  --tx: #ededed; --tx-2: #a6a6a6; --tx-3: #8a8a8a;
}
@media (prefers-color-scheme: dark) {
  :root[data-ambiance="terminal"]:not([data-theme="light"]) {
    --bg: #000000; --surface: #000000; --surface-2: #000000; --surface-hi: #000000;
    --line: #4a4a4a; --line-strong: #6e6e6e; --line-controle: #8a8a8a;
    --tx: #ededed; --tx-2: #a6a6a6; --tx-3: #8a8a8a;
  }
}

/* --- moderne : rayons larges, ombres portées, gris froids ---------------- */
:root[data-ambiance="moderne"] {
  --bg: #f6f8fb; --surface: #ffffff; --surface-2: #eef2f8; --surface-hi: #ffffff;
  --line: #e2e8f1; --line-strong: #cbd4e2; --line-controle: #8d97a8;
  --tx: #0f131b; --tx-2: #596375; --tx-3: #6d7688;
  --r-s: 10px; --r-m: 16px; --r-l: 22px; --r-xl: 28px;
  --shadow-1: 0 1px 3px rgba(16,24,40,.06);
  --shadow-2: 0 4px 14px -4px rgba(16,24,40,.10), 0 1px 3px rgba(16,24,40,.06);
  --shadow-3: 0 12px 32px -12px rgba(16,24,40,.18);
  /* Explicite, et pas un oubli : sans valeur par défaut au :root pour
     --face-titre, ne pas la poser laisserait `.titre-affiche` retomber sur
     SON repli à lui (`--serif`) — `moderne` se retrouverait avec des titres
     en serif, ce que rien dans sa description ne demande. */
  --face-titre: var(--sans);
  --graisse-titre: 700; --lettrage-titre: -.035em;
}
:root[data-ambiance="moderne"][data-theme="dark"] {
  --bg: #0a0d13; --surface: #141922; --surface-2: #1d232e; --surface-hi: #1f2733;
  --line: #232b38; --line-strong: #333d4e; --line-controle: #667389;
  --tx: #edf1f7; --tx-2: #9aa5b8; --tx-3: #7e8899;
  --shadow-1: 0 1px 3px rgba(0,0,0,.4);
  --shadow-2: 0 6px 20px -6px rgba(0,0,0,.6), 0 1px 3px rgba(0,0,0,.4);
  --shadow-3: 0 14px 36px -14px rgba(0,0,0,.8);
}
@media (prefers-color-scheme: dark) {
  :root[data-ambiance="moderne"]:not([data-theme="light"]) {
    --bg: #0a0d13; --surface: #141922; --surface-2: #1d232e; --surface-hi: #1f2733;
    --line: #232b38; --line-strong: #333d4e; --line-controle: #667389;
    --tx: #edf1f7; --tx-2: #9aa5b8; --tx-3: #7e8899;
    --shadow-1: 0 1px 3px rgba(0,0,0,.4);
    --shadow-2: 0 6px 20px -6px rgba(0,0,0,.6), 0 1px 3px rgba(0,0,0,.4);
    --shadow-3: 0 14px 36px -14px rgba(0,0,0,.8);
  }
}

/* --- classique : serif de titre, filets plutôt qu'ombres, gris chauds ----- */
/* La serif ne touche QUE les titres : sur un paragraphe d'app, elle fatigue. */
:root[data-ambiance="classique"] {
  --bg: #faf7f1; --surface: #fffdf8; --surface-2: #f2ede3; --surface-hi: #fffdf8;
  --line: #ddd6c7; --line-strong: #c4bbaa; --line-controle: #8b8471;
  --tx: #1d1a13; --tx-2: #5c5545; --tx-3: #6d6555;
  --r-s: 2px; --r-m: 3px; --r-l: 4px; --r-xl: 6px;
  --shadow-1: none; --shadow-2: none; --shadow-3: none;
  --face-titre: ui-serif, Georgia, "Iowan Old Style", "Times New Roman", serif;
  --graisse-titre: 600; --lettrage-titre: 0;
}
:root[data-ambiance="classique"][data-theme="dark"] {
  --bg: #14120d; --surface: #1c1913; --surface-2: #28241e; --surface-hi: #2c2822;
  --line: #3b3625; --line-strong: #4b4430; --line-controle: #7d745c;
  --tx: #f2ece0; --tx-2: #b3aa96; --tx-3: #968d7a;
}
@media (prefers-color-scheme: dark) {
  :root[data-ambiance="classique"]:not([data-theme="light"]) {
    --bg: #14120d; --surface: #1c1913; --surface-2: #28241e; --surface-hi: #2c2822;
    --line: #3b3625; --line-strong: #4b4430; --line-controle: #7d745c;
    --tx: #f2ece0; --tx-2: #b3aa96; --tx-3: #968d7a;
  }
}

/* ==========================================================================
   TERMINAL — LES CADRES EN ASCII
   Le seul endroit du fichier où une ambiance touche à autre chose qu'un jeton.
   C'est assumé : « chasse fixe et angles vifs » ne fait pas un terminal, les
   cadres en font un.

   LE HTML NE CHANGE PAS, et c'est la contrainte qui a dicté tout le dessin.
   Les cinq apps rendent déjà leurs cartes, leurs boutons et leurs badges ; une
   ambiance qui exigerait un balisage particulier ne serait plus une ambiance,
   ce serait un framework — et l'adopter demanderait de modifier cinq apps.
   Tout passe donc par `content` sur des pseudo-éléments.

   DEUX COINS, PAS QUATRE. Un élément n'a que deux pseudo-éléments ; en poser
   quatre exigerait un enfant de plus, donc du HTML. Les deux coins opposés
   suffisent à faire lire le cadre : la bordure dessine déjà les côtés, l'œil
   complète les angles.

   L'ACCENT N'EST PAS TOUCHÉ. `.bouton.principal` garde son fond d'accent : il
   appartient à l'app, c'est lui qui dit dans laquelle on se trouve. Les
   crochets s'ajoutent autour du libellé, ils ne le remplacent pas.
   ========================================================================== */

:root[data-ambiance="terminal"] .carte,
:root[data-ambiance="terminal"] .carte-flottante,
:root[data-ambiance="terminal"] .panneau { position: relative; }

:root[data-ambiance="terminal"] .carte::before,
:root[data-ambiance="terminal"] .carte::after,
:root[data-ambiance="terminal"] .carte-flottante::before,
:root[data-ambiance="terminal"] .carte-flottante::after,
:root[data-ambiance="terminal"] .panneau::before,
:root[data-ambiance="terminal"] .panneau::after {
  position: absolute; color: var(--tx); background: var(--bg);
  line-height: 1; font-size: 1.05em; pointer-events: none;
}
:root[data-ambiance="terminal"] .carte::before,
:root[data-ambiance="terminal"] .carte-flottante::before,
:root[data-ambiance="terminal"] .panneau::before {
  content: "\250C"; top: -1px; left: -1px; padding: 0 2px 2px 0;
}
:root[data-ambiance="terminal"] .carte::after,
:root[data-ambiance="terminal"] .carte-flottante::after,
:root[data-ambiance="terminal"] .panneau::after {
  content: "\2518"; bottom: -1px; right: -1px; padding: 2px 0 0 2px;
}

/* Un bouton de terminal se lit entre crochets. Le `gap` du bouton fournit
   déjà l'espace : les crochets n'en portent pas, sinon il compte double. */
:root[data-ambiance="terminal"] .bouton { text-transform: uppercase; letter-spacing: .06em; }
:root[data-ambiance="terminal"] .bouton::before { content: "["; }
:root[data-ambiance="terminal"] .bouton::after { content: "]"; }

/* Un badge devient un jeton entre chevrons — la convention des méta-variables
   dans une page de manuel. */
:root[data-ambiance="terminal"] .badge {
  text-transform: uppercase; background: transparent; border-color: currentColor;
}
:root[data-ambiance="terminal"] .badge::before { content: "<"; }
:root[data-ambiance="terminal"] .badge::after { content: ">"; }

/* Le repli troque son chevron dessiné contre un marqueur de nœud d'arbre, la
   convention de tout explorateur en console. Le `transform` du chevron est
   annulé : il ferait pivoter le texte. */
:root[data-ambiance="terminal"] .repli > summary::before {
  content: "[+]"; width: auto; height: auto; border: 0;
  transform: none; transition: none; font-family: var(--mono);
}
:root[data-ambiance="terminal"] .repli[open] > summary::before {
  content: "[-]"; transform: none;
}

/* Le message d'erreur GARDE SA COULEUR — un rouge devenu monochrome cesserait
   de se lire comme une erreur. Il gagne seulement le préfixe des sorties
   d'erreur d'un terminal. */
:root[data-ambiance="terminal"] .alerte::before { content: "!! "; font-weight: 700; }

/* ==========================================================================
   LA DENSITÉ
   Axe INDÉPENDANT de l'ambiance : un terminal aéré et un countryside
   resserré sont deux demandes légitimes.

   Elle ne touche AUCUNE couleur — c'est ce qui garde la vérification de
   contraste à 8 combinaisons (4 ambiances × 2 luminosités) au lieu de 16.

   Et elle ne descend sous AUCUN des deux planchers : 44px de cible tactile,
   16px sur un champ de saisie. Ce sont des règles d'usage, pas de style — en
   dessous de 16px Safari iOS zoome au focus et ne dézoome jamais, que la vue
   soit compacte ou non. Les deux sont verrouillés par tests/test_css.py.
   ========================================================================== */
:root[data-densite="compact"] {
  --e-1: 3px; --e-2: 6px; --e-3: 8px; --e-4: 11px;
  --e-5: 16px; --e-6: 22px; --e-7: 32px;
  --e-section: 18px;
  --e-bloc: 8px;
  /* Le spec demande les espacements ET la taille de base du texte — cette
     seconde partie manquait. --taille-corps, jamais `font-size` en dur ici :
     un champ de saisie reste à 16px littéral, gravé dans sa propre règle,
     quelle que soit la densité. Rien dans ce bloc ne descend sous ce
     plancher, seul le corps de texte (`body`) le peut. */
  --taille-corps: 14px;
  --t-xs: .7rem; --t-s: .8rem; --t-m: .85rem; --t-l: 1rem; --t-xl: 1.35rem;
  --h-ligne: 44px;   /* jamais moins : c'est le plancher de cible tactile */
}

/* --- Réinitialisation ---------------------------------------------------- */
*, *::before, *::after { box-sizing: border-box; }
* { margin: 0; padding: 0; }

/* `hidden` doit l'emporter sur les `display:flex` posés plus bas, sinon un
   élément masqué par le serveur reste visible. */
[hidden] { display: none !important; }

html { -webkit-text-size-adjust: 100%; }

body {
  background: var(--bg);
  color: var(--tx);
  font-family: var(--sans);
  font-size: var(--taille-corps);
  line-height: 1.5;
  min-height: 100dvh;   /* jamais 100vh : sur iOS la barre d'adresse rétracte la hauteur */
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  overflow-x: hidden;
}

a { color: inherit; text-decoration: none; }
img, svg { max-width: 100%; }
img { display: block; }

:focus-visible {
  outline: 2px solid var(--accent, var(--tx));
  outline-offset: 2px;
  border-radius: 4px;
}

/* Lien de sortie d'une page qui ne partage pas le bandeau de l'app — la page
   d'administration au premier chef. Sans lui on y entre sans pouvoir en
   sortir. Cible tactile de 44 px comme partout ailleurs. */
.retour {
  display: inline-flex; align-items: center; gap: var(--e-1);
  min-height: 44px; color: var(--tx-2); text-decoration: none;
}
.retour:hover { color: var(--tx); }

/* En-tête d'une page qui ne partage pas le bandeau de l'app (l'admin, au
   premier chef — voir README, « La page d'administration a toujours une
   sortie »). Sans elle, le titre et le lien de sortie sont deux éléments
   empilés au hasard : ici le lien se lit comme une VRAIE sortie, posée à
   distance du titre plutôt que perdue au-dessus dans son propre `<p>`. */
.entete-page {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--e-3); flex-wrap: wrap;
}
.entete-page h1 { margin: 0; }

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .01ms !important;
    transition-duration: .01ms !important;
  }
}

/* --- Typographie --------------------------------------------------------- */
/* `--face-titre` sans repli concurrent plus bas dans le fichier (vérifié :
   aucune autre règle ne cible `h1, h2, h3` sur `font-family`) : la variable
   peut donc se poser ici sans risquer de perdre la cascade. Le repli
   `var(--sans)` reproduit l'existant — aucune ambiance ne change le corps de
   la page tant qu'elle ne pose pas --face-titre. */
h1, h2, h3 { font-family: var(--face-titre, var(--sans)); line-height: 1.25; letter-spacing: -.015em; font-weight: 640; }
h1 { font-size: var(--t-xl); }
h2 { font-size: var(--t-l); }
h3 { font-size: 1rem; }

/* Le serif est réservé aux grands titres et écrans d'accueil. Toute
   l'interface courante reste en --sans.

   C'EST ICI, et nulle part avant, que `.titre-affiche` lit les trois
   variables — pas dans une règle antérieure à spécificité égale qu'une
   règle plus bas pourrait ensuite écraser en silence. Le repli de chaque
   `var()` reproduit exactement l'apparence d'avant la branche : aucune
   ambiance active ne change ce rendu. */
.titre-affiche {
  font-family: var(--face-titre, var(--serif));
  font-weight: var(--graisse-titre, 500);
  letter-spacing: var(--lettrage-titre, -.01em);
}

/* Chiffres en chasse fixe tabulaire : durées, quantités, montants, compteurs.
   Sans elle, une colonne « 5 min / 45 min / 120 min » danse d'une ligne à
   l'autre. */
.num {
  font-family: var(--mono);
  font-variant-numeric: tabular-nums;
  font-size: .92em;
  letter-spacing: -.01em;
}

.doux { color: var(--tx-2); }
.service { color: var(--tx-3); font-size: var(--t-s); }

.contenu { max-width: var(--lg-lecture); margin: 0 auto; padding: var(--e-4); }
.contenu-large { max-width: var(--lg-large); margin: 0 auto; padding: var(--e-4); }
/* Voir `--lg-tableau` : une page-tableau avec actions par ligne. */
.contenu-tableau { max-width: var(--lg-tableau); margin: 0 auto; padding: var(--e-4); }

/* Rythme vertical PAR DÉFAUT des deux conteneurs de page. `.contenu` ne
   posait que largeur, centrage et rembourrage — rien sur l'espacement entre
   ses enfants directs. `.carte` et `.apres-carte` portent leur propre marge et
   sauvent les apparences ; un `<p>`, un `<h1>`, un `<img>` ou un `<div>` nu
   n'en porte aucune, et une page qui les empile obtient un mur au contact.
   Mesuré au navigateur sur `surpriseme` : six écarts sur huit valaient 0px
   entre les enfants de `.contenu` (voir docs/revue-ux-2026-08-12.md).

   Même valeur que `.rythme` (`--e-5`), sur le même sélecteur `> * + *` — pas
   une coïncidence, c'est le même rythme. `.contenu-tableau` n'est PAS
   concerné : il s'écrit toujours `class="contenu-tableau rythme"`,
   explicitement (voir la page d'administration) — une page-tableau a des
   sections plus denses que la lecture ou une grille de cartes, et le choix
   reste local à qui l'utilise. Une app peut resserrer localement (une
   `.pile` a son propre `gap`, plus serré) ; elle ne doit plus avoir à
   DÉCOUVRIR l'absence de rythme. */
.contenu > * + *, .contenu-large > * + * { margin-top: var(--e-5); }

/* --- Écran isolé : connexion, activation --------------------------------- */
/* Une page sans navigation, une carte, rien d'autre. Sans cette règle, la
   carte s'étale sur toute la largeur du conteneur et le formulaire flotte au
   milieu de nulle part — vu en production sur `random` le 2026-08-24, et
   c'est la raison pour laquelle l'écran de connexion est descendu dans le
   socle plutôt que d'être recopié une sixième fois.

   `min-height` plutôt qu'un centrage vertical strict : sur un téléphone en
   paysage, une carte centrée verticalement se retrouve à cheval sur le
   clavier dès que le champ prend le focus. */
.ecran-isole {
  display: flex;
  justify-content: center;
  align-items: flex-start;
  min-height: 60vh;
  padding-block: var(--e-6);
}
.ecran-isole > * { width: 100%; }

/* ==========================================================================
   COMPOSANTS
   ========================================================================== */

/* --- Bandeau collant ----------------------------------------------------- */
.bandeau {
  position: sticky; top: 0; z-index: 50;
  background: var(--bg);
  border-bottom: 1px solid var(--line);
  padding: var(--e-2) var(--e-4);
  padding-top: calc(var(--e-2) + env(safe-area-inset-top, 0px));
}
@supports (backdrop-filter: blur(1px)) {
  .bandeau {
    background: color-mix(in srgb, var(--bg) 88%, transparent);
    backdrop-filter: saturate(180%) blur(14px);
    -webkit-backdrop-filter: saturate(180%) blur(14px);
  }
}
.bandeau .dedans {
  display: flex; align-items: center; gap: var(--e-3);
  max-width: var(--lg-large); margin: 0 auto;
  /* Nowrap PAR DÉFAUT, explicitement : au-dessus du seuil mobile (640px,
     ci-dessous), la ligne ne casse jamais toute seule. Avant ce lot,
     l'absence de valeur ici (le `nowrap` implicite du flexbox) cohabitait
     mal avec le `flex-wrap: wrap` du bloc 640px plus bas — selon l'ordre du
     fichier, l'un écrasait l'autre à spécificité égale, et chaque enfant de
     `.dedans` (marque, recherche, bouton, thème, menu) pouvait finir sur sa
     propre ligne. C'est le bug constaté : le bouton de thème au-dessus du
     menu de compte. Un seul point de rupture, déclaré une seule fois : voir
     `.bandeau-actions` et le bloc `@media (max-width: 640px)` plus bas. */
  flex-wrap: nowrap;
}
/* La marque : une icône optionnelle, puis le nom. L'icône n'est PAS posée ici
   en `<img>` — elle vient en SVG inline, rendu par l'app, pour prendre
   `currentColor` et donc `--accent` ; une image ne se colore pas en CSS.
   Cible tactile de 44px comme ses voisins, sans que le dessin lui-même
   grossisse : la taille est fixée sur le SVG (`.mark-svg`), pas sur le lien.

   PIÈGE DÉJÀ PAYÉ : cette règle remplace une ancienne version qui traitait
   `.marque` comme un lien simple (`font-weight` et `letter-spacing` posés
   directement dessus). Une app qui n'a pas encore adopté le contrat à deux
   enfants (`.mark` + `.nom-app`) perd ce style au prochain `installer_css.py`
   — c'est le prix assumé de ne PAS doubler le sélecteur : deux déclarations
   de `.bandeau .marque` à quelques lignes d'écart, c'est le bug déjà vu sur
   `.avis-form`, où celle qui gagnait était invisible à qui lisait l'autre. */
/* `--e-2` (8px) lisait comme deux éléments côte à côte plutôt qu'une seule
   marque — l'icône et le nom sont UNE chose, pas deux : resserré à `--e-1`. */
.bandeau .marque { display: flex; align-items: center; gap: var(--e-1); margin-right: auto; }
.bandeau .marque .mark {
  display: inline-flex; align-items: center; justify-content: center;
  width: 44px; height: 44px; margin-left: -10px;
  color: var(--accent); flex: none;
}
.bandeau .marque .mark-svg { width: 22px; height: 22px; }
.bandeau .marque .mark:hover { filter: brightness(1.1); }
/* Même trois variables que `.titre-affiche`, avec le repli PROPRE au nom
   d'app (640 / -.02em / --sans hérité) : une ambiance qui pose --face-titre,
   --graisse-titre ou --lettrage-titre les impose aux deux à la fois — c'est
   voulu, un terminal a des titres ET un nom d'app en chasse fixe — mais tant
   que rien ne les pose, chacun garde SA propre allure d'aujourd'hui. */
.bandeau .nom-app {
  font-family: var(--face-titre, var(--sans));
  font-weight: var(--graisse-titre, 640);
  letter-spacing: var(--lettrage-titre, -.02em);
}

/* --- Bloc de droite : thème + compte, jamais séparés ----------------------
   Avant ce lot, `.bascule-theme` et `<details class="menu">` étaient deux
   enfants directs de `.dedans`, au même niveau que la marque et la
   recherche : quand la ligne manquait de place, `flex-wrap` les répartissait
   CHACUN où il pouvait — le bouton de thème pouvait finir seul au-dessus du
   menu de compte, séparé de son voisin. Ce conteneur les regroupe en UN
   SEUL item flex de `.dedans`. `flex: none` : il ne grandit ni ne rétrécit,
   donc ne cède jamais sa place — c'est le repère de navigation, il reste sur
   la ligne de la marque à toute largeur. Contrat HTML : voir README,
   section « Bandeau ». */
.bandeau-actions { display: flex; align-items: center; gap: var(--e-2); flex: none; }

/* --- Bouton d'ajout : perd son libellé avant de disparaître ---------------
   `.bouton-ajouter` porte DEUX enfants : un signe `+` toujours visible, et un
   `<span class="libelle">` masqué sous le seuil mobile. `aria-label` est posé
   sur `.bouton-ajouter` lui-même (contrat README) — il fixe le nom
   accessible dans les DEUX cas, donc masquer `.libelle` en CSS ne retire
   rien pour un lecteur d'écran. Un bouton d'action ne doit pas s'évaporer en
   silence : sous le seuil il reste une cible de 44px avec un `+` lisible,
   jamais un élément qui disparaît sans repli. */
.bandeau .bouton-ajouter { flex: none; }

/* La croissance de `.recherche` (elle prend la place libre, sans jamais
   descendre sous 240px) vit désormais sur le composant lui-même — plus bas
   dans ce fichier, bloc « Barre de recherche » — via `flex-grow` /
   `flex-shrink` / `min-width`, PAS `flex-basis` : posée sur `.recherche`,
   `flex-basis` devient une hauteur dès que le parent est en colonne (mesuré
   à 240px de haut pour un champ de 42 dans le `<form class="pile">` de
   roms). Un premier correctif (revue du 2026-08-16) avait déplacé la
   croissance ICI, sur `.bandeau .recherche` — ça réglait roms, mais ça
   retirait la règle de sous `surpriseme`, qui pose `.recherche` dans un
   flex ROW hors bandeau (`.entete-recherche`) et documente dans son propre
   CSS qu'il compte dessus. `flex-grow` + `min-width` sur le composant
   marchent dans les DEUX sens : en ligne ils reproduisent l'ancien
   comportement, en colonne `flex-grow` n'a aucune hauteur à distribuer
   (`.pile` n'en fixe pas) donc l'élément garde sa hauteur naturelle — le
   bug des 240px de haut ne peut plus revenir. Le bandeau n'a donc plus
   besoin de règle de croissance dédiée, seulement de la bascule mobile
   ci-dessous. */

/* Sous 640px (breakpoint mobile unifié, déjà en place ailleurs dans ce
   fichier — voir la densité et `.nav-basse`) : LA RECHERCHE EST CE QUI CÈDE,
   rien d'autre. `order` + `flex-basis: 100%` la poussent sur sa PROPRE
   ligne, en dernier — un choix délibéré (elle ne tient plus dans la largeur
   disponible entre la marque et le bloc de droite), pas un débordement qui
   dépendrait de la largeur exacte du contenu ce jour-là. La marque et
   `.bandeau-actions` restent `flex: none` : ils ne participent pas au calcul
   qui pousserait la recherche à la ligne, ils sont simplement toujours là. */
@media (max-width: 640px) {
  .bandeau .dedans { flex-wrap: wrap; row-gap: var(--e-2); }
  .bandeau .recherche { order: 5; flex-basis: 100%; }
  .bandeau .bouton-ajouter .libelle { display: none; }
}
.bandeau nav { display: flex; gap: var(--e-4); font-size: var(--t-m); }
/* 25px de haut, mesuré : c'est la navigation la plus touchée de tout le
   parc, et la seule famille de liens du socle sans plancher tactile. Neuf
   autres règles posent déjà `min-height: 44px` — celle-ci avait été
   oubliée. `inline-flex` + `align-items: center` pour que le plancher
   grandisse la CIBLE sans faire descendre le texte de la ligne. */
.bandeau nav a {
  color: var(--tx-2);
  display: inline-flex; align-items: center;
  min-height: 44px;
}
.bandeau nav a:hover, .bandeau nav a[aria-current] { color: var(--tx); }

/* --- Carte --------------------------------------------------------------- */
.carte {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r-l);
  padding: var(--e-4);
  box-shadow: var(--shadow-1);
}
.carte-flottante {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r-xl);
  padding: var(--e-6) var(--e-5) var(--e-5);
  box-shadow: var(--shadow-3);
}

/* --- Boutons ------------------------------------------------------------- */
/* 44px de haut : cible tactile minimale. Un bouton de 32px se rate au doigt. */
.bouton {
  display: inline-flex; align-items: center; justify-content: center; gap: var(--e-2);
  min-height: 44px; padding: 0 var(--e-4);
  font: inherit; font-weight: 600;
  border: 1px solid var(--line-controle);
  border-radius: var(--r-m);
  background: var(--surface); color: var(--tx);
  cursor: pointer;
  transition: background var(--t), border-color var(--t), color var(--t);
}
.bouton:hover { background: var(--surface-2); }
.bouton:active { transform: translateY(1px); }
.bouton[disabled] { opacity: .5; cursor: not-allowed; }

.bouton.principal {
  background: var(--accent); color: var(--accent-tx);
  border-color: transparent;
}
.bouton.principal:hover { filter: brightness(.94); background: var(--accent); }

.bouton.discret {
  background: transparent; border-color: transparent; color: var(--tx-2);
  min-height: 36px; padding: 0 var(--e-2); font-weight: 500; font-size: var(--t-m);
}
.bouton.discret:hover { background: var(--surface-2); color: var(--tx); }

/* Le geste destructeur, sans quoi chaque app invente son propre rouge —
   c'était `wishlist`, vécu. `--danger` est la seule teinte que le socle pose
   lui-même (voir sa déclaration plus haut) : contrairement à `--accent`, elle
   n'appartient à aucune marque, donc aucune app ne peut la faire dériver.
   Contourné plutôt que plein par défaut : la hiérarchie d'action (README,
   section « Composition ») veut ce bouton REPLIÉ, jamais dans la rangée des
   gestes ordinaires — un rouge plein à côté d'un `.bouton.principal` ferait
   deux boutons qui crient. Le remplissage n'arrive qu'au survol, une fois
   l'intention confirmée par le geste de la souris. */
.bouton.danger {
  background: transparent; border-color: var(--danger); color: var(--danger);
}
.bouton.danger:hover { background: var(--danger); color: var(--danger-tx); }

.bouton.pleine-largeur { width: 100%; }

/* --- Champs -------------------------------------------------------------- */
.champ { display: flex; flex-direction: column; gap: var(--e-1); }
.champ > span { font-size: var(--t-s); color: var(--tx-2); }

/* `input:not([type])` n'est pas une coquetterie : un <input name="x"> sans
   attribut type est un champ texte parfaitement valide, et c'est la forme la
   plus courante dans un gabarit. Sans cette ligne il ressort au style natif du
   navigateur au milieu de champs stylés — l'app a l'air cassée. */
.champ input, .champ select, .champ textarea,
input:not([type]), input[type=text], input[type=email], input[type=password],
input[type=search], input[type=number], input[type=date], input[type=url],
input[type=tel], select, textarea {
  width: 100%;
  padding: var(--e-2) var(--e-3);
  /* 16px, et pas moins : en dessous, Safari zoome la page au focus. */
  font: inherit; font-size: 16px;
  /* Le contrôle le plus fréquent d'un formulaire ratait le plancher de 2px,
     partout, dans les cinq apps : `padding` + texte + deux bordures font
     42px, jamais 44. Neuf autres règles du socle posent déjà ce plancher. */
  min-height: 44px;
  color: var(--tx);
  background: var(--surface);
  border: 1px solid var(--line-controle);
  border-radius: var(--r-m);
  transition: border-color var(--t), box-shadow var(--t);
}
.champ input:focus, .champ select:focus, .champ textarea:focus,
input:focus, select:focus, textarea:focus {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--ring);
}
textarea { min-height: 5rem; resize: vertical; }

/* --- Case à cocher et bouton radio ---------------------------------------
   La liste de sélecteurs des champs, juste au-dessus, couvre onze formes de
   saisie et s'arrête juste avant ces deux-là — les seules qui soient à la
   fois un champ ET une cible de clic, donc les seules à cumuler les deux
   planchers du socle. Elles ressortaient à 13x13px au style natif du
   navigateur, au milieu de champs stylés à 42px.

   LE LIBELLÉ EST LA CIBLE, pas seulement la case : c'est ce qui donne les
   44px sans dessiner une case géante. `<label class="champ-case">` enveloppe
   l'input et son texte — contrat HTML, voir README. */
.champ-case {
  display: flex; align-items: center; gap: var(--e-2);
  min-height: 44px; cursor: pointer;
}
.champ-case input[type="checkbox"],
.champ-case input[type="radio"] {
  width: 20px; height: 20px; min-height: 0; flex: none;
  margin: 0; padding: 0;
  /* Le socle ne définit aucun accent : le repli reproduit la couleur de
     texte plutôt que de laisser le contrôle invalide sans --accent posé. */
  accent-color: var(--accent, var(--tx));
  cursor: pointer;
}
.champ-case input[type="radio"] { border-radius: 999px; }
/* `accent-color` plutôt qu'une case redessinée : le contrôle natif garde son
   comportement clavier et son rendu de plateforme. */

/* Un champ occupe 100% de son conteneur — ce qui est juste dans une carte
   étroite, et faux dans `.contenu-large`, prévu pour les grilles et les
   tableaux. Un champ « Nom » de 1200px ne dit plus rien de ce qu'on attend :
   la largeur d'une saisie est une information. Les formulaires linéaires se
   bornent donc explicitement, sans toucher au 100% dont tout le reste dépend. */
.formulaire-etroit { max-width: var(--lg-formulaire); }

/* Même largeur de champ que `.formulaire-etroit` colonne les longues
   saisies (nom, email) — mais un mot de passe provisoire ou un rôle de
   quinze caractères n'a rien à dire à 480px : une longueur qui ne reflète
   pas ce qu'on y tape est plus dure à remplir, pas plus facile. Posée sur
   `.champ` lui-même (pas sur son `<input>`) pour que le libellé suive la
   même largeur que le contrôle. */
.champ.champ-etroit { max-width: 12rem; }

/* Rythme vertical d'un gabarit autonome. Une page qui empile un titre, un
   tableau et une carte les met au contact sans elle : la réinitialisation
   annule les marges par défaut du navigateur, et rien ne les remplace. Sur
   `* + *` plutôt que sur chaque bloc, pour qu'un premier élément ne décolle
   pas du haut de la page. */
.rythme > * + * { margin-top: var(--e-5); }

/* Une pile de champs. Un `form` n'espace rien tout seul, et chaque app
   redéfinissait la même règle dans sa propre feuille — c'est le genre de
   contrat qui appartient au socle. Plus serré que `.rythme` : à l'intérieur
   d'un formulaire, les champs sont un groupe, pas des sections. */
.pile { display: flex; flex-direction: column; gap: var(--e-4); }

/* Un bouton dans une pile s'étirerait à la largeur des champs — `stretch` est
   l'alignement par défaut d'un flex en colonne. Sa largeur doit dire ce qu'il
   fait, pas la taille de la saisie au-dessus. Volontairement portée par
   `.formulaire-etroit` et non par `.pile` seule : les apps utilisent déjà
   `.pile` avec leurs propres boutons, et une règle globale changerait leur
   rendu sans qu'elles aient rien demandé. */
.formulaire-etroit .pile > .bouton { align-self: flex-start; }

/* Un champ et le bouton qui l'applique vont sur une ligne, pas l'un sous
   l'autre : dessous, le bouton se lit comme la légende du champ. `flex-end`
   les aligne par le bas, seul alignement correct quand le champ porte un
   libellé au-dessus et pas le bouton. */
.champ-action { display: flex; align-items: flex-end; gap: var(--e-2); }
.champ-action .champ { flex: 1; min-width: 0; }

/* UN MENU DÉROULANT DANS UN TABLEAU GARDE LA LARGEUR DE SES OPTIONS.
   `select { width: 100% }` (plus bas, avec les autres champs) veut dire « toute
   la place du conteneur » dans un formulaire, où elle est large. Dans une
   cellule elle ne l'est pas : le menu des rôles s'y réduisait à « me » pour
   « membre », puis au seul chevron. Un menu dont on ne peut pas lire l'option
   choisie ne dit plus ce qu'on est en train de changer.

   `auto` rend au `<select>` sa largeur naturelle — celle de son option la plus
   longue — et le tableau élargit la colonne d'autant : le contenu décide, au
   lieu de subir le partage de la place restante. */
.tableau .champ-action select { width: auto; }

/* --- Séparateur de deux chemins ------------------------------------------
   Le filet « ou » entre le mot de passe et la connexion externe, monté ici au
   troisième usage identique (fitness, surpriseme, wishlist).

   Les deux traits sont des pseudo-éléments qui prennent la place restante, et
   PAS un `<hr>` surmonté d'un mot : un `<hr>` ne se recentre pas quand le
   libellé change de longueur, et « ou » n'est pas le seul libellé possible.

   Aucune marge ici, volontairement : l'espacement vient du conteneur — une
   `.rythme`, ou le `gap` de la pile qui porte les deux chemins. `surpriseme`
   avait dû écrire la sienne parce que son conteneur de connexion n'avait ni
   flex ni gap ; portée par le socle, cette marge s'ajouterait au `gap` dans
   toutes les apps dont le conteneur fait déjà son travail. */
.separateur {
  display: flex; align-items: center; gap: var(--e-3);
  color: var(--tx-3); font-size: var(--t-s);
}
.separateur::before, .separateur::after {
  content: ""; flex: 1; height: 1px; background: var(--line);
}

/* --- Badges d'état ------------------------------------------------------- */
.badge {
  display: inline-flex; align-items: center; gap: var(--e-1);
  padding: 2px var(--e-2);
  font-size: var(--t-s); font-weight: 600;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: var(--surface-2); color: var(--tx-2);
}
.badge.ok   { background: var(--ok-bg);   color: var(--ok-tx);   border-color: var(--ok-line); }
.badge.warn { background: var(--warn-bg); color: var(--warn-tx); border-color: var(--warn-line); }
.badge.err  { background: var(--err-bg);  color: var(--err-tx);  border-color: var(--err-line); }
.badge.info { background: var(--info-bg); color: var(--info-tx); border-color: var(--info-line); }

/* --- Alertes ------------------------------------------------------------- */
.alerte {
  padding: var(--e-3);
  border: 1px solid var(--line);
  border-radius: var(--r-m);
  background: var(--surface-2); color: var(--tx-2);
  font-size: var(--t-m);
}
.alerte.ok   { background: var(--ok-bg);   color: var(--ok-tx);   border-color: var(--ok-line); }
.alerte.warn { background: var(--warn-bg); color: var(--warn-tx); border-color: var(--warn-line); }
.alerte.err  { background: var(--err-bg);  color: var(--err-tx);  border-color: var(--err-line); }
.alerte.info { background: var(--info-bg); color: var(--info-tx); border-color: var(--info-line); }

/* --- Listes -------------------------------------------------------------- */
.liste { list-style: none; }
.liste > li {
  padding: var(--e-3) 0;
  border-bottom: 1px solid var(--line);
}
.liste > li:last-child { border-bottom: 0; }
/* Scopée à `.liste` ET `.collection` : `collection()` (socle/ecrans.html)
   rend `.titre-ligne` dans `.collection`, où rien ne matchait avant — le
   titre y rendait au poids du corps de texte pendant que `.sous-titre`,
   déjà scopé aux deux (`.collection .sous-titre`), recevait bien le sien.
   Élargir sans toucher `.liste` seule : ce que `.liste` rend aujourd'hui ne
   doit pas bouger. */
.liste .titre-ligne, .collection .titre-ligne { font-weight: 600; }

/* `lien` (socle/ecrans.html, collection()) rend le titre en `<a>` plutôt
   qu'en `<span>` — même classe, même graisse (règle ci-dessus), mais un lien
   texte hérite d'une hauteur de LIGNE, pas de CELLULE : sans cette règle, le
   `<a>` mesure ~20px alors que `.collection[data-view="list"] .ligne` pose
   `min-height: var(--h-ligne)` (44px, plancher de cible tactile) sur le
   CONTENEUR — `align-items: center` centre ensuite la cellule de 20px au
   milieu des 44px, laissant du vide cliquable pour rien et un lien de 20px
   de haut pour de vrai. `display: flex; align-items: center; min-height:
   var(--h-ligne)` sur le lien lui-même fait grandir sa cellule (grille dont
   la hauteur suit le plus grand enfant) jusqu'à 44px : le texte reste centré
   au même endroit à l'œil, mais toute la hauteur de la ligne devient
   cliquable. Scopée à `data-view="list"` : en grille la carte entière est
   déjà une grande surface, imposer 44px au lien seul y déformerait
   `.cellule` sans rien gagner pour le doigt. */
.collection[data-view="list"] a.titre-ligne {
  display: flex; align-items: center; min-height: var(--h-ligne);
}
.liste .meta { display: block; color: var(--tx-2); font-size: var(--t-s); }

/* --- Tableaux ------------------------------------------------------------ */
/* Le conteneur défile, pas la page : sur téléphone un tableau large ne doit
   jamais provoquer un défilement horizontal du document entier. */
.tableau-conteneur { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.tableau { width: 100%; border-collapse: collapse; font-size: var(--t-m); }
.tableau th, .tableau td {
  padding: var(--e-2) var(--e-3);
  text-align: left;
  border-bottom: 1px solid var(--line);
  white-space: nowrap;
}
.tableau th { color: var(--tx-3); font-weight: 600; font-size: var(--t-s); }
.tableau tbody tr:hover { background: var(--surface-2); }
/* La ligne de création (admin, « Créer un compte ») porte des champs, pas du
   texte : `white-space: nowrap` hérité de `.tableau td` ne les gêne pas, mais
   sans cette teinte de fond elle se lirait comme un compte de plus plutôt
   que comme une action. */
.tableau tbody tr.ligne-creation { background: var(--surface-2); }
.tableau tbody tr.ligne-creation td { white-space: normal; }

/* --- Navigation basse (téléphone) ---------------------------------------- */
.nav-basse { display: none; }
@media (max-width: 640px) {
  .nav-basse {
    display: flex;
    position: fixed; inset: auto 0 0 0; z-index: 30;
    background: var(--surface);
    border-top: 1px solid var(--line);
    padding-bottom: env(safe-area-inset-bottom, 0px);
  }
  .nav-basse a {
    flex: 1;
    display: flex; flex-direction: column; align-items: center; justify-content: center;
    gap: 3px; min-height: 56px;
    color: var(--tx-3); font-size: var(--t-xs); font-weight: 600;
  }
  .nav-basse a[aria-current] { color: var(--accent); }
  .nav-basse .ico { font-size: 1.15rem; line-height: 1; }
}

/* ==========================================================================
   COMPOSANTS DE PARITÉ — grille/liste, recherche, palette, panneau
   Ajoutés le 2026-08-04 pour ce que recipes sait faire et qu'aucune autre app
   ne savait reproduire sans tout réécrire.
   ========================================================================== */

/* --- Accessible mais invisible ------------------------------------------- */
/* Un libellé de champ que le dessin ne montre pas doit exister quand même : un
   `<select>` sans <label> n'est annoncé par rien. `display:none` le retirerait
   aussi du lecteur d'écran — c'est tout l'intérêt de cette recette. */
.visuellement-cache {
  position: absolute; width: 1px; height: 1px;
  padding: 0; margin: -1px; overflow: hidden;
  clip-path: inset(50%); white-space: nowrap; border: 0;
}

/* Le premier lien de la page, visible seulement au clavier : sans lui, tabuler
   sur une app à bandeau oblige à traverser toute la navigation à chaque page. */
.saut-contenu {
  position: absolute; left: var(--e-3); top: -100%; z-index: 100;
  padding: var(--e-2) var(--e-3);
  background: var(--surface-hi); border: 1px solid var(--line-strong);
  border-radius: var(--r-m); box-shadow: var(--shadow-2);
}
.saut-contenu:focus { top: var(--e-3); }

/* --- Grille ou liste ------------------------------------------------------ */
/* Les deux dispositions du MÊME balisage : le serveur rend les mêmes éléments
   et pose `data-view` sur le conteneur. Rien à re-rendre pour basculer, et la
   bascule marche donc aussi sans JavaScript, au rechargement. */
.collection[data-view="grid"] {
  display: grid;
  gap: var(--e-4);
  grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr));
}
/* UNE CARTE À RATIO NATIF NE S'ÉTIRE PAS À LA HAUTEUR DE SA VOISINE.
   `align-items` vaut `normal`, donc `stretch` sur une grille : chaque carte
   d'une rangée prend la hauteur de la plus haute. C'est ce qu'on veut quand
   les cartes se ressemblent — et c'est un défaut dès que les médias ont des
   ratios différents. Mesuré chez roms sur une recherche multi-plateformes :
   une carte N64 (jaquette en 1,43) s'arrêtait à 364px de contenu dans une
   boîte de 547px, soit 184px de vide encadré — 34 % de la carte.

   Scopée à `[data-ratio="natif"]`, que la macro ne pose QUE pour
   `ratio="natif"` : sans cet attribut rien ne change, donc `wander` (seule
   autre app en vue grille, et sans ratio déclaré) garde ses cartes à hauteur
   égale. L'attribut est DÉRIVÉ d'un paramètre existant, ce n'est pas un
   onzième paramètre de `collection()` — le plafond de dix tient. */
.collection[data-view="grid"][data-ratio="natif"] { align-items: start; }
.collection[data-view="list"] {
  display: flex; flex-direction: column;
  gap: 0;
}
.collection[data-view="list"] > * {
  display: flex; align-items: center; gap: var(--e-3);
  padding: var(--e-3) 0;
  border-bottom: 1px solid var(--line);
  border-radius: 0; box-shadow: none; background: none;
}
.collection[data-view="list"] > :last-child { border-bottom: 0; }
/* En liste, l'image se réduit à une vignette — sinon la « liste » est une
   grille à une colonne, ce qui ne rend service à personne. `.media-absent`
   suit la même taille : sans elle, une jaquette manquante s'étalerait sur
   toute la colonne média (`width: 100%` de la règle grille) au lieu de
   rester une vignette comme ses voisines illustrées.

   La taille suit `--h-ligne`, jamais un `56px` gravé à côté : c'est le MÊME
   jeton que la macro pose comme largeur de colonne média
   (`socle/ecrans.html`, `colonnes.append("var(--h-ligne)")`) et que
   `.collection[data-view="list"] .ligne` pose comme `min-height`. Un `56px`
   local d'accord avec `--h-ligne` seulement à densité normale se dédisait en
   compact : `--h-ligne` descend à 44px (plancher de cible tactile) mais la
   vignette restait à 56, débordait de la ligne réduite, et `.cellule`
   (`overflow: hidden`) en rognait 12px — la vignette la plus haute perdait le
   plus. Un seul jeton pour une seule chose : la hauteur de ligne et la
   vignette qui la remplit ne peuvent plus se contredire. */
.collection[data-view="list"] img,
.collection[data-view="list"] .media-absent {
  width: var(--h-ligne); height: var(--h-ligne); object-fit: cover;
  border-radius: var(--r-m); flex: none;
}
/* `aspect-ratio: var(--ratio-vignette)` et `object-fit: cover` restent le
   DÉFAUT DE FEUILLE — pas une valeur que la macro doit écraser à chaque
   rendu. Cinq apps n'ont pas migré vers `collection()` et ne posent donc
   AUCUN style inline sur leurs `<img>` : pour elles, cette règle doit rester
   identique à avant, au caractère près (restos délègue explicitement sa
   grille 4:3 à ce bloc — `web/static/app.css`).

   Pour l'app migrée, la macro pose `style="aspect-ratio: …"` par carte, et un
   style inline bat toujours une règle de feuille : la vérité par élément
   arrive donc sans qu'on touche au défaut. `cover` ne coûte rien à ce cas —
   quand la boîte prend le ratio natif de l'image (`ratio="natif"`), boîte et
   image ont le même ratio, donc rien à rogner. `cover` ne mord que quand un
   ratio est IMPOSÉ (`ratio="1"` sur une grille de photos) : là, le rognage
   est un choix explicite de l'app, pas un défaut subi. */
.collection[data-view="grid"] img {
  width: 100%; aspect-ratio: var(--ratio-vignette); object-fit: cover;
  border-radius: var(--r-m) var(--r-m) 0 0;
  background: var(--surface-2);
}
/* `ratio="… entier"` : LES CARTES S'ALIGNENT ET RIEN N'EST ROGNÉ. Le fond
   teinté est déjà là (`background: var(--surface-2)` sur l'`<img>` de grille) :
   il devient visible en bande autour d'un objet qui ne remplit pas sa boîte,
   et c'est le prix assumé de l'alignement.

   Vaut dans LES DEUX VUES, pas seulement en grille : si l'app dit « montre
   l'image entière », une vignette de liste qui la rogne dirait le contraire du
   même objet. En liste, la boîte est le carré de `--h-ligne` (posé plus haut),
   donc `contain` y fait tenir une jaquette portrait dans un carré au lieu de
   lui couper la tête.

   Demandé à l'écran : des jaquettes de 0,58 (PSP) à 1,43 (N64) dans la même
   grille donnaient des cartes de hauteurs très différentes ; les aligner par un
   `cover` sur un 3/4 aurait coûté la moitié de la largeur d'un boxart N64. */
.collection[data-ratio="entier"] img { object-fit: contain; }

/* Une place réservée et teintée, jamais un trou. */
.media-absent { display: block; width: 100%; background: var(--surface-2);
                border-radius: var(--r-m); }

/* --- La collection en vue liste est une GRILLE À COLONNES -----------------
   `flex-wrap` faisait replier la ligne dès qu'elle manquait de place : mesuré
   chez roms, 809px nécessaires pour 688 disponibles, donc une ligne de 124px
   au lieu de 60, de hauteur variable, et des badges d'état sur deux abscisses
   différentes selon qu'un texte les accompagnait.

   Une grille aligne les colonnes d'une ligne à l'autre ; `--colonnes` est posé
   par la macro et partagé avec l'en-tête, comme une seule valeur et non une
   convention à respecter des deux côtés. */
/* MESURÉ CHEZ roms : 69px pour une ligne texte + un bouton de 44px, alors que
   `--h-ligne` vaut 56px. La règle `> *` ci-dessus, écrite pour l'ancien
   balisage `.carte` et toujours nécessaire à `restos` (`.carte` posé
   directement dans `.collection`, voir plus haut), pose `padding: var(--e-3)
   0` sur TOUT enfant direct — dont `.ligne`, qui est aussi un enfant direct.
   La ligne empilait donc 24px de padding hérité PAR-DESSUS son propre
   `min-height`, d'où 44 (bouton) + 24 (padding hérité) = 68-69px.

   `padding: 0 var(--e-3)` neutralise le vertical hérité (la hauteur doit
   venir de `--h-ligne` et du contenu, pas d'un padding en plus) tout en
   gardant l'horizontal : une ligne posée flush contre le bord d'un conteneur
   a besoin de cette respiration latérale, que `> *` fournissait aussi.
   NE PAS toucher à `> *` pour corriger ce point : vérifié balisage en main
   dans les six apps qui embarquent le socle (fitness, orders, movies-mcp,
   restos, wander, surpriseme). `restos` rend `<div class="carte …">` en
   enfant direct de `.collection[data-view="list"]` (`web/templates/
   _liste.html`) — c'est lui qui dépend de `> *` aujourd'hui. `wander` n'a
   qu'une vue grille (`.carte` aussi, via `carte_visite()`). `fitness`,
   `orders`, `movies-mcp` et `surpriseme` ne rendent pas encore `.collection`
   du tout. Seul `roms`, en cours de migration, rend `.ligne` — et lui seul
   doit sortir de la portée de `> *`. Changer `> *` casserait `restos` (et
   `wander` le jour où il ouvre sa vue liste), sans bénéfice pour personne
   d'autre. */
.collection[data-view="list"] .ligne {
  display: grid; grid-template-columns: var(--colonnes);
  align-items: center; gap: 0 var(--e-3);
  min-height: var(--h-ligne);
  padding: 0 var(--e-3);
  border-bottom: 1px solid var(--line);
}
.collection[data-view="list"] .ligne:last-child { border-bottom: 0; }
.collection[data-view="list"] .ligne:hover { background: var(--surface-2); }

/* --- La vue grille habille sa ligne en carte -------------------------------
   `.ligne` n'avait de style QU'en vue liste : en grille, elle ne recevait ni
   fond, ni bordure, ni radius, ni padding — seule l'image portait un radius
   (haut uniquement, voir `.collection[data-view="grid"] img` plus bas), posé
   sur une boîte qui, elle, restait nue. Une tuile de grille EST une carte ;
   reprend DÉLIBÉRÉMENT les valeurs de `.carte` (même fond, même bordure,
   même radius, même ombre) plutôt que d'en inventer une variante — deux
   cartes qui se ressemblent partout sauf ici seraient un défaut, pas un
   choix. Colonne, pas grille : `.ligne` porte `--colonnes` pour la vue
   liste, une valeur que la vue grille n'a jamais posée.

   FLUX DE BLOC, PAS UNE COLONNE FLEX — et c'est ce qui distingue une carte
   d'une ligne. Une ligne de liste est un jeu de colonnes ALIGNÉES avec ses
   voisines ; une carte est un bloc de texte sous une image, qui n'a rien à
   aligner avec personne. En colonne flex, tout enfant est « blockifié » :
   les trois cellules de méta d'une carte devenaient trois lignes pleine
   largeur d'un seul mot (mesuré chez roms : 256×23 chacune, « n64 », « 8.0
   Mo », « minerva », 69px pour trois mots). En flux de bloc, la règle
   `.cellule.doux` plus bas peut les remettre sur une seule ligne — un
   `display: inline` sur un enfant de conteneur flex serait ignoré. */
.collection[data-view="grid"] .ligne {
  display: block;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r-l);
  padding: var(--e-4);
  box-shadow: var(--shadow-1);
}

/* LES TROIS PROPRIÉTÉS ENSEMBLE, sur chaque cellule. `min-width: 0` sans
   lequel une cellule de grille refuse de rétrécir sous son contenu — et
   l'ellipse ne se déclenche jamais. */
.cellule { min-width: 0; white-space: nowrap; text-overflow: ellipsis; overflow: hidden; }
.cellule.media { display: flex; align-items: center; }

/* La troncature d'une cellule est juste EN LISTE, pas en grille — et c'est la
   même règle des deux côtés qui change de sens. En liste, les cellules sont
   des colonnes alignées à largeur contrainte : une ligne qui grandit casse
   l'alignement de toutes les autres, l'ellipse est ce qui protège la grille
   à colonnes (§4.4 de la spec). En grille, une tuile de 260px a de la place
   sous le titre et rien à aligner avec sa voisine : la même ellipse n'y
   protège rien, elle coupe un titre qui tiendrait en deux lignes. */
.collection[data-view="grid"] .cellule {
  white-space: normal; overflow: visible; text-overflow: clip;
}

/* EN GRILLE, LES MÉTA SONT UNE PHRASE ; EN LISTE, DES COLONNES. Mêmes
   cellules, même balisage — c'est la disposition qui change de sens, comme
   pour la troncature juste au-dessus. En liste, chaque méta occupe SA colonne
   et s'aligne verticalement avec celle des lignes voisines : c'est ce qui
   permet de comparer des tailles d'un coup d'œil. En grille, il n'y a rien à
   comparer avec la carte d'à côté, et trois cellules empilées donnaient trois
   lignes d'un seul mot (« n64 », « 8.0 Mo », « minerva ») : 69px qui se lisent
   comme une sortie de débogage plutôt que comme la description d'un objet.
   Sur une ligne, 23px et une phrase.

   `display: inline` et non `inline-block` : le retour à la ligne doit pouvoir
   tomber ENTRE deux méta quand la carte est étroite, pas forcer un
   débordement. Ne fonctionne que parce que la carte est en flux de bloc
   (voir `.collection[data-view="grid"] .ligne`) — un conteneur flex
   blockifierait ses enfants et ignorerait cet `inline`.

   Le séparateur est un `::before` sur les cellules SUIVANTES, jamais un
   `::after` sur toutes : un `::after` laisserait un point médian orphelin
   après la dernière méta. `.separateur` du socle est un ÉLÉMENT posé par une
   app dans son balisage ; ici l'app ne pose rien, la macro rend des cellules
   et c'est la feuille qui les ponctue. */
.collection[data-view="grid"] .cellule.doux { display: inline; }
.collection[data-view="grid"] .cellule.doux ~ .cellule.doux::before {
  content: " · "; color: var(--tx-3);
}

/* Le titre porte deux niveaux : le nom de l'objet, puis son identifiant
   technique. Ne rien jeter, ordonner.
   Scopée à `.collection .sous-titre` (pas globale) : la prochaine app qui
   écrit `class="sous-titre"` hors d'une collection ne doit pas hériter d'une
   taille et d'une couleur qu'elle n'a jamais demandées. */
.collection .sous-titre { display: block; font-size: var(--t-xs); color: var(--tx-3); }

/* LE CONTENEUR DE `groupe_actions` (socle/_actions.html), posé par la macro
   dans la dernière `.cellule` d'une ligne, et jamais défini ailleurs avant
   `conformite.classes_inventees` (voir `tests/test_conformite.py`) : la
   macro a été fusionnée sans son pendant CSS, et rien ne le voyait puisque
   aucun test ne compare les classes posées à celles qui existent.

   COLLISION DE NOM CONNUE, PAS UNE COÏNCIDENCE INOFFENSIVE, ET C'EST `orders`
   QUI A TIRÉ LE COUP, PAS `roms`. `roms` (pas encore migré sur
   `groupe_actions`) pose SES PROPRES `.actions` dans `app/web/static/app.css`
   — collision réelle mais restée LATENTE : `roms/base.html` charge
   `workbench.css` avant `app.css`, et à spécificité égale la déclaration
   chargée en dernier gagne, donc `roms` a toujours gagné par ordre de
   chargement, sans jamais dépendre de la forme du sélecteur.

   `orders` n'a pas cette protection, et c'est chez lui que le défaut s'est
   produit pour de vrai : `orders/app/web/templates/_rows.html` rend
   `<td class="actions">`, et `orders/app/web/static/app.css` (commentaire
   « Colonne d'actions », lignes 98-101) ne pose QUE `text-align` et
   `position: sticky` sur `.tableau td.actions` — rien n'y fixe `display`.
   Une règle `.actions { display: flex; … }` bare, posée par le socle,
   s'applique donc SANS RIEN POUR LA CONTESTER : la cellule perd
   `display: table-cell`, sort du calcul de largeur du tableau, et déborde —
   c'est l'incident documenté dans `orders/app/web/static/app.css` lui-même
   (bouton « Reçu » coupé, menu ⋯ invisible).

   Le correctif porte sur la BALISE, pas sur le nom : `groupe_actions` rend
   toujours un `<div class="actions">` (jamais un `<td>` ni un `<form>`), donc
   qualifier le sélecteur en `div.actions` ne retire rien au socle. Ça met en
   revanche `orders` (`<td class="actions">`) et `roms` (`<form
   class="actions">`, dans `_actions.html`) hors de portée du sélecteur, quel
   que soit l'ordre de chargement des feuilles — la protection ne tient plus à
   un accident, elle tient au balisage. NE PAS renommer la classe pour éviter
   la collision : `roms` migrera un jour vers `groupe_actions` et retirera ses
   propres règles, et un renommage défensif aujourd'hui laisserait au socle un
   nom définitivement moins bon que celui qui a sa place. */
div.actions { display: flex; align-items: center; gap: var(--e-2); }

/* Le <form> POST d'une action (voir la macro `action`) n'a rien à ajouter à
   la mise en page : sans `display: contents`, le form lui-même deviendrait
   un flex-item de `.actions` — avec l'étirement vertical par défaut d'un
   bloc — et son <button> ne s'alignerait plus avec les <a> voisins. Plus
   largement, le form ne doit JAMAIS devenir une boîte à l'intérieur d'une
   ligne de collection : `.ligne` est une grille dont `.cellule` est
   l'élément direct (`--colonnes`, voir plus haut) — une boîte de plus entre
   `.cellule` et le contenu réel casserait l'alignement des colonnes que
   toute la grille est construite pour garantir. `display: contents` retire
   le `<form>` de la mise en page sans retirer son `<button>` : c'est ce qui
   permet à un geste POST de s'aligner exactement comme un `<a>` voisin. */
.en-ligne { display: contents; }

/* L'ÉTAT VIDE TRAVAILLE : une carte au gabarit des autres, avec une action.
   `.vide` reste un bloc centré pleine largeur, correct sous un formulaire et
   difforme dans une grille. */
.collection-vide {
  display: flex; flex-direction: column; align-items: center;
  gap: var(--e-3); text-align: center; padding: var(--e-7) var(--e-4);
  color: var(--tx-2);
}
.collection-vide .ico { font-size: 2rem; opacity: .5; }

/* --- Bascule segmentée (thème, vue) --------------------------------------- */
/* Un groupe de boutons dont un seul est actif. Chacun est un <button> dans un
   <form>, donc la bascule fonctionne sans JavaScript. */
.segmente {
  display: inline-flex; align-items: center; flex: none;
  padding: 3px; gap: 2px;
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: var(--r-m);
}
.segmente button {
  /* `inline-flex` et non le défaut : sans lui, une icône posée à côté du
     libellé se colle au texte et pend sous la ligne de base. */
  display: inline-flex; align-items: center; justify-content: center;
  gap: var(--e-2);
  min-height: 38px; padding: 0 var(--e-3);
  font: inherit; font-size: var(--t-s); font-weight: 600;
  color: var(--tx-2); background: none; border: 0;
  border-radius: calc(var(--r-m) - 3px);
  cursor: pointer;
  transition: background var(--t), color var(--t);
}
.segmente button:hover { color: var(--tx); }
.segmente button[aria-pressed="true"] {
  background: var(--surface-hi); color: var(--tx);
  box-shadow: var(--shadow-1);
}
/* Sur téléphone la cible remonte à 44px : le segmenté est petit par nature,
   c'est justement là qu'on rate le bouton. */
@media (max-width: 640px) {
  .segmente button { min-height: 44px; }
}
/* Variante à icônes seules — c'est celle du THÈME (voir « Bascule de thème
   segmentée » dans le README). Trois libellés (« Système », « Clair »,
   « Sombre ») prennent la largeur d'un menu entier pour dire ce que trois
   glyphes disent sans être lus, et le socle a déjà ces glyphes : le bouton
   cyclant montre le moniteur, le soleil et la lune. Les deux formes du même
   réglage ne peuvent pas parler deux langues différentes selon l'app.

   Le carré est ici, pas dans le HTML : sans lui, `padding: 0 var(--e-3)` sur
   une icône de 18px donne trois rectangles étroits, plus hauts que larges.
   Une classe à part plutôt qu'un changement de `.segmente button` : la
   taille du panneau de l'assistant (« coin » / « plein écran ») reste
   légitimement en texte, et un segmenté à libellés n'a pas à devenir carré. */
.segmente.icones button { min-width: 38px; padding: 0; }
@media (max-width: 640px) {
  .segmente.icones button { min-width: 44px; }
}

/* --- Bouton de thème cyclant ---------------------------------------------
   Le bouton porte les TROIS icônes, rendues par le serveur ; on n'en montre
   qu'une. C'est ce qui permet à `workbench.js` de se contenter de changer
   `data-etat` — il ne fabrique aucun élément, conformément à la règle du
   dépôt, et le rendu sans JavaScript est déjà le bon.                      */
.bascule-theme {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 38px; min-height: 38px; padding: 0;
  color: var(--tx-2); background: var(--surface-2);
  border: 1px solid var(--line); border-radius: var(--r-m);
  cursor: pointer;
  transition: color var(--t), background var(--t);
}
.bascule-theme:hover { color: var(--tx); background: var(--surface-hi); }
/* Ni taille ni couleur ici : `icons.icon()` pose déjà `stroke="currentColor"`
   et les dimensions. Le seul rôle de ce bloc est de n'en montrer qu'une. */
.bascule-theme svg { display: none; }
.bascule-theme[data-etat="auto"]  .ico-auto,
.bascule-theme[data-etat="light"] .ico-light,
.bascule-theme[data-etat="dark"]  .ico-dark { display: block; }
/* Même bouton, même mécanique, pour la densité (deux états au lieu de
   trois) : `.bascule-theme` n'a jamais été spécifique au thème, seul son nom
   l'est resté — voir `js/workbench.js`, « Thème, et tout autre réglage
   cyclant sur le même patron ». */
.bascule-theme[data-etat="normal"]  .ico-normal,
.bascule-theme[data-etat="compact"] .ico-compact { display: block; }

@media (max-width: 640px) {
  .bascule-theme { min-width: 44px; min-height: 44px; }
}

/* --- Barre de recherche --------------------------------------------------- */
/* Ces propriétés flex n'ont d'effet QUE si `.recherche` est elle-même un
   enfant flex : c'est le cas dans `.bandeau .dedans` (ligne) et dans
   `surpriseme/.entete-recherche` (ligne aussi). Ailleurs — par exemple dans
   `<form class="pile">` de roms, qui est une COLONNE — une propriété flex
   sur un item flex agit bien, mais pas forcément comme sur l'autre axe :
   voir plus bas pourquoi `flex-grow` et `min-width` sont sans risque là où
   `flex-basis` ne l'était pas.

   `flex: 1 1 240px` n'est PAS ici : posée sur `.recherche` elle-même,
   `flex-basis` devient une HAUTEUR dès que le parent est en colonne —
   mesuré à 240px de haut pour un champ de 42 dans le `<form class="pile">`
   de roms, soit 423px de page avant le premier résultat (revue du
   2026-08-16). Un premier correctif avait déplacé toute la règle sur
   `.bandeau .recherche`, ce qui réglait roms mais cassait `surpriseme` :
   cette app pose `.recherche` dans un flex ROW hors bandeau
   (`.entete-recherche`, voir `surpriseme/app/static/app.css`) et documente
   qu'elle attend cette croissance du socle — sans elle le champ retombe à
   `flex: 0 1 auto` et se tasse à sa largeur intrinsèque, laissant du vide
   à côté de `.filtres`.

   `flex-grow: 1` + `flex-shrink: 1` + `min-width: 240px` remplacent
   `flex: 1 1 240px` et marchent dans les DEUX sens :
   - en ligne (bandeau, `.entete-recherche` de surpriseme) : `flex-grow`
     prend la place libre sur l'axe horizontal, `min-width` empêche de
     descendre sous 240px — même résultat visuel que l'ancien `flex-basis`.
   - en colonne (`.pile`) : `flex-grow` distribue de l'espace libre sur
     l'axe VERTICAL, et `.pile` n'a pas de hauteur fixée — il n'y a rien à
     distribuer, l'élément garde sa hauteur naturelle. `min-width` ne
     contraint QUE la largeur, jamais la hauteur : le bug des 240px de haut
     ne peut plus revenir, quelle que soit la direction du parent. */
/* CE `min-width` SUPPOSE UN CONTENEUR D'AU MOINS 240px + son rembourrage.
   Vrai partout aujourd'hui : dans `.contenu` sur un téléphone de 320px il
   reste 288px, et dans le bandeau la recherche occupe la ligne entière sous
   640px. Faux le jour où on la posera dans une carte étroite, une colonne de
   deux, une modale — et `body { overflow-x: hidden }` avalerait le
   débordement SANS RIEN MONTRER, ce qui est pire qu'une barre de défilement.
   Une app dans ce cas pose `min-width: 0` sur son propre sélecteur, comme
   `restos` le fait déjà pour d'autres raisons. */
.recherche {
  position: relative; display: flex; align-items: center;
  flex-grow: 1; flex-shrink: 1; min-width: 240px;
}
.recherche input[type=search] {
  padding-left: calc(var(--e-4) + 1.1rem);
  padding-right: calc(var(--e-4) + 1.5rem);
  /* Le rond natif de Safari efface le champ sans prévenir le formulaire. */
  -webkit-appearance: none; appearance: none;
}
.recherche .ico {
  position: absolute; left: var(--e-3);
  color: var(--tx-3); pointer-events: none; font-size: 1rem;
}
/* Le badge `/` qui rappelle le raccourci clavier : même allure que
   `.palette .raccourci` (c'est le même signifiant — « voici la touche qui
   déclenche ceci »), mais collé au bord DROIT du champ plutôt qu'à la fin
   d'une ligne de résultat, donc `position: absolute` et non `margin-left:
   auto`. Optionnel : une app sans raccourci clavier n'a simplement pas ce
   `<span>` dans son balisage, le champ garde son `padding-right` de réserve. */
.recherche .raccourci {
  position: absolute; right: var(--e-3);
  font-family: var(--mono); font-size: var(--t-xs);
  color: var(--tx-3); pointer-events: none;
  padding: 2px var(--e-2);
  border: 1px solid var(--line); border-radius: var(--r-s);
}
/* L'indicateur d'attente ne remplace pas la liste : la remplacer par un
   chargeur fait clignoter la page à chaque frappe. On l'affiche à côté. */
.recherche[data-busy] .ico { opacity: .4; }

/* --- Pictogrammes --------------------------------------------------------- */
/* Rendus par `workbench.icons.icon()`. Ils héritent de la couleur du texte —
   donc ils suivent le thème et l'état du bouton sans une règle de plus. */
.ico { flex: 0 0 auto; vertical-align: -.15em; }
/* Dans un bouton, l'icône se cale sur le libellé sans l'écarter : `.bouton`
   pose déjà le `gap`. Sans ce `margin: 0`, une icône ajoutée à un bouton
   existant décale tous les autres de la rangée. */
.bouton .ico { margin: 0; }

/* --- Note en étoiles ------------------------------------------------------ */
/* Rendue par `workbench.icons.stars()`. Le tracé est le même pour les trois
   états : seul le remplissage change. */
.etoiles { display: inline-flex; align-items: center; gap: var(--e-2); }
.etoiles .glyphes { display: inline-flex; gap: 1px; }
.etoiles .etoile {
  position: relative; display: inline-flex;
  width: 16px; height: 16px;
  color: var(--tx-3);
}
.etoiles .etoile svg { position: absolute; inset: 0; }
.etoiles .etoile.pleine { color: var(--accent); }
.etoiles .etoile.pleine svg { fill: currentColor; }
/* La demie porte deux tracés : le premier reste vide, le second est peint puis
   rogné à la moitié gauche. `inset()` coupe à droite, pas au milieu du trait —
   la pointe de l'étoile reste nette. */
.etoiles .etoile.demie svg:last-child {
  color: var(--accent); fill: currentColor;
  clip-path: inset(0 50% 0 0);
}
.etoiles .valeur {
  font-size: var(--t-s); font-weight: 600; color: var(--tx-2);
  font-variant-numeric: tabular-nums;
}

/* --- Pagination ----------------------------------------------------------- */
.pagination {
  display: flex; align-items: center; justify-content: center;
  gap: var(--e-3); padding: var(--e-5) 0;
}
.pagination .position { color: var(--tx-3); font-size: var(--t-s); }

/* --- État vide ------------------------------------------------------------ */
/* Une liste vide sans explication ressemble à une panne. */
.vide {
  text-align: center; padding: var(--e-7) var(--e-4);
  color: var(--tx-2);
}
.vide .ico { font-size: 2rem; opacity: .5; margin-bottom: var(--e-3); }
.vide p { margin-bottom: var(--e-4); }

/* --- Palette de commandes ------------------------------------------------- */
/* Le même composant que les raccourcis clavier, pas un second : la palette
   liste ce que les raccourcis déclenchent, et c'est la seule façon qu'ont les
   raccourcis d'être découvrables. */
.palette {
  position: fixed; inset: 0; z-index: 200;
  display: flex; align-items: flex-start; justify-content: center;
  padding: max(10vh, var(--e-6)) var(--e-4) var(--e-4);
  background: color-mix(in srgb, var(--bg) 60%, transparent);
  backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
}
.palette .boite {
  width: min(100%, 560px);
  background: var(--surface-hi);
  border: 1px solid var(--line-strong);
  border-radius: var(--r-l);
  box-shadow: var(--shadow-3);
  overflow: hidden;
}
.palette input {
  border: 0; border-radius: 0;
  border-bottom: 1px solid var(--line);
  background: none; padding: var(--e-4);
}
.palette input:focus { box-shadow: none; border-color: var(--line); }
.palette .resultats { max-height: min(50vh, 360px); overflow-y: auto; }
.palette .resultats button {
  display: flex; align-items: center; gap: var(--e-3);
  width: 100%; min-height: 44px; padding: var(--e-2) var(--e-4);
  font: inherit; text-align: left;
  color: var(--tx); background: none; border: 0; cursor: pointer;
}
.palette .resultats button:hover,
.palette .resultats button[aria-selected="true"] { background: var(--surface-2); }
.palette .raccourci {
  margin-left: auto;
  font-family: var(--mono); font-size: var(--t-xs);
  color: var(--tx-3);
  padding: 2px var(--e-2);
  border: 1px solid var(--line); border-radius: var(--r-s);
}

/* --- Panneau latéral (assistant) ------------------------------------------ */
/* Deux tailles, et la préférence vit dans un cookie lu par le serveur : sans
   lui, le panneau s'ouvre en coin puis saute en plein écran une fois le script
   passé. */
/* La pastille qui OUVRE le panneau. Le README la nommait depuis le lot
   « bandeau et admin » sans que le socle ne la porte : `commandes` ouvrait
   donc son assistant par un bouton de bandeau, seule app du parc à le faire,
   et le bandeau y perdait sa neutralité — il porte la marque, la navigation
   et les réglages, pas les outils de la page.

   Elle vit dans le coin bas droit, là où le panneau « coin » s'ouvre : le
   bouton devient le panneau, au lieu de le convoquer depuis l'autre bout de
   l'écran. `z-index` sous celui du panneau — au-dessus, elle resterait posée
   sur le pied de saisie qu'elle vient d'ouvrir. Le script la masque à
   l'ouverture (voir `installPanel`), il ne la rend pas : sans JavaScript,
   c'est tout l'assistant qui est absent, pas seulement sa pastille. */
.pastille-assistant {
  position: fixed; z-index: 140;
  right: var(--e-4);
  bottom: calc(var(--e-4) + env(safe-area-inset-bottom, 0px));
  width: 52px; height: 52px;
  display: flex; align-items: center; justify-content: center;
  border: 0; border-radius: 999px;
  /* Replis obligatoires : le socle ne déclare AUCUN accent — c'est ce qui
     distingue une app des autres. Sans eux, une app qui n'en pose pas obtient
     une pastille transparente portant une icône invisible. */
  background: var(--accent, var(--tx)); color: var(--accent-tx, var(--bg));
  box-shadow: var(--shadow-3);
  cursor: pointer;
  transition: transform var(--t), filter var(--t);
}
.pastille-assistant:hover { filter: brightness(1.08); transform: translateY(-1px); }
.pastille-assistant:focus-visible {
  outline: 2px solid var(--accent, var(--tx)); outline-offset: 3px;
}
/* Deux couches dans le rond : le robot devant dit qu'on parle à une machine,
   l'icône de l'app derrière dit À LAQUELLE. Le filigrane est le prix à payer
   pour que la superposition reste lisible — deux dessins nets dans 52px se
   cannibalisent. Il REMPLIT le rond (46 dans 52) et reste pâle : c'est ce qui
   le fait lire comme un fond plutôt que comme une seconde icône. Le premier
   essai le posait à 34px — vu dans un navigateur, l'icône disparaissait
   entièrement derrière le robot et ne laissait dépasser que ses bords, qui se
   lisent comme de la saleté. `position: fixed` sur le bouton lui sert
   d'ancre. */
.pastille-assistant .fond {
  position: absolute; width: 46px; height: 46px;
  opacity: 0.35; pointer-events: none;
}
/* L'emoji, seule entorse du parc à « SVG stroke + currentColor » (voir
   `icons.py`) : il porte ses propres couleurs, donc il ne suit ni le thème ni
   l'accent, et son dessin appartient à l'OS du visiteur. Assumé — un robot au
   trait de 1.75 dans un rond de 52px n'a jamais que deux yeux et un carré.
   La pile de familles n'est pas décorative : sans elle, un Linux sans police
   emoji rend un carré vide à la place du bouton. */
.pastille-assistant .robot {
  position: relative; /* au-dessus du filigrane, sans z-index à arbitrer */
  font-size: 22px; line-height: 1;
  font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji",
               sans-serif;
}

/* CE `z-index` PERD CONTRE UNE CARTE LEAFLET, ET DEUX APPS L'ONT PAYÉ.
   Leaflet empile ses couches à `z-index: 400` et ses contrôles à `800` : très
   au-dessus du panneau (150) et de la pastille (140). Une app qui affiche une
   carte voit donc son assistant s'ouvrir DERRIÈRE les tuiles — constaté à
   l'écran chez `wander`, et chez `restos` par le panneau du menu de compte qui
   passait sous la carte.

   Le correctif n'est PAS ici : monter le panneau à 900 le mettrait au-dessus de
   n'importe quoi et déplacerait l'arbitrage au lieu de le résoudre. Il est sur
   le conteneur de carte de l'app — `isolation: isolate`, qui enferme tous ces
   400 dans leur propre contexte d'empilement, où ils valent 0 face au panneau.
   Les deux apps ont trouvé cette solution séparément, sans se lire.

   Écrit ici parce que c'est ici qu'on vient chercher pourquoi un panneau passe
   dessous. Deux usages ne font pas monter un correctif au socle (règle du
   troisième usage), et celui-ci n'y monterait pas de toute façon : `#carte` est
   un identifiant d'app. Ce qui monte, c'est l'avertissement. */
.panneau {
  position: fixed; z-index: 150;
  display: flex; flex-direction: column;
  background: var(--surface);
  border: 1px solid var(--line-strong);
  box-shadow: var(--shadow-3);
}
.panneau[data-size="corner"] {
  right: var(--e-4); bottom: var(--e-4);
  width: 400px; max-height: min(70vh, 640px);
  border-radius: var(--r-l);
}
.panneau[data-size="full"] {
  inset: 0; border-radius: 0; border: 0;
}
.panneau .entete {
  display: flex; align-items: center; gap: var(--e-2);
  padding: var(--e-3) var(--e-4);
  border-bottom: 1px solid var(--line);
}
.panneau .entete .titre { font-weight: 640; margin-right: auto; }
.panneau .fil { flex: 1; overflow-y: auto; padding: var(--e-4); }
/* Le pied porte une saisie et son bouton : ils sont EN LIGNE. Sans ce flex,
   chaque app le redécouvre en voyant son champ, son trombone et son bouton
   « Envoyer » empilés les uns sous les autres — constaté en ouvrant le panneau
   de `commandes`, la première app à l'exercer dans un navigateur.
   Le champ prend la place restante ; les boutons gardent la leur. */
.panneau .pied {
  display: flex;
  align-items: center;
  gap: var(--e-2);
  border-top: 1px solid var(--line);
  padding: var(--e-3) var(--e-4);
  padding-bottom: calc(var(--e-3) + env(safe-area-inset-bottom, 0px));
}
.panneau .pied input[type="text"],
.panneau .pied textarea { flex: 1; min-width: 0; }
/* Sous 640px il est DÉJÀ plein écran : un panneau de 400px déborde d'un
   téléphone de 390. La commande de taille n'y est pas rendue — elle ne
   voudrait rien dire. */
@media (max-width: 640px) {
  .panneau, .panneau[data-size="corner"] {
    inset: 0; width: auto; max-height: none;
    border-radius: 0; border: 0;
  }
  .panneau .taille { display: none; }
}

/* --- Bulles de conversation ----------------------------------------------- */
.bulle { padding: var(--e-3); border-radius: var(--r-m); margin-bottom: var(--e-3); }
.bulle.moi {
  background: var(--accent-soft); border: 1px solid var(--accent-line);
  margin-left: var(--e-6);
}
.bulle.lui { background: var(--surface-2); border: 1px solid var(--line); }
/* L'outil en cours d'exécution : discret, il n'est pas la réponse. */
.bulle.outil {
  font-family: var(--mono); font-size: var(--t-xs);
  color: var(--tx-3); background: none; border: 0; padding: var(--e-1) 0;
}
/* Le tour qui s'est mal terminé. La famille était incomplète : un flux SSE
   finit par `done` OU par `error`, et sans bulle pour le second cas chaque app
   se fabrique la sienne — c'est-à-dire quatre rouges différents.
   Relevé en branchant `commandes`, première app à exercer le panneau. */
.bulle.erreur {
  background: var(--err-bg); color: var(--err-tx);
  border: 1px solid var(--err-line);
}

/* --- Onboarding ----------------------------------------------------------- */
/* Une seule carte, au centre, la première fois. Pas un carrousel de cinq
   écrans : ce qui n'est pas lu à la première carte ne le sera pas à la
   cinquième. */
.accueil-premiere-fois {
  display: flex; align-items: center; justify-content: center;
  min-height: 100dvh; padding: var(--e-4);
}
.accueil-premiere-fois .carte-flottante { width: min(100%, 480px); }
.accueil-premiere-fois h1 { margin-bottom: var(--e-3); }
.accueil-premiere-fois .etapes { margin: var(--e-4) 0; }
.accueil-premiere-fois .etapes li {
  display: flex; gap: var(--e-3); padding: var(--e-2) 0;
  color: var(--tx-2); list-style: none;
}
.accueil-premiere-fois .etapes .ico { color: var(--accent); flex: none; }

/* --- Menu déroulant -------------------------------------------------------
   `<details>/<summary>` natif, pas un composant JavaScript : il s'ouvre et se
   ferme sans script, au clavier comme au doigt. `workbench.js` n'ajoute que le
   confort — fermeture au clic extérieur et à Échap.

   Sert d'abord au bandeau : au-delà d'une poignée de liens, une barre étalée
   sur toute la largeur n'est plus une navigation, c'est une liste.

   LE SEUIL EST À 1024px, et il est choisi sur des appareils réels, pas sur un
   nombre rond. Largeurs CSS des iPad en portrait :

       mini 8.3      744        Air / 10.9    820
       Pro 11        834        Pro 12.9     1024

   Un seuil à 900px laissait donc l'iPad Pro 12.9 en portrait avec la barre à
   plat — précisément le symptôme qu'on corrige. En paysage (1133 à 1366px) la
   barre à plat tient sans se serrer, et c'est bien ce qu'on veut : le repli
   n'est pas une punition pour les grands écrans. */
.menu { position: relative; }

.menu > summary {
  display: inline-flex; align-items: center; justify-content: center;
  gap: var(--e-2);
  min-height: 44px; min-width: 44px; padding: 0 var(--e-3);
  border: 1px solid var(--line-controle);
  border-radius: var(--r-m);
  background: var(--surface); color: var(--tx);
  font-weight: 600; cursor: pointer;
  list-style: none;   /* retire le triangle Firefox */
  transition: background var(--t);
}
/* Safari a besoin du pseudo-élément, `list-style` ne lui suffit pas. */
.menu > summary::-webkit-details-marker { display: none; }
.menu > summary::marker { content: ""; }

/* PIÈGE VÉRIFIÉ — une app qui style tous ses `<details>` (chevron dessiné,
   couleur atténuée pour les replis) touche AUSSI ce menu, qui en est un. Le
   symptôme : un chevron en trop à côté du ☰, et un bouton grisé. La règle de
   l'app doit exclure `.menu` — le socle ne se bat pas en spécificité contre
   les feuilles qu'il ne connaît pas. Vu sur wishlist, sur iPad. */
.menu > summary:hover { background: var(--surface-2); }

/* Un menu qui porte un libellé (« Yann ») a besoin de dire qu'il s'ouvre — un
   ☰ le dit déjà par lui-même, pas un prénom. Le chevron pivote à l'ouverture :
   c'est le seul retour visuel quand le panneau sort sous le doigt. */
.menu.avec-fleche > summary::after {
  content: "";
  width: .4rem; height: .4rem; flex: none;
  margin-left: var(--e-1);
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: rotate(45deg) translate(-2px, -2px);
  opacity: .6;
  transition: transform var(--t);
}
.menu.avec-fleche[open] > summary::after {
  transform: rotate(-135deg) translate(-2px, -2px);
}
.menu[open] > summary { background: var(--surface-2); }

.menu > .panneau-menu {
  position: absolute; right: 0; top: calc(100% + var(--e-2)); z-index: 60;
  min-width: max(200px, 100%);
  padding: var(--e-2);
  background: var(--surface-hi);
  border: 1px solid var(--line-strong);
  border-radius: var(--r-l);
  box-shadow: var(--shadow-2);
  display: flex; flex-direction: column; gap: var(--e-1);
}
/* Ancré à gauche quand le menu est à gauche de son conteneur. */
.menu.a-gauche > .panneau-menu { right: auto; left: 0; }

.menu > .panneau-menu a,
.menu > .panneau-menu button {
  display: flex; align-items: center; gap: var(--e-2);
  min-height: 44px; padding: 0 var(--e-3);
  border: 0; border-radius: var(--r-m);
  background: none; color: var(--tx);
  font: inherit; font-weight: 500; text-align: left;
  cursor: pointer; white-space: nowrap;
}
.menu > .panneau-menu a:hover,
.menu > .panneau-menu button:hover { background: var(--surface-2); }
.menu > .panneau-menu a[aria-current] { font-weight: 700; color: var(--accent); }
.menu > .panneau-menu hr {
  border: 0; border-top: 1px solid var(--line); margin: var(--e-1) 0;
}
/* Un intertitre dans le menu : « Thème », « Affichage ». */
.menu > .panneau-menu .titre-groupe {
  padding: var(--e-1) var(--e-3) 0;
  font-size: var(--t-xs); font-weight: 700; letter-spacing: .04em;
  text-transform: uppercase; color: var(--tx-3);
}
/* Un segmenté tient dans le menu, mais sur toute la largeur. */
.menu > .panneau-menu .segmente { display: flex; }
.menu > .panneau-menu .segmente button { flex: 1; justify-content: center; }

/* --- Le repli ------------------------------------------------------------
   Un `<details>` ordinaire garde le marqueur ▶ du navigateur : le socle ne
   stylisait `summary` que sous `.menu`, donc chaque app se débrouillait —
   ou ne se débrouillait pas.

   UNE CLASSE, PAS `details` NU. Styliser tout `<details>` du document
   imposerait une allure à un élément dont les usages diffèrent d'une app à
   l'autre. Une classe se demande ; un sélecteur d'élément s'impose.

   Le repli reste NATIF : le navigateur tient `aria-expanded` sur le
   `<summary>` tout seul, et le bloc s'ouvre sans script. Le dupliquer à la
   main créerait une seconde source de vérité, celle qui se désynchronise
   toujours en premier. */
.repli { border: 1px solid var(--line); border-radius: var(--r-m); background: var(--surface); }
.repli > summary {
  display: flex; align-items: center; gap: var(--e-2);
  min-height: 44px; padding: 0 var(--e-4);
  font-size: var(--t-m); color: var(--tx-2);
  cursor: pointer; list-style: none; user-select: none;
}
.repli > summary::-webkit-details-marker { display: none; }
.repli > summary::marker { content: ""; }
.repli > summary:hover { color: var(--tx); }
.repli > summary:focus-visible { outline: 2px solid var(--accent, var(--tx)); outline-offset: -2px; }
/* Le chevron est dessiné ici et tourne en CSS : rien à synchroniser en
   JavaScript, donc rien à désynchroniser. */
.repli > summary::before {
  content: ""; width: 7px; height: 7px; flex: 0 0 auto;
  border-right: 2px solid currentColor; border-bottom: 2px solid currentColor;
  transform: rotate(45deg) translate(-2px, -2px);
  transition: transform var(--t, .15s);
}
.repli[open] > summary::before { transform: rotate(-135deg) translate(-2px, -2px); }
.repli[open] > summary { border-bottom: 1px solid var(--line); }
.repli-corps { padding: var(--e-4); }

/* --- L'action principale -------------------------------------------------
   Une page a UNE action mise en avant. Cette classe est un EMPLACEMENT, pas
   un bouton : elle se pose sur le conteneur, et le bouton garde `.bouton`.

   Elle existe parce que « Démarrer la séance » de fitness touchait la carte
   suivante : sans rythme déclaré, l'espacement d'une action dépend de qui
   l'a écrite. */
.action-principale { margin: var(--e-section) 0; }
.action-principale > .bouton { width: 100%; }
@media (min-width: 641px) {
  .action-principale > .bouton { width: auto; min-width: 220px; }
}

/* --- Bandeau : à plat, ou replié ------------------------------------------
   Les deux formes coexistent dans le HTML rendu ; seule l'une est visible.
   C'est ce qui permet au repli de fonctionner sans JavaScript et sans que le
   serveur ait à connaître la largeur de l'écran. */
/* `.si-replie` — dans un menu, ce qui ne sert QUE lorsque la barre est
   repliée : les liens de navigation, qui sont déjà visibles à plat au-dessus
   du seuil. Les répéter dans le menu à toute largeur donnerait deux chemins
   vers la même page à trois centimètres l'un de l'autre. */
/* Sélecteur aligné sur `.menu > .panneau-menu a` (0,2,1) : une règle moins
   spécifique perdrait la cascade et les liens resteraient visibles à toute
   largeur. Vu à l'écran — deux chemins vers la même page à trois centimètres
   l'un de l'autre. */
.menu > .panneau-menu .si-replie { display: none; }

/* --- Un réglage dans le menu de compte ------------------------------------
   L'ambiance a quatre valeurs : un bouton cyclant demanderait jusqu'à trois
   clics pour revenir à celle qu'on voulait. C'est donc un <select> dans son
   propre formulaire — soumis sans JavaScript, comme les sélecteurs de rôle de
   la page d'administration. La densité, elle, n'a que deux valeurs : elle
   garde le bouton cyclant. */
.menu-reglage { display: flex; flex-direction: column; gap: var(--e-1); padding: var(--e-2) var(--e-3); }
.menu-reglage > label { font-size: var(--t-xs); color: var(--tx-3); }
.menu-reglage select { width: 100%; min-height: 44px; font-size: 16px; }
/* Un `.bouton` en colonne flex s'étire à la largeur du parent par défaut
   (`align-items: stretch`) — le même piège que `.pile`, corrigé là par
   `.formulaire-etroit .pile > .bouton`. Ici la carte « Apparence » de
   l'admin réutilise `.menu-reglage` hors `.pile` : sans cette règle, son
   bouton « Appliquer » prend toute la largeur de la carte alors que son
   jumeau du tableau de rôles (dans `.champ-action`) reste à la largeur de
   son texte — les deux font la même chose, ils doivent se ressembler. */
.menu-reglage .bouton { align-self: flex-start; }

@media (max-width: 1024px) {
  .bandeau nav.a-plat { display: none; }
  .bandeau .segmente.a-plat { display: none; }
  /* `flex` et non `block` : le séparateur et les liens s'empilent. */
  .menu > .panneau-menu .si-replie { display: flex; }
  /* Le bandeau redevient une seule ligne : la marque, puis le menu. */
  .bandeau .dedans { flex-wrap: nowrap; }
}
