﻿/* TALPPA Remote — amorce de charte graphique (cf. PRD_CHARTE_GRAPHIQUE_TALPPA.md).
   Theme light uniquement. Sera etoffe aux phases UI (2 a 4). */
/* 🔴 DM Mono est la police « code et donnees techniques » de la charte (PRD §3.1),
   declaree depuis l'origine et jamais chargee (RUX-F20). Elle n'habille QUE
   `.mono` : l'ajouter ici ne cree aucun selecteur, et la CSP autorise deja
   fonts.googleapis.com / fonts.gstatic.com (src/server.js). */
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=DM+Mono:wght@400;500&family=Quicksand:wght@500;600;700&display=swap');

:root {
  --primary: #2563eb;
  --primary-light: #eff6ff;
  --primary-dark: #1d4ed8;
  --accent: #03989e;
  --bg: #f0f2f7;
  --surface: #ffffff;
  --surface-2: #f7f8fc;
  --ink: #111827;
  --ink-2: #374151;
  --ink-muted: #6b7280;
  --border: #e3e6ef;
  --border-dark: #c8cde0;
  --success: #16a34a;
  --success-light: #f0fdf4;
  --warn: #d97706;
  --warn-light: #fffbeb;
  --danger: #dc2626;
  --danger-light: #fef2f2;
  --radius-card: 10px;
  --radius-input: 8px;
  --radius-badge: 20px;
  --shadow-card: 0 1px 4px rgba(0, 0, 0, 0.07), 0 4px 16px rgba(0, 0, 0, 0.06);

  /* ---- Tokens d'espacement du layout (cf. PLAN_RESPONSIVE_UI.md §3.4) ----
     Valeurs desktop (>= 1024px). Elles sont REDEFINIES PAR PALIER dans la
     section « Responsive » en fin de feuille — jamais ailleurs.
     Les marges negatives qui annulent le padding des cartes (.card-head,
     .table-wrap) s'expriment en calc(var(--card-pad-x) * -1) : elles suivent
     ainsi automatiquement le padding et restent alignees a tout palier. */
  --pad-page: 24px;   /* gouttiere de .content et de .topbar */
  --card-pad-x: 24px; /* padding horizontal de .card (et inset des cellules) */
  --card-pad-y: 20px; /* padding vertical de .card */
  --modal-gutter: 24px; /* marge entre une modale et le bord de l'ecran */
  --sidebar-w: 220px; /* largeur de la barre laterale */
  --topbar-h: 56px;   /* hauteur minimale du header */
  /* 🔴 PLAFOND DE LARGEUR DU CONTENU (phase 8 de la refonte UI). C'est la
     largeur UTILE, hors gouttiere : `.content` y ajoute ses deux `--pad-page`.
     Mesure d'avant : sans plafond, `.content` faisait 1700px a 1920 et une
     ligne « designation / valeur » s'y etirait de bord a bord — le libelle et
     sa valeur cessaient de se lire ensemble. Il commande AUSSI le plancher de
     `.inputs-grid` : voir l'arithmetique ecrite la-bas. */
  --content-max: 1200px;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  font-family: 'DM Sans', sans-serif;
  background-color: var(--bg);
  color: var(--ink);
}

.muted { color: var(--ink-muted); font-size: 14px; }

.logo {
  font-family: 'Quicksand', sans-serif;
  font-weight: 700;
  letter-spacing: -0.02em;
}
.logo-talp { color: var(--ink); }
.logo-pa { color: var(--accent); }

.boot {
  max-width: 520px;
  margin: 12vh auto;
  padding: 32px;
  background: var(--surface);
  border-radius: var(--radius-card);
  box-shadow: var(--shadow-card);
  text-align: center;
}

::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-thumb { background: var(--border-dark); border-radius: 2px; }

/* ============================================================
   Utilitaires de mise en page
   ------------------------------------------------------------
   La CSP du serveur declare `styleSrc` SANS 'unsafe-inline'
   (src/server.js) : tout attribut `style=` ecrit dans le HTML est
   SILENCIEUSEMENT IGNORE par le navigateur. Ces quelques classes
   remplacent les styles en ligne residuels des pages publiques
   (cf. PLAN_RESPONSIVE_UI.md §1.3, Phase 5). Toute nouvelle mise en
   forme passe par une classe — jamais par un attribut `style`.
   ============================================================ */
.text-center { text-align: center; }
.mt-16 { margin-top: 16px; }
/* Item d'un conteneur flex qui ne doit pas s'etirer sur l'axe transversal
   (ex. lien « Retour a la connexion » au pied d'un formulaire en colonne). */
.self-center { align-self: center; }

/* ============================================================
   Wordmark TALPPA — « TALP » encre · « PA » accent · suffixe encre
   ============================================================ */
.wordmark {
  font-family: 'Quicksand', sans-serif;
  font-weight: 700;
  letter-spacing: -0.01em;
  color: var(--ink);
}
.wm-talp { color: var(--ink); }
.wm-pa { color: var(--accent); }
.wm-suffix { color: var(--ink); margin-left: 0.4ch; font-weight: 600; }
.wordmark-lg { font-size: 26px; }

/* ============================================================
   Boutons
   ============================================================ */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  font-family: inherit;
  font-size: 13px;
  font-weight: 500;
  line-height: 1;
  padding: 0 16px;
  height: 36px;
  border: 1px solid transparent;
  border-radius: var(--radius-input);
  cursor: pointer;
  transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
  white-space: nowrap;
}
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-block { width: 100%; height: 44px; font-size: 14px; }

.btn-primary { background: var(--primary); color: #fff; }
.btn-primary:hover:not(:disabled) { background: var(--primary-dark); }
.btn-primary:active:not(:disabled) { background: #1a3fb8; }

/* 🔴 LE RANG SECONDAIRE DOIT SE VOIR AU REPOS, SINON CE N'EST PLUS UN RANG. Avec
   une bordure transparente, `.btn-ghost` etait indiscernable d'un libelle tant
   qu'aucune souris ne le survolait : les cinq actions du pied d'un relais
   (Basculer, Impulsion, Modifier, Plages de commande, Planification) se lisaient
   comme du texte — exactement le « texte cliquable nu » qu'interdit
   CONVENTIONS_UI.md §1 —, et au DOIGT, ou le survol n'existe pas, rien ne les
   annoncait jamais. Sorti par la relecture d'ecran du 2026-09-06.
   ⛔ Le fond, lui, reste transparent : c'est ce qui separe le secondaire du
   primaire plein, et le survol garde son remplissage. 🎯 Zero regle ajoutee —
   une seule valeur change. */
.btn-ghost {
  background: transparent;
  color: var(--ink-2);
  border-color: var(--border);
}
.btn-ghost:hover { background: var(--surface-2); color: var(--ink); }

.btn-danger { background: var(--danger-light); color: var(--danger); border-color: rgba(220, 38, 38, 0.2); }
.btn-danger:hover:not(:disabled) { background: var(--danger); color: #fff; }

/* Spinner de chargement dans un bouton primary. */
.btn.is-loading .btn-label { visibility: hidden; }
.btn.is-loading::after {
  content: '';
  position: absolute;
  width: 18px;
  height: 18px;
  border: 2px solid rgba(255, 255, 255, 0.4);
  border-top-color: #fff;
  border-radius: 50%;
  animation: spin 0.7s linear infinite;
}
.btn-block.is-loading { position: relative; }
@keyframes spin { to { transform: rotate(360deg); } }

/* Indicateur d'activite INLINE — le seul de la charte qui ne soit pas dans un
   bouton.
   ⚠️ AJOUT DE REGLE, donc ECART A L'ENGAGEMENT P7-m, et il est assume : la
   question prescrite est « quelle classe existante reprendre », et la reponse
   honnete etait « aucune ». `.btn.is-loading` est un ::after positionne dans un
   bouton, en blanc sur fond primary : il ne peut ni vivre dans un badge, ni
   prendre la couleur de son contexte.
   🎯 Ce qui EST repris : `@keyframes spin` ci-dessus — aucune animation nouvelle.
   🎯 `currentColor` fait que CETTE SEULE REGLE sert le badge « En cours », le
   bandeau d'attente et le titre de la carte d'operation, chacun dans sa couleur.
   Taille en `em` : il suit le texte a cote duquel il se trouve. */
.spinner-inline {
  display: inline-block;
  width: 0.75em;
  height: 0.75em;
  margin-right: 0.4em;
  vertical-align: -0.05em;
  border: 2px solid currentColor;
  border-top-color: transparent;
  border-radius: 50%;
  animation: spin 0.7s linear infinite;
}

/* 🔴 PUREMENT DECORATIF, DONC RETIRABLE SANS PERTE : partout ou il apparait, le
   mot « en cours » est ecrit juste a cote. Une animation infinie est un vrai
   probleme pour qui a active la reduction des animations — et ici on peut la
   supprimer sans retirer une seule information.
   ⚠️ Ce @media est volontairement ICI et non dans la section « Responsive » de
   fin de feuille : ce n'est pas un palier de largeur, et une garde
   d'accessibilite separee de la regle qu'elle protege est une garde qu'on
   oublie. Cf. CONVENTIONS_CSS.md. */
@media (prefers-reduced-motion: reduce) {
  .spinner-inline { display: none; }
}

.link-btn {
  background: none;
  border: none;
  padding: 0;
  font: inherit;
  font-size: 13px;
  color: var(--primary);
  cursor: pointer;
}
.link-btn:hover { color: var(--primary-dark); text-decoration: underline; }

/* ---- Systeme d'action (docs/CONVENTIONS_UI.md) ----
   🔴 DEUX MOTIFS, ET AUCUN N'EST UN COMPOSANT : ce sont les primitives que
   CONVENTIONS_UI.md rend obligatoires. Elles sont ecrites ICI, en phase 1 du
   plan de refonte, et posees AVANT leurs emplois — les ecrans les reprennent
   aux phases 4, 6 et 7. Bilan net publie a chaque cloture (RUX-D14). */

/* 🔴 L'ANNEAU DE FOCUS EST OBLIGATOIRE, ET IL NE VIT PAS DANS UNE CLASSE.
   Mesure d'avant (RUX-F35) : la feuille ne portait qu'UN `:focus`, sur `.input`,
   et AUCUN `:focus-visible` — le clavier ne dependait donc que de l'anneau par
   defaut du navigateur, qu'un seul `outline: none` futur aurait supprime partout
   sans que rien ne le dise. Cibler les ELEMENTS NATIFS plutot que la liste des
   classes d'action est ce qui fait tenir la regle pour les composants a naitre.
   ⚠️ Les champs de saisie sont volontairement HORS de cette liste : ils gardent
   `.input:focus` (voir Formulaires), et `:focus-visible` s'y declencherait aussi
   a la souris — un anneau au clic dans un champ n'apprend rien a personne.
   `outline-offset` positif : l'anneau se pose SUR LE FOND, donc il reste visible
   sur un `.btn-primary` plein comme sur un `.btn-ghost` transparent. */
a:focus-visible,
button:focus-visible,
select:focus-visible,
[tabindex]:focus-visible {
  outline: 2px solid var(--primary);
  outline-offset: 2px;
}

/* 🔴 LE LIEN ETENDU — une carte entiere ouvre sa destination, sans qu'aucun
   `<button>` ne soit imbrique dans un `<a>` (HTML invalide, et cible doublee
   pour un lecteur d'ecran) ni qu'un `role="link"` ne soit simule.
   🎯 C'est le PSEUDO-ELEMENT du lien qui couvre la carte, pas le lien lui-meme :
   le nom reste le TEXTE du lien — donc lu tel quel, souligne au survol, et
   annonce une seule fois. Un `<a>` vide etendu sur la carte aurait exige un
   `aria-label` recopie, c'est-a-dire une seconde source de verite.
   ⚠️ `.stretched-actions` est le seul recours pour ce qui doit rester
   cliquable A L'INTERIEUR de la zone (menu, boutons de pied) : sans lui, le
   pseudo-element les recouvre en silence. */
.stretched { position: relative; }
.stretched-link::after {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 1;
  border-radius: inherit;
}
.stretched-actions { position: relative; z-index: 2; }
/* 🔴 LE SURVOL MARQUE LE BLOC, PAS LE TEXTE (phase 7, RUX-F1). Un lien etendu
   dont seul le nom reagit laisse croire que seul le nom est cliquable — c'est
   exactement le defaut que le motif corrige. Une seule regle sert les DEUX
   densites du composant de site : la carte et la rangee sont la meme chose.
   ⛔ Aucune transformation ni aucun deplacement : la cible ne doit pas bouger
   sous le doigt qui la vise (RUX-D25 applique au survol). */
.stretched:hover { border-color: var(--primary); background: var(--surface-2); }

/* ============================================================
   Formulaires
   ============================================================ */
.form { display: flex; flex-direction: column; gap: 18px; }
.field { display: flex; flex-direction: column; gap: 6px; }
/* 🔴 `hidden` est declare `display: none` par la feuille du NAVIGATEUR : la regle
   d'auteur ci-dessus, si banale soit-elle, l'emporte donc, et un `<div
   class="field" hidden>` reste VISIBLE alors que `element.hidden` vaut bien
   `true`. Troisieme occurrence du meme piege dans ce depot, apres
   `.site-panel[hidden]` et `.perm-dirty[hidden]` — et la premiere a avoir ete
   MESUREE avant d'etre corrigee (sonde de la phase 3 du module Plages horaires,
   sur le `display` calcule).
   Elle repare deux ecrans a la fois : les bornes de dates d'une plage horaire, et
   celles d'une regle de PLANIFICATION DE RELAIS (`#g-champ-debut` /
   `#g-champ-fin`, livrees en phase 7), ou le defaut passait inapercu depuis
   l'origine. La-bas, sa consequence n'etait pas seulement cosmetique : sur une
   regle « Toute l'annee », les deux champs restaient offerts a la saisie alors
   que `valider()` ne les lit pas — les dates saisies etaient donc ignorees EN
   SILENCE. */
.field[hidden] { display: none; }
.label {
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--ink-2);
}
.input {
  width: 100%;
  height: 44px;
  padding: 0 14px;
  font-family: inherit;
  font-size: 14px;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-input);
  outline: none;
  transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.input::placeholder { color: var(--ink-muted); }
.input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); }

.input-wrap { position: relative; }
.input-wrap .input { padding-right: 44px; }
.input-eye {
  position: absolute;
  top: 0;
  right: 0;
  height: 44px;
  width: 44px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  background: none;
  border: none;
  color: var(--ink-muted);
  cursor: pointer;
}
.input-eye:hover { color: var(--ink-2); }

.form-error {
  margin: 0;
  padding: 10px 14px;
  font-size: 13px;
  color: var(--danger);
  background: var(--danger-light);
  border: 1px solid rgba(220, 38, 38, 0.2);
  border-radius: var(--radius-input);
}

.checkbox {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  font-size: 13px;
  color: var(--ink-2);
  cursor: pointer;
  user-select: none;
}
.checkbox input { width: 16px; height: 16px; accent-color: var(--primary); cursor: pointer; }

/* ============================================================
   Page de connexion
   ============================================================ */
.auth-screen {
  /* 100dvh : sur mobile, la hauteur suit l'apparition/disparition de la barre
     d'URL (R9). 100vh reste declare en repli pour les navigateurs sans dvh. */
  min-height: 100vh;
  min-height: 100dvh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
}
.auth-wrap { width: 100%; max-width: 400px; }
.auth-head { text-align: center; margin-bottom: 24px; }
.auth-logo { color: var(--accent); display: inline-flex; }
.auth-brand { margin: 12px 0 4px; }
.auth-tagline { margin: 0; font-size: 13px; color: var(--ink-muted); }

.card {
  background: var(--surface);
  border-radius: var(--radius-card);
  box-shadow: var(--shadow-card);
  /* Marge interne par défaut : aucun champ/texte ne touche les bords de la carte.
     Les cartes à padding propre (auth) la redéfinissent ailleurs. */
  padding: var(--card-pad-y) var(--card-pad-x);
}
.auth-card { padding: 32px; }
.auth-tab {
  margin: 0 0 24px;
  padding-bottom: 12px;
  font-size: 15px;
  font-weight: 600;
  color: var(--primary);
  text-align: center;
  border-bottom: 2px solid var(--primary);
}
/* Note d'introduction placee juste sous l'onglet d'authentification
   (« Compte : … », « Saisissez votre adresse e-mail… »). Un <p> porte une marge
   par defaut de 1em : on le recolle au titre, et on redonne au bloc suivant
   l'espacement d'une section de formulaire (18px, cf. `.form { gap }`). */
.auth-note { margin-top: -8px; margin-bottom: 18px; }
/* Dans un `.form`, l'espacement vertical vient deja du `gap` : conserver la
   marge basse ferait double emploi (36px au lieu des 18px du rythme). */
.form > .auth-note { margin-bottom: 0; }

.auth-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.auth-foot { text-align: center; margin-top: 20px; font-size: 12px; }

/* ============================================================
   Layout applicatif (sidebar + header + contenu)
   ============================================================ */
/* Hauteur FERMEE, et non `min-height` : le PRD §10.1 prescrit `flex h-screen`
   pour la coquille, avec la seule zone de contenu defilante. Avec `min-height`,
   la coquille avait le droit de grandir avec son contenu — donc elle grandissait,
   `overflow-y: auto` de `.content` ne s'enclenchait jamais, et c'est la PAGE
   ENTIERE qui defilait : la sidebar quittait l'ecran par le haut, emportant la
   navigation puis l'identite. L'intention etait pourtant deja ecrite plus bas
   (« la zone de contenu, qui est le vrai conteneur defilant »).
   Repli `vh` avant `dvh` conserve (R9, cf. .auth-screen). */
.app-shell { height: 100vh; height: 100dvh; }
.shell { display: flex; height: 100vh; height: 100dvh; overflow: hidden; }

/* ---- Tiroir de navigation (decision D1, PRD §12.3) ----
   La sidebar devient un panneau `fixed` glissant, double d'un backdrop et ouvert
   par le hamburger du header. Ne vivent ICI que la DEFINITION des deux elements
   propres au tiroir, tous deux `display: none`.

   🔴 CE QUI ALLUME LE TIROIR EST UNE CLASSE, PLUS UN PALIER (**RUX-D30**,
   point ouvert n° 28). `.nav-tiroir` est posee sur `.app-shell` par `layout.js`
   dans DEUX cas : sous 1024px comme avant, et — nouveau — quand la navigation
   filtree par role ne porte qu'UNE entree, c'est-a-dire chez un compte client.
   Mesure qui l'a decide : la colonne gardait 816px de gris a vide et 515px
   ENCORE au plafond de 8 favoris (**RUX-F66**) — les favoris ne la remplissent
   pas, ils la reduisent.

   ⚠️ LE PALIER NE VIT PLUS QU'A UN SEUL ENDROIT, et c'est un piege referme :
   `NAV_DRAWER_QUERY` dans `layout.js`. Il etait ecrit ici AUSSI, et B6 demandait
   de garder les deux synchronises.

   ⛔ CE QUE LE DEPLACEMENT NE COUTE PAS : la garantie D5 tient, et pour la meme
   raison qu'avant. Sans `.nav-tiroir`, une classe `.is-open` residuelle ne peut
   RIEN afficher — les regles du tiroir ne s'appliquent tout simplement pas.
   🎯 Et un `@media` ne protegeait rien de plus que le JS : la coquille ENTIERE
   est construite par `renderApp()`, il n'y a pas de page sans lui. La classe est
   posee AVANT l'insertion dans le DOM, `matchMedia` etant synchrone — donc
   aucun clignotement. */
.nav-toggle {
  display: none; /* → inline-flex sous 1024px (section « Responsive ») */
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  /* 40px : cible tactile conforme au PRD §12.3 (>= 32px, idealement 44px). */
  width: 40px;
  height: 40px;
  color: var(--ink-2);
  background: none;
  border: 1px solid var(--border);
  border-radius: var(--radius-input);
  cursor: pointer;
  transition: background-color 0.15s ease, color 0.15s ease;
}
.nav-toggle:hover { background: var(--surface-2); color: var(--ink); }
.sidebar-backdrop {
  display: none; /* → block avec .is-open, sous 1024px uniquement */
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.3);
  z-index: 30;
}
/* Verrou de defilement de l'arriere-plan pendant l'ouverture du tiroir (la
   classe est posee sur <body> par layout.js). Le backdrop intercepte par
   ailleurs les gestes sur la zone de contenu, qui est le vrai conteneur
   defilant (.content). */
body.nav-open { overflow: hidden; }

.sidebar {
  width: var(--sidebar-w);
  min-width: var(--sidebar-w);
  background: var(--surface);
  border-right: 1px solid var(--border);
  display: flex;
  flex-direction: column;
  padding: 14px 0;
  /* Scroller legitime, comme le tiroir qui le declare deja : une fenetre courte
     (ou un futur ajout d'entrees) ne doit pas pousser le pied de sidebar —
     identite + deconnexion — hors de l'ecran. */
  overflow-y: auto;
}

/* --- L'etat TIROIR, allume par `.nav-tiroir` (cf. bloc ci-dessus) ---
   Ces quatre regles vivaient dans le palier < 1024px ; elles n'y sont plus
   parce qu'elles ne dependent plus d'une largeur. ⛔ Elles ne sont donc PAS
   « une regle responsive hors de la section Responsive » : c'est une classe qui
   les gouverne, comme la garde `prefers-reduced-motion` de `.spinner-inline`
   vit a cote de sa regle et non au palier. */
.nav-tiroir .nav-toggle { display: inline-flex; }
.nav-tiroir .sidebar {
  position: fixed;
  inset-block: 0;
  left: 0;
  z-index: 40;
  transform: translateX(-100%);
  /* `visibility` rend le tiroir ferme non focalisable : sans elle, la
     tabulation traverserait une navigation invisible. Sa transition ne sert
     qu'a la FERMETURE : `visibility` est une propriete discrete, elle reste
     donc `visible` pendant tout le glissement de sortie et ne bascule qu'a la
     fin. */
  visibility: hidden;
  transition: transform 0.2s ease-in-out, visibility 0.2s ease-in-out;
}
.nav-tiroir .sidebar.is-open {
  transform: translateX(0);
  visibility: visible;
  /* A l'OUVERTURE, `visibility` est volontairement retiree de la transition :
     interpolee, elle vaudrait encore `hidden` a l'instant t=0 et le tiroir ne
     pourrait pas recevoir le focus (mesure de recette Phase 2). Ici elle
     s'applique sans delai, le glissement restant porte par `transform`. */
  transition: transform 0.2s ease-in-out;
}
.nav-tiroir .sidebar-backdrop.is-open { display: block; }

.sidebar-logo {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 0 16px 14px;
  margin-bottom: 8px;
  border-bottom: 1px solid var(--border);
}
.sidebar-icon { color: var(--accent); display: inline-flex; }
.sidebar-brand .wordmark { font-size: 16px; }

/* Logo officiel (image PNG) — sidebar & page de connexion. */
.brand-logo { display: block; object-fit: contain; border-radius: 6px; }

/* Bas de sidebar : avatar + identité + déconnexion (collé en bas via margin-top). */
.sidebar-foot {
  margin: auto 8px 0; /* margin-top:auto → bloc collé en bas de la sidebar */
  display: flex;
  align-items: center;
  gap: 10px;
  padding-top: 12px;
  border-top: 1px solid var(--border);
}
.user-avatar {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 34px;
  height: 34px;
  border-radius: 50%;
  background: var(--primary-light);
  color: var(--primary);
  font-size: 12px;
  font-weight: 700;
}
.user-meta { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; line-height: 1.3; }
.btn-logout-icon {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  color: var(--ink-muted);
  background: none;
  border: 1px solid var(--border);
  border-radius: 6px;
  cursor: pointer;
  transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.btn-logout-icon:hover { background: var(--danger-light); color: var(--danger); border-color: rgba(220, 38, 38, 0.2); }

.nav { padding: 0 8px; }
.nav-section {
  margin: 6px 8px 4px;
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-muted);
}
.nav-items { display: flex; flex-direction: column; gap: 2px; }
.nav-item {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  padding: 8px 10px;
  font-family: inherit;
  font-size: 13px;
  font-weight: 500;
  color: var(--ink-muted);
  background: none;
  border: none;
  border-radius: 7px;
  cursor: pointer;
  text-align: left;
  transition: background-color 0.15s ease, color 0.15s ease;
}
.nav-item:hover { background: var(--surface-2); color: var(--ink-2); }
.nav-item.active { background: var(--primary-light); color: var(--primary); }
.nav-ico { flex-shrink: 0; }
.nav-label { flex: 1; }

.badge {
  display: inline-flex;
  align-items: center;
  padding: 2px 8px;
  font-size: 10px;
  font-weight: 600;
  border-radius: var(--radius-badge);
}
.badge-soon { background: var(--warn-light); color: var(--warn); }

.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.topbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  /* Garde-fous R5 : le header peut passer a la ligne plutot que deborder, et ne
     se laisse pas elargir par son contenu. Sans effet en desktop (le titre est
     reductible via .topbar-title { min-width: 0 }). */
  flex-wrap: wrap;
  min-width: 0;
  gap: 16px;
  min-height: var(--topbar-h);
  padding: 10px var(--pad-page);
  background: var(--surface);
  border-bottom: 1px solid var(--border);
}
/* min-width: 0 → un titre long peut se reduire au lieu de pousser la zone
   d'etat (horloge + badge MQTT) hors du header (R5). */
/* `flex: 1` — LE TITRE OCCUPE LA PLACE, IL NE SE FAIT PAS CENTRER PAR ELLE.
   `.topbar` est en `space-between` : avec DEUX items (titre + etat) le titre se
   colle a gauche, avec TROIS (hamburger + titre + etat) il se retrouve pousse au
   MILIEU. Sans effet tant que le hamburger etait cantonne au mobile ; **RUX-D30**
   l'amene en desktop chez un compte client, et le titre partait alors a 551px du
   bord pour 24px chez un superadmin (mesure).
   🎯 `flex-basis: 0` ne reclame AUCUNE largeur intrinseque : le garde-fou R5
   (`flex-wrap`) ne peut donc pas se declencher a cause de lui — c'est plus sur
   que la base `auto` d'avant. ⛔ Et rien ne bouge pour l'existant : mesure
   inchangee a 24px (superadmin desktop) et 72px (mobile, les deux roles). */
.topbar-title { min-width: 0; flex: 1; }
.page-title { margin: 0; font-size: 15px; font-weight: 600; color: var(--ink); }
.page-sub { margin: 2px 0 0; font-size: 12px; }
/* Zone droite du header : horloge + état de connexion MQTT. */
.topbar-status { display: flex; align-items: center; gap: 10px; flex: 0 0 auto; }
.clock-pill {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 5px 10px;
  font-size: 13px;
  font-weight: 500;
  color: var(--ink-2);
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: var(--radius-badge);
}
.clock-pill .clock-ico { color: var(--ink-muted); }
.clock-time { font-variant-numeric: tabular-nums; letter-spacing: 0.02em; }

.mqtt-badge {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 5px 10px;
  font-size: 12px;
  font-weight: 600;
  border: 1px solid transparent;
  border-radius: var(--radius-badge);
  white-space: nowrap;
}
.mqtt-dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; flex: 0 0 auto; }
.mqtt-ok { background: var(--success-light); color: var(--success); border-color: rgba(22, 163, 74, 0.2); }
.mqtt-off { background: var(--danger-light); color: var(--danger); border-color: rgba(220, 38, 38, 0.2); }
.mqtt-unknown { background: var(--surface-2); color: var(--ink-muted); border-color: var(--border); }

/* Identité (bas de sidebar). Ellipsis pour les noms longs. */
.user-name { font-size: 13px; font-weight: 600; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.user-role { font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

/* Zone de contenu : colonne avec gouttière → cartes empilées correctement espacées.
   `min-height: 0` n'est PAS decoratif : un enfant de conteneur flex vaut
   `min-height: auto`, donc il refuse de retrecir sous la hauteur de son contenu
   et `overflow-y: auto` reste lettre morte. C'est la seconde moitie du correctif
   de la coquille (voir `.shell`) — sans elle, fermer la hauteur ne suffit pas. */
/* 🔴 LE PLAFOND DE LARGEUR EST PORTE PAR `.content`, ET PAS PAR `.topbar`
   (phase 8). Le bandeau superieur est du CHROME : il porte un fond, un filet
   bas et l'etat de la connexion, et un bandeau qui s'arreterait au milieu de
   l'ecran cesserait d'etre un bandeau. Consequence assumee et VISIBLE : au-dela
   de ~1450px, le titre de page reste a gauche pendant que le contenu se centre.
   ⚠️ `margin-inline` et non un conteneur intercalaire : `.content` EST le
   conteneur defilant (voir `.shell`), et l'envelopper aurait deplace le
   defilement d'un cran — c'est exactement le defaut que `4d3bf75` a corrige.

   🔴 ET `width: 100%` N'EST PAS DECORATIF : SANS LUI LE PLAFOND DEVIENT UN
   PLANCHER MOUVANT. Une marge `auto` sur l'axe TRANSVERSAL d'un item flex
   ANNULE son etirement (`align-self: stretch` ne vaut que si les marges de cet
   axe ne sont pas `auto`) : la zone retombe alors en shrink-to-fit, donc a la
   largeur de son CONTENU. Mesure du defaut, introduit puis corrige dans la meme
   phase : `.content` faisait **764px a 1024 comme a 2560px** sur l'annuaire, et
   ~1248 dans un panneau de relais — la largeur de l'ecran ne comptait plus, le
   contenu la dictait. ⛔ Ne jamais retirer ce `width`. */
.content { flex: 1; min-width: 0; min-height: 0; width: 100%; max-width: calc(var(--content-max) + var(--pad-page) * 2); margin-inline: auto; padding: var(--pad-page); overflow-y: auto; display: flex; flex-direction: column; gap: 20px; }
.card-title { margin: 0 0 10px; font-size: 18px; font-weight: 600; color: var(--ink); }

/* ============================================================
   Toasts
   ============================================================ */
.toasts {
  position: fixed;
  bottom: 20px;
  right: 20px;
  display: flex;
  flex-direction: column;
  gap: 10px;
  z-index: 1000;
}
.toast {
  max-width: 360px;
  padding: 12px 16px;
  font-size: 13px;
  border-radius: var(--radius-input);
  box-shadow: var(--shadow-card);
  background: var(--surface);
  border: 1px solid var(--border);
  color: var(--ink-2);
  animation: toast-in 0.2s ease;
}
.toast-success { border-color: rgba(22, 163, 74, 0.3); background: var(--success-light); color: var(--success); }
.toast-error { border-color: rgba(220, 38, 38, 0.3); background: var(--danger-light); color: var(--danger); }
.toast-info { border-color: var(--border-dark); }
.toast-out { opacity: 0; transform: translateY(8px); transition: opacity 0.2s ease, transform 0.2s ease; }
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }

/* ============================================================
   Cartes de contenu, en-têtes, tableaux
   ============================================================ */
.card-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  /* Garde-fou general (meme discipline que `.topbar` en Phase 2, `.pager` en
     Phase 4 et `.modal-foot` en Phase 5) : sans effet en desktop, ou le titre
     et le bouton d'action tiennent sur une ligne, il rend la demande du
     bandeau DIVISIBLE des que la carte est etroite. Les libelles d'action sont
     longs et en `white-space: nowrap` (« + Inviter un·e utilisateur·rice ») :
     le repli est le seul recours, le bouton ne peut pas se reduire. */
  flex-wrap: wrap;
  gap: 12px;
  /* Marges négatives = bandeau pleine largeur malgré le padding interne de .card.
     La bordure du bas s'étend ainsi de bord à bord ; 16px de gouttière sous le bandeau.
     Exprimees en calc(token * -1) : le bandeau suit le padding a tout palier. */
  margin: calc(var(--card-pad-y) * -1) calc(var(--card-pad-x) * -1) 16px;
  padding: var(--card-pad-y) var(--card-pad-x);
  border-bottom: 1px solid var(--border);
}
.card-head .card-title { margin: 0; }
.count-pill {
  display: inline-flex;
  align-items: center;
  margin-left: 6px;
  padding: 1px 9px;
  font-size: 12px;
  font-weight: 600;
  color: var(--ink-muted);
  background: var(--surface-2);
  border-radius: var(--radius-badge);
}
/* Tableaux pleine largeur : la zone s'étend de bord à bord de la carte (marges
   négatives annulant le padding de .card), pour des lignes bord-à-bord homogènes.
   Le padding --card-pad-x des cellules réaligne la 1ʳᵉ colonne sous le titre/les filtres. */
.table-wrap { margin: 0 calc(var(--card-pad-x) * -1); width: auto; overflow-x: auto; }
/* Les contenus non-tableau de la zone (Chargement…, messages vides/erreur) gardent
   l'inset du padding de carte pour rester alignés avec le corps de la carte. */
.table-wrap > :not(table) { padding-left: var(--card-pad-x); padding-right: var(--card-pad-x); }
/* Messages d'état vide : padding vertical seul (l'inset horizontal vient du conteneur). */
/* `grid-column` ajoute en phase 7 : dans `.sites-grid`, un etat vide occupait
   UNE cellule et se retrouvait tasse dans la premiere colonne d'un ecran large.
   Sans effet partout ailleurs, ou le parent n'est pas une grille — d'ou une
   declaration ajoutee a la regle existante, et zero selecteur. */
.empty { padding: 24px 0; grid-column: 1 / -1; }

.table { width: 100%; border-collapse: collapse; font-size: 13px; }
.table th {
  text-align: left;
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.03em;
  text-transform: uppercase;
  color: var(--ink-muted);
  padding: 10px var(--card-pad-x);
  border-bottom: 1px solid var(--border);
  white-space: nowrap;
}
.table td {
  padding: 12px var(--card-pad-x);
  border-bottom: 1px solid var(--border);
  color: var(--ink-2);
  vertical-align: middle;
}
.table tbody tr:last-child td { border-bottom: none; }
.table tbody tr:hover { background: var(--surface-2); }
.col-actions { text-align: right; white-space: nowrap; }

.action {
  font: inherit;
  font-size: 12px;
  font-weight: 500;
  color: var(--ink-2);
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: 6px;
  padding: 4px 10px;
  margin-left: 6px;
  cursor: pointer;
  transition: background-color 0.15s ease, color 0.15s ease;
}
.action:hover { background: var(--border); color: var(--ink); }
.action.danger { background: var(--danger-light); color: var(--danger); border-color: rgba(220, 38, 38, 0.2); }
.action.danger:hover { background: var(--danger); color: #fff; }

/* 🔴 LE MENU « … » D'ACTIONS SECONDAIRES (phase 7, RUX-D1) — `<details>` natif,
   comme le repli technique, et pour la meme raison : l'etat ouvert/ferme, le
   role ARIA et le clavier viennent du navigateur.
   ⚠️ LE MARQUEUR EST SUPPRIME ICI, ET C'EST LA SEULE FOIS OU C'EST LEGITIME. Le
   piege B6 dit qu'un repli sans triangle redevient du texte cliquable nu ; ici
   le resume n'est pas du texte, c'est un bouton de rang tertiaire dessine
   (`.action`) dont le « … » EST l'affordance. CONVENTIONS_UI.md §1 est tenu.
   ⛔ `list-style: none` NE SUFFIT PAS seul : Safari ne connait que le
   pseudo-element `::-webkit-details-marker`. */
.menu { position: relative; display: inline-block; }
.menu > summary { list-style: none; display: inline-block; }
.menu > summary::-webkit-details-marker { display: none; }
/* Le panneau flotte SOUS le resume, ancre a DROITE : ancre a gauche, il
   deborderait du cadre des la derniere colonne de la grille. `min-width` et non
   `width` : un libelle plus long l'elargit au lieu d'etre tronque. */
.menu-panneau {
  position: absolute;
  top: calc(100% + 4px);
  right: 0;
  z-index: 3;
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 168px;
  padding: 6px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-input);
  box-shadow: var(--shadow-card);
}
/* Les items REPRENNENT `.action` — c'est leur rang exact (CONVENTIONS_UI.md §1).
   Seules la marge horizontale et l'alignement changent : dans une colonne, un
   libelle centre serait illisible en diagonale. */
.menu-panneau .action { margin-left: 0; text-align: left; white-space: nowrap; }

/* Badges d'état / d'organisation */
.badge-ok { background: var(--success-light); color: var(--success); }
.badge-off { background: var(--danger-light); color: var(--danger); }
.badge-org { background: var(--primary-light); color: var(--primary); margin-right: 2px; }

/* ============================================================
   Modale
   ============================================================ */
.modal-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.3);
  display: flex;
  align-items: center;
  justify-content: center;
  /* Gouttiere de la modale, par token : elle est resserree par palier dans la
     section « Responsive » et la hauteur maximale du cadre s'y adosse. */
  padding: var(--modal-gutter);
  z-index: 900;
}
.modal {
  /* `width: 100%` + `max-width` : la modale prend sa largeur nominale tant que
     l'ecran le permet, et se borne d'elle-meme a la boite de contenu de
     l'overlay (largeur d'ecran moins deux gouttieres) en dessous. Les variantes
     larges (`:has(.form-grid)` 640px, `:has(.bulk-form)` 720px) heritent de ce
     bornage. */
  width: 100%;
  max-width: 460px;
  /* 90dvh avec repli 90vh (R9) : sur mobile, `vh` ignore la barre d'URL. Les
     deux valeurs sont identiques en desktop. Sous 768px, ce plafond est repris
     en `calc(100dvh - 2 gouttieres)` — cf. section « Responsive ». */
  max-height: 90vh;
  max-height: 90dvh;
  overflow-y: auto;
  background: var(--surface);
  border-radius: var(--radius-card);
  box-shadow: var(--shadow-card);
}
.modal-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 18px 22px;
  border-bottom: 1px solid var(--border);
}
.modal-title { margin: 0; font-size: 16px; font-weight: 600; color: var(--ink); }
/* Titre de section pose DANS une modale (Signe de vie, AS-D5, 2026-09-15) :
   `.card-title` rend 18px, ecrit pour un panneau, et depassait de 2px le titre
   de la modale (16px) — un sous-titre plus grand que son titre. Aucune classe
   existante ne donnait un titre interne plus petit sans passer en capitales
   (`.label`, 11px). Une declaration le ramene SOUS le titre ; `.info-section`
   garde son blanc au-dessus. Arbitrage pris avec l'utilisateur·rice, ecart
   n° 2 a l'engagement « 0 regle », justifie dans CONVENTIONS_CSS.md. */
.modal .card-title { font-size: 15px; }
.modal-body { padding: 22px; }
/* Pied de modale. `flex-wrap` est un garde-fou general (meme discipline que
   `.topbar` en Phase 2 et `.pager` en Phase 4) : sans effet en desktop, ou les
   actions tiennent sur une ligne, il rend la demande du pied DIVISIBLE des que
   le cadre est etroit. Indispensable a la modale « Nouveau contact », seule a
   porter 3 actions : ses boutons reclament 339px pour 298px utiles en
   mobile-390, et comme les libelles sont en `white-space: nowrap` (PRD §9.1)
   ils ne peuvent pas se reduire — le repli est le seul recours. Avec
   `justify-content: flex-end`, un pied en `nowrap` deborde par la GAUCHE : le
   bouton « Annuler » sortait ainsi de 20px hors du cadre. */
.modal-foot {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: 10px;
  padding: 16px 22px;
  border-top: 1px solid var(--border);
}
.icon-btn {
  background: none;
  border: none;
  color: var(--ink-muted);
  font-size: 16px;
  cursor: pointer;
  width: 28px;
  height: 28px;
  border-radius: 6px;
}
.icon-btn:hover { background: var(--surface-2); color: var(--ink); }

.checks { display: flex; flex-direction: column; gap: 8px; max-height: 180px; overflow-y: auto; }

/* ============================================================
   Sites (Phase 3) : barre d'outils, grille de cards, détection
   ============================================================ */
.sites-toolbar { display: flex; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; }
.search-wrap { flex: 1 1 280px; }
.filter-select { flex: 0 0 auto; width: auto; min-width: 170px; }

.sites-grid {
  display: grid;
  /* Correctif A du defaut §1.1 (PLAN_RESPONSIVE_UI) — deux choix, deux raisons :
     · `auto-fit` (et non `auto-fill`) : les pistes excedentaires sont EFFONDREES
       et leur place rendue aux cartes presentes. `auto-fill` creait au contraire
       autant de colonnes que la largeur le permettait, y compris vides, chaque
       piste se rapprochant alors de son plancher — d'ou des cartes de ~310px sur
       un ecran de 1920px, trop etroites pour leur pied.
     · `min(100%, 280px)` : sur un conteneur plus etroit que 280px (mobile a
       320px), la piste se rabat sur 100% au lieu de deborder. */
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
  gap: 14px;
}
.site-card {
  border: 1px solid var(--border);
  border-radius: var(--radius-card);
  background: var(--surface);
  padding: 16px;
  display: flex;
  flex-direction: column;
  gap: 12px;
  /* Garde-fou : une piste de grille ne doit jamais etre elargie de force par
     son contenu (min-width: auto est la valeur par defaut des items de grille). */
  min-width: 0;
}
.site-card-head { display: flex; justify-content: space-between; gap: 10px; align-items: flex-start; }
/* Le bloc de titres se laisse reduire au lieu de pousser les badges hors du
   cadre (garde-fou general du chantier, cf. plan §3.5). */
/* `flex: 1 1 auto` ajoute en phase 6 : le bloc de titres prend la place restante
   au lieu d'etre dimensionne par son contenu — indispensable a la RANGEE, ou il
   doit repousser l'etat a droite, et sans effet sur la carte, ou il etait deja
   le seul enfant extensible. C'est la geometrie exacte de `.contacts-titles` et
   `.module-titles` (RUX-F50). ⛔ Aucun selecteur nouveau : la declaration
   rejoint la regle existante. */
.site-titles { flex: 1 1 auto; min-width: 0; }
.site-name { margin: 0; font-size: 15px; font-weight: 600; color: var(--ink); }
/* chipId : chaine longue sans espace → cesure autorisee partout (R10). */
.site-chip { margin: 2px 0 0; font-size: 12px; font-family: ui-monospace, monospace; overflow-wrap: anywhere; }
.site-badges { display: flex; flex-direction: column; gap: 4px; align-items: flex-end; flex: 0 0 auto; }
/* align-items: flex-start — un badge est dimensionne par son contenu (PRD §9.4).
   Sans cela, l'etirement par defaut des items d'un flex en colonne donnait au
   badge d'organisation toute la largeur de la carte : defaut preexistant, mais
   rendu criant par l'elargissement des cartes du correctif A (761px sur 1920). */
.site-meta { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; font-size: 13px; color: var(--ink-2); }
.meta-line { color: var(--ink-2); }
.site-warn {
  margin: 0;
  font-size: 12px;
  color: var(--warn);
  background: var(--warn-light);
  border-radius: var(--radius-input);
  padding: 8px 10px;
}
/* Correctif B du defaut §1.1 : le pied etait un flex `nowrap` dont les enfants
   ne peuvent pas se reduire sous leur contenu (boutons en `white-space: nowrap`).
   Sa demande etait donc CONSTANTE (~401px mesures) a tous les paliers, quelle
   que soit la largeur de la carte -> le dernier bouton sortait du cadre.
   `flex-wrap: wrap` (aux deux niveaux) rend cette demande divisible : le pied se
   replie en deux lignes des que la carte est trop etroite, sans jamais deborder.
   On ne reduit PAS les boutons (`min-width: 0` seul) : leur libelle, non
   secable, deborderait alors de leur propre cadre — illisible et non conforme
   au PRD §9.1/§9.2. */
.site-card-foot { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 8px; margin-top: auto; }
/* 🔴 `margin-left: auto` — ET C'EST LA CAPTURE QUI L'A EXIGE, phase 7. Le pied
   est un flex `wrap` en `space-between` : sur UNE ligne, la grappe d'actions est
   deja a droite ; des que le pied se replie, elle se retrouve SEULE sur sa ligne,
   donc collee a GAUCHE. Le panneau du menu, ancre a droite de son resume, sortait
   alors de l'ecran — mesure a 375px : bord gauche a -40px. La marge automatique
   la maintient a droite a TOUT palier, et le panneau retombe dans le cadre.
   ⛔ Pas de regle de palier : le defaut ne venait pas d'une largeur d'ecran mais
   du repli, qui peut survenir a n'importe laquelle. */
.site-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-left: auto; }

/* ---------- Phase 6 : echelle, exceptions, regroupement ---------------------

   🔴 LA SECONDE DENSITE DU MEME COMPOSANT (RUX-D12), et non un second
   composant. `.site-row` MODIFIE `.site-card` : elle garde son cadre, son fond
   et son rayon, et ne change que la mise en flux. C'est ce qui garantit qu'une
   correction faite sur la carte suit la rangee — et c'est le principe de RUX-D6
   applique a un composant, pas une seconde implementation.
   ⚠️ Le seuil (12 resultats) est en JS, pas ici : il depend du TOTAL rendu par
   l'API, que le CSS ne connait pas. */

/* La grille redevient une colonne : une rangee occupe toute la largeur. */
.sites-rows { grid-template-columns: 1fr; gap: 6px; }

.site-row {
  flex-direction: row;
  align-items: center;
  /* `wrap` des le palier de base, et non dans la section Responsive : c'est le
     correctif B de `.site-card-foot`, pour la meme raison — les libelles ne sont
     pas secables, une demande non divisible finit toujours par deborder. Ainsi
     la rangee se replie a la largeur ou elle en a besoin, sans qu'aucun palier
     n'ait a etre devine. */
  flex-wrap: wrap;
  gap: 12px;
  padding: 8px 14px;
  /* ~56px : la cible du lien etendu couvre donc largement les 40px prescrits
     par CONVENTIONS_UI.md §1, sans qu'aucune regle de cible ne soit ecrite. */
  min-height: 56px;
}
/* Le sous-titre d'une rangee : serie, groupe, lieu, organisation, sur UNE
   ligne. `min-width: 0` sur son parent (.site-titles, deja la) laisse la
   troncature agir au lieu de pousser l'etat hors du cadre. */
.site-row-sub {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin: 2px 0 0;
  font-size: 12px;
}
/* L'etat + sa fraicheur, a droite du nom. Il ne se comprime pas : c'est la
   seule chose de la rangee qu'on lit en diagonale. */
.site-row-etat { flex: 0 0 auto; font-size: 12px; }
/* Le chevron dit que le bloc mene quelque part — l'affordance que RUX-F1
   reprochait a la carte de n'avoir nulle part. */
.site-row-chevron { flex: 0 0 auto; color: var(--ink-muted); font-size: 18px; line-height: 1; }

/* 🔴 LA PASTILLE D'ETAT — et ici, le vert et le rouge sont LEGITIMES.
   CONVENTIONS_UI.md §5 : ils sont reserves a ce qui est vrai dans tous les
   decors, et « en ligne / hors ligne » l'est. ⛔ C'est exactement l'inverse de
   la position d'un relais, ou ils sont interdits (RUX-D4) : ce que « ferme »
   actionne depend du cablage, ce que « hors ligne » signifie ne depend de rien.
   ⚠️ La couleur n'est JAMAIS seule : l'etat est ecrit a cote (rangee) ou en
   infobulle (barre laterale). */
.site-dot { flex: 0 0 auto; width: 10px; height: 10px; border-radius: 50%; }
.dot-ok { background: var(--success); }
.dot-off { background: var(--danger); }
/* Jamais vue : ni bonne ni mauvaise nouvelle — le plus souvent une fiche creee
   avant l'installation. L'ambre dit « l'ecran ne sait pas », pas « alarme ». */
.dot-unseen { background: var(--warn); }

/* Le bandeau d'exceptions (RUX-D10). Ambre : c'est une reserve, pas un danger —
   une carte hors ligne n'est pas une action destructrice, c'est une chose a
   regarder. */
.exceptions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 12px;
  margin-bottom: 16px;
  padding: 10px 12px;
  background: var(--warn-light);
  border: 1px solid rgba(217, 119, 6, 0.2);
  border-radius: var(--radius-input);
}
.exceptions-titre { margin: 0; font-size: 13px; font-weight: 600; color: var(--warn); }

/* Un compteur cliquable est un bouton de service : il REPREND `.action`, et n'a
   besoin que d'un etat « ce filtre est actif ». La meme regle sert au bouton
   d'epinglage, pour la meme raison — dire qu'un geste a deja ete fait. */
.action.is-on { background: var(--primary-light); color: var(--primary); border-color: rgba(37, 99, 235, 0.25); }

/* Intitule de groupe. `grid-column: 1 / -1` est OBLIGATOIRE : sans lui, le titre
   occuperait une seule cellule de la grille de cartes et les sites de son groupe
   se rangeraient a cote de lui, au lieu de dessous. */
.sites-groupe-titre {
  grid-column: 1 / -1;
  margin: 10px 0 2px;
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--ink-muted);
}

/* Pagination. Son texte reprend `.muted` : aucune classe propre a lui. */
.pager { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 12px; margin-top: 16px; }

/* Badges de mode réseau */
.badge-mode { background: var(--primary-light); color: var(--primary); }
.badge-warn { background: var(--warn-light); color: var(--warn); }
.badge-muted { background: var(--surface-2); color: var(--ink-muted); }

/* Détection parc */
.detected-list { display: flex; flex-direction: column; gap: 8px; margin-top: 10px; }
.detected-card {
  display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 10px;
  border: 1px dashed var(--border-dark);
  border-radius: var(--radius-input);
  padding: 10px 14px;
}
/* chipId + badge : bloc reductible, il ne pousse pas le bouton hors du cadre. */
.detected-card > div { min-width: 0; }
.detected-chip { font-family: ui-monospace, monospace; font-size: 13px; color: var(--ink); margin-right: 8px; }
.btn-sm { height: 32px; padding: 0 12px; font-size: 13px; }

/* Formulaire en grille (modale site) + textarea */
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.form-grid .field-wide { grid-column: 1 / -1; }
.modal:has(.form-grid) { max-width: 640px; }
textarea.input { height: auto; min-height: 60px; padding: 10px 14px; resize: vertical; }

/* ---------- Module Contacts (Phase 4) ---------- */
/* Le garde-fou de cesure de .mono (R10) est declare dans la section
   « Responsive » : en desktop il modifierait la repartition automatique des
   colonnes des tableaux (mesure de recette Phase 1), ce qu'interdit D5. */
/* RUX-F20 : la pile systeme reste DERRIERE DM Mono, jamais a sa place — si la
   police de Google ne se charge pas, la donnee technique doit rester chassee
   fixe, pas retomber sur la police de texte. */
.mono { font-family: 'DM Mono', ui-monospace, "SF Mono", Menlo, monospace; }

.contacts-head { display: flex; flex-direction: column; gap: 12px; }
.contacts-head-top { display: flex; align-items: flex-start; gap: 14px; }
/* ⛔ La regle qui chassait `.contacts-titles .site-chip` et `.contacts-titles
   .muted` en monospace est RETIREE (phase 7) : ce bloc ne porte plus que le titre
   du module. Le numero de serie qu'elle mettait en forme etait le doublon de
   celui de la coquille (RUX-F17), et il n'a plus de porteur ici. */
.contacts-titles { flex: 1 1 auto; min-width: 0; }
.contacts-badges { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; justify-content: flex-end; flex: 0 0 auto; }
.contacts-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }

.contacts-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; }
/* Base flexible de 280px : valable tant que la barre est une LIGNE (l'axe
   principal y est horizontal, la base vaut donc une largeur). Elle est
   remplacee sous 768px — cf. section « Responsive », correctif R11. */
.contacts-toolbar .search-wrap { flex: 1 1 280px; }

/* Cases relais alignées horizontalement (modale contact).
   `flex-wrap` : garde-fou sans effet en desktop (deux cases « R1 »/« R2 »), il
   evite qu'un futur troisieme relais ne deborde du champ en mobile. */
.checks-inline { flex-direction: row; flex-wrap: wrap; gap: 18px; max-height: none; }
.field-hint { font-size: 12px; margin: 4px 0 0; }

/* Pagination.
   `flex-wrap` est un garde-fou general (meme discipline que `.topbar`, Phase 2) :
   sans effet en desktop, ou les trois elements tiennent sur une ligne, il evite
   que « Precedent / Page N / Suivant » ne deborde des que la carte est etroite.
   Les libelles restent entiers (PRD §9.1 : pas d'icone seule ici). */
.pager { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 14px; margin-top: 14px; }
.pager:empty { display: none; }

/* ---------- Import de contacts en masse ---------- */
.modal:has(.bulk-form) { max-width: 720px; }
/* En-tete du champ de saisie en masse : libelle + bouton « Importer un fichier
   CSV ». `flex-wrap` est un garde-fou : les deux elements reclament ~309px pour
   314px utiles en mobile-390 — la marge est de 5px, un libelle un peu plus long
   ferait sortir le bouton du cadre. */
.bulk-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 6px; }
.bulk-head .label { margin: 0; }
.bulk-textarea { min-height: 130px; font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 13px; }

/* Résumé « N valides · N doublons · N erreurs » */
.bulk-summary { margin: 0; font-size: 13px; font-weight: 600; color: var(--ink-2); }

/* Prévisualisation : hauteur bornée, le tableau défile à l'intérieur. */
.bulk-preview {
  margin-top: 8px;
  max-height: 240px;
  overflow: auto;
  border: 1px solid var(--border);
  border-radius: var(--radius-input);
}
.bulk-table { font-size: 13px; }
.bulk-table td { padding: 6px 10px; vertical-align: middle; }
.bulk-line { color: var(--ink-muted); width: 42px; text-align: right; font-variant-numeric: tabular-nums; }
.bulk-msg { color: var(--ink-2); font-size: 12px; }

/* Barre de progression (élément natif : aucun style inline, CSP-compatible). */
.bulk-progress { width: 100%; height: 8px; margin: 10px 0; border: 0; border-radius: 999px; background: var(--surface-2); appearance: none; }
.bulk-progress::-webkit-progress-bar { background: var(--surface-2); border-radius: 999px; }
.bulk-progress::-webkit-progress-value { background: var(--primary); border-radius: 999px; }
.bulk-progress::-moz-progress-bar { background: var(--primary); border-radius: 999px; }

/* ---------- Vue « détail de site » à onglets (multi-modules) ---------- */
/* Carte réduite à sa barre d'onglets : le padding est porté par `.tabs`, pour
   que le filet de séparation et le soulignement de l'onglet actif s'étendent
   de bord à bord de la carte. */
.tabs-card { padding: 0; }

.tabs {
  display: flex;
  gap: 4px;
  padding: 0 var(--card-pad-x);
  /* 🔴 DEFILEMENT A TOUS LES PALIERS, et non plus sous 768px seulement —
     MODIFICATION du chantier n° 4 (aucune regle ajoutee : celle du palier
     mobile a ete retiree, elle est devenue redondante).
     MESURE qui l'impose : la barre reclame 825px de libelles des que le
     8e module est vendable, pour 756px utiles a 1024px et 794px a 834px. La
     regle n'existait qu'en mobile ; entre 768 et 1024px la barre debordait donc
     de sa carte, et le dernier onglet devenait inatteignable. Sept modules
     tenaient (~716px), le huitieme ne tient plus — et le neuvième
     (« Mise à jour ») aggraverait le cas.
     ⚠️ Le defilement etait DEJA la conception voulue de ce composant : `.tab`
     porte `white-space: nowrap` precisement parce que « c'est le defilement de
     la barre qui absorbe le manque de place ». Il manquait seulement partout
     ailleurs qu'en mobile. C'est un scroller LEGITIME, declare comme tel dans
     docs/RECETTE_RESPONSIVE.md §2.3 et dans `capture.cjs`.
     ⚠️ Corollaire R12 : `overflow-x` explicite force `overflow-y` a `auto`.
     C'est sans effet ici, et par construction : le filet est une ombre interne
     et le soulignement de l'onglet actif une bordure INCLUSE dans la boite —
     aucune marge negative, donc aucun debordement vertical a absorber. */
  overflow-x: auto;
  /* Filet de séparation en OMBRE INTERNE, et non en `border-bottom` : sous
     768px la barre devient défilante (`overflow-x: auto`, cf. section
     Responsive), et un soulignement d'onglet débordant du cadre par une marge
     négative y créerait un défilement VERTICAL parasite — un `overflow-x`
     explicite force `overflow-y` a `auto` (meme piege que `.content`, R12).
     L'ombre interne reste dans la boite, et comme les onglets en occupent
     toute la hauteur, le soulignement de l'onglet actif la recouvre exactement. */
  box-shadow: inset 0 -1px 0 var(--border);
}
.tab {
  font: inherit;
  font-size: 14px;
  /* Graisse CONSTANTE entre l'etat actif et inactif : la faire varier
     changerait la largeur de l'onglet a la selection, et decalerait toute la
     barre. Seuls la couleur et le soulignement distinguent l'onglet actif. */
  font-weight: 600;
  color: var(--ink-muted);
  background: none;
  border: none;
  /* Declare en base (transparent) plutot qu'ajoute sur `.active` : sans cela,
     le libelle sauterait de 2px a chaque changement d'onglet. */
  border-bottom: 2px solid transparent;
  padding: 14px 12px;
  cursor: pointer;
  /* Un libelle de module est court et ne doit jamais se couper (PRD §9.1) :
     c'est le defilement de la barre qui absorbe le manque de place. */
  white-space: nowrap;
  transition: color 0.15s ease, border-color 0.15s ease;
}
.tab:hover { color: var(--ink-2); }
.tab.active { color: var(--primary); border-bottom-color: var(--primary); }

/* 🔴 SANS CETTE REGLE, LA SOUS-BARRE MASQUEE RESTERAIT VISIBLE. `[hidden]
   { display: none }` est declare par la feuille du NAVIGATEUR : le `display:
   flex` de `.tabs` ci-dessus, si banal soit-il, l'emporte. C'est le piege B6,
   deja mesure trois fois dans ce depot (`.site-panel`, `.perm-dirty`,
   `.field`) — et une quatrieme occurrence n'aurait rien appris de plus. */
.tabs[hidden] { display: none; }

/* Second niveau de la barre : les modules de la famille courante (RUX-D7).
   Il ne porte PAS le filet de separation — deux filets empiles feraient croire
   a deux composants sans rapport, quand c'est une seule barre a deux etages —
   et son fond le rattache visuellement a celui du dessus. */
.tabs-sub { box-shadow: none; background: var(--surface-2); }
/* Un cran en dessous du premier niveau : c'est la seule chose qui dise lequel
   des deux commande l'autre. La graisse, elle, ne bouge pas — la faire varier
   decalerait la barre a chaque selection (meme motif que `.tab`). */
.tabs-sub .tab { font-size: 13px; padding: 10px 12px; }

/* Fil d'Ariane du header (RUX-F17) : « Sites / <site> », seul chemin de retour
   d'une vue de site depuis la phase 5.
   🔴 AUCUN `display` ICI, ET C'EST DELIBERE : l'element est masque par son
   attribut `hidden` sur tous les autres ecrans, et toute regle d'auteur portant
   `display` — meme `block` — neutraliserait le `[hidden]` du navigateur en
   silence (piege B6). Un `<nav>` est deja `block` : il n'y a rien a declarer. */
.crumbs { margin: 0 0 2px; font-size: 12px; color: var(--ink-muted); }

/* Panneaux d'onglet. Ils reprennent la gouttiere verticale de `.content`
   (20px) : un panneau qui rend plusieurs cartes — Contacts en rend trois — les
   espace donc exactement comme l'ecran d'origine. */
.site-panels { display: flex; flex-direction: column; }
.site-panel { display: flex; flex-direction: column; gap: 20px; }
/* `hidden` est declare `display: none` par la feuille du navigateur, de plus
   faible specificite que la regle ci-dessus : sans ce rappel, un panneau masque
   resterait affiche. */
.site-panel[hidden] { display: none; }

/* En-tete d'un panneau de module : bouton de retour, libelle du module, chipId.
   Chaque panneau porte le sien — c'est ce qui permet au panneau Contacts (dont
   l'en-tete est `.contacts-head`, inchange depuis la Phase 4) d'etre strictement
   iso-fonctionnel, la coquille n'en dupliquant aucun element. Meme geometrie que
   `.contacts-head-top`, dont il partage le comportement responsive. */
.module-head { display: flex; align-items: flex-start; gap: 14px; }
.module-titles { flex: 1 1 auto; min-width: 0; }
.module-titles .card-title { margin: 0; }
.module-titles .muted { margin: 2px 0 0; font-size: 12px; }
/* Badges d'en-tete de panneau (en ligne / mode). Meme geometrie que
   `.contacts-badges`, dont ils partagent le comportement responsive : le panneau
   Contacts garde son propre en-tete, inchange depuis la Phase 4. */
.module-badges { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; justify-content: flex-end; flex: 0 0 auto; }

/* ---------- Panneau « Vue d'ensemble » (refonte UI, phase 5) ---------- */
/* Les tuiles d'orientation. `auto-fit` et `min(100%, 240px)` reprennent
   exactement le raisonnement de `.sites-grid` : les pistes excedentaires sont
   effondrees plutot que laissees vides, et le plancher cede sous 240px au lieu
   de deborder. Le plancher est plus bas que celui des cartes de site (280px) :
   une tuile ne porte qu'un nom, une valeur et une phrase, la ou une carte de
   site porte un pied d'actions. */
.apercu-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr));
  gap: 12px;
}
/* La tuile EST le geste : c'est un `<button>`, jamais un bloc rendu cliquable
   par un ecouteur — « le texte cliquable nu est interdit » (CONVENTIONS_UI.md
   §1). Elle herite ainsi du focus clavier et de l'anneau global, sans une seule
   declaration de plus. `text-align: left` parce qu'un bouton centre par defaut :
   ce sont trois lignes de texte, pas un libellé d'action. */
.apercu-tuile {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 4px;
  /* Tres au-dela des 40px exiges par la densite d'exploitation (§6), mais
     declare : la regle vaut pour ce qui se clique, pas pour ce qui est grand. */
  min-height: 40px;
  padding: 14px 16px;
  border: 1px solid var(--border);
  border-radius: var(--radius-card);
  background: var(--surface);
  font: inherit;
  font-size: 14px;
  color: var(--ink);
  text-align: left;
  cursor: pointer;
  transition: border-color 0.15s ease, background 0.15s ease;
}
.apercu-tuile:hover { border-color: var(--border-dark); background: var(--surface-2); }
.apercu-tuile:active { background: var(--primary-light); }
/* La valeur relevee, quand il y en a une. ⛔ AUCUNE COULEUR SEMANTIQUE : ce que
   ces tuiles rapportent le plus souvent est une position de relais, ou vert et
   rouge sont INTERDITS (RUX-D4) — « ferme » est un etat logique dont le sens
   physique depend du cablage (RUX-F46). */
.apercu-valeur { font-weight: 600; font-size: 15px; color: var(--ink); }

/* ---------- Module Informations (multi-modules, phase 5) ---------- */
/* Barre d'actions du module : « Actualiser » + horodatage de derniere lecture.
   `flex-wrap` des la base : les deux elements tiennent largement en desktop, il
   n'y sert que de garde-fou, et il evite tout debordement en mobile sans avoir
   a passer la barre en colonne (ou une base flexible en pixels deviendrait une
   hauteur — piege R11). */
.info-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-top: 12px; }

/* Liste « designation / valeur », equivalent visuel du tableau 2 colonnes de
   l'onglet Informations local.

   POURQUOI UNE `<dl>` ET NON UN `<table class="table">` : sous 768px, les
   tableaux applicatifs se transforment en cartes empilees, chaque `<td>` etant
   prefixe de l'intitule de sa colonne. Un tableau « Designation | Valeur » y
   deviendrait 17 cartes de deux lignes (« DESIGNATION : Version » puis
   « VALEUR : v3.22.0 ») — illisible. L'en exclure aurait exige une TROISIEME
   exception `:not(...)` sur les neuf selecteurs du bloc d'empilement, qui est la
   zone la plus sensible de la recette responsive. Une liste de definitions rend
   nativement la meme geometrie a tous les paliers, sans toucher a ce bloc.
   Elle ne defile jamais horizontalement : ce n'est donc PAS un scroller a
   declarer dans les listes blanches des sondes. */
.info-list { margin: 0; }
/* Une ligne = un groupe `<div><dt><dd></div>` (autorise par la specification
   HTML dans une `<dl>`). C'est ce groupe qui porte le filet, ce qu'aucun
   selecteur de paire dt/dd ne permettrait de faire proprement. */
.info-row {
  display: flex;
  align-items: baseline;
  gap: 12px;
  padding: 9px 0;
  border-bottom: 1px solid var(--border);
}
.info-row:last-child { border-bottom: none; padding-bottom: 0; }
/* Base flexible en POURCENTAGE (jamais en pixels, piege R11) : la ligne reste
   une ligne a tous les paliers, et un intitule long se replie au lieu de
   recouvrir sa valeur — meme raisonnement que les cellules `data-label`. */
.info-list dt { flex: 0 0 38%; min-width: 0; color: var(--ink-muted); font-size: 13px; }
.info-list dd {
  flex: 1 1 auto;
  min-width: 0;
  margin: 0;
  color: var(--ink);
  font-weight: 600;
  /* `break-word` et non `anywhere` : la valeur la plus longue est un chipId de
     19 caracteres, qui doit pouvoir se couper a 320px sans que la cesure ne
     s'applique la ou elle n'est pas necessaire. */
  overflow-wrap: break-word;
}
/* Valeur absente : champ hors du protocole MQTT (« non disponible via le
   cloud »), ou valeur simplement non renseignee par la carte (« — »). Jamais un
   champ vide, qui laisserait croire a une valeur nulle. */
.info-na { color: var(--ink-muted); font-weight: 400; font-style: italic; }
.info-hint { display: block; margin-top: 2px; font-size: 12px; font-weight: 400; color: var(--ink-muted); }
/* Memoire libre : memes seuils et memes couleurs que l'interface locale de la
   carte (> 100 Ko vert, > 30 Ko orange, en deca rouge), pour qu'un·e
   technicien·ne retrouve la meme lecture des deux cotes. */
/* Note de bas de section : elle explique une bonne fois les TROIS causes d'une
   valeur absente — « non disponible via le cloud » (jamais transporte),
   « firmware anterieur a v3.27.0 » (se corrige par une mise a jour) et le simple
   tiret (la carte n'a pas la valeur a cet instant). Elle a son propre espacement
   (et non celui de `.field-hint`, prevu pour se coller a un champ de
   formulaire) : collee a la derniere ligne de la liste, elle se lisait comme une
   valeur de plus. */
.info-note { margin: 16px 0 0; font-size: 12px; line-height: 1.5; }
/* Titre de section a l'interieur d'une meme carte : il lui faut un blanc franc
   au-dessus, sans quoi il paraissait appartenir a la note qui le precede. */
.info-section { margin-top: 28px; }
.info-heap-ok { color: var(--success); }
.info-heap-warn { color: var(--warn); }
.info-heap-danger { color: var(--danger); }

/* ---------- Repli technique (refonte UI, phase 2 — RUX-F10) ---------- */
/* Ou descendent les topics MQTT et les valeurs publiees, qui occupaient quatre
   des dix lignes d'une carte de relais AVANT la premiere commande.

   `<details>`/`<summary>` NATIFS, et rien d'autre : l'etat ouvert/ferme, le role
   ARIA, le triangle d'affordance et TOUT le clavier viennent du navigateur. Un
   faux repli en `<div>` aurait exige une gestion clavier a la main — ce que le
   §4 de CONVENTIONS_UI.md interdit pour le lien etendu, et pour la meme raison.
   Le filet du dessus remplace celui que `.info-row:last-child` retire. */
.repli-technique { margin-top: 4px; border-top: 1px solid var(--border); }
/* 🔴 AUCUNE DECLARATION `display` ICI, ET C'EST LA SEULE CHOSE A NE PAS TOUCHER :
   sous Chrome, tout `display` autre que `list-item` sur un `<summary>` SUPPRIME
   le triangle d'affordance — et un repli sans triangle redevient exactement le
   « texte cliquable nu » que CONVENTIONS_UI.md §1 interdit. Un `display: flex`
   pour aligner le resume aurait donc coute l'affordance qu'on venait d'ajouter.
   `min-height` et non `height` (§1) : la cible de 40px vaut aussi sur desktop, et
   un resume qui se replie en deux lignes reste plus haut. */
.repli-technique > summary {
  min-height: 40px;
  padding: 10px 0;
  cursor: pointer;
  font-size: 13px;
  color: var(--ink-muted);
}
/* Le resume tient sur la MEME ligne que l'intitule : `.info-hint` est `block`
   parce qu'il vit normalement sous une valeur de `.info-list`. */
.repli-technique > summary .info-hint { display: inline; margin: 0; }

/* 🔴 L'AIDE CONTEXTUELLE (phase 7, RUX-D26) — ce qui remplace les paves gris.
   Meme mecanique que le repli ci-dessus, et meme exception que `.menu` : le
   marqueur natif cede la place a la PASTILLE « i », qui porte l'affordance a sa
   place. Un pave gris permanent se lit une fois puis devient du bruit ; l'aide,
   elle, reste LA sans etre OUVERTE.
   ⛔ Elle ne porte JAMAIS un avertissement : ce qui decide d'un geste reste
   ecrit en clair, hors de tout repli (`.input-warn`, `.site-warn`). */
.aide { margin-top: 6px; }
.aide > summary {
  list-style: none;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  min-height: 40px;
  cursor: pointer;
  font-size: 12px;
  color: var(--ink-muted);
}
.aide > summary::-webkit-details-marker { display: none; }
.aide-pastille {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 16px;
  height: 16px;
  flex: 0 0 auto;
  border: 1px solid currentColor;
  border-radius: 50%;
  font-size: 11px;
  font-style: italic;
  font-weight: 600;
  line-height: 1;
}
.aide > p { margin: 0 0 6px; font-size: 12px; line-height: 1.5; }

/* ---------- Module Entrees (multi-modules, phase 6) ---------- */

/* Grille des cartes d'entree — une carte par entree, comme l'onglet Entrees de
   l'interface locale de la carte.

   `auto-fit` (et JAMAIS `auto-fill`, cf. `.sites-grid`) : les pistes
   excedentaires sont effondrees, si bien que deux cartes s'ELARGISSENT quand
   l'ecran s'elargit au lieu de laisser une colonne vide. `min(100%, 380px)`
   empeche une piste de deborder d'un conteneur plus etroit que son plancher.
   Le plancher est plus haut que celui de `.sites-grid` (280px) parce qu'une
   carte d'entree porte des TOPICS MQTT : des chaines de 40 a 60 caracteres sans
   espace, que rien ne peut replier sous une largeur trop courte.

   🔴 ET DEPUIS LA PHASE 8, LE PLANCHER PORTE UNE SECONDE RAISON, QUI EST UNE
   ARITHMETIQUE : c'est LUI qui interdit la troisieme colonne, pas un palier.
   Sous le plafond de `--content-max` (1200px utiles), une troisieme piste
   demanderait `3 x plancher + 2 x gap`. A 380px cela faisait **1180px**, donc
   elle tenait : mesure d'avant, 3 colonnes a 1600px et **4 a 1920px**. A 420px
   elle en demande **1300**, et ne tient plus — deux colonnes au maximum, a
   toute largeur d'ecran. ⛔ Un plancher qu'on rabaisse, ou un plafond qu'on
   releve, rouvre donc la troisieme colonne EN SILENCE : c'est pourquoi
   l'inegalite est tenue par une epreuve (`tests/invariants/largeurContenu.test.js`)
   et non par ce commentaire.
   ⚠️ Le changement ne coute AUCUNE colonne aux paliers existants : deux pistes
   en reclament 860, et l'espace utile vaut 1012 a 1280px, 984 a 1024px — le
   palier ou l'on retombait deja a une seule colonne (736 a 768px) est inchange. */
.inputs-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr));
  gap: 20px;
}
/* Les cartes d'une meme rangee s'alignent sur la plus haute : sans cela, deux
   entrees dont l'une porte un avertissement et l'autre non prendraient des
   hauteurs differentes, et leurs pieds ne seraient plus alignes. */
.input-card { display: flex; flex-direction: column; }

/* En-tete de carte : nom de l'organe + son etat. `baseline` et non `center` :
   l'etat (`.relay-etat`, 22px, relais ET entrees depuis HE-D1) n'a pas la
   taille du titre, un centrage optique le ferait flotter. Les autres cartes de
   module y posent un `.badge` de statut, nettement plus petit — meme motif. */
.input-head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 8px;
  margin-bottom: 14px;
}
.input-head .card-title { margin: 0; }

/* Pied de carte : l'action d'edition, poussee en bas par `margin-top: auto`
   pour que les boutons de deux cartes de hauteurs differentes restent alignes.
   `flex-wrap` des la base, comme tous les pieds de carte du depot : les
   libelles de boutons sont en `white-space: nowrap`, le repli est donc le seul
   recours possible. */
.input-foot {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: 8px;
  margin-top: auto;
  padding-top: 14px;
}

/* Avertissements obligatoires (topic libre, valeur vide, topic vide) et
   marqueur « publie, non confirme ». Ils vivent DANS la valeur d'une ligne de
   `.info-list`, donc sous un `<dd>` en `font-weight: 600` : la graisse est
   remise a 400, sans quoi l'avertissement crierait plus fort que la donnee
   qu'il commente.
   `overflow-wrap: break-word` (et non `anywhere`) : ces phrases contiennent des
   mots longs mais aucune chaine technique, et `anywhere` abaisserait la largeur
   min-content de la cellule. */
.input-warn {
  margin: 6px 0 0;
  padding: 8px 10px;
  border-radius: 8px;
  font-size: 12px;
  font-weight: 400;
  line-height: 1.45;
  overflow-wrap: break-word;
}
/* Deux niveaux, volontairement distincts : `info` decrit une configuration
   VALIDE dont il faut connaitre la consequence (valeur vide = aucune
   publication), `warn` signale une configuration qui ne fera pas ce qu'on
   croit. Les confondre banaliserait le second. */
.input-warn-info { background: var(--surface-2); color: var(--ink-muted); }
.input-warn-warn { background: var(--warn-light); color: var(--warn); }

/* Compteur d'octets de la modale d'edition. En OCTETS et non en caracteres :
   le firmware tronque silencieusement au-dela de la taille de ses tampons, et
   un caractere accentue en occupe deux en UTF-8. */
.octet-count { display: block; text-align: right; font-variant-numeric: tabular-nums; }
.octet-trop { color: var(--danger); font-weight: 600; }

/* Note de bas de modale : elle explique une bonne fois les octets, la valeur
   vide et le topic libre. Meme espacement franc que `.info-note`, pour qu'elle
   ne se lise pas comme l'aide du dernier champ. */
.modal-note { margin: 16px 0 0; line-height: 1.5; }

/* ---------- Module Relais (multi-modules, phase 7) ---------- */

/* Le module Relais REUTILISE le motif de carte de la phase 6 (`.inputs-grid`,
   `.input-card`, `.input-head`, `.input-foot`, `.info-list`, `.input-warn`,
   `.octet-count`) : c'est exactement ce a quoi ce motif etait destine, et le
   plancher de 380px de la grille vaut ici pour la meme raison (une carte de
   relais porte deux topics MQTT de 40 a 60 caracteres sans espace). Rien n'est
   duplique sous un autre nom — seules les trois regles ci-dessous sont propres
   aux relais, faute d'equivalent cote entrees. */

/* Groupe des boutons de pilotage, dans le pied de la carte de relais.
   `margin-right: auto` le pousse a gauche et laisse « Modifier » /
   « Planification » a droite : les commandes a effet physique et les actions de
   parametrage ne se melangent pas dans la meme grappe.
   `flex-wrap` des la base : quatre boutons ne tiennent pas sur une carte de
   380px, et leurs libelles sont en `white-space: nowrap` — le repli est le seul
   recours (cf. tous les pieds de carte du depot). */
.relay-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-right: auto;
}

/* Groupe des actions de PARAMETRAGE (« Modifier », « Planification »), a droite
   du pied. Sans ce groupe, les deux boutons se replient separement des que la
   carte est etroite et « Planification » se retrouve seule sur une seconde
   ligne — ce qui se lit comme un defaut de mise en page. Groupes, ils se
   replient ensemble, sous les commandes de pilotage. */
.relay-config {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}

/* ---- La POSITION d'un relais (refonte UI, phase 4 — RUX-D3, RUX-D4) ----
   🆕 ET L'ETAT D'UNE ENTREE (plan d'harmonisation de l'etat des entrees,
   2026-09-14, HE-D1/HE-D3) : `.relay-etat`, `.relay-pastille*` et `.relay-glose`
   servent LES DEUX organes, repris tels quels par `entrees.js` — le prefixe
   `relay-` est un nom historique, pas un perimetre. ⛔ Ne pas les dupliquer
   sous un autre nom : 0 regle ajoutee, c'est l'engagement de CLAUDE.md §5.

   🔴 CE QUI A ETE CHERCHE AVANT D'ECRIRE CES REGLES, ET POURQUOI RIEN N'A ETE
   REPRIS. `.tabs`/`.tab` est le seul motif segmente de la feuille : il navigue,
   il ne commande pas un organe physique, et il n'a ni cadre ni fond — reprendre
   une classe parce qu'elle est la est exactement le piege que CLAUDE.md §1.2-3
   nomme. `.mqtt-dot` est bien une pastille, mais de 7px et TOUJOURS pleine
   (`background: currentColor`) : elle ne sait dire qu'une seule forme, quand
   c'est la FORME qui porte ici toute l'information. `.badge` etait ce qu'on
   remplace (11px, RUX-F6).

   ⛔ AUCUNE COULEUR SEMANTIQUE ICI, ET C'EST UNE INTERDICTION ECRITE
   (CONVENTIONS_UI.md §5, RUX-D4, RUX-F9). « Ferme » n'est ni bon ni mauvais :
   RUX-F46 mesure que `restStateNO` inverse le niveau de broche, si bien que deux
   relais dans le meme etat affiche portent des niveaux OPPOSES. Un vert/rouge
   serait donc exactement faux sur la moitie d'un parc — et un code couleur faux
   sur un organe physique laisse croire a une alarme, ou masque celle qui existe.
   L'ambre de la pastille « inconnue » est le seul ecart, et il ne dit pas
   « mauvais » : il dit « l'ecran ne sait pas », ce qui est vrai quel que soit le
   cablage. */

/* L'etat en 22px (RUX-D6), en tete de carte, a la place d'un badge de 11px.
   Un bloc simple et non un `display: flex` : `.input-head` aligne ses enfants
   sur leur BASELINE, et la baseline d'un conteneur flex est celle de son premier
   enfant — ici la pastille, qui n'a pas de texte. Le titre et l'etat auraient
   flotte l'un par rapport a l'autre. */
.relay-etat { margin: 0; font-size: 22px; font-weight: 600; line-height: 1.2; color: var(--ink); }

/* La pastille. `vertical-align: middle` la centre sur la hauteur du mot sans
   toucher a la baseline du paragraphe, qui reste celle du texte. */
.relay-pastille {
  display: inline-block;
  width: 14px;
  height: 14px;
  margin-right: 8px;
  border: 2px solid var(--ink-muted);
  border-radius: 50%;
  vertical-align: middle;
}
/* 🔴 TROIS FORMES, TROIS CLASSES, ET AUCUNE PAR DEFAUT. Laisser « creuse » etre
   l'absence de variante aurait economise une regle et pose la faute que
   CONVENTIONS_UI.md §5 annonce : « un ecran qui n'a pas de forme pour inconnu
   finira par afficher ouvert a sa place ». Une forme implicite est une forme
   qu'un etat non prevu prend en silence. */
.relay-pastille-pleine { background: var(--ink-muted); }
.relay-pastille-creuse { background: transparent; }
.relay-pastille-inconnue { background: transparent; border-style: dashed; border-color: var(--warn); }

/* La glose de position. Elle dit la seule chose vraie dans TOUS les decors
   (RUX-F46) : ce que cette position actionne depend du cablage. */
.relay-glose { margin: 6px 0 0; font-size: 13px; }

/* Les deux commandes de position (RUX-D31, qui amende RUX-D3). CE N'EST PLUS UNE
   PISTE SEGMENTEE : c'etaient deux ETATS dont le courant, peint en relief,
   etait le plus visible des deux alors qu'il etait le seul a ne rien faire. Ce
   sont maintenant deux VERBES au rang secondaire, dont celui de la position
   atteinte est `disabled`, motif ecrit a cote par la vue.
   ⛔ La piste en creux est donc partie avec eux : un fond et une bordure de
   groupe autour de deux boutons qui portent deja les leurs se lirait comme un
   troisieme cadre. Net : deux regles RETIREES (`.relay-seg:hover` et
   `.relay-seg.is-actuel`), aucune ajoutee.

   🔴 `flex` ET NON `inline-flex`, ET LE MOTIF N'EST PAS COSMETIQUE. `.input-card`
   est une colonne flex sans `align-items`, donc en `stretch` : un `inline-flex`
   s'y serait etire de toute facon — mais en shrink-to-fit, le `flex: 1` des
   boutons n'aurait plus rien a repartir, et chacun aurait pris la largeur de son
   mot. « Fermer » etant plus court qu'« Ouvrir », la frontiere entre les deux se
   serait DEPLACEE a chaque changement de position. C'est exactement le defaut
   que `.tab` evite en gardant une graisse constante ; ici, l'etirement le resout
   mieux, et il donne deux grandes cibles sur un ecran d'exploitation (RUX-D6).
   `max-width` : passe cette largeur, deux boutons cesseraient d'etre un couple
   pour devenir deux bandeaux. */
.relay-position {
  display: flex;
  max-width: 320px;
  gap: 8px;
  margin-top: 14px;
}
/* Le bouton de position est un `.btn.btn-ghost` : il herite ainsi de sa bordure
   au repos, de `:disabled` (opacite 0,6 et curseur), du survol, de la
   transition, du `white-space: nowrap` et de l'anneau de focus global — les
   quatre etats obligatoires (CONVENTIONS_UI.md §1) sans une regle de plus, et
   c'est ce qui a permis de RETIRER ses deux regles propres (RUX-D31).
   🔴 CE COMMENTAIRE A ETE FAUX PENDANT QUATRE PHASES, ET RIEN NE LE DISAIT : la
   vue ecrivait `class="relay-seg"` SANS `btn`. Mesure de la phase 8 — un segment
   `disabled` rendait `opacity: 1` et `cursor: default`, soit le rendu EXACT d'un
   segment arme, et la bordure `outset 2px` noire venait de la feuille du
   navigateur. ⛔ Une classe posee en CSS ne prouve pas qu'une vue la nomme : ce
   qui le prouve est `tests/invariants/largeurContenu.test.js`. `min-height` et non `height` : la cible de 40px vaut ici sur desktop
   aussi (RUX-D6), et `min-height` l'emporte sur le `height: 36px` du `.btn`.
   `flex: 1` : les deux boutons prennent la meme largeur, de sorte que la
   frontiere entre eux ne bouge pas quand la position change de cote. */
.relay-seg {
  flex: 1;
  min-height: 40px;
  padding: 0 18px;
  font-size: 14px;
}

/* Grille des sept jours de la semaine, dans la modale de regle de
   planification. `auto-fit` + `min(100%, …)` comme toutes les grilles du depot :
   les cases s'elargissent au lieu de se multiplier, et aucune piste ne peut
   deborder d'un conteneur plus etroit que son plancher. */
.days-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 130px), 1fr));
  gap: 8px 16px;
  margin-top: 6px;
}

/* Chapeau de la modale de confirmation de pilotage : c'est la phrase qui porte
   l'avertissement d'effet physique, elle doit se lire avant le tableau. */
.modal-lead { margin: 0 0 16px; line-height: 1.5; }

/* Nom de site cliquable (carte de l'annuaire) : il ouvre la fiche du site.
   Un bouton, et non un lien : la navigation est interne a la SPA, il n'y a
   aucune URL a offrir. Toute sa typographie est heritee de `.site-name` pour
   que la carte reste visuellement inchangee ; seul le survol le signale. */
.site-name-btn {
  font: inherit;
  color: inherit;
  background: none;
  border: none;
  padding: 0;
  text-align: left;
  cursor: pointer;
}
.site-name-btn:hover { color: var(--primary); text-decoration: underline; }

/* ---------- Signe de vie — composants portés de l'interface locale (SL-D3) ---------- */

/* 🔴 ÉCART ÉCRIT À L'ENGAGEMENT « 0 RÈGLE CSS » (RUX-D14), LE TROISIÈME DE LA
   FEUILLE, ET LE SEUL QUI PORTE DES COMPOSANTS. Décision SL-D3 du plan
   « Signe de vie comme le local », prise avec l'utilisateur·rice le
   2026-09-18 : la page cloud EST le formulaire de l'interface locale, édité en
   place (SL-D1), et la charte ne possédait AUCUN de ses composants (SL-F3 :
   0 `.toggle`, 0 `.day-btn`, 0 pastille de canal, 0 bandeau de statut). La
   question prescrite — « quelle classe existante reprendre » — a été posée
   composant par composant, et ce qui EST repris l'est : `.card`, `.card-title`,
   `.input-head`, `.info-hint`, `.checkbox` (la pastille de canal en est une
   variante, jamais une copie), `.field` / `.label` / `.input` / `.field-hint`,
   `.octet-count`, `.info-note`, `.btn-primary` / `.btn-ghost` (SL-D6 : aucun
   `.btn-success`), et le bloc des cibles tactiles de 40 px (section
   « Responsive »).
   🎯 Ce que le local dépense en ~54 règles (SL-F2, dont 8 mortes) tient ici
   en 30 (33 sélecteurs, 13 classes) : le minimum qui rend LE MÊME composant, avec les tokens de CETTE
   charte (`--surface-2`, `--ink-2` — les couleurs sont les mêmes des deux
   côtés, SL-F3). Aucune des 8 règles mortes n'est portée.
   ⛔ Le préfixe des classes est `sv-` (celui du panneau), jamais `hb-` (un nom
   de firmware). `.toggle` et `.day-btn` gardent leur nom : ce sont les
   composants eux-mêmes, et le local les appelle ainsi.
   ➡️ CONVENTIONS_CSS.md et CONVENTIONS_UI.md §7 portent l'exception et le
   bilan net avant / après. */

/* --- L'interrupteur ---
   Le composant du local (`style.css:475-514`) : 36 × 20 px, curseur sur
   `--border-dark`, rayon 20, bille blanche de 16 px, coché → `--primary` et
   glissement de 16 px. Balisage : `<label class="toggle"><input
   type="checkbox"><span class="toggle-slider"></span></label>` — la case
   NATIVE reste dans le DOM (clavier, lecteur d'écran, `checked` lisible par
   `lire()`), seul son rendu est confié au curseur.
   ⚠️ Deux règles que le local n'a pas, et qu'il ne peut pas ne pas avoir
   ici : l'anneau de focus (la case native est rendue invisible, le
   `button:focus-visible` de la charte ne la voit donc pas — un interrupteur
   qu'on ne peut pas voir prendre le focus est injouable au clavier) et l'état
   `disabled` (le formulaire entier se désactive pour un rôle sans écriture,
   A8 ; même rendu que `.btn:disabled`). `.toggle-wrap` et `.toggle-label` du
   local ne sont PAS portés : `.sv-entete` et `.card-title` tiennent ce rôle. */
.toggle {
  position: relative;
  display: inline-block;
  width: 36px;
  height: 20px;
  flex-shrink: 0;
  cursor: pointer;
}
.toggle input {
  position: absolute;
  width: 0;
  height: 0;
  margin: 0;
  opacity: 0;
}
.toggle-slider {
  position: absolute;
  inset: 0;
  background: var(--border-dark);
  border-radius: 20px;
  transition: background-color 0.2s ease;
}
.toggle-slider::before {
  content: '';
  position: absolute;
  top: 2px;
  left: 2px;
  width: 16px;
  height: 16px;
  background: #fff;
  border-radius: 50%;
  transition: transform 0.2s ease;
}
.toggle input:checked + .toggle-slider { background: var(--primary); }
.toggle input:checked + .toggle-slider::before { transform: translateX(16px); }
.toggle input:focus-visible + .toggle-slider { outline: 2px solid var(--primary); outline-offset: 2px; }
.toggle input:disabled + .toggle-slider { opacity: 0.6; cursor: not-allowed; }

/* 🔴 Le glissement de la bille est un MOUVEMENT déclenché par l'interaction,
   et il n'est pas porteur d'information : l'état se lit à la couleur du curseur
   et à la position finale. Sans transition, l'interrupteur bascule d'un coup.
   ⚠️ Ce @media est ICI et non dans la section « Responsive », pour la raison
   écrite au-dessus de `.spinner-inline` : ce n'est pas un palier de largeur,
   et une garde d'accessibilité séparée de la règle qu'elle protège est une
   garde qu'on oublie. */
@media (prefers-reduced-motion: reduce) {
  .toggle-slider,
  .toggle-slider::before { transition: none; }
}

/* --- La pastille de jour à bascule ---
   La variante du local que le signe de vie emploie (`.hb-days .day-btn`,
   `style.css:1472-1483` : min-width 42, hauteur 36, 12 px, rayon 6), JAMAIS sa
   base 32 × 28 / 10 px, qui n'est pas rendue sur cette page.
   C'est un `<button type="button" aria-pressed="true|false">Lu</button>` :
   l'état vit dans l'attribut ARIA, un seul endroit, lisible par `lire()` et
   annoncé au lecteur d'écran — pas de classe `.active` en doublon.
   ⚠️ COEXISTENCE ÉCRITE (risque R4 du plan) : `.days-grid` + `.checkbox`
   restent la forme de « cocher un jour » d'une règle de PLANIFICATION DE
   RELAIS ; `.day-btn` est celle du SIGNE DE VIE, parce que c'est celle du
   local qu'on reproduit. Aucun des deux écrans n'est harmonisé sur l'autre.
   Sous 768 px la pastille rejoint le bloc des cibles tactiles (40 px). */
.day-btn {
  min-width: 42px;
  height: 36px;
  padding: 0 10px;
  font-family: inherit;
  font-size: 12px;
  font-weight: 600;
  color: var(--ink-muted);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 6px;
  cursor: pointer;
  transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.day-btn[aria-pressed="true"] {
  color: #fff;
  background: var(--primary);
  border-color: var(--primary);
  box-shadow: 0 2px 4px rgba(37, 99, 235, 0.2);
}
.day-btn:disabled { opacity: 0.6; cursor: not-allowed; }

/* Rangée de pastilles — les sept jours d'un moment, ou les deux canaux d'un
   destinataire. Une seule classe pour les deux (le local en a deux, `.hb-days`
   gap 6 et `.hb-recipient-chans` gap 8 : l'écart de 2 px ne se voit pas).
   `min-height: 44px` : posée dans un `.field` sous son `.label`, la rangée
   prend la hauteur d'un `.input`, et les pastilles (32 ou 36 px) s'y centrent —
   c'est ce qui aligne les canaux d'un destinataire sur son champ de numéro
   sans le `padding-bottom` de compensation du local. */
.sv-rangee {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  min-height: 44px;
}

/* --- La pastille de canal (« SMS », « Appel ») ---
   Une VARIANTE de `.checkbox` (case native de 16 px, `accent-color`, libellé
   cliquable), jamais une copie : `.checkbox` fournit la boîte en ligne, le
   curseur et la case ; la variante n'ajoute que la PILULE du local
   (`.hb-chan-chip`, `style.css:1553-1571` : padding 6 / 10, rayon 20, fond
   `--surface-2`, coché → `--primary-light` / `--primary` en 600).
   Balisage : `<label class="checkbox sv-canal"><input type="checkbox"> SMS</label>`.
   `:has()` : la feuille l'emploie déjà (`.modal:has(.form-grid)`), le
   navigateur de recette est Chromium. */
.checkbox.sv-canal {
  gap: 6px;
  padding: 6px 10px;
  font-size: 12px;
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: var(--radius-badge);
  transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.checkbox.sv-canal:hover { border-color: var(--border-dark); }
.checkbox.sv-canal:has(input:checked) {
  color: var(--primary);
  font-weight: 600;
  background: var(--primary-light);
  border-color: var(--primary);
}
.checkbox.sv-canal:has(input:disabled) { opacity: 0.6; cursor: not-allowed; }

/* --- Le bandeau de statut ---
   La première carte du local (`.hb-status-card`) : à gauche l'interrupteur
   général, le titre et sa glose ; à droite « Prochain envoi » / « Dernier
   envoi », libellé en capitales de 11 px sur valeur de 14 px en 600, alignés à
   droite. Sous 768 px il passe en COLONNE et la partie droite s'aligne à gauche
   (section « Responsive »).
   `.sv-entete` (interrupteur + titre, `.hb-status-left` du local) sert AUSSI
   l'en-tête des cartes « Moment n° 1 / n° 2 » et « Canal MQTT », posé dans un
   `.input-head` ; `.sv-info-label` / `.sv-info-value` servent aussi le
   « Dernier : — » de ces mêmes en-têtes, en ligne. Le titre est `.card-title`
   et la glose `.info-hint` : la densité est celle du cloud, pas du local. */
.sv-statut {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
}
.sv-entete { display: flex; align-items: center; gap: 14px; }
.sv-statut-droite {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 4px;
  min-width: 240px;
  text-align: right;
}
.sv-info-label {
  font-size: 11px;
  color: var(--ink-muted);
  text-transform: uppercase;
  letter-spacing: 0.5px;
}
.sv-info-value {
  font-size: 14px;
  font-weight: 600;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

/* --- La ligne de destinataire ---
   Le champ « Contact n » (un `.field` : `.label` + `.input type="tel"`, SL-D4 —
   numéro libre, pas le carnet) et, à sa droite, un second `.field` portant les
   deux pastilles de canal dans une `.sv-rangee`. Grille `1fr auto` et non un
   flex : la colonne des canaux prend exactement sa largeur, le champ prend le
   reste, et aucun enfant ne porte de base flexible en pixels (piège R11).
   Un filet entre deux lignes, jamais après la dernière : `+` plutôt qu'un
   `:last-child` à annuler (une règle au lieu de deux). Sous 768 px, une seule
   colonne (section « Responsive »). */
.sv-destinataire {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: 12px;
  align-items: start;
  padding: 10px 0;
}
.sv-destinataire + .sv-destinataire { border-top: 1px solid var(--border); }

/* --- La carte d'actions, en pied ---
   SL-D10 : « Enregistrer » à GAUCHE, les deux gestes du chantier n° 5
   (« Envoyer un test maintenant », « Réinitialiser le compteur ») groupés à
   DROITE dans `.sv-actions-secondaires` — le précédent est le pied d'une carte
   de relais (`.relay-actions` à gauche, `.relay-config` à droite) : ce qui
   ÉCRIT sur la carte ne se mêle pas à ce qui FAIT APPELER trois personnes.
   `space-between` : avec le seul « Enregistrer » (phase 2, avant les deux
   boutons), il reste à gauche ; aucune marge `auto` sur un enfant, donc rien
   à annuler quand le pied passe en colonne (piège B6 de la marge transversale).
   `flex-wrap` dès la base, comme tous les pieds de carte du dépôt. */
.sv-actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.sv-actions-secondaires { display: flex; flex-wrap: wrap; gap: 12px; }

/* ---------- Ecran « Droits & abonnements » (multi-modules, phase 4) ---------- */

/* Barre de selection du site. Elle reste une LIGNE repliable a tout palier, et
   aucun de ses enfants ne porte de base flexible en PIXELS : une base en px
   s'exprime sur l'axe principal et deviendrait une HAUTEUR si la barre passait
   en colonne (piege R11, rencontre deux fois pendant le chantier
   responsivite). La base du champ est en POURCENTAGE, structurellement sure sur
   les deux axes. */
.droits-toolbar { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 12px 16px; }
.droits-toolbar .field { flex: 1 1 60%; min-width: 0; }
.droits-org {
  margin: 0;
  /* Recale le libelle sur le milieu du champ (44px) plutot que sur sa base. */
  padding-bottom: 12px;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  font-size: 13px;
  color: var(--ink-2);
}

/* Matrice de droits (modules x comptes), a la charte de la page « Profils » de
   l'interface locale : premiere colonne = intitule, colonnes suivantes = sujets,
   cases a cocher, lignes groupees par sections.

   ⚠️ C'est un tableau TECHNIQUE, relu case a case : il reste un TABLEAU a tous
   les paliers et ne se transforme JAMAIS en cartes empilees — l'empilement
   ferait perdre le croisement ligne x colonne qui en fait tout l'interet. D'ou
   l'exception `:not(.perm-matrix)` portee par les selecteurs d'empilement de la
   section « Responsive », exactement comme `.bulk-table`. Son defilement
   horizontal est celui de `.table-wrap`, deja declare scroller LEGITIME dans la
   sonde de docs/RECETTE_RESPONSIVE.md §2.3. */
.perm-matrix th,
.perm-matrix td { padding-left: 12px; padding-right: 12px; }
/* Premiere et derniere colonnes : elles reprennent l'inset de la carte, pour que
   la matrice s'aligne sur le titre et la legende qui l'encadrent. */
.perm-matrix tr > *:first-child { padding-left: var(--card-pad-x); }
.perm-matrix tr > *:last-child { padding-right: var(--card-pad-x); }

/* Colonne des modules. `position: sticky` : le libelle reste lisible quand la
   matrice defile horizontalement — sans lui, une case cochee au-dela du 3e
   compte ne serait plus rattachable a sa ligne. Le fond opaque est OBLIGATOIRE :
   une cellule sticky transparente laisserait defiler le contenu dessous. */
.perm-matrix .perm-module {
  position: sticky;
  left: 0;
  z-index: 1;
  background: var(--surface);
  text-align: left;
  /* `.table th` est en petites capitales grises : c'est la typographie d'un
     en-tete de colonne, pas celle d'un libelle de module. */
  font-size: 13px;
  font-weight: 500;
  letter-spacing: normal;
  text-transform: none;
  color: var(--ink-2);
  white-space: normal;
}
/* Coin haut-gauche : il se superpose a la fois aux en-tetes de comptes et a la
   colonne des modules. */
.perm-matrix thead .perm-module { z-index: 2; }
/* `.table tbody tr:hover` colore la ligne entiere : la cellule sticky, qui porte
   son propre fond opaque, doit suivre — sinon le survol trancherait net au
   milieu de la ligne. */
.perm-matrix tbody tr:hover .perm-module { background: var(--surface-2); }
/* `.table tbody tr:last-child td` supprime le filet de la derniere ligne, mais
   ne vise que les `td` : la premiere colonne de la matrice etant un `th`, son
   filet restait seul, comme un trait s'arretant au milieu du tableau. */
.perm-matrix tbody tr:last-child th { border-bottom: none; }
.perm-module-name { margin-right: 6px; }
.perm-flag { margin-right: 4px; vertical-align: middle; }

/* En-tete de section : motif « COMMANDES SMS » / « INTERFACE WEB » de la
   maquette — petites capitales sur toute la largeur du tableau. */
.perm-matrix .perm-section th {
  padding-top: 16px;
  padding-bottom: 6px;
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  text-align: left;
  color: var(--ink-muted);
  background: var(--surface-2);
}

/* Colonne d'un compte (en-tete ET cellules : les deux portent `.perm-col`). */
.perm-matrix .perm-col {
  text-align: center;
  white-space: normal; /* `.table th` est en nowrap */
  vertical-align: bottom;
  min-width: 116px;
}
.perm-matrix td.perm-col { vertical-align: middle; }
.perm-head-name {
  display: block;
  font-size: 13px;
  font-weight: 600;
  letter-spacing: normal;
  text-transform: none;
  color: var(--ink);
  /* Une adresse e-mail n'offre aucune coupure naturelle. La cesure est ici sans
     effet de bord : la matrice est un ecran NOUVEAU (aucun rendu desktop de
     reference a preserver, contrairement au garde-fou `.mono`) et la largeur des
     colonnes est plancheee par `min-width`. */
  overflow-wrap: anywhere;
}
.perm-head-role {
  display: block;
  margin-top: 2px;
  font-size: 11px;
  font-weight: 500;
  letter-spacing: normal;
  text-transform: none;
  color: var(--ink-muted);
}
.perm-head-badges { display: flex; flex-wrap: wrap; justify-content: center; gap: 4px; margin-top: 5px; }

/* Colonne modifiee mais pas encore enregistree : l'en-tete entier est teinte, en
   plus de la mention — la couleur seule ne doit jamais porter une information. */
.perm-col.is-dirty { background: var(--warn-light); }
.perm-dirty {
  display: block;
  margin-top: 5px;
  /* voir le rappel `[hidden]` juste apres la regle */
  font-size: 10px;
  font-weight: 600;
  letter-spacing: normal;
  text-transform: none;
  color: var(--warn);
}
/* `hidden` est declare `display: none` par la feuille du NAVIGATEUR, de plus
   faible priorite que la regle d'auteur ci-dessus : sans ce rappel, le marqueur
   « modifie » resterait affiche en permanence sur les quatre colonnes — defaut
   reellement rencontre, invisible a la sonde (la propriete `.hidden` valait bien
   `true`) et vu a la relecture des captures. Meme piege que `.site-panel[hidden]`. */
.perm-dirty[hidden] { display: none; }

/* La case a cocher occupe toute la cellule : la cible de clic est la cellule
   entiere, et la case reste centree sous l'en-tete de sa colonne. */
.perm-check { display: flex; justify-content: center; gap: 0; }

/* Colonnes en lecture seule (operateur·rice / lecture) : elles affichent le
   perimetre HERITE de l'organisation (decision D2), jamais des cases. Le fond
   les distingue au premier coup d'oeil des colonnes editables. */
.perm-matrix .perm-lecture { background: var(--surface-2); }
.perm-etat {
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.03em;
  text-transform: uppercase;
  color: var(--primary);
}
.perm-etat-non { color: var(--ink-muted); }

/* Deux actions dans le bandeau de carte (« Annuler les modifications » et
   « Enregistrer ») : elles se replient ensemble sous le titre grace au
   `flex-wrap` de `.card-head`, puis passent en colonne sous 480px. */
.perm-head-actions { display: flex; flex-wrap: wrap; gap: 8px; }

/* Bandeau du risque R11 : perimetre herite vide. */
.perm-banner {
  margin: 0 0 16px;
  padding: 12px 14px;
  font-size: 13px;
  line-height: 1.55;
  color: var(--warn);
  background: var(--warn-light);
  border: 1px solid rgba(217, 119, 6, 0.2);
  border-radius: var(--radius-input);
}
.perm-legend { margin: 14px 0 0; font-size: 12px; line-height: 1.6; }

/* Journal d'audit */
.audit-ts { white-space: nowrap; color: var(--ink-2); }
/* Colonne « Détail » : payloads techniques sans espace → cesure autorisee (R10). */
.audit-detail { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; color: var(--ink-2); word-break: break-word; overflow-wrap: anywhere; }

/* ============================================================
   RESPONSIVE — section unique (cf. docs/PLANS/PLAN_RESPONSIVE_UI.md §3.3)

   Paliers de reference (decision D4) :
     · mobile   : < 768px          → @media (max-width: 767.98px)
     · tablette : 768 a 1023.98px  → @media (max-width: 1023.98px)
     · desktop  : >= 1024px        → regles de base, hors requete media
   Les bornes fractionnaires (…98px) evitent tout trou de couverture lorsque le
   navigateur rapporte une largeur non entiere (zoom systeme).

   Ordre imperatif : base (desktop) → tablette → mobile → paliers fins.
   TOUTE nouvelle regle responsive vient ICI, jamais dispersee dans la feuille.
   ============================================================ */

/* ---------- Tablette et moins (< 1024px) ---------- */
@media (max-width: 1023.98px) {
  :root {
    --pad-page: 20px;
    --sidebar-w: 260px; /* largeur du tiroir (PRD §10.2, colonne Mobile/Tablet) */
  }

  /* --- Sidebar en tiroir (D1) ---
     🔴 LES QUATRE REGLES SONT REMONTEES EN BASE, sous `.nav-tiroir` (**RUX-D30**,
     section Layout). Ce palier ne les porte plus : ce n'est pas la largeur qui
     allume le tiroir, c'est la classe — que `layout.js` pose ICI comme avant,
     et EN PLUS chez un compte dont la navigation ne porte qu'une entree.
     ⛔ Ne pas les redescendre : le palier serait alors ecrit a deux endroits,
     ce que le piege B6 demandait justement de tenir synchronise. */

  /* --- Header adaptatif (R5) ---
     Le titre est borne a 2 lignes avec ellipsis. Volontairement ABSENT du
     desktop : `display: -webkit-box` y modifierait la boite du h1 alors que le
     titre ne deborde jamais a cette largeur (D5). `-webkit-line-clamp` est la
     forme interoperable de la limitation de lignes. */
  .page-title {
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 2;
    overflow: hidden;
  }
  /* Le titre absorbe l'espace libre : sans cela, `justify-content:
     space-between` le pousserait contre le bord droit du header des que le
     hamburger apparait (et, en cas de retour a la ligne, le decollerait de lui).
     `flex-grow` ne modifie pas la taille de base des items, donc n'influence pas
     la decision de retour a la ligne. */
  .topbar-title { flex: 1 1 auto; }

  /* Garde-fou R10 : numeros et identifiants sont des chaines longues sans
     espace ; sous 1024px la largeur est comptee, la cesure est donc autorisee.
     Volontairement ABSENT du desktop : `overflow-wrap: anywhere` abaisse la
     largeur min-content d'une cellule, ce qui change la repartition
     automatique des colonnes (mesure de recette Phase 1 : le numero de la
     previsualisation d'import basculait sur deux lignes en 1440 et 1920) —
     interdit par la decision D5. */
  .mono { overflow-wrap: anywhere; }
  /* Exception : la previsualisation d'import reste un tableau technique lu
     ligne a ligne, a defilement horizontal a tous les paliers (cf. Phase 4) ;
     y couper les numeros nuirait a la relecture des lignes en erreur. */
  .bulk-table .mono { overflow-wrap: normal; }
}

/* ---------- Mobile (< 768px) ---------- */
@media (max-width: 767.98px) {
  :root {
    --pad-page: 16px;
    --card-pad-x: 16px;
    --card-pad-y: 16px;
    --modal-gutter: 16px;
  }
  /* L'empilement historique de la barre laterale (R2 : bandeau pleine largeur
     au-dessus du contenu, ~250px perdus) a ete retire en Phase 2 : le tiroir
     declare au palier tablette couvre desormais tout ce qui est sous 1024px. */

  /* ---- Ecran Sites (Phase 3) ---- */

  /* Barre d'outils : recherche puis filtres, chacun sur toute la largeur.
     Regles remontees du palier transitoire 560px (le plan les voulait des
     767.98px) ; elles servent aussi au journal d'audit, qui reutilise
     `.sites-toolbar` pour ses trois filtres. */
  .sites-toolbar { flex-direction: column; }
  /* R11 : `flex: 1 1 280px` exprime une base sur l'AXE PRINCIPAL. Des que la
     barre passe en colonne, ces 280px deviennent donc une HAUTEUR — le champ
     de recherche etait loge dans un bloc de 280px de haut (236px de vide
     mesures en Phase 0). La base est neutralisee ; la largeur pleine vient de
     `align-items: stretch`, valeur par defaut du conteneur. */
  .sites-toolbar .search-wrap { flex: 0 0 auto; }
  /* `width: auto` de la regle de base annulerait l'etirement du conteneur. */
  .filter-select { width: 100%; }

  /* En-tete de carte : le titre prend toute la largeur, les badges passent en
     ligne dessous. Les laisser en colonne a droite (rendu desktop) reserverait
     la largeur du plus long badge et etranglerait le nom du site. */
  .site-card-head { flex-direction: column; }
  .site-badges { flex-direction: row; flex-wrap: wrap; align-items: center; }

  /* Pied de carte en colonne : action principale pleine largeur, actions
     secondaires sur la ligne suivante. Les cibles tactiles de ce pied (le
     bouton « Gerer les contacts » et les `.action`) etaient portees a 40px ici
     meme en Phase 3 ; elles le sont desormais par les regles generales de
     `.btn` et `.action` du bloc « Cibles tactiles » en fin de palier — une
     seule source de verite pour la taille des cibles. */
  .site-card-foot { flex-direction: column; align-items: stretch; }

  /* ---- Tableaux -> cartes empilees (D2, Phase 4) ----
     Un tableau de 4 a 6 colonnes n'est pas lisible sous 768px : la moitie
     droite — dont la colonne d'actions — sortait du champ visible dans le
     defilement interne de `.table-wrap` (R4). Chaque `<tr>` devient donc une
     carte et chaque `<td>` une ligne « libelle : valeur », le libelle etant
     repris de l'intitule de colonne via l'attribut `data-label` pose par les
     vues. Aucune logique JS n'est touchee : seul cet attribut de presentation
     est ajoute.

     DEUX EXCEPTIONS, meme raison : ce sont des tableaux TECHNIQUES, dont la
     lecture EN COLONNES fait tout l'interet — les empiler la detruirait. Elles
     restent donc des tableaux defilants a tous les paliers, d'ou le
     `:not(.bulk-table):not(.perm-matrix)` porte par chacun des selecteurs
     ci-dessous :
       · `.bulk-table`  : previsualisation d'import, relue ligne a ligne
         (n° de ligne, etat, message), deja bornee en hauteur et pourvue de son
         propre defilement dans `.bulk-preview` ;
       · `.perm-matrix` : matrice de droits (phase 4 du chantier multi-modules),
         relue CASE A CASE au croisement d'un module et d'un compte. Empilee,
         chaque ligne deviendrait une carte « module » sans aucune colonne de
         compte en regard : la matrice n'aurait plus de sens. */

  /* La zone de tableau reprend l'inset de la carte : les cartes-lignes portent
     desormais leur propre bordure et s'alignent sur le titre, alors que les
     marges negatives ne servaient qu'aux lignes bord-a-bord du tableau. */
  .table-wrap { margin-inline: 0; }
  .table-wrap > :not(table) { padding-inline: 0; }

  .table:not(.bulk-table):not(.perm-matrix),
  .table:not(.bulk-table):not(.perm-matrix) tbody { display: block; }

  /* En-tete sorti du flux mais NON supprime : `display: none` le retirerait de
     l'arbre d'accessibilite. Deporte a gauche hors ecran — en LTR, le
     debordement du cote inline-start n'est pas defilable, il ne peut donc creer
     aucun scroll horizontal (ni page, ni conteneur). Sa structure de tableau
     est laissee intacte (aucun `display` force sur ses `tr`/`th`). */
  .table:not(.bulk-table):not(.perm-matrix) thead { position: absolute; left: -9999px; }

  .table:not(.bulk-table):not(.perm-matrix) tbody tr {
    display: block;
    margin-bottom: 10px;
    padding: 10px 14px;
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: var(--radius-card);
  }
  .table:not(.bulk-table):not(.perm-matrix) tbody tr:last-child { margin-bottom: 0; }

  /* Ligne « libelle : valeur ». `display: flex` plutot qu'un `::before` en
     position absolue : un libelle long (« Relais autorises ») peut alors se
     replier sans jamais recouvrir la valeur, et la cellule ne peut pas
     deborder. Corollaire assume : en contexte flex, les blancs entre elements
     en ligne d'une meme cellule (badges de relais, badges d'organisation) ne
     produisent plus d'espace — c'est `column-gap` qui les remplace. */
  .table:not(.bulk-table):not(.perm-matrix) tbody td {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: 4px 10px;
    padding: 5px 0;
    border-bottom: none;
    /* En carte empilee, la largeur des colonnes n'est plus calculee par
       l'algorithme de tableau : la cesure des chaines longues (e-mails, chipId,
       detail d'audit) est ici sans effet de bord — contrairement au desktop,
       ou elle redistribuerait les colonnes (cf. `.mono`, palier 1024px). */
    overflow-wrap: anywhere;
  }
  .table:not(.bulk-table):not(.perm-matrix) tbody td::before {
    content: attr(data-label);
    flex: 0 0 38%;
    min-width: 0;
    /* Typographie de `.table th` : le libelle de colonne garde son identite. */
    font-size: 11px;
    font-weight: 600;
    letter-spacing: 0.03em;
    text-transform: uppercase;
    color: var(--ink-muted);
  }

  /* Actions : ligne dediee en pied de carte, isolee par un filet, boutons
     etires. Leur hauteur de 40px (R8) vient de la regle generale de `.action`
     (bloc « Cibles tactiles », en fin de palier), ou elle etait declaree ici en
     Phase 4. Pas de libelle affiche — la position en pied et le filet les
     identifient.
     `white-space: nowrap` (regle de base de `.col-actions`) est conserve : il
     protege les libelles de boutons sans empecher le repli des items flex. */
  .table:not(.bulk-table):not(.perm-matrix) tbody td.col-actions {
    margin-top: 6px;
    padding: 10px 0 0;
    gap: 6px;
    border-top: 1px solid var(--border);
  }
  .table:not(.bulk-table):not(.perm-matrix) tbody td.col-actions::before { content: none; }
  .table:not(.bulk-table):not(.perm-matrix) tbody td.col-actions .action {
    flex: 1 1 auto;
    margin-left: 0; /* l'espacement vient de `gap` */
  }

  /* ---- Modales & formulaires (Phase 5) ---- */

  /* Modale ancree en HAUT de l'ecran. Centree, elle « saute » a l'ouverture du
     clavier virtuel : la zone visible se reduit de moitie, le recentrage
     escamote l'en-tete et le champ en cours de saisie. Ancree en haut, la
     modale reste stable ; la marge haute est celle de l'overlay
     (`--modal-gutter`), donc coherente avec les trois autres cotes. */
  .modal-overlay { align-items: flex-start; }

  /* Le plafond de hauteur s'adosse aux gouttieres plutot qu'a un pourcentage :
     la modale occupe TOUT l'espace disponible moins ses marges (contre 90% de
     l'ecran auparavant, soit 84px perdus en mobile-390 alors meme que trois
     modales sur six y sont deja tronquees). `100dvh` suit la barre d'URL (R9),
     `100vh` sert de repli.
     Volontairement ABSENT du desktop : en 1440x900, `calc(100dvh - 48px)`
     vaudrait 852px contre 810px pour `90vh` — la modale de previsualisation
     d'import, seule a etre clampee a cette taille, y grandirait de 42px, soit
     une regression du rendu desktop (D5). */
  .modal {
    max-height: calc(100vh - var(--modal-gutter) * 2);
    max-height: calc(100dvh - var(--modal-gutter) * 2);
  }

  /* Formulaires en grille (modale de site) sur une seule colonne : a deux
     colonnes, chaque champ tomberait sous 130px de large. Regle remontee du
     palier transitoire 560px (R6 : le plan la voulait des 767.98px). */
  .form-grid { grid-template-columns: 1fr; }

  /* ---- Ecran Contacts (Phase 6) ---- */

  /* En-tete : bouton de retour, titres et badges empiles. Regles remontees du
     palier transitoire 560px (le plan les voulait des 767.98px) — ce palier
     disparait avec elles.
     `align-items: stretch` remplace le `flex-start` de la regle de base : en
     colonne, c'est l'etirement transversal qui donne au bouton « ← Sites »
     toute la largeur, sans `width: 100%` redondant. */
  .contacts-head-top { flex-direction: column; align-items: stretch; }
  .contacts-badges { justify-content: flex-start; }
  /* En-tete des autres panneaux de module : meme empilement, meme raison — en
     colonne, l'etirement transversal donne au bouton « ← Sites » toute la
     largeur. Aucun de ses enfants ne porte de base flexible en pixels (piege
     R11), le passage en colonne est donc sans effet de bord. */
  .module-head { flex-direction: column; align-items: stretch; }
  /* Badges du panneau : alignes a gauche une fois l'en-tete en colonne, comme
     ceux de Contacts juste au-dessus (le `flex-end` de la regle de base n'a de
     sens qu'en ligne, ou ils bordent la droite de l'en-tete). */
  .module-badges { justify-content: flex-start; }

  /* Barre d'outils : la recherche prend toute la ligne, « Actualiser » et le
     compteur passent ENSEMBLE sur la suivante.
     Seconde moitie du correctif R11 : la barre reste une LIGNE `wrap`, elle ne
     passe pas en colonne. Deux raisons, l'une geometrique et l'autre
     fonctionnelle. (1) En colonne, la base flexible `1 1 280px` de la regle de
     base s'exprimerait sur l'axe vertical et deviendrait une HAUTEUR — c'est
     exactement le defaut R11 corrige sur `.sites-toolbar` en Phase 3 (un champ
     de 44px loge dans un bloc de 280px). (2) Un empilement en colonne mettrait
     « Actualiser » et le compteur sur deux lignes distinctes, alors qu'ils
     doivent partager la meme. La base passe donc a `100%` : exprimee sur l'axe
     principal (horizontal), elle vaut une largeur pleine et repousse d'elle-meme
     le reste a la ligne suivante. Une base en pourcentage est au passage
     STRUCTURELLEMENT sure : sur l'axe vertical d'un conteneur a hauteur
     indefinie, elle se resout en `auto` (contenu) la ou 280px resterait 280px. */
  .contacts-toolbar .search-wrap { flex: 1 1 100%; }

  /* ---- Vue de site à onglets (multi-modules) ---- */

  /* La barre d'onglets defile — regle REMONTEE EN BASE au chantier n° 4, elle
     n'a plus a etre redite ici (cf. `.tabs` dans la section « Vue detail de
     site »). Rien ne subsiste a ce palier pour ce composant. */

  /* ---- Ecran « Droits & abonnements » (multi-modules, phase 4) ---- */

  /* Les colonnes de comptes se resserrent pour qu'une troisieme reste
     atteignable sans defilement en mobile-390. La premiere et la derniere
     gardent l'inset de la carte : leurs regles de base sont plus specifiques
     (`.perm-matrix tr > *:first-child`), elles l'emportent d'elles-memes. */
  .perm-matrix th,
  .perm-matrix td { padding-left: 10px; padding-right: 10px; }
  .perm-matrix .perm-col { min-width: 96px; }

  /* ⛔ `.placeholder` A ÉTÉ RETIRÉE EN PHASE 8, base et palier — DEUX règles, DEUX
     sélecteurs, UNE classe de moins. Elle n'habillait que le « Tableau de bord »
     que **RUX-D9** a supprimé en phase 6, et plus aucune vue ne la nommait.
     ⚠️ `docs/inventaire-selecteurs.mjs` NE L'AVAIT PAS VUE MOURIR : il cherche le
     mot dans les vues, et `el.placeholder = 'AAAA-MM-JJ'` (relais.js) plus les
     attributs `placeholder=` des champs le lui rendaient vivant. C'est le
     symétrique exact de `.dot-ok`/`.dot-off`/`.dot-unseen`, qu'il croit mortes
     parce qu'elles sont construites par concaténation : ⛔ la liste de l'outil
     oriente une vérification, elle ne la remplace jamais. */

  /* ---- Pages d'authentification ---- */
  /* Ces deux paddings sont HORS du systeme de tokens a dessein : le PRD §5.2
     impose 32px a la carte d'authentification (`p-8`), et `.auth-screen` n'est
     pas `.content` — ni l'un ni l'autre ne suit `--card-pad-*` / `--pad-page`.
     Les exprimer par `--pad-page` les changerait aussi en TABLETTE (20px), ce
     qu'interdit D5. D'ou deux valeurs litterales, au palier mobile seul,
     exactement comme les valeurs de base qu'elles remplacent. */
  .auth-screen { padding: 16px; }
  .auth-card { padding: 24px; }

  /* ---- Toasts (R7) ----
     ⚠️ `.toasts` est une boite `fixed` en `left: auto` : elle est donc
     dimensionnee en SHRINK-TO-FIT et bornee a `largeur d'ecran - 20px`. Le
     toast ne debordait donc JAMAIS — il se retrecissait. Le defaut reel etait
     un toast COLLE AU BORD GAUCHE (0px de gouttiere d'un cote, 20px de
     l'autre) des que la largeur passait sous ~380px, la ou tout le reste de
     l'interface respecte 16px. C'est ce que corrige l'ancrage aux DEUX bords,
     par `--pad-page` (16px a ce palier) : les toasts s'alignent ainsi sur le
     contenu qu'ils commentent, avec des gouttieres symetriques.

     `align-items: flex-end` (arbitrage de la Phase 7, mesure a l'appui) : le
     conteneur est ancre aux deux bords, mais le toast garde la largeur de son
     CONTENU, plafonnee par le `max-width: 360px` de la regle de base. Sans
     cela (`align-items: stretch` par defaut), il occupait toute la largeur du
     conteneur — soit 735px a 767px de large pour « Journal rafraichi. », qui
     n'en reclame que 133 : une boite vide a 82%, qui se lit comme un defaut de
     mise en page plutot que comme une notification.
     Le benefice R7 est INTEGRALEMENT preserve : sous ~392px, le contenu
     atteint le bord du conteneur et les deux comportements convergent —
     mesure a 390 / 375 / 320px, le toast fait 358 / 343 / 288px, soit
     `ecran - 32px`, gouttieres symetriques de 16px, exactement comme avec
     `stretch`. L'ecart ne joue donc qu'entre ~480 et 767px (tablette etroite,
     telephone en paysage). */
  .toasts { left: var(--pad-page); right: var(--pad-page); align-items: flex-end; }

  /* ---- Cibles tactiles (R8) ----
     PRD §12.3 : >= 32px, ideal 44px. Objectif du plan : 40px sous 768px.
     `min-height` plutot que `height` : il l'emporte sur la hauteur declaree
     quand il est superieur, mais laisse intactes les variantes DEJA plus
     hautes — `.btn-block` (44px, boutons pleine largeur des pages
     d'authentification) garde ainsi sa taille au lieu d'etre rabaissee. */

  /* `.action` etait deja porte a 40px dans ses deux seuls contextes (pied de
     `.site-card` en Phase 3, ligne d'actions des cartes-lignes en Phase 4).
     La regle est ici GENERALISEE au composant : effet mesure identique dans les
     deux contextes, et toute occurrence future en herite. */
  .action { min-height: 40px; }

  /* Ecart assume par rapport a la lettre du plan, qui ne cite que `.action`,
     `.icon-btn`, `.btn-sm` et `.link-btn` : la regle porte sur `.btn` (36px),
     dont `.btn-sm` (32px) n'est qu'une variante — les deux classes sont
     TOUJOURS posees ensemble dans les vues, une seule declaration suffit donc
     a couvrir l'item du plan. Ne viser que `.btn-sm` aurait au contraire cree
     une INVERSION : la variante « petite » a 40px et le bouton standard reste
     a 36px, cote a cote dans l'en-tete des Contacts (« ← Sites » en `btn-sm`,
     les trois actions en `btn`). 36px satisfaisait deja le minimum du PRD ;
     l'uniformisation a 40px releve donc de la coherence visuelle autant que de
     l'ergonomie. `.btn-block` (44px) est preserve par la semantique de
     `min-height`. */
  .btn { min-height: 40px; }

  /* Pastille de jour du signe de vie (`.day-btn`, 36 px en base) : même
     discipline que `.btn` ci-dessus — `min-height`, jamais `height`. */
  .day-btn { min-height: 40px; }

  /* Bouton de fermeture des modales (✕) : 28px carre en base. */
  .icon-btn { width: 40px; height: 40px; }

  /* `.link-btn` (« Mot de passe oublie ? », « Retour a la connexion ») est
     declare `inline` dans la feuille, or `min-height` est inerte sur une boite
     inline. Ses deux emplois actuels sont des items flex, donc BLOCKIFIES —
     la hauteur y prendrait effet malgre tout (mesure : `display` calcule
     `block`, jamais `inline`). Mais faire dependre une regle de composant du
     mode de mise en page de son parent est fragile : `inline-flex` la rend
     valide quel que soit le contexte, et `align-items: center` maintient le
     libelle centre dans la boite de 40px. */
  .link-btn { display: inline-flex; align-items: center; min-height: 40px; }

  /* Case a cocher de la matrice de droits. La case elle-meme fait 16px (regle
     `.checkbox input`, commune a toute l'interface) : c'est son ETIQUETTE, qui
     occupe toute la cellule et porte le clic, que l'on porte a 40px. Meme
     discipline que ci-dessus — `min-height`, jamais `height`. */
  .perm-check { min-height: 40px; align-items: center; }

  /* Onglet de la vue de site. Plancher EXPLICITE : le padding vertical de 14px
     porte deja l'onglet a ~48px, la regle ne modifie donc rien aujourd'hui —
     elle garantit le minimum si ce padding venait a etre resserre. */
  .tab { min-height: 40px; }

  /* Nom de site cliquable : c'est une cible de navigation a part entiere, et
     la SEULE voie d'entree vers la fiche quand le raccourci « Gerer les
     contacts » n'est pas offert (module non attribue). `inline-flex` est
     necessaire, comme pour `.link-btn` : `min-height` est inerte sur une boite
     inline. Le bouton reste dimensionne par son texte, qui se replie
     normalement s'il est long. */
  .site-name-btn { display: inline-flex; align-items: center; min-height: 40px; }

  /* ---- Signe de vie — composants portés de l'interface locale (SL-D3) ----
     Les deux replis du local (`style.css`, `@media (max-width: 768px)`), sur
     le palier de CETTE feuille : le bandeau de statut et la carte d'actions
     passent en colonne, la partie droite du bandeau s'aligne à gauche (son
     `min-width: 240px` est levé : en colonne il n'a plus rien à réserver), et
     la ligne de destinataire passe sur une colonne. `align-items: stretch`
     (valeur par défaut) suffit à étirer les boutons, comme `.modal-foot`. */
  .sv-statut,
  .sv-actions,
  .sv-actions-secondaires { flex-direction: column; align-items: stretch; }
  .sv-statut-droite { align-items: flex-start; min-width: 0; text-align: left; }
  .sv-destinataire { grid-template-columns: 1fr; }
}

/* ---------- Palier fin (< 480px) ----------
   Note : le palier transitoire 560px, herite de l'etat anterieur au chantier
   et conserve tel quel en Phase 1, a DISPARU en Phase 6 — ses deux dernieres
   regles (.contacts-head-top / .contacts-badges) ont ete relevees au palier
   mobile, ou le plan les voulait. Les paliers de la feuille sont desormais
   exactement ceux de la decision D4, plus ce seul palier fin. */
@media (max-width: 479.98px) {
  :root {
    --modal-gutter: 12px; /* les modales reprennent 8px de large de chaque cote */
  }
  /* Detection parc : le chipId et le bouton d'ajout ne tiennent plus cote a
     cote ; le bouton passe pleine largeur sous le libelle. */
  .detected-card .btn-sm { width: 100%; }

  /* Le header n'a plus la place des deux pastilles : l'horloge s'efface au
     profit du titre. Le badge MQTT, porteur d'une information critique (la
     carte est-elle pilotable ?), est TOUJOURS conserve. */
  .clock-pill { display: none; }

  /* Actions de l'ecran Contacts : les trois boutons (« Ajouter des contacts en
     masse », « Nouveau contact », « Tout effacer ») ne tiennent plus a deux par
     ligne et le repli laisse des largeurs tres inegales ; empiles, chacun offre
     toute la largeur de la carte. Meme raisonnement que `.modal-foot`
     ci-dessous, et meme mecanique : `align-items: stretch` (valeur par defaut)
     suffit a les etirer, un `width: 100%` serait redondant. */
  .contacts-actions { flex-direction: column; align-items: stretch; }

  /* Bouton d'action primaire d'un bandeau de carte (« + Nouveau site »,
     « + Inviter un·e utilisateur·rice », « + Nouvelle organisation ») : son
     libelle est long et non secable. Le `flex-wrap` declare en base sur
     `.card-head` l'a deja fait passer sous le titre ; il y prend ici toute la
     largeur. Contrairement aux deux pieds ci-dessus, `width: 100%` est ici
     necessaire : `.card-head` reste une LIGNE (l'etirement transversal y
     jouerait sur la hauteur, pas sur la largeur). */
  .card-head .btn { width: 100%; }

  /* Bandeau de la matrice de droits : ses DEUX actions passent en colonne. La
     regle `.card-head .btn { width: 100% }` ci-dessus s'applique bien a elles
     (selecteur de descendance), mais elle ne suffirait pas seule : dans un
     conteneur flex dimensionne par son contenu, un enfant a `width: 100%` se
     resout sur une largeur elle-meme deduite de ce contenu. Le conteneur est
     donc explicitement etire, et l'empilement declare ici. */
  .perm-head-actions { flex-direction: column; align-items: stretch; width: 100%; }

  /* Pied de modale : actions empilees en pleine largeur. Le repli en deux
     lignes du palier superieur laisse des boutons de tailles inegales, mal
     vises au pouce ; empilees, elles offrent chacune toute la largeur du cadre.
     L'ORDRE DU DOM EST CONSERVE : l'action primaire, declaree en dernier par
     `openModal()`, reste la plus basse — donc la plus proche du pouce et la
     derniere lue. `align-items: stretch` (valeur par defaut) suffit a l'etirer :
     un `width: 100%` serait redondant. */
  .modal-foot { flex-direction: column; align-items: stretch; }
}
