﻿/* ======================================
   Q-BOT — Site statique

   MISE EN CACHE — à ne pas oublier : cette feuille et assets/js/main.js sont
   appelées avec un paramètre de version (`?v=2026-08-17`) sur les 23 pages. Elles
   n'en avaient aucun, contrairement à scrolly.css/scrolly.js : après une
   modification, le navigateur continuait de servir l'ancienne version, et un
   style.css périmé face à un HTML à jour donne une page qui paraît cassée.
   Quand on modifie l'une des deux, bumper la date dans les 23 pages :
     grep -rl 'style.css?v=' --include='*.html' . | xargs sed -i '' 's/v=ANCIENNE/v=NOUVELLE/g'
   Charte (Documentations/Q-BOT BrandGuidelines.pdf) :
     • Couleur primaire   : #00CBBE
     • Couleur secondaire : #000000
     • Titres             : Roboto Bold
     • Corps              : Roboto Regular
   La charte ne définit que ces deux couleurs : tout le reste du site est
   construit sur une échelle de gris NEUTRE (aucune teinte bleue/navy), reprise
   des gris utilisés par le document de charte lui-même.
   ====================================== */

/* ======================================
   POLICE — Roboto, hébergée chez nous

   Elle était appelée chez Google : deux « preconnect » plus une feuille de style
   BLOQUANTE sur un domaine tiers, donc résolution DNS, poignée de main TLS et un
   aller-retour avant que le premier caractère ne s'affiche. Ici, la déclaration
   vit dans cette feuille : le navigateur n'apprend rien de nouveau, il connaît
   déjà l'origine et la connexion est ouverte.

   Google sert désormais Roboto en police VARIABLE : un seul fichier couvre 100 à
   900, d'où deux fichiers au lieu des cinq graisses qu'on croyait devoir prendre
   (vérifié à l'axe « wght » dans le woff2). L'italique, lui, est resté statique
   en 400 ; une italique gras est donc synthétisée par le navigateur, exactement
   comme avant, puisque la requête d'origine ne demandait que l'italique 400.

   Seuls « latin » et « latin-ext » sont embarqués : le site est en français et en
   anglais. Les sous-ensembles cyrillique, grec, vietnamien et mathématique que
   Google déclare ne seraient jamais téléchargés, mais chaque bloc déclaré coûte
   des octets à lire sur les 29 pages.

   « font-display: swap » est conservé : le texte s'affiche tout de suite dans la
   police de repli, puis bascule. C'est le réglage que Google servait déjà.
   ====================================== */
@font-face {
  font-family: 'Roboto';
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url('../fonts/roboto-latin-ext.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: 'Roboto';
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url('../fonts/roboto-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Roboto';
  font-style: italic;
  font-weight: 400;
  font-display: swap;
  src: url('../fonts/roboto-latin-ext-italic.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: 'Roboto';
  font-style: italic;
  font-weight: 400;
  font-display: swap;
  src: url('../fonts/roboto-latin-italic.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

/* ---- Reset & base ---- */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

:root {
  /* ── Palette ──
     Primaire + secondaire viennent directement de la charte ; les gris sont
     l'échelle neutre du document (#231F20 → #F9F9FA). */
  --teal:        #00CBBE;   /* charte — primaire */
  --teal-dark:   #00A79C;   /* nuance foncée du primaire (même teinte, H175) */
  --teal-tint:   #5CDCD3;   /* nuance claire du primaire (reflets/dégradés) */
  --teal-light:  rgba(0, 203, 190, 0.10);
  --black:       #000000;   /* charte — secondaire */
  --dark:        #000000;   /* fonds sombres = panneau « Negative » de la charte */
  --ink:         #231F20;   /* gris le plus foncé du document de charte */
  --gray:        #4C4B4C;
  --muted:       #808285;
  --light:       #F4F4F5;
  --border:      #E1E1E3;
  --white:       #FFFFFF;

  /* ── Typographie (charte p. 4-5) ── */
  --font:         'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  --font-heading: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  --font-size:    16px;
  --line-height:  1.7;

  /* ── Formes ── */
  --radius-sm:   6px;
  --radius:      16px;
  --radius-lg:   22px;
  --radius-pill: 100px;

  /* ── Ombres directionnelles (neutres, à base du noir de charte) ── */
  --shadow-sm:   0 2px 12px rgba(0,0,0,0.07);
  --shadow:     -4px 14px 32px rgba(0,0,0,0.09);
  --shadow-lg:  -8px 32px 64px rgba(0,0,0,0.13);

  --transition:  0.25s ease;

  /* ── Motion (voir la section « MOTION SYSTEM » en fin de fichier) ──
     Trois courbes seulement, pour que tout le site partage la même « physique » :
       --ease-out   : sortie longue et calme — entrées de contenu, reveals
       --ease-soft  : symétrique discrète — hovers, changements d'état
       --ease-spring: léger dépassement — micro-interactions (icônes, chips)
     Pas de rebond marqué : la sensation cible est « lourd mais fluide »
     (Apple / Linear), pas « élastique ». */
  --ease-out:    cubic-bezier(0.22, 1, 0.36, 1);
  --ease-soft:   cubic-bezier(0.4, 0, 0.2, 1);
  --ease-spring: cubic-bezier(0.34, 1.32, 0.64, 1);

  /* Durées de reveal — les médias sont volontairement plus lents que le texte :
     une image qui se dévoile lentement lit « premium », un texte lent lit « lent ». */
  --dur-text:  0.7s;
  --dur-media: 1.05s;

  /* Conteneur fluide, plafonné. Il valait 1180 px fixes : à 1920 cela laissait
     370 px de marge de chaque côté, à 2560 près de 700, et le site paraissait
     étroit sur grand écran (signalé par le client). Il grandit maintenant avec
     l'écran, mais dans des bornes :
     - plancher 1180 px, donc RIEN NE CHANGE jusqu'à 1639 px : les portables, les
       tablettes et le 1366 × 768 gardent exactement la mise en page validée ;
     - 72 vw entre 1639 et 2000 px, soit 1382 px en 1080p — 75 % de l'écran, la
       proportion habituelle d'une mise en page large ;
     - plafond 1440 px, parce qu'au-delà ce sont les longueurs de ligne qui cassent,
       pas la largeur disponible. Les blocs de prose ont leur propre plafond.
     La gouttière de la séquence 3D suit automatiquement : elle est calculée à partir
     de cette variable (cf. scrolly.css). */
  --container:   clamp(1180px, 72vw, 1440px);
  --section-py:  112px;

  /* Hauteur de la barre de navigation. Elle était écrite trois fois en dur :
     la hauteur de `.nav__inner`, le `top` du menu déroulant sous 901 px, et
     rien ne disait que les trois valeurs devaient rester d'accord. Le décalage
     d'ancre ci-dessous en dérive aussi : la barre est collante à toutes les
     largeurs (relevé : 72 px de 375 à 2560 px), donc elle occulte en
     permanence le haut de la zone de défilement. */
  --nav-h:       72px;

  /* ── Espace de protection du logo (charte p. 3 : « Always allow a minimum
     space around the logo »).
     Mesuré sur la grille pointillée du PDF : marge uniforme de 42,8 pt autour
     d'un lockup de 69,3 pt de haut, soit 0,618 × la hauteur du lockup.
     logo-baseline.png porte déjà une marge transparente interne (le lockup
     occupe 88/128 de la hauteur du fichier, 240/300 de sa largeur) ; les
     facteurs ci-dessous ne sont donc que le *complément* à ajouter, exprimé en
     fraction de la hauteur de rendu du <img> :
       vertical   = 0.618 × 88/128 − 20/128 = 0.269
       horizontal = 0.618 × 88/128 − 30/128 = 0.190                        ── */
  --logo-clear-y: 0.299;
  --logo-clear-x: 0.084;

  /* Variante texte du teal — contraste AA (4.5:1) sur fond clair/blanc.
     Le teal de marque (#00CBBE) est trop clair pour du texte sur blanc (2.0:1). */
  --teal-text:   #00857D;
}

/* Le fond de page est posé sur `html`, pas sur `body` : c'est ce qui laisse la
   place au calque ambiant (`body::before`, plus bas), qui doit se glisser ENTRE
   le fond et le contenu. Avec le fond sur `body`, un pseudo-élément en
   `z-index: -1` passe derrière lui et ne se voit jamais. */
/* ══ LE HAUT DE LA PAGE EST OCCULTÉ PAR LA BARRE ═══════════════════════════
   `scroll-padding-top` décrit une bande du haut de la zone de défilement que
   quelque chose recouvre en permanence. Le navigateur la retire de tous ses
   calages : navigation par ancre, `scrollIntoView`, retour de focus.

   SANS ELLE, L'INDEX DE LA FAQ ENVOYAIT LE VISITEUR SUR UNE QUESTION
   INVISIBLE. Relevé avant correction, clic sur « Combien coûte Q-Bot ? » : la
   question arrivait à 0 px du haut de la fenêtre et le bas de la barre à
   72 px, donc elle était entièrement derrière, et le visiteur atterrissait au
   milieu de la réponse. Même effet à l'arrivée directe sur
   `cas-usage.html#cas`, que 17 pieds de page visent : les 72 premiers pixels
   de la section passaient sous la barre.

   ON LA POSE SUR LA ZONE DE DÉFILEMENT ET NON EN `scroll-margin-top` SUR LES
   CIBLES : il y a 19 questions de FAQ par langue, cinq exemples d'appel, les
   quatre pas de la séquence et les sections à ancre, et une ancre écrite plus
   tard n'aurait pas la règle. Une seule déclaration sur la zone les couvre
   toutes, y compris celles qui n'existent pas encore.

   Les 16 px sont l'échelle du site : la cible se pose SOUS la barre, elle ne
   la touche pas. Les autres zones de défilement du site (la piste du
   carrousel, la bande d'exemples) sont horizontales et n'en héritent pas. */
html {
  font-size: var(--font-size);
  scroll-behavior: smooth;
  background: var(--white);
  scroll-padding-top: calc(var(--nav-h) + 16px);
}

body {
  font-family: var(--font);
  color: var(--black);
  line-height: var(--line-height);
  background: transparent;   /* cf. le fond posé sur `html` */
  -webkit-font-smoothing: antialiased;
}

/* ══ CALQUE AMBIANT ════════════════════════════════════════════════════════
   Deux halos teal très larges, fixes, qui dérivent lentement avec la
   progression dans la page.

   POURQUOI. Relevé sur les 22 pages : le halo n'existait que dans le hero de
   l'accueil (`.hero::before` / `::after`) et dans la scène de la séquence 3D.
   Les sous-pages n'ont qu'un orbe de 340 px dans leur en-tête, puis plus rien
   sur deux à sept écrans de noir plat — la FAQ traverse 1 420 mots comme ça, et
   les six articles de blog quatre à sept écrans. C'est le procédé n°1 relevé sur
   linearity.io : chez eux un canvas WebGL et un halo restent en `position:
   fixed` derrière toute la page, à opacité constante, et ne réagissent à rien.
   Ce n'est pas une animation, c'est un fond vivant.

   CE QUI DIFFÈRE DE CHEZ EUX. Pas de WebGL : nous en avons déjà un sur
   l'accueil, et c'est le poste le plus lourd de la page. Deux dégradés radiaux
   coûtent quelques octets et aucun contexte de rendu supplémentaire.

   DEUX DÉTAILS QUI COMPTENT.
   - `z-index: -1` : le calque doit passer sous le contenu, y compris sous le
     texte des blocs non positionnés. À `z-index: 0` un élément fixe se peint
     APRÈS les fonds et le contenu en ligne des blocs en flux — le halo passerait
     par-dessus les paragraphes.
   - La valeur de repli de `--amb-p` est 0.32, pas 0 : sous
     `prefers-reduced-motion` le moteur ne tourne pas, la variable n'est jamais
     écrite, et une valeur nulle laisserait les deux halos collés à leur position
     de départ. C'est le même piège que le trait de la section évolution, qui
     restait invisible faute d'une valeur par défaut.

   L'intensité monte avec la progression : au sommet de l'accueil le hero a déjà
   ses deux orbes, un troisième halo n'y a rien à ajouter. */
/* DEUX COUCHES, ET UN `translate` PLUTÔT QU'UNE POSITION DE DÉGRADÉ.
   Premier jet : un seul pseudo-élément portant les deux dégradés, dont la
   position du centre était calculée à partir de `--amb-p`. Le rendu était juste,
   le coût non : déplacer le centre d'un dégradé repeint toute la surface à chaque
   image. Mesuré sur l'accueil à 390 px, là où le canevas 3D tourne déjà,
   65,5 → 56,6 images par seconde, soit 14 % perdus pour un fond.
   Un `translate` sur un élément fixe, lui, est composité : aucun repeint. Il faut
   alors deux couches, puisque les deux halos dérivent en sens opposés — d'où
   `::before` et `::after`. Coût remesuré : indiscernable du bruit. */
body::before,
body::after {
  content: '';
  position: fixed;
  z-index: -1;
  pointer-events: none;
  will-change: transform;
}
body::before {
  inset: -30vh -18vw 0 -18vw;
  height: 88vh;
  background: radial-gradient(38% 44% at 22% 50%, rgba(0, 203, 190, 0.11), transparent 70%);
  translate: 0 calc(var(--amb-p, 0.32) * 46vh);
  opacity: calc(0.42 + var(--amb-p, 0.32) * 0.58);
}
body::after {
  inset: auto -18vw -26vh -18vw;
  height: 80vh;
  background: radial-gradient(32% 42% at 82% 50%, rgba(0, 203, 190, 0.085), transparent 72%);
  translate: 0 calc(var(--amb-p, 0.32) * -42vh);
  opacity: calc(0.42 + var(--amb-p, 0.32) * 0.58);
}
img { max-width: 100%; height: auto; display: block; }
a { color: inherit; text-decoration: none; }
ul { list-style: none; }
button { cursor: pointer; font-family: var(--font); }

/* ---- Utilitaires ---- */
.container {
  max-width: var(--container);
  margin: 0 auto;
  padding: 0 24px;
}

.section { padding: var(--section-py) 0; }
.section--gray { background: var(--light); }
.section--dark { background: var(--dark); color: var(--white); }

.text-teal   { color: var(--teal); }
.text-muted  { color: var(--muted); }

/* ---- Typographie ---- */
/* Roboto Bold (charte p. 5). Tracking très légèrement resserré : correction
   optique aux grandes tailles uniquement, le dessin reste celui de la charte. */
h1, h2, h3, h4 { font-family: var(--font-heading); line-height: 1.15; font-weight: 700; letter-spacing: -0.01em; }
/* Les titres de rang 3 et 4 sont petits (20 et 16 px) : à 1,15 leurs lignes font
   23 et 18 px, ce qui se lit serré dès qu'ils passent à la ligne, ce qu'ils font
   souvent dans une carte. Les h1 et h2 gardent 1,15 : à 32-56 px, un interligne
   serré est justement ce qu'il faut. */
h3, h4 { line-height: 1.3; }
h1 { font-size: clamp(2rem, 5vw, 3.5rem); }
h2 { font-size: clamp(1.5rem, 3vw, 2.25rem); }
h3 { font-size: 1.25rem; }
h4 { font-size: 1rem; }

p { color: var(--gray); }
.section--dark p, .section--dark li { color: rgba(255,255,255,0.75); }

.section-label {
  /* Capitales espacées : à 1,15 (l'interligne des titres, qui s'applique
     quand ce libellé est promu en <h2>) deux lignes se touchent presque. */
  line-height: 1.45;
  display: inline-block;
  font-size: 0.75rem;
  font-weight: 700;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--teal-text);
  margin-bottom: 12px;
}
/* Sur fond sombre (hero), le teal plein passe très bien (contraste eleve) */
.hero .section-label,
.page-hero .section-label,
.section--dark .section-label { color: var(--teal); }

.section-title { margin-bottom: 16px; }
.section-subtitle { font-size: 1.0625rem; max-width: 600px; }

/* ══ UN TITRE SUIVI D'UN BLOC RESPIRE ══════════════════════════════════════
   `.section-title` porte 16 px de marge basse, ce qui est juste quand vient
   ensuite son CHAPEAU : le titre et son chapeau forment une unité de lecture.
   Quand vient un BLOC de contenu (une fiche, un cadre, une grille), 16 px le
   collent au titre.
   Relevé avant correction, sur les 23 pages : 16 px sur les fiches techniques
   et les deux cadres 3D, 28 px sur l'encadré de l'accueil, 32 px sur les
   grilles de produits, et 16 px en français contre 32 en anglais pour le MÊME
   bloc de la page contact. Une seule valeur désormais, un cran de l'échelle
   de 8 px.
   Les marges adjacentes fusionnent : le total est 32 px, pas 16 + 32. */
.section-title + div,
.section-title + ul,
.section-title + ol,
.section-title + form { margin-top: 32px; }
.section-header { margin-bottom: 64px; }
/* Il n'y a plus de variante centrée : sur ce site un bloc de texte est aligné à
   gauche sur la gouttière du conteneur — celle de l'encre du logo — ou à droite,
   jamais au centre. La classe `centered` a été retirée des 26 en-têtes qui la
   portaient plutôt que gardée en mentant sur son nom. */

/* ---- Boutons ---- */
.btn {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 12px 28px;
  border-radius: var(--radius-pill);
  font-size: 0.9375rem;
  font-weight: 700;
  transition: var(--transition);
  border: 2px solid transparent;
}

.btn--primary {
  background: var(--teal);
  /* ENCRE ADOUCIE LE 2026-08-28 : le client trouvait le noir pur trop dur sur
     le teal. #231F20 est le gris le plus sombre de la charte, donc on ne sort
     pas de la palette, et il reste très au-dessus du seuil : 8,0:1 sur le teal
     de fond, 5,4:1 sur le teal foncé du survol, contre 10,3:1 pour du noir pur.
     NE PAS L'ÉCLAIRCIR DAVANTAGE SANS MESURER. Le teal de charte est une
     couleur CLAIRE : c'est le noir qui s'y lit, pas le blanc, qui y plafonne à
     2:1. Ce piège a déjà coûté seize défauts de contraste le 2026-08-24, sur
     les ronds numérotés des pages Démo. */
  color: #231F20;
  border-color: var(--teal);
}
.btn--primary:hover {
  background: var(--teal-dark);
  border-color: var(--teal-dark);
  transform: translateY(-1px);
  box-shadow: 0 6px 20px rgba(0,203,190,0.35);
}

.btn--outline {
  background: transparent;
  color: var(--black);
  border-color: var(--border);
}
.btn--outline:hover {
  border-color: var(--teal);
  color: var(--teal-text);
}

.btn--outline-white {
  background: transparent;
  color: var(--white);
  border-color: rgba(255,255,255,0.4);
}
.btn--outline-white:hover {
  border-color: var(--white);
  background: rgba(255,255,255,0.1);
}

.btn--lg { padding: 14px 36px; font-size: 1rem; }

/* « Q-Bot » ne se coupe pas en fin de ligne, demandé le 2026-08-28 : c'est le
   nom complet du produit.
   AUCUNE PROPRIÉTÉ CSS NE PEUT LE FAIRE SANS CE MARQUAGE. Une coupure après un
   trait d'union est autorisée par la norme Unicode et aucune valeur de
   `word-break` ou de `line-break` ne la retire. L'autre solution serait le
   trait insécable U+2011, écarté : il ferait de « Q-Bot » et « Q‑Bot » deux
   chaînes différentes pour un moteur de recherche et pour toute recherche dans
   le dépôt. */
.nb { white-space: nowrap; }

/* ---- Navigation ---- */
.nav {
  position: sticky;
  top: 0;
  z-index: 100;
  background: var(--dark);
  transition: transform 0.38s cubic-bezier(0.4, 0, 0.2, 1), box-shadow var(--transition);
  will-change: transform;
}
/* .nav--hidden : retiré le 2026-08-28. La barre ne se masque plus au
   défilement, elle doit rester sous les yeux en permanence. */
.nav::after {
  content: '';
  position: absolute;
  bottom: 0; left: 0; right: 0;
  height: 1px;
  background: linear-gradient(90deg, transparent 0%, rgba(0,203,190,0.2) 25%, rgba(0,203,190,0.45) 50%, rgba(0,203,190,0.2) 75%, transparent 100%);
  pointer-events: none;
}
.nav.scrolled { box-shadow: 0 4px 24px rgba(0,0,0,0.35); }

.nav__inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  height: var(--nav-h);
}

/* Desktop : logo à gauche, puis menu + actions groupés à droite — l'implantation
   du site live. Le `space-between` ci-dessus, avec le menu et les actions comme
   seuls autres enfants, poussait le menu au centre de la barre.
   Scopé en min-width parce que sous 768px le menu sort du flux (dropdown
   absolu) : les enfants restants sont le logo, le burger et les actions, et un
   gap de 40px les ferait déborder à 390px. Le mobile garde donc son
   space-between d'origine. */
@media (min-width: 769px) {
  .nav__inner { justify-content: flex-end; gap: 40px; }
  .nav__logo { margin-right: auto; }
}

/* Le nav étant sur fond noir, on sert la version « Negative » du logo de la
   charte (p. 2) : lettrage blanc + accent teal conservé. Surtout pas de
   filter: brightness(0) invert(1) — ça aplatit le logo en blanc uni et
   supprime l'accent teal, seul élément coloré du lockup.
   Le padding matérialise l'espace de protection (charte p. 3) ; la marge
   négative à gauche le fait déborder sur la gouttière du conteneur pour que le
   lockup reste optiquement aligné sur le bord du contenu. */
.nav__logo {
  --logo-h: 31px;
  display: inline-flex;
  align-items: center;
  /* Le lockup ne se comprime jamais : c'est un élément flex, et à 320 px il se
     faisait écraser de 87 à 61 px de large — le logo était donc déformé, ce que
     la charte interdit. C'est au reste de la barre de céder, pas à la marque. */
  flex-shrink: 0;
  padding: calc(var(--logo-h) * var(--logo-clear-y)) calc(var(--logo-h) * var(--logo-clear-x));
  margin-left: calc(-1 * var(--logo-h) * var(--logo-clear-x));
}
.nav__logo img {
  height: var(--logo-h);
  width: auto;
  transition: opacity var(--transition);
}
.nav__logo:hover img { opacity: 0.85; }

.nav__menu {
  display: flex;
  align-items: center;
  gap: 36px;
}

.nav__link {
  font-size: 0.9375rem;
  font-weight: 500;
  color: rgba(255,255,255,0.65);
  transition: color var(--transition);
  position: relative;
}
.nav__link::after {
  content: '';
  position: absolute;
  bottom: -3px; left: 0; right: 0;
  height: 2px;
  background: var(--teal);
  transform: scaleX(0);
  transition: transform var(--transition);
  transform-origin: left;
}
.nav__link:hover { color: var(--white); }
.nav__link:hover::after, .nav__link.active::after { transform: scaleX(1); }
/* La page courante se lit sans comparaison : elle est la seule en teal, et son
   trait est déjà posé. Le survol reste blanc, donc les deux états ne se
   confondent pas quand la souris passe sur l'entrée active. */
.nav__link.active { color: var(--teal); font-weight: 600; }

.nav__actions {
  display: flex;
  align-items: center;
  gap: 12px;
}

/* Un seul lien : la langue vers laquelle on bascule (globe + code), pas les
   deux codes dont un ne servait qu'à indiquer la page où l'on est déjà —
   c'était deux fois la largeur pour la même information, et une pastille
   teal de plus juste à côté du CTA. Style calqué sur .nav__link (pas de
   pastille, pas de bordure) pour qu'il se lise comme la fin du menu.
   Le padding porte la cible tactile, cf. section CIBLES TACTILES. */
.nav__lang { display: flex; align-items: center; }
.nav__lang a {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 6px 8px;
  font-size: 0.75rem;
  font-weight: 700;
  letter-spacing: 0.04em;
  color: rgba(255,255,255,0.65);
  transition: color var(--transition);
}
.nav__lang a:hover { color: var(--white); }
.nav__lang a svg { opacity: 0.8; }
.nav__lang a:hover svg { opacity: 1; }

.nav__toggle {
  display: none;
  flex-direction: column;
  gap: 5px;
  background: none;
  border: none;
  padding: 4px;
}
.nav__toggle span {
  display: block;
  width: 24px;
  height: 2px;
  background: rgba(255,255,255,0.85);
  border-radius: 2px;
  transition: var(--transition);
}

/* ---- Hero ---- */
.hero {
  background: var(--dark);
  color: var(--white);
  padding: 120px 0 100px;
  overflow: hidden;
  position: relative;
}

.hero::before {
  content: '';
  position: absolute;
  top: -100px; right: -100px;
  width: 500px; height: 500px;
  background: radial-gradient(circle, rgba(0,203,190,0.18) 0%, transparent 70%);
  pointer-events: none;
  animation: heroOrb1 24s ease-in-out infinite alternate;
  translate: 0 var(--orb-y, 0px);
}

.hero__inner {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 64px;
  align-items: center;
}

.hero__label {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  background: rgba(0,203,190,0.15);
  border: 1px solid rgba(0,203,190,0.35);
  color: var(--teal);
  /* MÊME TYPOGRAPHIE QUE .section-label, demandé le 2026-08-28 : les deux
     libellés sont le même objet, une accroche de section en capitales, et rien
     ne justifiait qu'ils n'aient pas le même espacement.
     Le 0,06 em d'origine venait d'un libellé français de 58 caractères
     (« Authentification à deux facteurs (2FA) plus simple que jamais ! ») qui
     dépassait la colonne du hero et passait sur deux lignes dans une pastille à
     rayon 100 px. Le libellé actuel en fait 44 : mesuré à 0,12 em, il tient sur
     une ligne à partir de 1024 px, et en dessous il se replie proprement, d'où
     la hauteur de ligne et le padding vertical un peu plus généreux.
     La pastille, elle, reste : c'est la signature du hero, pas de la
     typographie. */
  font-size: 0.75rem;
  font-weight: 700;
  letter-spacing: 0.12em;
  line-height: 1.4;
  text-transform: uppercase;
  padding: 7px 15px;
  border-radius: 100px;
  margin-bottom: 24px;
  max-width: 100%;
}
/* Le pictogramme ne se comprime pas et reste sur la première ligne quand le
   libellé se replie : centré verticalement sur deux lignes, il flottait au
   milieu du texte. */
.hero__label svg { flex-shrink: 0; align-self: flex-start; margin-top: 2px; }

.hero__title {
  color: var(--white);
  margin-bottom: 20px;
  /* Équilibrage des lignes du titre. Sans lui, « La 2FA sans prise de tête. »
     laissait « tête. » seul sur sa ligne — à 1440, 1024 et 390 px, mesuré. Le
     titre porte un <br> entre ses deux phrases : l'équilibrage s'applique à
     chacune, la seconde reste donc sur une ligne. */
  text-wrap: balance;
}

.hero__title em {
  background: linear-gradient(135deg, var(--teal) 0%, var(--teal-tint) 50%, var(--teal-dark) 100%);
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
  font-style: normal;
}

.hero__desc {
  font-size: 1.0625rem;
  color: rgba(255,255,255,0.7);
  margin-bottom: 40px;
  max-width: 480px;
}

.hero__actions { display: flex; gap: 16px; flex-wrap: wrap; }

/* ── Film produit dans le hero ──
   Le film est tourné sur fond gris clair alors que la page est noire. On ne
   peut pas le détourer (le boîtier est noir : il disparaîtrait sur le noir de
   la page) et le passer en fond de hero voilé ne donne qu'un aplat gris — ce
   sont surtout des gros plans. Il est donc présenté comme un objet à part
   entière : cadre arrondi, filet teal discret, halo — le panneau clair devient
   un élément de composition assumé plutôt qu'un rectangle qui déborde. */
.hero__film {
  position: relative;
  border-radius: var(--radius-lg);
  overflow: hidden;
  border: 1px solid rgba(0, 203, 190, 0.22);
  box-shadow:
    0 30px 70px rgba(0, 0, 0, 0.55),
    0 0 60px rgba(0, 203, 190, 0.10);
  aspect-ratio: 16 / 9;
  background: var(--dark);
}
.hero__film-video {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
/* Vignette : raccorde les bords clairs du film au fond noir du hero. */
.hero__film::after {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  box-shadow: inset 0 0 60px 18px rgba(0, 0, 0, 0.45);
}

/* ---- .hero__shot : une PHOTO du produit dans le hero ----
   Même traitement que le film, et pour la même raison : la photo a un fond clair
   (mur blanc, bureau en bois) posé sur une page noire, et sans vignette ses bords
   se lisent comme un rectangle collé. Deux écarts avec .hero__film :
   1. pas d'`aspect-ratio` imposé : c'est l'image qui donne le sien (1,99), un
     16/9 en `cover` rognerait la plante à gauche et l'écran à droite ;
   2. aucun mouvement. Ni parallaxe (le cadre n'est pas dans la table du moteur)
     ni inclinaison au pointeur (module 10 ne vise que `.hero__image img`) : la
     leçon du film est qu'un cadre qui dérive se lit comme un cadre instable. */
.hero__shot {
  position: relative;
  border-radius: var(--radius-lg);
  overflow: hidden;
  border: 1px solid rgba(0, 203, 190, 0.22);
  box-shadow:
    0 30px 70px rgba(0, 0, 0, 0.55),
    0 0 60px rgba(0, 203, 190, 0.10);
  background: var(--dark);
}
.hero__shot img { display: block; width: 100%; height: auto; }
.hero__shot::after {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  box-shadow: inset 0 0 60px 18px rgba(0, 0, 0, 0.45);
}

.hero__image {
  position: relative;
  display: flex;
  justify-content: center;
}

.hero__image img {
  max-height: 480px;
  object-fit: contain;
  filter: drop-shadow(0 20px 60px rgba(0,0,0,0.4));
}

/* ---- Section intro ---- */
.intro__grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 80px;
  align-items: center;
}

/* Le radius/l'ombre sont portés par le cadre .intro__image (cf. « Image produit
   — cadre clippé »), pas par l'image : dans un conteneur overflow:hidden une
   ombre posée sur l'enfant est de toute façon rognée. */

/* ---- Avantages / Features ---- */
.features__grid {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 32px;
}
/* ---- Variante à trois cartes ----
   Posée le 2026-09-02 : la review demande « 3 avantages maximum », et la même
   grille sert la section « Pour qui ». Trois cartes dans une grille à quatre
   pistes laissaient une colonne vide.
   `auto-fit` PLUTÔT QU'UNE REQUÊTE MÉDIA : avec un plancher de 260 px la grille
   se rabat d'elle-même, trois cartes au large, deux puis une en dessous.
   LE SÉLECTEUR EST DOUBLÉ, ET CE N'EST PAS DÉCORATIF. Écrit `.features__grid--3`
   seul il pèse (0,1,0), c'est-à-dire EXACTEMENT le poids des deux requêtes média
   qui rabattent `.features__grid` à 2 puis 1 colonne plus bas dans ce fichier :
   à poids égal c'est l'ordre qui tranche, elles gagnaient, et la grille tombait
   à une colonne dès 768 px et à deux dès 1024 (mesuré avant correction). Doublé,
   il pèse (0,2,0) et gouverne sa propre grille. Un nom de classe à tirets reste
   UNE classe : c'est le piège de ce lot.
   `minmax(260px, 1fr)` et non `1fr` : un élément de grille a `min-width: auto`,
   donc sans plancher explicite un contenu large étire sa colonne. */
.features__grid.features__grid--3 { grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }

.feature-card {
  position: relative;
  overflow: hidden;
  background: var(--white);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  padding: 32px 24px;
  box-shadow: var(--shadow-sm);
  transition:
    border-color 0.25s ease,
    box-shadow   0.3s ease,
    transform    0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.feature-card:hover {
  border-color: rgba(0,203,190,0.18);
  box-shadow: var(--shadow-lg), 0 0 0 1px rgba(0,203,190,0.14);
  transform: translateY(-6px);
}

.feature-card__icon {
  width: 52px; height: 52px;
  background: var(--teal-light);
  border-radius: var(--radius-sm);
  display: flex; align-items: center; justify-content: center;
  margin-bottom: 20px;
}
.feature-card__icon svg { width: 24px; height: 24px; color: var(--teal); }

.feature-card h3 { margin-bottom: 10px; font-size: 1.0625rem; }
.feature-card p { font-size: 0.9375rem; }

/* ---- Compatible tools ---- */
.tools__grid {
  display: grid;
  /* `minmax(0, 1fr)` et non `1fr` : un élément de grille a `min-width: auto`, donc
     un contenu de largeur intrinsèque étire sa colonne. C'est arrivé trois fois sur
     ce site ; le plancher est posé partout où la colonne est redéclarée. */
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 32px;
  /* En haut, pas au centre : les deux colonnes de la matrice de compatibilité n'ont
     pas le même nombre de cases (cinq applications, neuf outils), et centrées, leurs
     titres ne tombaient pas sur la même ligne — mesuré 89 px d'écart à 1 440 px. */
  align-items: start;
}

/* ---- Vidéo ---- */
.video__wrapper {
  max-width: 800px;
  margin: 0 auto;
  border-radius: var(--radius-lg);
  overflow: hidden;
  box-shadow: var(--shadow-lg);
  aspect-ratio: 16 / 9;
  background: var(--dark);
}
.video__wrapper iframe,
.video__wrapper .video__player {
  width: 100%; height: 100%;
  border: none;
  display: block;
}
/* Le film produit est en 16/9 comme le cadre : `cover` évite un liseré si
   l'encodage arrondit d'un pixel, sans rien rogner de visible. */
.video__wrapper .video__player { object-fit: cover; background: var(--dark); }

/* ---- .video__wrapper--film : le film dans une colonne, pas au centre ----
   Le cadre de base est centré et plafonné à 800 px, ce qui convient à une
   section pleine largeur. Ici il est la moitié droite d'un `.intro__grid` : le
   plafond et le centrage sautent, sinon la colonne de gauche et lui ne
   commencent pas sur la même ligne de base.
   Filet teal + vignette : même raison que `.hero__shot`. Le film est un rendu
   CAO sur fond gris CLAIR, et sans vignette ses bords se lisent comme un
   rectangle collé sur la page. `pointer-events: none` sur la vignette, sans
   quoi elle avalerait les clics destinés aux contrôles de lecture. */
.video__wrapper--film {
  max-width: none;
  margin: 0;
  border: 1px solid rgba(0, 203, 190, 0.22);
  box-shadow:
    0 30px 70px rgba(0, 0, 0, 0.55),
    0 0 60px rgba(0, 203, 190, 0.10);
}
/* AUCUNE INTERACTION. Le film n'a pas de contrôles, mais sans cette ligne un
   clic droit ouvrirait le menu contextuel du navigateur, avec « télécharger » et
   « boucle » : ce serait une interface. Le client a demandé « vraiment juste une
   vidéo loop » le 2026-08-25. */
.video__wrapper--film .video__player { pointer-events: none; }
/* ---- .video__wrapper--playable : le film qu'on peut piloter ----
   `--film` coupe les `pointer-events` du lecteur, parce que la boucle de la
   fiche technique n'a aucune commande à offrir et qu'un clic droit y proposerait
   « télécharger » et « boucle ». Le film de l'accueil, lui, est un CONTENU de
   cinquante secondes, posé là le 2026-09-02 pour qu'on comprenne le produit sans
   tout lire : il porte `controls`, donc il faut lui RENDRE les clics.
   Règle posée après celle de `--film` : les deux ont la même spécificité
   (0,2,0), c'est donc l'ordre dans le fichier qui tranche.
   La vignette du cadre reste peinte au-dessus de la barre de commandes et
   l'assombrit d'un cran, sans jamais l'attraper (`pointer-events: none`). */
.video__wrapper--playable .video__player { pointer-events: auto; }

.video__wrapper--film::after {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  box-shadow: inset 0 0 60px 18px rgba(0, 0, 0, 0.45);
  /* La vignette est peinte AU-DESSUS de l'affiche comme du lecteur, donc au-dessus
     du lien : sans z-index elle passerait derrière lui et les bords clairs
     reviendraient. Et sans `pointer-events: none` elle avalerait le clic. */
  z-index: 2;
}


/* ---- Pricing ---- */
.pricing-card {
  max-width: 480px;
  margin: 0 auto;
  background: var(--white);
  border: 2px solid var(--teal);
  border-radius: var(--radius-lg);
  padding: 48px;
  box-shadow: var(--shadow-lg);
}

.pricing-card__price {
  font-size: 3.5rem;
  font-weight: 700;
  color: var(--black);
  line-height: 1;
  margin: 24px 0 8px;
}
.pricing-card__price span { font-size: 1.25rem; font-weight: 500; color: var(--muted); }

.pricing-card__desc { font-size: 0.875rem; margin-bottom: 32px; }

.pricing-card__features {
  text-align: left;
  margin-bottom: 36px;
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.pricing-card__features li {
  display: flex;
  align-items: center;
  gap: 12px;
  font-size: 0.9375rem;
  color: var(--dark);
}

.pricing-card__features li::before {
  content: '';
  width: 18px; height: 18px;
  background: var(--teal);
  border-radius: 50%;
  flex-shrink: 0;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20,6 9,17 4,12'/%3E%3C/svg%3E");
  background-size: 11px;
  background-repeat: no-repeat;
  background-position: center;
}

/* ---- FAQ ---- */
.faq__list { max-width: 760px; margin: 0; display: flex; flex-direction: column; gap: 12px; }

.faq-item {
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  overflow: hidden;
  box-shadow: var(--shadow-sm);
  transition: border-color var(--transition), box-shadow var(--transition);
}
.faq-item.open { border-color: var(--teal); }

.faq-item__question {
  /* Un <button> n'hérite pas de l'interligne du corps et retombe sur le
     « normal » du navigateur, soit 1,2 : serré pour une question qui passe à la
     ligne, ce que toutes font sur téléphone. */
  line-height: 1.4;
  /* PAS DE FLEX ICI. Un <span> posé dans un conteneur flex devient un élément
     flex à part entière (blocification, CSS Flexbox §4). Depuis que « Q-Bot »
     est entouré d'un <span class="nb"> (2026-08-28), « Qu'est-ce que »,
     « Q-Bot » et « ? » étaient donc TROIS éléments, que justify-content:
     space-between étalait sur toute la largeur du bouton. Mesuré à 1440 px :
     « What is » à x=179, « Q-Bot » à x=425, « ? » à x=661.
     Un bloc simple avec l'icône positionnée remet le texte en contenu en ligne,
     donc n'importe quel <span> ajouté plus tard s'y insère sans rien casser. */
  display: block;
  position: relative;
  width: 100%;
  background: none;
  border: none;
  padding: 20px 64px 20px 24px;
  text-align: left;
  font-size: 0.9375rem;
  font-weight: 700;
  color: var(--black);
  cursor: pointer;
  transition: color var(--transition);
}
.faq-item.open .faq-item__question { color: var(--teal-text); }

.faq-item__icon {
  position: absolute;
  right: 24px;
  top: 50%;
  transform: translateY(-50%);
  width: 24px; height: 24px;
  border: 2px solid var(--border);
  border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  transition: var(--transition);
  color: var(--muted);
}
.faq-item.open .faq-item__icon {
  background: var(--teal);
  border-color: var(--teal);
  color: var(--white);
  transform: translateY(-50%) rotate(45deg);
}

/* UN BLOC DE CODE DANS UN ACCORDÉON. Depuis le 2026-09-02, les cinq exemples
   d'appel de la page « comment ça marche » sont des accordéons : le composant est
   celui de la FAQ, réutilisé tel quel. La marge basse de la réponse est portée
   par `.faq-item__answer p`, qui ne s'applique pas à un bloc de code ; sans cette
   règle, le code touche le filet de séparation. */
.faq-item__answer .code-block { margin-bottom: 20px; }

.faq-item__answer {
  max-height: 0;
  overflow: hidden;
  transition: max-height 0.42s cubic-bezier(0.4, 0, 0.2, 1);
}
/* max-height on open is controlled by JS (scrollHeight) */

.faq-item__answer p {
  padding: 0 24px 20px;
  font-size: 0.9375rem;
}

/* LES LISTES D'UNE RÉPONSE ÉTAIENT D'UN POINT PLUS GRANDES QUE SES PARAGRAPHES.
   Seuls les `p` recevaient les 15 px, les `li` restaient aux 16 px du corps de
   page : dans une même réponse, relevé 15 px / interligne 25,5 pour le
   paragraphe et 16 px / 27,2 pour la liste juste en dessous. Deux tailles pour
   une seule prose.
   Effet de bord sur la mesure de lecture : le plafond en `ch` suit la police,
   donc un `li` à 16 px valait 701 px et 96 caractères par ligne quand son
   paragraphe voisin en faisait 658 et 79.
   Le retrait est calculé, pas choisi : 44 px, soit les 24 px de retrait du
   paragraphe plus 20 px de gouttière de puce, ce qui aligne les puces sur le
   bord gauche du TEXTE du paragraphe. Le retrait droit et le retrait bas
   reprennent ceux du paragraphe, sans quoi la liste s'étend plus loin que lui
   et la réponse ne se referme pas à la même hauteur. */
.faq-item__answer ul,
.faq-item__answer ol {
  padding: 0 24px 20px;
  margin: 8px 0 0;
  font-size: 0.9375rem;
}
/* Les `ul` du site ne portent pas de puce (`list-style: none` global), donc leur
   texte s'aligne sur celui du paragraphe, sans retrait. Une liste numérotée, si :
   ses numéros ont besoin d'une gouttière, d'où les 20 px de plus. */
.faq-item__answer ol { padding-left: 44px; }
/* L'espacement des items vivait dans un style EN LIGNE, sur une seule des deux
   langues : 6 px entre les items côté français, 0 côté anglais, pour la même
   réponse. Cinquième fois que ce dépôt se fait prendre par un style en ligne
   invisible à la feuille (après les centrages de `commandez`, les vignettes de
   blog, la largeur de `.faq__list` et les fonds clairs de `.spec-item`). */
.faq-item__answer li + li { margin-top: 6px; }

/* ---- Message d'état des formulaires ---- */
/* Les six formulaires ne signalaient RIEN : la page se rechargeait et la saisie
   disparaissait. Cette ligne est la réponse visible à l'envoi — succès, échec, ou
   « votre client courrier vient de s'ouvrir ». Alignée à gauche comme le reste. */
.form-status {
  margin-top: 14px;
  padding: 10px 14px;
  border-radius: var(--radius-sm);
  font-size: 0.875rem;
  line-height: 1.5;
  border: 1px solid transparent;
}
.form-status[data-state="ok"] {
  color: var(--teal-text);
  background: rgba(0, 203, 190, 0.1);
  border-color: rgba(0, 203, 190, 0.35);
}
.form-status[data-state="info"] {
  color: rgba(255, 255, 255, 0.82);
  background: rgba(255, 255, 255, 0.05);
  border-color: rgba(255, 255, 255, 0.14);
}
/* L'échec ne prend pas de rouge : la charte n'a que le teal et le noir, et un
   rouge inventé jurerait. Le contraste du cadre suffit à marquer l'anomalie. */
.form-status[data-state="error"] {
  color: var(--white);
  background: rgba(255, 255, 255, 0.07);
  border-color: rgba(255, 255, 255, 0.4);
  font-weight: 500;
}
/* Le formulaire newsletter est une rangée flex : sans ces deux lignes, le message
   se plaçait À CÔTÉ du bouton et rétrécissait le champ de saisie de 490 à 256 px.
   Il prend maintenant toute la largeur, sous la rangée. */
.newsletter__form { flex-wrap: wrap; }
.newsletter__form .form-status { flex-basis: 100%; margin-top: 4px; }
/* Le bloc de consentement a rejoint l'INTÉRIEUR du formulaire (dehors, il
   n'était transmis nulle part, cf. le commentaire dans index.html). Il devient
   donc un élément de cette rangée flex, et il lui faut la même base que le
   message d'état pour passer à la ligne au lieu de rétrécir le champ.
   `margin-top` retombe à 2 px : la rangée wrappée apporte déjà les 12 px de
   `gap`, et sans ça la bande gagnait 12 px de haut pour rien. */
.newsletter__form .newsletter__consent { flex-basis: 100%; margin-top: 2px; }
/* Pot de miel du service d'envoi : un champ que seul un robot remplit, et qui
   doit arriver VIDE. `display:none` est ce qu'il faut ici, pas la classe
   `visually-hidden` : celle-ci laisse le champ dans l'ordre de tabulation et
   dans l'arbre d'accessibilité, où il n'a rien à faire. Un champ en
   `display:none` est bel et bien envoyé, seul `disabled` l'exclurait. */
.form-honeypot { display: none; }

/* ── La photo LuxTrust : bridée en largeur, et la colonne se resserre sur elle
   ─────────────────────────────────────────────────────────────────────────────
   `qbot-photo-dock.jpg` fait 699 x 860 pixels réels, et c'est TOUT ce qui existe.
   Vérifié dans la brochure dont elle est tirée : ses trois pages sont des images
   aplaties à 300 dpi et la photo n'y est qu'un petit encart de la page 1. Il n'y a
   pas de version plus fine à aller chercher, seulement un agrandissement.

   216 PX, ET LE NOMBRE TOMBE JUSTE DEUX FOIS : c'est `699 / 3 / 1,08` (le 1,08 est
   la sur-échelle que `.intro__image` applique à son contenu au repos), donc la
   largeur au-delà de laquelle l'image serait encore agrandie sur un écran à
   densité 3 ; et c'est aussi la demande du client, « la même taille que le bloc
   texte », le bloc voisin mesurant 286 px de haut pour 287 à l'image.
   Un premier plafond à 320 px suffisait à densité 2 mais laissait x1,48 à
   densité 3 : un plafond calculé pour densité 2 ne suffit pas, les téléphones et
   une partie des portables sont à densité 3 et un zoom navigateur à 125 % produit
   le même effet.

   LA COLONNE SE RESSERRE SUR L'IMAGE, sinon le plafond creuse un trou. Mesuré
   après le seul plafonnement : 216 px d'image, puis 310 px de noir, puis le texte
   au milieu de la page. Signalé par le client (« ça fait un peu vide »), et à
   juste titre : un écart de cette taille ENTRE deux blocs liés se lit comme un
   manque, alors que le même espace APRÈS le texte se lit comme une marge. La
   grille passe donc en `auto 1fr` (ou `1fr auto` selon le côté de l'image) et le
   paragraphe garde sa mesure de lecture ; seul le titre gagne en largeur.

   `:has()` évite de toucher au balisage des quatre pages. Sans lui, la grille
   reste en deux colonnes égales : c'est exactement l'état d'avant, donc une
   dégradation gracieuse et jamais une mise en page cassée.

   LE SEUIL EST 769 PX POUR LES DEUX, ET IL A ÉTÉ MESURÉ, PAS DÉDUIT. J'avais écrit
   901 pour `.intro__grid` en me fiant à une note interne parlant de 900 px : le
   relevé montre qu'elle est encore à DEUX colonnes à 769 px (320,5 px chacune), et
   le plafond ne s'appliquait donc pas entre 769 et 900, où l'image restait agrandie
   1,49 fois. Les deux grilles passent à une colonne à 768.

   LE PARAGRAPHE N'EST BRIDÉ QUE QUAND L'IMAGE EST À GAUCHE. À droite (pages Démo),
   le brider recréerait le trou : le reste tomberait ENTRE le texte et l'image au
   lieu de tomber sur la marge. Leurs quatre paragraphes font 96 à 153 caractères,
   donc une ligne à pleine largeur, et une ligne unique n'a pas de problème de
   mesure. */
@media (min-width: 769px) {
  .intro__grid > .intro__image--dock { max-width: 216px; }
  .intro__grid:has(> .intro__image--dock) { grid-template-columns: auto 1fr; }
  .intro__grid:has(> .intro__image--dock) > div:not(.intro__image) p { max-width: 62ch; }
}
@media (min-width: 769px) {
  /* `margin-left: auto` est le filet quand `:has()` n'est pas reconnu : la colonne
     resterait à `1fr` et l'image flotterait au milieu au lieu de toucher la
     gouttière droite. Avec `:has()`, la colonne `auto` s'en charge déjà. */
  .split-2 > .intro__image--dock { max-width: 216px; margin-left: auto; }
  .split-2:has(> .intro__image--dock) { grid-template-columns: 1fr auto; }
}

/* En dessous du seuil, la photo reprend toute la largeur de sa colonne, à la
   demande du client. 350 px est le plafond de ce cas, et il n'a rien d'arbitraire :
   sur un téléphone la colonne fait 342 px, donc la règle est SANS EFFET là où la
   pleine largeur a été demandée. Elle ne mord qu'au-dessus, sur une tablette en une
   colonne, où la colonne monte à 720 px et où l'agrandissement atteignait **x3,34**
   à densité 3. Autrement dit : la photo garde partout au plus la qualité qu'elle a
   sur un téléphone, et jamais moins. */
@media (max-width: 768px) {
  .intro__image--dock { max-width: 350px; }
}

/* BLOC DE SECOURS DU REPLI COURRIER (module 15). Il n'apparaît qu'après un envoi,
   quand le message a été passé au logiciel de courrier : il montre ce texte, au
   cas où rien ne se soit ouvert.
   TOUT EST EN `currentColor` ET EN `inherit`, à dessein : ce bloc vit sur la bande
   newsletter (fond teal, texte noir de charte) ET dans la carte du formulaire de
   contact (fond sombre, texte clair). Une couleur écrite en dur serait juste dans
   l'un des deux et illisible dans l'autre, ce qui est exactement le défaut relevé
   16 fois par l'audit du 2026-08-11. Le bouton hérite donc du contraste déjà
   validé de son conteneur. */
.form-relay {
  flex-basis: 100%;
  margin-top: 12px;
  color: inherit;
}
.form-relay__lead {
  font-size: 0.8125rem;
  line-height: 1.5;
  margin: 0 0 8px;
  color: inherit;
}
.form-relay__lead a { color: inherit; text-decoration: underline; }
/* La zone de texte garde un fond clair et un texte sombre dans les deux
   contextes : c'est un champ de formulaire, comme ceux au-dessus, et on y lit du
   texte dense. Même traitement que `.form-group textarea`. */
.form-relay__text {
  width: 100%;
  padding: 12px 14px;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  font-family: var(--font);
  font-size: 0.8125rem;
  line-height: 1.55;
  color: var(--dark);
  background: var(--white);
  resize: vertical;
}
.form-relay__copy {
  margin-top: 8px;
  padding: 9px 16px;
  /* 24 px de haut minimum : WCAG 2.2 (2.5.8). Mesuré ici à 38 px. */
  min-height: 38px;
  border: 1px solid currentColor;
  border-radius: var(--radius-sm);
  background: transparent;
  color: inherit;
  font-family: var(--font);
  font-size: 0.8125rem;
  font-weight: 500;
  cursor: pointer;
  transition: background 0.2s ease;
}
.form-relay__copy:hover { background: rgba(0, 0, 0, 0.08); }

/* Le bouton du formulaire de contact dit « Envoyer ma demande », or le site ne
   poste rien : il compose un message et le passe au logiciel du visiteur, par
   décision du client. Le dire AVANT le clic, et non le laisser découvrir après,
   c'est la même exigence que le `<noscript>` juste en dessous. */
.form-hint {
  margin: 10px 0 0;
  font-size: 0.8125rem;
  line-height: 1.5;
  color: var(--gray);
}
/* Sur la bande newsletter, fond teal : le texte repasse au noir de charte. */
.newsletter .form-status {
  color: #001A18;
  background: rgba(0, 26, 24, 0.08);
  border-color: rgba(0, 26, 24, 0.25);
}
.newsletter .form-status[data-state="ok"] { border-color: rgba(0, 26, 24, 0.5); font-weight: 500; }

/* ---- Bloc d'appel à l'action de fin de page ---- */
/* Aligné à gauche sur la gouttière du conteneur, comme l'en-tête de page et tout
   le contenu : ces blocs étaient centrés par un `style="text-align:center"` posé
   sur le conteneur, donc hors d'atteinte de toute feuille de style. Les dix blocs
   du site partagent maintenant cette classe — libellé, titre, sous-titre et
   boutons partent du même bord que le logo. */
.cta-block .section-subtitle { margin-left: 0; margin-right: 0; }
.cta-block__actions {
  display: flex;
  gap: 16px;
  flex-wrap: wrap;
}
/* Sous 520 px les boutons prennent toute la largeur, comme ceux des hero : c'est
   la même règle, pour la même raison — une cible à la largeur du pouce et une
   hiérarchie qui se lit. */
@media (max-width: 520px) {
  .cta-block__actions { flex-direction: column; align-items: stretch; }
  /* Le sélecteur couvre aussi le bouton seul, sans rangée autour de lui : la
     moitié des blocs n'en a qu'un, et sans ça le même bloc changeait d'allure
     selon qu'il portait un bouton ou deux. */
  .cta-block__actions .btn,
  .cta-block > .btn { width: 100%; justify-content: center; }
}

/* ---- Deux colonnes égales ---- */
/* Cette classe existe parce que le motif était écrit en style INLINE dans
   `commandez.html` / `en/order.html` : `display:grid; grid-template-columns:1fr 1fr`.
   Aucune media query ne peut atteindre un attribut `style`, si bien qu'à 390 px les
   deux colonnes restaient forcées : le contenu ne pouvait pas se réduire davantage
   et la page débordait de 42 px — elle défilait horizontalement, sur toute sa
   hauteur. C'est le genre de bogue qu'un style inline rend impossible à corriger
   ailleurs que dans le HTML. */
.split-2 {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 48px;
  align-items: center;
  margin-top: 48px;
}
@media (max-width: 768px) {
  .split-2 { grid-template-columns: 1fr; gap: 32px; }
}

/* ---- Newsletter ---- */
/* Le teal de marque est une couleur CLAIRE : du blanc dessus plafonne à
   1,8:1, très en dessous du minimum AA de 4,5:1 — c'est le noir de charte qui
   va sur le teal (≈ 11:1), pas l'inverse. Vaut pour toute surface teal pleine.
   Bande newsletter — volontairement compacte : une accroche d'une ligne, le
   champ, puis le consentement et la mention légale sur deux lignes fines.
   La version longue (trois paragraphes empilés) occupait presque un écran
   entier pour un simple champ email.

   PLUS AUCUNE PAGE N'EMPLOIE CETTE BANDE DEPUIS LE 2026-09-02. Le client l'a
   trouvée trop importante sur l'accueil (« comme si c'était l'info
   principale ») et la newsletter tient maintenant en une ligne du pied de page,
   cf. `.footer__news`. Le bloc est gardé : il porte le seul exemple de
   formulaire complet du site (rangée, consentement, mention du processeur), et
   c'est ce dont on repartirait le jour où un vrai programme d'emailing existe.
   NE PAS LE RÉINTRODUIRE SUR UNE PAGE sans le redemander. */
.newsletter {
  background: var(--teal);
  color: #001A18;
  padding: 52px 0;
}
.newsletter h2 { color: #001A18; margin-bottom: 8px; }
.newsletter p { color: rgba(0,26,24,0.86); margin-bottom: 22px; }

.newsletter__consent {
  max-width: 560px;
  margin: 14px 0 0;
}
.newsletter__consent label {
  display: flex;
  align-items: flex-start;
  gap: 9px;
  cursor: pointer;
  font-size: 0.8125rem;
  line-height: 1.5;
  color: rgba(0,26,24,0.86);
  text-align: left;
}
.newsletter__consent input[type="checkbox"] {
  width: auto;
  margin-top: 2px;
  flex-shrink: 0;
  accent-color: #001A18;
}
.newsletter__consent a { text-decoration: underline; }
/* Sélecteur doublé : .newsletter p (couleur + margin-bottom) est plus
   spécifique qu'une classe seule et l'emporterait. */
.newsletter .newsletter__legal {
  font-size: 0.75rem;
  line-height: 1.5;
  color: rgba(0,26,24,0.72);
  /* Même mesure que la ligne de consentement juste au-dessus (560 px, héritée du
     conteneur) : les deux blocs de petit texte s'alignent au lieu de former deux
     largeurs différentes. */
  margin: 12px 0 0;
}

.newsletter__form {
  display: flex;
  gap: 12px;
  /* 640 et non 500 : le placeholder fait 58 caractères (« Veuillez renseigner
     votre adresse email pour vous inscrire ») et se retrouvait coupé en plein
     mot dans un champ de 350 px — sur un formulaire, un libellé tronqué est ce
     que le visiteur lit en premier. */
  max-width: 640px;
  margin: 0;
}

.newsletter__form input[type="email"] {
  flex: 1;
  padding: 14px 20px;
  border-radius: 8px;
  border: none;
  font-size: 0.9375rem;
  font-family: var(--font);
  outline: none;
}
.newsletter__form input[type="email"]:focus { box-shadow: 0 0 0 2px var(--teal-dark); }

.newsletter__form .btn {
  background: #001A18;
  color: var(--teal);
  border-color: #001A18;
  white-space: nowrap;
}
.newsletter__form .btn:hover {
  background: var(--white);
  color: #00544E;
  border-color: var(--white);
}

/* ---- Page hero (sous-pages) ---- */
/* Aligné à gauche, comme tout le reste du site. Il était centré : un bloc centré
   posé au-dessus d'une page entièrement alignée sur la gouttière du conteneur —
   le fil d'Ariane juste en dessous, les titres de section, le pied de page.
   Aucun `text-align` ici, donc l'en-tête part du bord de la boîte de contenu de
   `.container`, qui est exactement là où commence l'encre du logo (celui-ci porte
   une marge négative égale à son espace de protection transparent, cf.
   `.nav__logo`). L'alignement se règle donc tout seul si la gouttière change. */
.page-hero {
  background: var(--dark);
  color: var(--white);
  padding: 80px 0 64px;
}
.page-hero h1 { color: var(--white); margin-bottom: 16px; }
/* Une seule règle : elle était déclarée deux fois de suite, la seconde écrasant
   la couleur de la première — la première ne servait à rien. */
.page-hero p {
  color: rgba(255,255,255,0.7);
  margin: 0;
  font-size: 1.0625rem;
}
/* LA MESURE DE LECTURE ÉTAIT DÉCLARÉE ICI ET ANNULÉE PLUS BAS. `.page-hero p`
   pèse (0,1,1), exactement comme `.container > p`, et ce dernier est écrit plus
   loin dans le fichier : à poids égal l'ordre tranche, donc le chapeau prenait
   la mesure du conteneur et non la sienne. Relevé avant : 745 px au lieu de 560
   sur 10 pages sur 14, jusqu'à 98 caractères par ligne sur la politique de
   confidentialité anglaise, dont la prose est dense. Les quatre pages qui
   tombaient juste le devaient à un style en ligne ou à une autre règle.
   Quatrième occurrence de cette famille après `.usecase__fig`,
   `.newsletter__legal` et `.evo-card__link` : une propriété déclarée puis
   annulée pour TOUS les éléments qu'elle vise.
   Deux classes plutôt qu'une pour que le sélecteur gagne, et la mesure reste
   celle du chapô du hero d'accueil, les deux se répondant. Elle est à fleur de
   gouttière : c'est `margin: 0 auto` qui recentrait le paragraphe. */
.page-hero .container > p { max-width: 560px; }

/* ---- À propos ---- */
.about-section__grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 80px;
  align-items: center;
}

.about-section__grid.reverse { direction: rtl; }
.about-section__grid.reverse > * { direction: ltr; }

.about-section__content .btn { margin-top: 28px; }

.product-card {
  position: relative;
  overflow: hidden;
  background: var(--white);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  padding: 32px;
  display: flex;
  gap: 20px;
  align-items: center;
  box-shadow: var(--shadow-sm);
  transition: box-shadow 0.3s ease, border-color 0.25s ease, transform 0.3s cubic-bezier(0.16,1,0.3,1);
}
.product-card:hover { border-color: rgba(0,203,190,0.18); box-shadow: var(--shadow-lg), 0 0 0 1px rgba(0,203,190,0.12); transform: translateY(-5px); }
.product-card__icon { height: 36px; width: auto; max-width: 140px; object-fit: contain; flex-shrink: 0; }
.product-card h3 { margin-bottom: 6px; font-size: 1rem; }
.product-card p { font-size: 0.875rem; }

.products-grid { display: flex; flex-direction: column; gap: 16px; }

/* ---- Caractéristiques techniques ---- */
.specs__grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 80px;
  align-items: start;
}

/* Le bloc qui ouvre la fiche technique, à côté du film de l'intérieur du boîtier.
   IL PORTAIT `style="margin-bottom:56px; align-items:center;"` EN LIGNE, et le
   centrage contredisait le `start` ci-dessus, qu'aucune règle ne pouvait donc
   rétablir. Effet mesuré à 1440 px : le texte fait 108 px de haut contre 401 px
   pour le film, donc le centrage le poussait de 155 px vers le bas et ouvrait
   248 px de vide entre le chapeau de section et son propre titre. C'est le motif
   déjà jugé fautif le 2026-08-26 — un écart de cette taille ENTRE deux blocs liés
   se lit comme un manque, alors que le même espace APRÈS le texte se lit comme une
   marge — et la section voisine de la même page, elle, était bien en `start`.
   Relevé après retrait : 32 px, soit exactement le rythme titre → bloc fixé le
   2026-08-25, et hauteur de section inchangée (2 537 px).
   Sixième fois que ce dépôt se fait prendre par un style en ligne, après les
   centrages de `commandez`, les vignettes de blog, les fonds clairs de
   `.spec-item` et les deux listes de FAQ. La marge basse, elle, est RÉELLE : le
   voisin `.tools__grid` a `margin-top: 0`, donc rien ne la remplace. */
.specs__grid--intro { margin-bottom: 56px; }
@media (max-width: 900px) { .specs__grid--intro { margin-bottom: 40px; } }

/* Fiche technique sur deux colonnes, demandé le 2026-08-28.
   DEUX LISTES CÔTE À CÔTE, PAS UNE LISTE À QUATRE PISTES. L'alignement des
   valeurs repose sur `subgrid` (voir plus haut) : il fait qu'une liste a UNE
   colonne de libellés, calée sur son libellé le plus long. Une seule liste
   étalée sur quatre pistes casserait cette garantie, alors que deux listes
   voisines la gardent, chacune pour elle. */
.specs__cols {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 24px 64px;
  align-items: start;
  margin-top: 56px;
}
@media (max-width: 900px) { .specs__cols { grid-template-columns: 1fr; } }

.specs__list { display: flex; flex-direction: column; gap: 24px; }

/* ══ UNE SEULE COLONNE DE LIBELLÉS PAR FICHE ═══════════════════════════════
   `.spec-item__label` avait `min-width: 120px`, c'est-à-dire un PLANCHER et non
   une colonne : dès qu'un libellé dépassait cette largeur il fixait la sienne,
   et sa valeur commençait plus à droite que toutes les autres. Signalé par le
   client le 2026-08-25 sur « Connexion internet », et reproduit : deux positions
   de départ à 1920 et à 768 px côté français, TROIS sur presque toutes les
   largeurs côté anglais (« INTERNET CONNECTION » et « MANUFACTURING »).

   La largeur ne pouvait pas être réglée sur `.spec-item` : chaque ligne est son
   propre conteneur et ne sait rien des autres. C'est la LISTE qui doit porter la
   mesure, d'où `subgrid` : la colonne s'ajuste au libellé le plus long de sa
   propre liste, sans constante écrite à la main, et chaque liste garde la sienne
   (117 px pour la fiche matérielle, 213 px pour celle des points d'entrée API).

   Sous `@supports`, donc le comportement en flex reste le repli exact d'avant
   pour un moteur sans subgrid : les libellés y débordent comme aujourd'hui, ce
   qui est moins bien mais pas cassé. */
@supports (grid-template-columns: subgrid) {
  .specs__list {
    display: grid;
    grid-template-columns: max-content 1fr;
    column-gap: 16px;   /* l'ancien `gap` de .spec-item, qui n'est plus un flex */
  }
  .specs__list > .spec-item:not(.spec-item--card) {
    display: grid;
    grid-column: 1 / -1;
    grid-template-columns: subgrid;
  }
}

/* LE FILET DE SÉPARATION APPARTIENT À LA VARIANTE « LIGNE », PAS À LA VARIANTE
   « CARTE ». Il était posé sur `.spec-item` tout court, et retiré du dernier par
   `.spec-item:last-child` — dont la spécificité (0,1,1) dépasse celle de
   `.spec-item--card` (0,1,0). Conséquence : la DERNIÈRE carte d'une liste perdait
   son bord inférieur, donc son cadre n'était pas fermé. Le défaut ne dépendait ni
   de la fenêtre ni du défilement — mesuré identique de 1440 × 700 à 3840 × 2160,
   `border-bottom-width: 0px` sur la troisième carte contre 1px sur les deux
   autres — ce qui explique qu'il ait survécu à deux corrections visant la hauteur
   de la scène épinglée. Le `:not()` règle la cause : une carte n'est jamais
   concernée par la logique de séparateur. */
.spec-item {
  display: flex;
  gap: 16px;
}

/* ══ LES FICHES PASSENT EN UNE COLONNE SUR TÉLÉPHONE ═══════════════════════
   À deux colonnes, la colonne des valeurs se réduit à ce que laisse le libellé
   le plus long. Mesuré à 390 px : 170 px pour la fiche matérielle et **123 px**
   pour celle des points d'entrée API, dont les libellés sont des URL en chasse
   fixe. À 123 px on lit trois mots par ligne, ce qui n'est plus un tableau mais
   une colonne de confettis.
   Le libellé passe donc AU-DESSUS de sa valeur, qui récupère toute la largeur.
   C'est le motif habituel d'un tableau de caractéristiques sur petit écran.
   Le seuil est calculé : le libellé le plus long du site fait 203 px (API) et
   il faut de quoi respirer à droite, donc on repasse à deux colonnes dès qu'il
   reste environ 290 px pour la valeur, soit 560 px de fenêtre.
   La spécificité doit ÉGALER celle du bloc `@supports` plus haut, sinon la
   subgrid l'emporte : d'où le sélecteur répété à l'identique. */
@media (max-width: 560px) {
  .specs__list { display: flex; flex-direction: column; }
  .specs__list > .spec-item:not(.spec-item--card) { display: block; }
  .spec-item__label { display: block; min-width: 0; margin-bottom: 6px; }
}

.spec-item:not(.spec-item--card) {
  padding-bottom: 20px;
  border-bottom: 1px solid var(--border);
}
.spec-item:not(.spec-item--card):last-child { border-bottom: none; }

.spec-item__label {
  font-size: 0.8125rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--muted);
  min-width: 120px;
}
.spec-item__value { font-size: 0.9375rem; color: var(--dark); }

/* Un lien DANS un libellé de fiche. Le libellé est en capitales espacées et en
   gris sourd : un lien teal souligné y ferait une tache et casserait la colonne
   que `subgrid` aligne. Il garde donc la couleur du libellé et se signale par un
   soulignement discret, qui passe en teal au survol et au focus. Le contour de
   focus du site reste, lui, inchangé. */
.spec-item__label a {
  color: inherit;
  text-decoration: underline;
  text-decoration-style: dotted;
  text-underline-offset: 3px;
  text-decoration-color: rgba(0, 203, 190, 0.55);
  transition: color 0.18s ease, text-decoration-color 0.18s ease;
}
.spec-item__label a:hover,
.spec-item__label a:focus-visible {
  color: var(--teal);
  text-decoration-color: currentColor;
}

/* Variante "carte" (section Interface & API) : au lieu d'un fond forcé en
   inline style (invisible en dark mode), le fond suit le thème. */
.spec-item--card {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 20px;
  background: var(--white);
}
[data-theme="dark"] .spec-item--card {
  background: #141414;
  border-color: #2B2B2B;
}

/* .specs__image img : radius et ombre sont regroupés dans la section
   « Image produit » avec ceux de .intro__image. */

/* ---- Contact ---- */
.contact__grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 80px;
  align-items: start;
}

.contact-form {
  background: var(--white);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  padding: 40px;
  box-shadow: var(--shadow);
  box-shadow: -4px 14px 40px rgba(0,0,0,0.09);
}

.form-group { margin-bottom: 20px; }

.form-group label {
  display: block;
  font-size: 0.875rem;
  font-weight: 700;
  margin-bottom: 8px;
  color: var(--dark);
}

.form-group input,
.form-group textarea,
.form-group select {
  width: 100%;
  padding: 12px 16px;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  font-size: 0.9375rem;
  font-family: var(--font);
  color: var(--dark);
  background: var(--white);
  transition: border-color var(--transition);
  outline: none;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus { border-color: var(--teal); box-shadow: 0 0 0 3px rgba(0,203,190,0.15); }

.form-group textarea { resize: vertical; min-height: 120px; }

.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }

.contact-info { display: flex; flex-direction: column; gap: 32px; }

.contact-info__item { display: flex; gap: 16px; align-items: flex-start; }

.contact-info__icon {
  width: 44px; height: 44px;
  background: var(--teal-light);
  border-radius: var(--radius-sm);
  display: flex; align-items: center; justify-content: center;
  flex-shrink: 0;
}
.contact-info__icon svg { width: 20px; height: 20px; color: var(--teal); }

.contact-info__item h3 { margin-bottom: 4px; font-size: 0.875rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
.contact-info__item a, .contact-info__item p { font-size: 0.9375rem; color: var(--dark); font-weight: 500; }
.contact-info__item a:hover { color: var(--teal-text); }

.booking-box {
  background: var(--teal-light);
  border: 1px solid rgba(0,203,190,0.2);
  border-radius: var(--radius);
  padding: 32px;
}

/* ── Agenda de réservation, en fenêtre modale ──────────────────────────────
   L'agenda s'ouvre PAR-DESSUS la page et non plus dans son flux. Ce n'est pas un
   choix d'esthétique : le contenu de Bookings fait 1 523 px de haut à 1 130 px de
   large et 1 838 px à 342 px. Inséré dans la page, il imposait donc soit un
   défilement imbriqué qui capture la molette, soit 4,7 écrans de panneau blanc sur
   un téléphone. Dans une fenêtre dédiée, un défilement interne est ATTENDU.

   La fenêtre est un <dialog> natif : le piège de focus, la touche Échap, le fond
   assombri et le retour du focus sur le bouton d'origine sont donnés par le
   navigateur, sans une ligne de JavaScript pour les émuler.

   ET BOOKINGS N'ACCEPTE L'ENCADREMENT QUE DEPUIS UNE PAGE HTTPS
   (« frame-ancestors https: »). Hors https, ou sans <dialog>, ou sans JavaScript,
   le module 20 n'arme pas le bouton et c'est le lien externe qui reste l'action. */
.booking-facts { margin-top: 28px; }
.booking-frame {
  margin-top: 32px;
  background: var(--white);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  overflow: hidden;
}
.booking-frame__ask {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 16px;
  padding: 44px 32px;
  max-width: 640px;              /* le cadre est large : sans mesure, le texte s'y perd */
}
.booking-frame__ask-pin { color: var(--teal); flex-shrink: 0; }
.booking-frame__ask-titre {
  margin: 0;
  font-size: 1.125rem;
  font-weight: 700;
  color: var(--black);
}
.booking-frame__ask-note {
  font-size: 0.875rem;
  color: var(--gray);
  margin: 0;
  max-width: 62ch;
}
/* Par défaut, et donc sans JavaScript comme hors https : c'est le LIEN externe qui
   est l'action principale. Le bouton n'apparaît qu'armé par le module 20. */
.booking-frame__ask-link {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  min-height: 44px;
  padding: 10px 20px;
  border-radius: var(--radius);
  background: var(--teal);
  color: #001A18;                /* le teal de charte est CLAIR : jamais de blanc dessus */
  font-weight: 500;
  transition: background .2s ease, transform .2s ease;
}
.booking-frame__ask-link:hover { background: var(--teal-tint); transform: translateY(-1px); }
.booking-frame__ask-btn { display: none; }
.booking-frame.is-armee .booking-frame__ask-btn { display: inline-flex; }
.booking-frame.is-armee .booking-frame__ask-link { display: none; }
.booking-frame__ask-btn {
  align-items: center;
  gap: 8px;
  font: inherit;
  font-weight: 500;
  cursor: pointer;
  padding: 10px 20px;
  min-height: 44px;
  border: none;
  border-radius: var(--radius);
  background: var(--teal);
  color: #001A18;
  transition: background .2s ease, transform .2s ease;
}
.booking-frame__ask-btn:hover { background: var(--teal-tint); transform: translateY(-1px); }
/* Le pied n'est un recours que si la fenêtre est bien le chemin principal. Sinon
   l'action principale EST déjà ce lien, et le pied le dirait deux fois. Le
   « display » par défaut est posé ICI, dans la règle de base : une règle
   « display: none » écrite plus haut serait écrasée par celle-ci, à spécificité
   égale c'est l'ordre qui tranche. */
.booking-frame__foot {
  display: none;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px 12px;
  margin: 0;
  padding: 16px 32px;
  border-top: 1px solid var(--border);
  font-size: 0.875rem;
  color: var(--gray);
}
.booking-frame.is-armee .booking-frame__foot { display: flex; }
.booking-frame__foot a { color: var(--teal-text); font-weight: 500; }
@media (max-width: 640px) {
  .booking-frame__ask { padding: 30px 20px; }
  .booking-frame__foot { padding: 14px 20px; }
}

/* ── La fenêtre elle-même ── */
.booking-modal {
  /* Un <dialog> auquel on fixe des dimensions perd le centrage du navigateur :
     il faut le lui redonner explicitement, sinon la fenêtre se colle en haut à
     gauche et chevauche la barre de navigation. */
  inset: 0;
  margin: auto;
  padding: 0;
  border: 0;
  background: transparent;
  width: min(1120px, 94vw);
  max-width: none;
  height: min(920px, 92vh);
  max-height: none;
  overflow: visible;
}
.booking-modal::backdrop {
  background: rgba(0, 0, 0, 0.78);
  animation: bookingVoile .32s ease both;
}
/* Le flou est le langage du site depuis la passe « verre sombre » du 2026-08-20 :
   « saturate(130%) » compense la désaturation qu'il provoque. */
@supports (backdrop-filter: blur(6px)) {
  .booking-modal::backdrop { backdrop-filter: blur(6px) saturate(130%); }
}
@keyframes bookingVoile { from { opacity: 0; } to { opacity: 1; } }
.booking-modal__box {
  display: flex;
  flex-direction: column;
  height: 100%;
  background: var(--white);
  /* Filet et halo teal repris À L'IDENTIQUE du cadre du film du hero et de celui de
     la visionneuse 3D : c'est la façon dont ce site encadre un média. */
  border: 1px solid rgba(0, 203, 190, 0.22);
  border-radius: var(--radius-lg);
  overflow: hidden;
  box-shadow:
    0 30px 70px rgba(0, 0, 0, 0.55),
    0 0 60px rgba(0, 203, 190, 0.10);
  animation: bookingIn .34s cubic-bezier(0.16, 1, 0.3, 1) both;
}
/* Même courbe que les cartes du site, et une amplitude volontairement faible : une
   fenêtre qui bondit se remarque, une fenêtre qui se pose ne se remarque pas. */
@keyframes bookingIn {
  from { opacity: 0; transform: translateY(12px) scale(.988); }
  to   { opacity: 1; transform: none; }
}
/* La barre est à NOUS, le contenu est à Microsoft : elle porte notre noir de charte
   pour que la frontière entre les deux soit lisible. */
.booking-modal__bar {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  flex-shrink: 0;
  padding: 12px 12px 12px 20px;
  background: var(--dark);
  color: #fff;
}
/* Le même filet teal dégradé que le bas de la barre de navigation : c'est le signe
   le plus court qui dise « ce bandeau est à nous, ce qui suit est à Microsoft ». */
.booking-modal__bar::after {
  content: '';
  position: absolute;
  bottom: 0; left: 0; right: 0;
  height: 1px;
  background: linear-gradient(90deg, transparent 0%, rgba(0,203,190,0.12) 25%, rgba(0,203,190,0.25) 50%, rgba(0,203,190,0.12) 75%, transparent 100%);
}
.booking-modal__legende { display: flex; align-items: center; gap: 10px; min-width: 0; }
.booking-modal__legende svg { color: var(--teal); flex-shrink: 0; }
.booking-modal__titre {
  margin: 0;
  font-size: 0.9375rem;
  font-weight: 700;
  letter-spacing: -0.01em;       /* la tenue des titres du site */
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.booking-modal__close {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 44px;                   /* WCAG 2.5.8 : une cible de fermeture se rate mal */
  height: 44px;
  flex-shrink: 0;
  padding: 0;
  border: none;
  border-radius: 50%;
  cursor: pointer;
  background: rgba(255, 255, 255, 0.12);
  color: #fff;
  transition: background .2s ease;
}
.booking-modal__close:hover { background: var(--teal); color: #001A18; }
.booking-modal__zone { position: relative; flex: 1; min-height: 0; }
.booking-modal__iframe {
  display: block;
  width: 100%;
  height: 100%;
  border: 0;
  background: #fff;              /* Bookings est clair : sinon on voit le fond sombre pendant le chargement */
  opacity: 0;
  transition: opacity .4s ease;
}
.booking-modal.is-pret .booking-modal__iframe { opacity: 1; }
/* Sans cet état, on regarde un rectangle vide sans savoir si c'est en cours ou en
   panne : c'est la leçon de l'indicateur de la séquence 3D. */
.booking-modal__chargement {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  margin: 0;
  font-size: 0.9375rem;
  color: var(--gray);
  pointer-events: none;
}
.booking-modal.is-pret .booking-modal__chargement { display: none; }
.booking-modal__chargement i {
  width: 6px; height: 6px; border-radius: 50%;
  background: var(--teal);
  animation: bookingPulse 1.1s ease-in-out infinite;
}
.booking-modal__chargement i:nth-child(2) { animation-delay: .18s; }
.booking-modal__chargement i:nth-child(3) { animation-delay: .36s; }
@keyframes bookingPulse {
  0%, 100% { opacity: .25; transform: scale(.8); }
  50%      { opacity: 1;   transform: scale(1.15); }
}
/* Sur téléphone la fenêtre prend tout l'écran : à 342 px de large, une fenêtre
   flottante avec des marges ne laisserait pas la place au calendrier. */
@media (max-width: 640px) {
  .booking-modal {
    width: 100vw; height: 100vh;
    max-height: 100dvh;
  }
  .booking-modal__box { border-radius: 0; border: 0; }
}

.booking-box h3 { margin-bottom: 8px; font-size: 1rem; }
.booking-box p { font-size: 0.9rem; margin-bottom: 16px; }

/* ---- Footer ---- */
.footer {
  background: var(--dark);
  color: rgba(255,255,255,0.65);
  padding: 72px 0 36px;
  position: relative;
}
.footer::before {
  content: '';
  position: absolute;
  top: 0; left: 0; right: 0;
  height: 1px;
  background: linear-gradient(90deg, transparent 0%, rgba(0,203,190,0.2) 25%, rgba(0,203,190,0.45) 50%, rgba(0,203,190,0.2) 75%, transparent 100%);
}

.footer__grid {
  display: grid;
  grid-template-columns: 2fr 1fr 1fr 1fr;
  gap: 48px;
  padding-bottom: 48px;
  border-bottom: 1px solid rgba(255,255,255,0.08);
  margin-bottom: 28px;
}

/* Idem nav : version « Negative » de la charte, accent teal préservé, plus de
   filtre d'inversion. Pas d'opacity non plus — la charte ne prévoit pas de
   logo atténué, et 0.9 désaturait l'accent teal. */
.footer__logo {
  --logo-h: 29px;
  display: inline-flex;
  align-items: center;
  padding: calc(var(--logo-h) * var(--logo-clear-y)) calc(var(--logo-h) * var(--logo-clear-x));
  /* Le padding est retranché de la marge basse : l'espace de protection est
     garanti sans changer le rythme vertical existant du footer. */
  margin: 0 0 calc(16px - var(--logo-h) * var(--logo-clear-y)) calc(-1 * var(--logo-h) * var(--logo-clear-x));
}
.footer__logo img { height: var(--logo-h); width: auto; }

.footer__desc { font-size: 0.875rem; line-height: 1.7; max-width: 280px; color: rgba(255,255,255,0.65); }

.footer__meta-row {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 16px;
  margin-top: 20px;
}

.footer__social {
  display: flex;
  gap: 10px;
}
.footer__social a {
  width: 34px; height: 34px;
  border: 1px solid rgba(255,255,255,0.12);
  border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  transition: var(--transition);
  color: rgba(255,255,255,0.5);
}
.footer__social a:hover {
  border-color: var(--teal);
  background: rgba(0,203,190,0.12);
  color: var(--teal);
}
.footer__social a svg { width: 15px; height: 15px; }

.footer__badge {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-size: 0.75rem;
  color: rgba(255,255,255,0.68);
  border: 1px solid rgba(255,255,255,0.16);
  border-radius: 20px;
  padding: 5px 12px;
}

.footer__col h3 {
  font-size: 0.6875rem;
  font-weight: 700;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: rgba(255,255,255,0.66);
  margin-bottom: 20px;
}

.footer__col ul { display: flex; flex-direction: column; gap: 12px; }
.footer__col ul li a {
  font-size: 0.875rem;
  color: rgba(255,255,255,0.72);
  transition: color var(--transition);
}
.footer__col ul li a:hover { color: var(--teal); }
/* La page courante n'est plus un lien vers elle-même dans la liste du pied de
   page. Elle reste ÉCRITE — retirer la ligne rendrait le plan du site incomplet,
   et différemment sur chaque page — mais elle est marquée « vous êtes ici » en
   blanc, le même langage que le lien actif de la barre de navigation.
   UNE EXCEPTION, décidée par le client : « Démo gratuite » / « Free Demo » reste
   un lien sur la page de contact. La règle tient sur les entrées qui NOMMENT la
   page (« À propos », « FAQ », « Commander »…) : les marquer dit au visiteur où il
   est. Celle-ci nomme une action, pas la page — la page s'appelle « Contact » —
   donc la marquer « vous êtes ici » désignait un endroit qui n'existe pas dans le
   plan du site, alors qu'un appel à l'action y reste légitime. */
.footer__col ul li [aria-current="page"] {
  font-size: 0.875rem;
  color: var(--white);
  font-weight: 500;
  cursor: default;
}

.footer__address {
  margin-top: 20px;
  font-size: 0.8125rem;
  line-height: 1.6;
  color: rgba(255,255,255,0.66);
}

/* ---- Newsletter, réduite à une ligne du pied de page ----
   Elle occupait une bande pleine largeur en bas de l'accueil ; le client l'a
   trouvée trop importante le 2026-09-02 (« comme si c'était l'info
   principale ») et a demandé « une phrase et un bouton discret dans le
   footer ». Donc : une rangée entre deux filets, du texte secondaire à la
   nuance des autres mentions du pied de page, et un bouton fantôme.
   LE BOUTON EST EN TEAL SUR LE NOIR, jamais l'inverse : le teal de charte est
   une couleur CLAIRE, un fond teal plein avec du blanc dessus plafonne à
   2,04:1 (piège relevé quatre fois sur ce site). Ici le teal est l'encre,
   donc ~10:1 sur le fond du pied de page.
   `flex-wrap` et non une requête média : la phrase et le bouton passent d'eux
   mêmes sur deux lignes quand la place manque, et le `gap` vertical est déjà
   prévu pour ce cas. */
.footer__news {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 12px 24px;
  padding-bottom: 22px;
  margin-bottom: 22px;
  border-bottom: 1px solid rgba(255,255,255,0.06);
}
.footer__news p {
  margin: 0;
  font-size: 0.875rem;
  line-height: 1.6;
  color: rgba(255,255,255,0.66);
  max-width: 58ch;
}
.footer__news-btn {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  flex: 0 0 auto;
  padding: 9px 18px;
  border: 1px solid rgba(0,203,190,0.42);
  border-radius: 999px;
  font-size: 0.8125rem;
  font-weight: 500;
  color: var(--teal);
  text-decoration: none;
  transition: border-color var(--transition), background var(--transition);
}
.footer__news-btn:hover {
  border-color: var(--teal);
  background: rgba(0,203,190,0.10);
}
.footer__news-btn svg { flex: 0 0 auto; }

.footer__bottom {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  flex-wrap: wrap;
}

.footer__copy { font-size: 0.8125rem; color: rgba(255,255,255,0.64); }

.footer__legal {
  display: flex;
  gap: 20px;
  align-items: center;
}
.footer__legal a {
  font-size: 0.75rem;
  color: rgba(255,255,255,0.64);
  transition: color var(--transition);
  text-decoration: none;
}
.footer__legal a:hover { color: var(--teal); }

/* ---- Timeline ---- */
.timeline { position: relative; max-width: 700px; margin: 0 auto; }
.timeline::before {
  content: '';
  position: absolute;
  left: 20px; top: 0; bottom: 0;
  width: 2px;
  background: var(--border);
  z-index: 0;   /* sous les items (cf. .timeline-item) */
}

.timeline-item {
  display: flex;
  gap: 24px;
  padding-bottom: 36px;
  /* z-index explicite sur l'item plutôt que sur la seule pastille : les deux
     traits verticaux de .timeline sont en absolute, donc peints après le flux
     normal. Sans cela, tout ce qui transforme l'item en contexte d'empilement
     (un filter de reveal, par exemple) enferme le z-index de la pastille et
     laisse les traits passer par-dessus les numéros. */
  position: relative;
  z-index: 1;
}
.timeline-item:last-child { padding-bottom: 0; }

.timeline-item__dot {
  width: 40px; height: 40px;
  border-radius: 50%;
  background: var(--white);
  border: 2px solid var(--teal);
  display: flex; align-items: center; justify-content: center;
  flex-shrink: 0;
  /* `position: relative` explicite : sans elle, le z-index ci-dessous n'était
     pas appliqué et la pastille se retrouvait *sous* les traits verticaux de
     .timeline (le numéro était barré par le trait de progression, qui est un
     ::after donc plus loin dans l'ordre de l'arbre). */
  position: relative;
  z-index: 1;
  font-size: 0.75rem;
  font-weight: 700;
  color: var(--teal-text);
}

.timeline-item__content { padding-top: 8px; }
.timeline-item__date { font-size: 0.8125rem; font-weight: 700; color: var(--teal-text); margin-bottom: 4px; }
.timeline-item__content h4 { margin-bottom: 4px; }
.timeline-item__content p { font-size: 0.9rem; }

/* ---- Évolution du produit ----
   Trois étapes côte à côte : Q-Bot V1, la génération 2, et le NFC pour la
   suite. Remplace la timeline datée de la homepage.

   LE RAIL DE PROGRESSION A ÉTÉ RETIRÉ LE 2026-08-28, à la demande du client :
   « ça n'apporte pas grand-chose et on a l'impression que c'est pas fini ».
   C'était un trait horizontal reliant les trois cartes, dont la portion teal
   s'arrêtait sur la pastille de la carte courante. Il est parti d'un bloc,
   avec ses deux pseudo-éléments, les pastilles `.evo-card__node`, la variable
   `--evo-fill` et les deux animations d'entrée qui le dessinaient.

   Ne pas le réintroduire pour « marquer où en est le produit » : c'est le
   libellé d'étape de chaque carte qui le dit, et il le dit en toutes lettres.

   AUCUNE PAGE N'EMPLOIE PLUS CE BLOC DEPUIS LE 2026-09-02 : la section a été
   retirée des deux accueils sur retour du client (« j'ai une réserve sur
   l'évolution de Q-Bot, pas utile sur la homepage »). Le bloc est GARDÉ, comme
   `.timeline`, parce que l'objection porte sur la PLACE et non sur le motif :
   trois cartes d'états du produit ont leur sens sur « À propos », pas sur la
   page où le visiteur découvre le produit.
   ATTENTION, UNE SEULE CLASSE D'ICI EST ENCORE EMPLOYÉE : `.evo-card__link`,
   qui sert de style de lien fléché sur les deux fiches techniques. La retirer
   en croyant faire du ménage casserait ces liens. */
.evolution__rail {
  --evo-gap: 24px;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: var(--evo-gap);
  position: relative;
}

.evo-card {
  position: relative;
  display: flex;
  flex-direction: column;
  background: #141414;
  border: 1px solid #2B2B2B;
  border-radius: var(--radius);
  padding: 22px 24px 26px;
  transition: border-color var(--transition), transform 0.5s var(--ease-out);
}
.evo-card:hover { border-color: rgba(0,203,190,0.35); transform: translateY(-4px); }

.evo-card__media {
  position: relative;
  display: grid;
  place-items: center;
  aspect-ratio: 4 / 3;
  margin-bottom: 20px;
  border-radius: var(--radius-sm);
  background:
    radial-gradient(80% 70% at 50% 45%, rgba(0,203,190,0.10), transparent 70%),
    linear-gradient(165deg, #1B1B1B, #0E0E0E);
  overflow: hidden;
}
/* Positionnement absolu + object-fit plutôt qu'un max-height en pourcentage :
   la hauteur du cadre vient de son aspect-ratio, et Chrome ne résout pas
   toujours un max-height % contre une hauteur obtenue ainsi — l'image
   débordait alors du cadre et se faisait rogner en bas. */
.evo-card__media img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  padding: 16px;
  object-fit: contain;
  transition: transform 0.85s var(--ease-out);
}

/* Le cadre en pointillés et ses trois points qui « respiraient » ont été
   retirés le 2026-08-28 : la troisième carte porte désormais un vrai visuel,
   le boîtier et un téléphone qui échangent par NFC. Un cadre vide se lisait
   comme une page inachevée, ce que le client a signalé. */
  35%           { opacity: 1;    transform: translateY(-4px); }
}

.evo-card__body {
  display: flex;
  flex-direction: column;
  flex: 1;
  min-width: 0;
}

.evo-card__step {
  font-size: 0.6875rem;
  font-weight: 700;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--muted);
  margin-bottom: 8px;
}
.evo-card--current .evo-card__step { color: var(--teal); }
.evo-card h3 { margin-bottom: 10px; }
.evo-card p  { font-size: 0.9375rem; }

.evo-card__link {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  margin-top: auto;
  padding-top: 18px;
  font-size: 0.875rem;
  font-weight: 500;
  color: var(--teal);
}
.evo-card__link svg { transition: transform var(--transition); }
.evo-card__link:hover svg { transform: translateX(4px); }

/* Sous 900 px, la grille passe à une colonne — et la carte à une *ligne* :
   image à gauche, texte à droite. Sans ça le cadre image, en aspect-ratio 4/3,
   suit la pleine largeur de la carte (≈ 640 px de haut sur une carte de
   850 px) : trois pavés géants empilés, une section de 2 500 px et un cadre
   « prochaine étape » vide grand comme l'écran. */
@media (max-width: 900px) {
  .evolution__rail { grid-template-columns: 1fr; gap: 18px; }

  .evo-card { flex-direction: row; align-items: center; gap: 24px; padding: 20px; }
  .evo-card__media {
    flex: 0 0 min(34%, 210px);
    width: min(34%, 210px);
    aspect-ratio: 1;
    margin-bottom: 0;
  }
  .evo-card__link { padding-top: 14px; }
}

/* Trop étroit pour deux colonnes dans la carte : on réempile. Hauteur fixe
   plutôt qu'un ratio — un 4/3 pleine largeur redonnerait un cadre inutilement
   haut, et les visuels sont détourés donc contenus quoi qu'il arrive. */
@media (max-width: 520px) {
  .evo-card { flex-direction: column; align-items: stretch; gap: 0; padding: 20px 20px 24px; }
  .evo-card__media {
    flex: none;
    width: auto;
    aspect-ratio: auto;
    height: 190px;
    margin-bottom: 18px;
  }
}

/* Le fil d'Ariane visible a été retiré le 2026-08-28, à la demande du client :
   « supprimer la navigation bar qui est sous les titres ». Ses styles partent
   avec lui plutôt que de rester dormants. Le BreadcrumbList des données
   structurées reste, lui : il n'était pas affiché, il était lu. */


/* En-tête de page avec visuel, demandé le 2026-08-28 pour la FAQ : « ça fait
   vide ». Le bandeau ne portait que son titre et une phrase, sur toute la
   largeur, et laissait la moitié droite noire.
   LE VISUEL EST PLAFONNÉ À 560 px, ET CE N EST PAS UN RÉGLAGE À L OEIL : le
   fichier fait 1400 px de large, donc au-delà il serait agrandi sur un écran à
   densité 2,5. C est la règle apprise sur la photo LuxTrust le 2026-08-26, où
   un visuel affiché à 526 px pour 699 px de source était agrandi 2 fois. */
.page-hero--media .page-hero__grid {
  display: grid;
  grid-template-columns: 1.15fr 0.85fr;
  gap: 64px;
  align-items: center;
}
.page-hero__media { justify-self: end; max-width: 560px; width: 100%; }
.page-hero__media img {
  display: block;
  width: 100%;
  /* CADRE 4/3 IMPOSÉ, ET C'EST SANS EFFET SUR LES DEUX PAGES FAQ : leur visuel est
     déjà en 4/3 (1400 x 1050), donc `cover` ne recadre rien. La règle existe pour
     la photo d'équipe de la page « À propos », en portrait 768 x 1024 : sans
     recadrage l'en-tête passait de 485 à 749 px de haut, alors que le document du
     client demande une page « courte et aérée ».
     `object-position: center 42%` garde le plan de travail et les personnes, qui
     sont au milieu du cadre, plutôt que le plafond ou le plancher. */
  aspect-ratio: 4 / 3;
  height: auto;
  object-fit: cover;
  object-position: center 42%;
  border-radius: var(--radius-lg);
  border: 1px solid #212121;
}
@media (max-width: 900px) {
  .page-hero--media .page-hero__grid { grid-template-columns: 1fr; gap: 32px; }
  .page-hero__media { justify-self: start; max-width: 100%; }
}

/* ---- Made in Luxembourg badge ---- */
.badge-lux {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  background: var(--white);
  border: 1px solid var(--border);
  border-radius: 100px;
  padding: 8px 16px;
  font-size: 0.8125rem;
  font-weight: 700;
  color: var(--dark);
  margin-top: 24px;
}

/* ---- Stats ---- */
.stats__grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 32px;
}
.stat-item__number {
  font-size: 2.5rem;
  font-weight: 700;
  color: var(--teal);
  line-height: 1;
  margin-bottom: 8px;
}
.stat-item__label { font-size: 0.875rem; color: var(--muted); }

/* ---- Accessibilité ---- */
/* DEUX NOMS POUR LA MÊME CHOSE, et c'était un piège. `.sr-only` vit ici,
   `.visually-hidden` dans assets/css/scrolly.css : une page qui emploie la
   seconde sans charger ce fichier afficherait en clair un texte réservé aux
   lecteurs d'écran. Aucune ne le fait aujourd'hui (seules les deux pages
   d'accueil l'emploient, et elles chargent scrolly.css), mais l'alias ferme la
   porte pour de bon. */
.sr-only,
.visually-hidden {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip: rect(0,0,0,0);
  white-space: nowrap;
  border: 0;
}

/* ---- Responsive ---- */
@media (max-width: 1024px) {
  :root { --section-py: 88px; }
  .features__grid { grid-template-columns: repeat(2, 1fr); }
  .footer__grid { grid-template-columns: 1fr 1fr; }
}

@media (max-width: 768px) {
  :root { --section-py: 64px; }

  .nav__menu { display: none; position: absolute; top: var(--nav-h); left: 0; right: 0; background: var(--dark); border-bottom: 1px solid rgba(255,255,255,0.08); flex-direction: column; align-items: flex-start; padding: 16px 24px 24px; gap: 20px; box-shadow: 0 8px 32px rgba(0,0,0,0.4); }
  .nav__menu.open { display: flex; }
  .nav__toggle { display: flex; }

  /* 16 px sur tous les champs de saisie, et pas 15. En dessous de 16 px, Safari
     iOS ZOOME la page à la prise de focus pour rendre le texte lisible, puis ne
     dézoome pas : le visiteur se retrouve dans un formulaire recadré, décalé
     horizontalement, et doit pincer pour revenir. C'est le défaut d'ergonomie
     mobile le plus courant sur un formulaire de contact, et il ne coûte qu'un
     point de taille. */
  .form-group input,
  .form-group textarea,
  .form-group select,
  .newsletter__form input[type="email"] {
    font-size: 1rem;
  }

  /* Barre de navigation en petite largeur. Trois défauts se cumulaient à 390 px :
     le libellé « Demo gratuite » passait sur DEUX lignes, ce qui donnait une
     pastille de 72 px de haut débordant d'une barre qui en fait 72 ; le bouton
     hamburger se retrouvait collé au logo au lieu d'être au bord droit (trois
     enfants en `space-between`, le bouton large poussant celui du milieu) ; et le
     logo à 38 px ne laissait pas la place au reste.
     Correctif : on ordonne — logo à gauche, actions puis hamburger à droite — et
     on compacte le bouton (`nowrap` : c'est le retour à la ligne qui cassait la
     hauteur de la barre, pas la largeur du texte). Le CTA n'est PAS masqué : il
     est le chemin de conversion, et il tient une fois compacté. */
  .nav__inner { gap: 8px; }
  .nav__logo { --logo-h: 32px; }
  .nav__actions { margin-left: auto; gap: 4px; }
  .nav__actions .btn {
    padding: 9px 14px;
    font-size: 0.8125rem;
    white-space: nowrap;
  }
  .nav__toggle { order: 4; }

  /* Sous 400 px il n'y a plus la place pour les quatre éléments : le logo passe à
     28 px, le bouton se resserre, et le sélecteur de langue ne garde que son
     globe. `font-size: 0` masque le code « FR » sans toucher au SVG, qui porte
     ses propres dimensions — l'alternative demandait d'envelopper ce texte dans
     un span sur les 23 pages. L'intitulé accessible du lien reste complet. */
    /* Les boutons des deux hero (accueil et commande) prennent toute la largeur
     sous 520 px. Ils gardaient la largeur de leur texte, centrés : deux pastilles
     de tailles différentes, empilées, dont on devait viser le milieu. Pleine
     largeur, la hiérarchie se lit (le teal d'abord, le contour ensuite) et la
     cible fait la largeur du pouce. Cela ne vaut que pour les hero : ce sont les
     seuls endroits où le bouton EST l'action de l'écran. */
  @media (max-width: 520px) {
    .hero__actions,
    .order-hero__actions {
      flex-direction: column;
      align-items: stretch;
    }
    .hero__actions .btn,
    .order-hero__actions .btn {
      width: 100%;
      justify-content: center;
    }
  }

@media (max-width: 400px) {
    .nav__logo { --logo-h: 28px; }
    .nav__actions .btn { padding: 8px 11px; font-size: 0.75rem; }
    /* Le padding porte la cible tactile : sans le code de langue, le lien se
       réduisait au globe de 14 px et tombait à 22 px de large, sous le minimum
       de 24 px de la WCAG 2.5.8. */
    .nav__lang a { font-size: 0; gap: 0; padding: 9px 7px; }
  }

  /* Le hero d'accueil n'est plus centré sur mobile : même règle que partout,
     le texte part de la gouttière. */
  .hero__inner { grid-template-columns: 1fr; gap: 48px; }
  .hero__desc { margin: 0 0 40px; }
  .hero__image { order: -1; }
  .hero__image img { max-height: 300px; }
  .hero__film { order: -1; }
  .hero__shot { order: -1; }

  .intro__grid,
  .specs__grid,
  .contact__grid,
  .tools__grid,
  .about-section__grid { grid-template-columns: 1fr; gap: 40px; }
  /* Même plancher qu'au-dessus : `1fr` vaut `minmax(auto, 1fr)`, donc la colonne
     suivrait la piste en `max-content` d'une bande d'outils. Mesuré sans cette
     ligne : 1 835 px de colonne et jusqu'à 1 484 px de débordement horizontal
     entre 375 et 768 px. C'est la troisième fois sur ce site qu'une largeur
     intrinsèque fait déborder une grille — le plancher doit être posé partout où
     la colonne est redéclarée. */
  .tools__grid { grid-template-columns: minmax(0, 1fr); }
  .about-section__grid.reverse { direction: ltr; }
  /* EN UNE COLONNE, L'IMAGE SUIT TOUJOURS SON TEXTE.
     Sur deux colonnes, alterner les côtés d'une section à l'autre donne le rythme ;
     en une colonne, cette alternance met deux images bout à bout. Relevé à 390 px
     sur la page d'accueil : « La solution » se lit texte → image et la section
     LuxTrust juste après image → texte, donc la suite était « t I I t » — deux
     visuels d'affilée, sans un mot entre eux. Toutes les autres grilles du site
     sont déjà en texte → image ; cette règle aligne la dernière exception plutôt
     que d'inventer un ordre nouveau. */
  .intro__grid > .intro__image,
  .about-section__grid > .intro__image { order: 2; }

  .features__grid { grid-template-columns: 1fr; }
  .stats__grid { grid-template-columns: 1fr; gap: 32px; }
  .form-row { grid-template-columns: 1fr; }

  .footer__grid { grid-template-columns: 1fr; gap: 32px; }
  .footer__desc { max-width: 100%; }
  .footer__bottom { flex-direction: column; align-items: flex-start; gap: 12px; }
  .footer__legal { flex-wrap: wrap; gap: 12px; }

  .newsletter__form { flex-direction: column; }

  h1 { font-size: 2rem; }
  h2 { font-size: 1.5rem; }
}

@media (max-width: 480px) {
  .hero { padding: 80px 0 64px; }
  .pricing-card { padding: 32px 24px; }
  .contact-form { padding: 24px; }
}

/* ══════════════════════════════════════════════
   ANIMATIONS & INTERACTIONS
   Keyframes, scroll reveal, hover, hamburger X,
   back-to-top, focus, reduced-motion
══════════════════════════════════════════════ */

/* ── Keyframes ── */
@keyframes menuSlideDown {
  from { opacity: 0; transform: translateY(-10px); }
  to   { opacity: 1; transform: none; }
}

/* ── Entrances (hero + page-hero) ──
   Le texte monte avec un très léger flou de mise au point (« focus-in ») :
   c'est ce qui distingue une entrée premium d'un simple fadeInUp. */
@keyframes riseIn {
  from { opacity: 0; transform: translateY(22px); filter: blur(6px); }
  to   { opacity: 1; transform: none;             filter: blur(0); }
}

/* Variante sans flou, pour les éléments dont le texte est peint par un
   dégradé (`background-clip: text` + `text-fill-color: transparent`) :
   combiner un filter avec cette technique casse le rendu du dégradé sur
   certaines versions de Safari. Concerne .hero__title (son <em>) et
   .section-label (shimmer). */
@keyframes riseInSoft {
  from { opacity: 0; transform: translateY(22px); }
  to   { opacity: 1; transform: none; }
}

/* Le média du hero se dévoile par un masque qui remonte + un léger dézoom,
   au lieu de glisser depuis la droite puis de flotter en boucle. */
@keyframes mediaIn {
  from { opacity: 0; clip-path: inset(0 0 22% 0); transform: scale(1.06); }
  to   { opacity: 1; clip-path: inset(0 0 0 0);   transform: scale(1); }
}

.hero__label   { animation: riseIn     0.75s var(--ease-out) 0.05s both; }
.hero__title   { animation: riseInSoft 0.85s var(--ease-out) 0.15s both; }
.hero__desc    { animation: riseIn     0.85s var(--ease-out) 0.27s both; }
.hero__actions { animation: riseIn     0.85s var(--ease-out) 0.39s both; }

/* fill-mode `backwards` : `both` figerait `transform: scale(1)` après coup et
   neutraliserait l'inclinaison au pointeur posée plus bas. */
.hero__image img,
.hero__film,
.hero__shot { animation: mediaIn 1.25s var(--ease-out) 0.30s backwards; }

/* ── Page hero sous-pages — entrance ── */
.page-hero .section-label { animation: riseInSoft 0.70s var(--ease-out) 0.00s both; }
.page-hero h1             { animation: riseIn     0.85s var(--ease-out) 0.12s both; }
.page-hero p              { animation: riseIn     0.85s var(--ease-out) 0.24s both; }

/* ── Scroll reveal — CSS-class driven ──
   Base commune : montée courte + focus-in. Les variantes (--media, --card,
   --text) sont posées par main.js selon le type d'élément ; voir la section
   « MOTION SYSTEM » en fin de fichier pour le détail de chacune. */
.reveal {
  --reveal-delay: calc(var(--stagger-i, 0) * 0.075s);
  opacity: 0;
  transform: translateY(22px);
  filter: blur(5px);
  transition:
    opacity   var(--dur-text) var(--ease-out) var(--reveal-delay),
    transform var(--dur-text) var(--ease-out) var(--reveal-delay),
    filter    var(--dur-text) var(--ease-out) var(--reveal-delay);
}
/* `filter: none` et non `blur(0)` : un filter non-`none`, même à 0, laisse
   l'élément en contexte d'empilement et sur sa propre couche de composition
   pour toute la durée de vie de la page — sur une page à 30 cartes c'est 30
   couches inutiles, et cela réordonne le rendu (le z-index des enfants n'est
   plus comparable à celui du reste de la page : voir .timeline-item__dot).
   L'interpolation blur(4px) → none est bien animée, `none` étant traité comme
   l'identité du filtre. */
.reveal.is-visible {
  opacity: 1;
  transform: none;
  filter: none;
}

/* ── Hamburger → X ── */
.nav__toggle[aria-expanded="true"] span:nth-child(1) {
  transform: translateY(7px) rotate(45deg);
}
.nav__toggle[aria-expanded="true"] span:nth-child(2) {
  opacity: 0;
  transform: scaleX(0);
}
.nav__toggle[aria-expanded="true"] span:nth-child(3) {
  transform: translateY(-7px) rotate(-45deg);
}

/* ── Mobile menu — animation slide-down ── */
@media (max-width: 768px) {
  .nav__menu.open {
    animation: menuSlideDown 0.28s cubic-bezier(0.16,1,0.3,1) both;
  }
}

/* ── Image produit — cadre clippé ──
   Le cadre porte le border-radius et le clipping ; l'image dedans est
   sur-dimensionnée (--media-scale) pour laisser de la marge au parallaxe
   interne sans jamais découvrir un bord vide. Voir « MOTION SYSTEM ».
   Ne concerne que .intro__image, dont le conteneur ne contient qu'une image :
   .specs__image, lui, héberge aussi un bouton (« Explorer le modèle 3D ») et
   est traité juste en dessous. */
.intro__image {
  position: relative;
  overflow: hidden;
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-lg);
}
/* AUCUN AGRANDISSEMENT AU SURVOL SUR LES IMAGES, retiré le 2026-08-28 à la
   demande du client : « ça n'apporte pas grand-chose ». La sur-échelle au
   REPOS reste, elle : ce n'est pas un effet, c'est la marge dont le
   contre-parallaxe a besoin pour ne pas découvrir les bords du cadre. */
.intro__image img {
  --media-scale: 1.08;
  display: block;
  width: 100%;
  transform: scale(var(--media-scale));
  transition: transform 0.85s var(--ease-out);
}

/* Variante « produit » — rendus 3D détourés (PNG transparents) plutôt que
   photos plein cadre. Le cadre reçoit donc sa propre surface (halo teal + fond
   sombre) pour que l'objet ne flotte pas dans le vide, et la sur-échelle
   interne retombe à 1 : elle sert à masquer les bords lors du parallaxe d'une
   photo qui remplit le cadre, ici elle ne ferait que rogner le produit. */
.intro__image--product {
  padding: 30px;
  background:
    radial-gradient(70% 60% at 50% 42%, rgba(0,203,190,0.12), transparent 72%),
    linear-gradient(165deg, #171717, #0C0C0C);
  border: 1px solid #212121;
}
.intro__image--product img { --media-scale: 1; }

/* Variante « ajustée » — l'image tient exactement entre le libellé de section et
   le bouton, demandé par le client le 2026-09-01. Mesuré avant : l'image carrée
   dépassait la colonne de texte de 47 px à 1440 et de 233 px à 2560.

   LE MÉCANISME EST DE SORTIR L'IMAGE DU CALCUL DE LA RANGÉE. En position absolue
   elle ne compte plus dans la hauteur de la grille : la rangée est donc dictée
   par le TEXTE seul, et le cadre, étiré par défaut, en prend la hauteur. L'image
   la remplit à ratio constant. Aucun recadrage : `object-fit: cover` aurait
   rogné 35 % de la hauteur à 2560 px, où la colonne de texte est bien plus courte
   que l'image est large.

   Elle est collée à DROITE (`inset: 0 0 0 auto`), donc sur la gouttière du
   conteneur, jamais centrée dans sa colonne : c'est la règle du dépôt.

   Le rayon et l'ombre passent sur l'IMAGE et quittent le cadre : celui-ci est
   plus large qu'elle dès que le texte est court, et son ombre dessinerait une
   boîte autour du vide. Même raison que sur `.specs__image`.

   Et la sur-échelle retombe à 1, comme sur la variante « produit » : à 1,08 une
   image dimensionnée au pixel près déborderait de son cadre. Le contre-parallaxe
   interne est retiré avec elle, dans main.js. */
/* ---- .intro__image--portrait : une photo verticale dans une grille horizontale ----
   Posé le 2026-09-02 pour la photo fournie par le client (le boîtier avec son fond
   d'écran, 768 x 1024).
   POURQUOI PAS `--fit` : ce modificateur cale la hauteur de l'image sur celle de
   la COLONNE DE TEXTE, ce qui est juste pour une photo en paysage et absurde pour
   une verticale devant un texte court. Mesuré avec `--fit` : 298 px de large côté
   français, et 163 côté anglais, où le texte est plus court. Une photo de 163 px
   ne montre rien.
   Ici c'est l'IMAGE qui donne la mesure : 420 px au plus, posée sur la gouttière
   droite, et le texte se centre en face (la grille est déjà en align-items: center).
   `--media-scale: 1` et exclusion du contre-parallaxe dans main.js, comme pour
   `--product` : la sur-échelle de 8 % n'existe que pour masquer les bords d'une
   photo pendant le parallaxe, et sur un cadre qui ne bouge pas elle ne fait que
   rogner. */
/* ---- Carrousel de photos ----
   Demandé par le client le 2026-09-02, à la place de la bande statique de trois
   photos posée le matin même : « fais un carrousel juste en dessous, en vrai ».

   LA PISTE EST UN DÉFILEMENT NATIF AVEC ACCROCHAGE, pas un empilement piloté en
   JavaScript. Sans script, on fait défiler au doigt, à la molette ou à la barre,
   et les quatre photos sont là : c'est l'état au repos complet que ce dépôt
   demande à toute mise en scène. Le module 21 n'ajoute que les flèches, les
   pastilles et leur état.
   `scroll-snap-type: x mandatory` + `scroll-snap-align: start` : l'accrochage
   fait qu'on ne s'arrête jamais entre deux photos.
   `scrollbar-width: none` masque la barre, que les flèches remplacent ; le
   défilement lui-même reste possible partout.
   LES PHOTOS SONT RECADRÉES EN 4/5 EN `cover` : elles sont fournies en portrait
   768 x 1024, et à leur rapport d'origine trois vues côte à côte donnent une
   bande de 493 px de haut à 1440 px. `object-position: center 28%` garde les
   personnes, qui sont dans le haut du cadre. */
.carousel { position: relative; margin-top: 40px; }
.carousel__track {
  display: flex;
  gap: 24px;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-padding-left: 0;
  scrollbar-width: none;
  -webkit-overflow-scrolling: touch;
  padding-bottom: 4px;
}
.carousel__track::-webkit-scrollbar { display: none; }
.carousel__slide {
  flex: 0 0 calc((100% - 48px) / 3);
  scroll-snap-align: start;
  margin: 0;
}
.carousel__slide img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 5;
  object-fit: cover;
  object-position: center 28%;
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-lg);
}
/* Les commandes sous la piste, alignées sur la gouttière comme tout le reste :
   flèches à gauche, pastilles à droite. */
.carousel__nav {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 24px;
  margin-top: 24px;
}
.carousel__arrows { display: flex; gap: 12px; }
.carousel__btn {
  width: 44px;
  height: 44px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: 1px solid var(--border);
  border-radius: 50%;
  background: transparent;
  color: var(--black);
  cursor: pointer;
  transition: border-color var(--transition), color var(--transition), background var(--transition);
}
.carousel__btn:hover { border-color: var(--teal); color: var(--teal); }
/* Un bouton en butée reste VISIBLE mais dit qu'il ne fait plus rien : le retirer
   ferait sauter la mise en page à chaque extrémité. */
/* L'ATTRIBUT `hidden` NE CACHE PAS UN SVG, et c'est un piège de plateforme, pas
   une erreur de code : la règle `[hidden] { display: none }` des navigateurs ne
   vise que les éléments HTML, et un `<svg>` est dans l'espace de noms SVG. Le
   bouton de lecture affichait donc SES DEUX icônes côte à côte, pause et
   lecture, alors que le script en masquait bien une. Relevé au banc (les deux
   `getBoundingClientRect().width > 0`), invisible à toute vérification de l'état
   JavaScript. */
.carousel__btn svg[hidden] { display: none; }
.carousel__dots { display: flex; gap: 4px; }
/* LA PASTILLE FAIT 24 x 24, ET SEUL SON POINT EST PETIT. Premier essai : un point
   de 8 px avec une aire cliquable posée en `::before` — l'audit d'accessibilité
   l'a refusé, et il avait raison : WCAG 2.5.8 mesure la BOÎTE de la cible, pas un
   pseudo-élément qui dépasse, et il relevait « 8x8, marge -12 » sur les cinq
   pastilles. La cible est donc le bouton lui-même, à 24 px, et le point visible
   est un `::after` centré dedans. La règle des 24 px est alors satisfaite sans
   avoir à invoquer l'exception d'espacement. */
.carousel__dot {
  width: 24px;
  height: 24px;
  padding: 0;
  border: 0;
  background: transparent;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
}
.carousel__dot::after {
  content: '';
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--border);
  transition: background var(--transition), transform var(--transition);
}
.carousel__dot:hover::after { background: var(--muted); }
.carousel__dot[aria-current]::after { background: var(--teal); transform: scale(1.5); }

@media (max-width: 900px) {
  .carousel__slide { flex: 0 0 calc((100% - 24px) / 2); }
}
@media (max-width: 600px) {
  .carousel__slide { flex: 0 0 100%; }
}
/* En mouvement réduit, le défilement ne glisse pas : il saute. Le `scroll-behavior`
   des appels JavaScript est neutralisé par la règle globale du fichier, celle-ci
   couvre le défilement natif. */
@media (prefers-reduced-motion: reduce) {
  .carousel__track { scroll-behavior: auto; }
}


.intro__image--portrait {
  align-self: center;
  overflow: visible;
  border-radius: 0;
  box-shadow: none;
}
.intro__image--portrait img {
  --media-scale: 1;
  display: block;
  width: auto;
  height: auto;
  max-width: min(100%, 420px);
  margin-left: auto;
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-lg);
}
@media (max-width: 900px) {
  /* En une colonne, la photo se cale à gauche comme les autres visuels du site :
     jamais au centre. */
  .intro__image--portrait img { margin-left: 0; }
}

.intro__image--fit {
  /* `align-self` EXPLICITE : la grille est en `align-items: center`, donc le cadre
     n'était pas étiré et, son contenu étant hors flux, il tombait à 0 px de haut.
     C'est l'étirement qui lui donne la hauteur de la rangée, dictée par le texte. */
  align-self: stretch;
  border-radius: 0;
  box-shadow: none;
}
.intro__image--fit img {
  --media-scale: 1;
  position: absolute;
  /* DEUX PLAFONDS ET AUCUNE TAILLE IMPOSÉE, et c'est ce qui préserve le rapport.
     Écrit `height: 100%` avec un `max-width` qui mord, la boîte devenait
     387 x 516 et l'image était ÉCRASÉE : mesuré à 901 et à 1024 px, où la colonne
     est plus étroite que la rangée n'est haute. Avec deux maxima et des
     dimensions automatiques, le navigateur choisit lui-même la contrainte qui
     s'applique et garde le carré. */
  top: 0;
  right: 0;
  width: auto;
  height: auto;
  max-width: 100%;
  max-height: 100%;
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-lg);
}
@media (max-width: 900px) {
  /* En une colonne, l'image reprend le flux : il n'y a plus de rangée à suivre. */
  .intro__image--fit { border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); }
  .intro__image--fit img { position: static; height: auto; width: 100%;
                           border-radius: 0; box-shadow: none;
    /* PLAFOND AJOUTÉ LE 2026-09-02, et le nombre est calculé : la source est une
       image extraite du film, 820 px de côté, donc 410 px d'affichage est la
       largeur au-delà de laquelle un écran de densité 2 agrandit. Sans lui, la
       colonne de 720 px d'une tablette donnait un agrandissement de 1,76 : on ne
       montre alors que du détail qui n'existe pas dans le fichier. Sur un
       téléphone la colonne fait 342 px, donc le plafond ne mord pas et l'image
       reste en pleine largeur, comme le client l'a demandé pour la photo
       LuxTrust le 2026-08-26. Même arbitrage, même calcul. */
                           max-width: 410px; }
}

/* ══ SCHÉMA « LE PARCOURS D'UN TEST » (.flow2fa) ═══════════════════════════
   Posé le 2026-09-07 sur retour du client : « à droite du bloc de texte "La
   solution", je trouve qu'il y a un peu trop de vide. C'est justement à cet
   endroit que je verrais bien un schéma très simple pour expliquer le
   fonctionnement avant de montrer le produit physique. Par exemple : test
   automatisé -> 2FA -> Q-Bot -> smartphone réel -> le test continue. »
   Le vide était réel et mesurable : la photo verticale qui occupait cette
   colonne était plafonnée à 420 px et collée à la gouttière droite, donc il
   restait 186 px de noir entre le texte et l'image à 1440 px, 316 à 2560.

   C'EST DU BALISAGE, PAS UNE IMAGE, et c'est le point de conception. La colonne
   fait 526 px à 1440, 656 à 2560 et environ 342 px sur un téléphone : un raster
   dessiné pour une de ces largeurs est illisible aux autres. Le dépôt en a la
   preuve — `qbot-2fa-flux.jpg`, dessiné pour un cadre de 656 px, tombait à des
   sous-libellés de 10 px dans une demi-colonne de 566 (note du 2026-09-02).
   En balisage : le schéma se reflow, son texte est lu par un lecteur d'écran et
   citable par un moteur de réponse, il pèse deux kilo-octets au lieu de 88, et
   les deux langues sortent du même composant sans regénérer quoi que ce soit.

   DEUX COLONNES : les cinq pas à gauche, le téléphone à droite, centré. LE
   TÉLÉPHONE EST HORS DE LA LISTE, et c'est un correctif : posé DANS le pas
   « sur un vrai smartphone », il faisait 169 px de haut contre 110 px de texte,
   donc il restait deux cents pixels de vide dans le cadre de ce pas — le même
   « trop de vide » en miniature. Hors de la liste, c'est la colonne des pas qui
   donne la hauteur et le téléphone se centre dedans, sans vide et plus grand,
   donc plus lisible.

   L'ILLUSTRATION N'EST PAS UNE REPRODUCTION D'INTERFACE. Anneau de validation,
   six pavés muets pour le code, une pastille d'action et l'onde d'un appui :
   même précaution que l'écran 2FA du modèle 3D, il ne faut pas la transformer
   en copie de l'application LuxTrust.

   DEUX MARQUES, PAS TROIS. Le deuxième pas porte le blocage (anneau tireté et
   sourd : ici ça s'arrête), le quatrième porte le différenciateur (anneau teal
   plein, plus le téléphone à côté : « le visiteur doit comprendre en un coup
   d'oeil que Q-Bot agit sur un vrai smartphone, il ne simule pas simplement la
   2FA »). Un troisième accent les diluerait.

   LE TRAIT EST UN SEGMENT PAR INTERVALLE, du bas d'un anneau au haut du
   suivant, jamais un trait continu qui traverse les anneaux : c'est la
   convention d'un indicateur d'étapes, et c'est le défaut corrigé sur la
   maquette d'interface le 2026-08-25. Le dernier pas n'en porte pas, il n'y a
   rien après lui.

   ENTRÉE : variante « groupe » du module 4 sur `.flow2fa__steps` (l'élément est
   dans REVEAL_MAP), donc le cadre ne bouge pas et les cinq pas s'enchaînent,
   segment de trait compris, ce qui fait que le parcours SE DESSINE. L'état au
   repos est l'état complet : sans JavaScript le module n'ajoute pas `.reveal`
   et tout est visible. */
.flow2fa {
  /* FLEX ET NON GRID : une grille ne s'enroule pas, elle déborde. C'est
     l'enroulement qui fait passer le téléphone sous les pas là où la colonne est
     trop étroite pour les deux (320 px à 769 px de fenêtre). */
  display: flex;
  flex-wrap: wrap;
  gap: 20px 24px;
  align-items: center;
  padding: 28px 26px;
  background: #141414;
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
}

.flow2fa__steps {
  margin: 0;
  padding: 0;
  list-style: none;
  display: grid;
  row-gap: 18px;
  /* Base d'enroulement : sous 210 px de place le téléphone passe SOUS les pas.
     Un seuil de fenêtre ne marcherait pas — la colonne ne suit pas la largeur de
     fenêtre : 342 px à 390 (une colonne), puis 320 px à 769 (deux colonnes),
     386 à 900, 526 à 1440. Entre 769 et 1000 px, un seuil de fenêtre laissait
     donc le téléphone à côté d'un texte de 182 px. Mesuré. */
  flex: 1 1 210px;
  min-width: 210px;
}

.flow2fa__step {
  position: relative;
  padding-left: 42px;
  min-height: 26px;
}

/* Le segment de liaison. `top` part sous l'anneau, `bottom` mord de 12 px dans
   la gouttière de 18, ce qui laisse 6 px de dégagement avant l'anneau suivant. */
.flow2fa__step:not(:last-child)::after {
  content: '';
  position: absolute;
  left: 12px;
  top: 31px;
  bottom: -12px;
  width: 2px;
  border-radius: 1px;
  background: var(--teal);
  opacity: 0.5;
}

.flow2fa__ico {
  position: absolute;
  left: 0;
  top: 0;
  width: 26px;
  height: 26px;
  display: grid;
  place-items: center;
  border-radius: 50%;
  border: 1.5px solid rgba(0, 203, 190, 0.45);
  background: rgba(0, 203, 190, 0.10);
  color: var(--teal);
}
.flow2fa__ico svg { width: 14px; height: 14px; }

.flow2fa__body { display: block; }
.flow2fa__body b {
  display: block;
  font-size: 15px;
  font-weight: 700;
  line-height: 1.3;
  color: var(--black);
}
/* `> span` ET NON `span` : le sélecteur descendant attrapait aussi le
   `<span class="nb">Q-Bot</span>` qui vit DANS le titre, et en faisait un bloc
   gris de 13,5 px sur sa propre ligne. Mesuré : le titre du troisième pas tenait
   sur deux lignes à toutes les largeurs, 2560 px comprise. */
.flow2fa__body > span {
  display: block;
  margin-top: 3px;
  font-size: 13.5px;
  line-height: 1.45;
  color: var(--gray);
}

/* ---- Le pas du blocage : anneau tireté et sourd ---- */
.flow2fa__step--stop .flow2fa__ico {
  border-style: dashed;
  border-color: rgba(255, 255, 255, 0.28);
  background: transparent;
  color: var(--muted);
}

/* ---- Le pas différenciant : anneau teal plein ----
   L'ENCRE EST LE NOIR DE CHARTE ET JAMAIS DU BLANC : le teal de charte est une
   couleur CLAIRE, du blanc dessus plafonne à 2,04:1. Le noir y tient 8:1. C'est
   le défaut relevé seize fois par l'audit du 2026-08-11. */
.flow2fa__step--key .flow2fa__ico {
  background: var(--teal);
  border-color: var(--teal);
  color: #231F20;
}

.flow2fa__phone {
  display: block;
  width: 112px;
  color: var(--teal);
  /* Le halo, comme le cadre du film du hero : c'est ce qui détache
     l'illustration du fond de panneau sans lui poser de cadre de plus. */
  filter: drop-shadow(0 0 26px rgba(0, 203, 190, 0.16));
}
.flow2fa__phone svg { display: block; width: 100%; height: auto; }

@media (max-width: 767px) {
  /* La requête média ne décide pas du placement du téléphone, c'est
     l'enroulement de la grille qui le fait. Elle ne garde que ce qui dépend
     vraiment de la fenêtre : moins de remplissage, un téléphone plus petit, et
     un téléphone plus petit. */
  .flow2fa { padding: 22px 20px; }
  .flow2fa__step { padding-left: 38px; }
  .flow2fa__phone { width: 96px; }
}

/* .specs__image : image + bouton dans le même bloc. Le radius et l'ombre
   restent donc portés par l'image (un cadre clippé arrondirait aussi les
   angles du bouton), et le bloc entier se déplace d'un seul tenant au
   parallaxe — pas de contre-mouvement interne, qui décollerait l'image
   de son bouton. */
.specs__image { position: relative; overflow: hidden; }
.specs__image img {
  display: block;
  width: 100%;
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-lg);
  transition: transform 0.85s var(--ease-out);
}

/* ---- .specs__film : le film « ce qu'il y a à l'intérieur » ----
   Posé le 2026-09-03. Il prend la place exacte de l'image qu'il remplace, mais il
   ne reçoit NI rayon NI ombre, contrairement à `.specs__image img`, et ce n'est
   pas un oubli : son fond est peint dans le fichier à `#121212`, c'est-à-dire la
   couleur de fond de la section (relevée dans le navigateur). Le rectangle est
   donc invisible, un rayon n'arrondirait rien de visible, et une ombre
   dessinerait une boîte autour du vide. C'est le défaut déjà documenté sur
   `.intro__image` et `.specs__image`, et la seule façon de ne pas le reproduire
   est de ne pas poser l'ombre.
   `pointer-events: none` pour la même raison que `.video__wrapper--film` : le
   film n'a pas de commandes, et sans cette ligne un clic droit ouvrirait le menu
   du navigateur avec « télécharger » et « boucle », donc une interface. */
.specs__film {
  display: block;
  width: 100%;
  height: auto;
  pointer-events: none;
}

/* ── Timeline dot — glow au reveal ── */
.timeline-item__dot {
  transition: box-shadow 0.45s ease 0.3s;
}
.timeline-item.is-visible .timeline-item__dot {
  box-shadow: 0 0 0 8px rgba(0,203,190,0.12), 0 0 0 1px rgba(0,203,190,0.2);
}

/* ── Pricing card — hover lift ── */
.pricing-card {
  transition: box-shadow 0.3s ease, transform 0.3s cubic-bezier(0.16,1,0.3,1);
}
.pricing-card:hover {
  transform: translateY(-4px);
  box-shadow: 0 16px 56px rgba(0,203,190,0.18), 0 4px 16px rgba(0,0,0,0.08);
}

/* ── Stat item number — scale entrance ── */
.stat-item__number {
  display: block;
  transition: transform 0.5s cubic-bezier(0.16,1,0.3,1);
}

/* ── Focus visible ── */
:focus-visible {
  outline: 2px solid var(--teal);
  outline-offset: 3px;
  border-radius: 4px;
}
button:focus-visible,
a:focus-visible { border-radius: var(--radius-sm); }

/* ── Back to top FAB ── */
.back-to-top {
  position: fixed;
  bottom: 28px;
  right: 24px;
  width: 44px;
  height: 44px;
  border-radius: 50%;
  background: var(--dark);
  color: var(--teal);
  border: 1px solid rgba(0,203,190,0.3);
  display: flex;
  align-items: center;
  justify-content: center;
  box-shadow: 0 4px 20px rgba(0,0,0,0.35);
  opacity: 0;
  transform: translateY(12px) scale(0.8);
  transition:
    opacity   0.3s ease,
    transform 0.3s cubic-bezier(0.16,1,0.3,1),
    background 0.2s ease;
  pointer-events: none;
  z-index: 90;
  cursor: pointer;
}
.back-to-top.visible {
  opacity: 1;
  transform: none;
  pointer-events: auto;
}
.back-to-top:hover {
  background: var(--teal);
  color: var(--dark);
  border-color: var(--teal);
  transform: translateY(-3px) scale(1.05);
  box-shadow: 0 8px 24px rgba(0,203,190,0.35);
}

/* ════════════════════════════════════════
   BLOG
════════════════════════════════════════ */

/* ---- Blog listing ---- */
.blog__featured {
  display: grid;
  grid-template-columns: 1.5fr 1fr;
  gap: 48px;
  align-items: center;
  margin-bottom: 64px;
}
.blog__featured-img {
  border-radius: var(--radius-lg);
  overflow: hidden;
  aspect-ratio: 16/9;
  background: var(--light);
}
.blog__featured-img img { width: 100%; height: 100%; object-fit: cover; display: block; }
.blog__featured-img .blog-color-banner {
  width: 100%; height: 100%; min-height: 240px;
}
.blog__grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 32px;
}

/* ---- Blog card ---- */
.blog-card {
  background: var(--white);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  overflow: hidden;
  display: flex;
  flex-direction: column;
  box-shadow: var(--shadow-sm);
  transition: box-shadow 0.3s ease, border-color 0.25s ease, transform 0.3s cubic-bezier(0.16,1,0.3,1);
}
.blog-card:hover {
  border-color: transparent;
  box-shadow: var(--shadow-lg);
  transform: translateY(-4px);
}
/* La vignette d'une carte est CARRÉE, et sa taille vient d'ici et non d'un style en
   ligne. Elle portait `width:100%;height:100%;object-fit:cover` dans le balisage,
   donc hors d'atteinte de cette feuille : dans une carte en colonne flex, ce
   `height:100%` ne résolvait rien et la hauteur retombait sur le rapport de chaque
   fichier. Relevé avant : 436 px pour une carte, 459 pour sa voisine, en portrait.
   `aspect-ratio: 1/1` donne la même hauteur pour toutes et raccourcit la carte de
   19 % à toutes les largeurs (436 → 354 à 1440, 321 → 261 à 900, 418 → 340 à 390).
   La vedette (`.blog__featured-img`) n'est PAS concernée : elle est plus grande à
   dessein, et le client l'a confirmée telle quelle. */
.blog-card > img {
  display: block;
  width: 100%;
  aspect-ratio: 1 / 1;
  object-fit: cover;
}
.blog-card__banner {
  width: 100%;
  aspect-ratio: 16/9;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 2.5rem;
}
.blog-card__content { padding: 24px; flex: 1; display: flex; flex-direction: column; }
.blog-card__tag {
  display: inline-block;
  background: var(--teal-light);
  color: var(--teal-text);
  font-size: .75rem;
  font-weight: 700;
  padding: 4px 10px;
  border-radius: 100px;
  margin-bottom: 12px;
  text-transform: uppercase;
  letter-spacing: .06em;
}
.blog-card__title {
  font-size: 1.0625rem;
  font-weight: 700;
  color: var(--dark);
  line-height: 1.4;
  margin-bottom: 10px;
}
.blog-card__title a { color: inherit; text-decoration: none; }
.blog-card__title a:hover { color: var(--teal-text); }
.blog-card__excerpt {
  font-size: .9375rem;
  color: var(--gray);
  line-height: 1.6;
  flex: 1;
  margin-bottom: 16px;
}
.blog-card__meta {
  display: flex;
  align-items: center;
  gap: 16px;
  font-size: .8125rem;
  color: var(--muted);
  padding-top: 16px;
  border-top: 1px solid var(--border);
}
.blog-card__read-more {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  color: var(--teal-text);
  font-weight: 700;
  font-size: .9375rem;
  text-decoration: none;
  margin-top: 16px;
}
.blog-card__read-more:hover { gap: 10px; }

/* ---- Article page ---- */
.article-header {
  padding: 56px 0 40px;
  border-bottom: 1px solid var(--border);
  margin-bottom: 48px;
}
.article-header .blog-card__tag { font-size: .8125rem; margin-bottom: 16px; }
.article-header h1 {
  font-size: clamp(1.75rem, 4vw, 2.5rem);
  line-height: 1.2;
  color: var(--dark);
  margin-bottom: 16px;
}
.article-meta {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 24px;
  color: var(--muted);
  font-size: .875rem;
}
.article-meta strong { color: var(--dark); }
.article-body {
  /* 70ch et non 780 px : à 16 px la prose juridique tenait 99 caractères par
     ligne, pour une cible de 80 (cf. « PLAFONDS DE LECTURE » plus bas). Relevé
     après : 629 px, 79 caractères en anglais et 81 en français.
     LE PLAFOND EST SUR LE CONTENEUR ET NON SUR SES PARAGRAPHES, contrairement
     aux réponses de FAQ : les `h2` de ces pages portent un filet bas, et le
     poser sur les seuls paragraphes laisserait ce filet dépasser le texte de
     150 px. Ici le conteneur ne sert qu'à la prose, il peut se resserrer. */
  max-width: 70ch;
}
.article-body h2 {
  font-size: 1.5rem;
  font-weight: 700;
  color: var(--dark);
  margin: 48px 0 16px;
  padding-bottom: 10px;
  border-bottom: 2px solid var(--teal-light);
}
.article-body h3 {
  font-size: 1.1875rem;
  font-weight: 700;
  color: var(--dark);
  margin: 32px 0 12px;
}
.article-body p { margin-bottom: 20px; line-height: 1.8; }
/* La date de dernière mise à jour des pages légales. Le live l'écrit en
   italique et un cran plus petit ; on garde cette présentation, elle sépare
   la date du corps du texte. Le mot à mot, lui, vient du live. */
.article-body .legal-date {
  font-size: 0.875rem;
  font-style: italic;
  color: var(--muted);
  margin-bottom: 32px;
}
.article-body ul, .article-body ol {
  padding-left: 24px;
  margin-bottom: 20px;
}
.article-body li {
  margin-bottom: 8px;
  line-height: 1.7;
  color: var(--gray);
}
.article-body blockquote {
  border-left: 4px solid var(--teal);
  padding: 16px 24px;
  background: var(--teal-light);
  margin: 32px 0;
  border-radius: 0 var(--radius) var(--radius) 0;
}
.article-body blockquote p { color: var(--dark); font-weight: 500; margin: 0; }
.article-body code {
  background: var(--light);
  padding: 2px 7px;
  border-radius: 4px;
  font-family: 'Courier New', monospace;
  font-size: .875em;
  color: var(--teal-text);
  border: 1px solid var(--border);
}
.article-body pre {
  background: #141414;
  color: #E6E7E8;
  padding: 24px;
  border-radius: var(--radius);
  overflow-x: auto;
  margin: 24px 0;
  font-size: .875rem;
  line-height: 1.6;
}
.article-body pre code { background: none; border: none; color: inherit; padding: 0; }
.article-cta {
  background: linear-gradient(135deg, var(--teal) 0%, var(--teal-dark) 100%);
  color: var(--white);
  padding: 40px 48px;
  border-radius: var(--radius-lg);
  margin: 48px 0;
}
.article-cta h3 { color: var(--white); font-size: 1.375rem; margin-bottom: 8px; }
.article-cta p { color: rgba(255,255,255,0.85); margin-bottom: 24px; }
.article-cta .btn { background: var(--white); color: #00857D; border-color: var(--white); }
.article-cta .btn:hover { background: transparent; color: var(--white); }
.article-layout {
  display: grid;
  grid-template-columns: 1fr 320px;
  gap: 64px;
  align-items: start;
}
.article-sidebar {
  position: sticky;
  top: 96px;
}
.sidebar-card {
  background: var(--white);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  padding: 32px;
  margin-bottom: 24px;
  box-shadow: var(--shadow-sm);
}
.sidebar-card h4 { font-size: 1rem; color: var(--dark); margin-bottom: 12px; }
/* Le titre de la carte « Aperçu rapide » est un h3, pas un h4 : il ne recevait donc
   aucune marge et collait à la première ligne de la fiche. */
.sidebar-card h3 { margin-bottom: 18px; }
.sidebar-card p { font-size: .9375rem; }
.sidebar-card .btn { width: 100%; justify-content: center; margin-top: 16px; }
.related-articles { margin-top: 64px; padding-top: 48px; border-top: 1px solid var(--border); }
.related-articles h2 { font-size: 1.375rem; margin-bottom: 32px; }

/* ---- Order / Commandez page ---- */
/* `.order-hero-price` a été retiré : le tarif n'est plus prononcé sur le site
   (arbitrage client du 2026-08-24), la pastille teal du hero de commande n'a
   donc plus rien à afficher. Ce qui l'a remplacée est le calculateur de ROI,
   plus bas dans ce fichier. */
.order-process {
  --op-gap: 32px;
  --op-rond: 56px;
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: var(--op-gap);
  margin-top: 48px;
  position: relative;
}
/* LE TRAIT RELIE LE CENTRE DU PREMIER ROND À CELUI DU DERNIER, ET LA FORMULE EST
   DÉRIVÉE, PAS DEVINÉE. Quatre colonnes de largeur c et trois gouttières g dans
   une largeur W : c = (W − 3g)/4, la dernière colonne commence à 3c + 3g, soit
   0,75 W + 0,75 g, et le centre de son rond est 28 px plus loin. La distance au
   bord droit vaut donc 25 % − 0,75 g − 28 px.
   POURQUOI ELLE EST ÉCRITE EN FONCTION DE `--op-gap` : la valeur figée
   `25% − 46px` avait été calculée pour des gouttières de 24 px. La passe
   d'aération du 2026-08-20 les a portées à 32 px, et le trait dépassait depuis
   le dernier rond de 6 px, ce qui laissait un moignon à droite. Une constante
   posée à la main périme à la première refonte d'espacement ; celle-ci suit. */
.order-process::before {
  content: '';
  position: absolute;
  top: calc(var(--op-rond) / 2 - 1px);
  left: calc(var(--op-rond) / 2);
  right: calc(25% - 0.75 * var(--op-gap) - var(--op-rond) / 2);
  height: 2px;
  background: var(--teal-light);
  z-index: 0;
}
/* ---- Variante à cinq étapes ----
   Posée le 2026-09-02 pour la section « Comment ça fonctionne » de l'accueil,
   qui compte cinq étapes là où la page Démo en a quatre. Le composant est
   réutilisé tel quel ; seules la piste et la formule du trait changent.
   LA FORMULE EST REDÉRIVÉE, PAS DEVINÉE, exactement comme celle à quatre
   colonnes : cinq colonnes de largeur c et quatre gouttières g dans une largeur
   W donnent c = (W − 4g)/5 ; la dernière colonne commence à 4c + 4g, soit
   0,8 W + 0,8 g, et le centre de son rond est à la moitié du rond plus loin. La
   distance au bord droit vaut donc 20 % − 0,8 g − rond/2.
   LE SÉLECTEUR N'EST PAS DOUBLÉ ICI, ET C'EST L'INVERSE DE `.features__grid--3`
   POSÉ LE MÊME JOUR. Une seule classe pèse (0,1,0), donc exactement comme la
   règle de base juste au-dessus ET comme les requêtes média plus bas : l'ordre
   du fichier tranche, ce qui donne précisément la cascade voulue. La variante
   gagne sur la base (elle est écrite après), et les requêtes gagnent sur la
   variante (elles sont écrites après elle), donc la grille retombe bien à deux
   colonnes sur petit écran, trait masqué. Doubler le sélecteur ici bloquerait ce
   repli à cinq colonnes sur un téléphone. */
.order-process--5 {
  grid-template-columns: repeat(5, minmax(0, 1fr));
}
.order-process--5::before {
  right: calc(20% - 0.8 * var(--op-gap) - var(--op-rond) / 2);
}

.order-step { position: relative; z-index: 1; }
.order-step__number {
  width: 56px;
  height: 56px;
  background: var(--teal);
  /* Pas de blanc sur le teal : il plafonne à 2,04:1 (mesuré). Le teal de charte
     est une couleur CLAIRE, c'est le noir qui va dessus, même règle que pour la
     bande newsletter et l'ancienne pastille de prix, cf. la note plus haut. */
  color: #001A18;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 1.25rem;
  font-weight: 700;
  /* Le rond suit son texte, à gauche : centré dans sa colonne alors que le titre
     et le paragraphe sont alignés à gauche, il flottait au-dessus d'eux. C'est
     aussi ce qui fait tomber juste le trait de `.order-process::before`, calculé
     entre les CENTRES du premier et du dernier rond. */
  margin: 0 0 16px;
  border: 4px solid var(--white);
  box-shadow: 0 0 0 2px var(--teal);
}
.order-step h3 { font-size: .9375rem; font-weight: 700; color: var(--dark); margin-bottom: 6px; }
.order-step p { font-size: .875rem; color: var(--gray); }
.guarantee-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 32px;
  margin-top: 40px;
}
.guarantee-item {
  background: var(--white);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  padding: 28px 24px;
  box-shadow: var(--shadow-sm);
}
/* Pictogramme au trait (plus d'emoji sur le site — cf. la règle dans
   CLAUDE.md) : le teal de marque, pas la couleur de texte du bloc. */
.guarantee-item__icon { margin-bottom: 12px; color: var(--teal); line-height: 0; }
.guarantee-item__icon svg { display: block; }
.guarantee-item h3 { font-size: 1rem; font-weight: 700; color: var(--dark); margin-bottom: 8px; }
.guarantee-item p { font-size: .9375rem; }
.pricing-highlight {
  background: linear-gradient(135deg, var(--dark) 0%, #1F1F1F 100%);
  color: var(--white);
  border-radius: var(--radius-lg);
  padding: 48px;
  max-width: 540px;
  margin: 0;
}
.pricing-highlight ul {
  list-style: none;
  text-align: left;
  margin: 24px 0;
  display: flex;
  flex-direction: column;
  gap: 12px;
}
.pricing-highlight li {
  display: flex;
  align-items: center;
  gap: 10px;
  font-size: .9375rem;
  color: rgba(255,255,255,0.85);
}
.pricing-highlight li svg { color: var(--teal); flex-shrink: 0; }
.pricing-highlight .btn { width: 100%; justify-content: center; font-size: 1rem; }

/* ══════════════════════════════════════════════
   VIEWER 3D INTERACTIF — modele-3d.html
   ══════════════════════════════════════════════ */
.model-viewer-frame {
  position: relative;
  border-radius: var(--radius-lg);
  overflow: hidden;
  /* Un plateau, pas une boîte : une lueur teal montant du bas pose l'objet sur
     quelque chose, et la vignette en creux resserre le regard au centre. Les deux
     sont DERRIÈRE le canevas (l'une est un calque de `background`, l'autre une
     ombre interne) : le rendu 3D est sur fond transparent, elles se voient donc
     autour du produit sans jamais le voiler. */
  background:
    radial-gradient(60% 44% at 50% 92%, rgba(255, 255, 255, 0.055), transparent 70%),
    radial-gradient(46% 30% at 50% 84%, rgba(0, 203, 190, 0.1), transparent 70%),
    linear-gradient(160deg, var(--dark) 0%, #141414 100%);
  /* La vignette est posée PAR-DESSUS les calques de fond : à 0,42 d'opacité elle
     éteignait la lueur de sol qu'elle est censée encadrer — mesuré à 6/255 au lieu
     des ~16 visés. Le gris neutre porte l'essentiel du plateau, le teal n'est
     qu'une teinte : c'est une lumière de studio, pas un néon. */
  box-shadow: var(--shadow-lg), inset 0 0 90px 10px rgba(0, 0, 0, 0.35);
  border: 1px solid rgba(255, 255, 255, 0.06);
  aspect-ratio: 4 / 3;
  min-height: 420px;
}
.model-viewer-frame model-viewer { width: 100%; height: 100%; display: block; }

/* Chargement — barre custom (slot="progress-bar") */
.mv-loading {
  position: absolute;
  inset: 0;
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-direction: column;
  gap: 14px;
  background: linear-gradient(160deg, var(--dark) 0%, #141414 100%);
  transition: opacity 0.4s ease;
}
.mv-loading__label { font-size: 0.8125rem; color: rgba(255,255,255,0.55); letter-spacing: 0.04em; }
.mv-loading__bar {
  width: 160px; height: 4px;
  border-radius: 4px;
  background: rgba(255,255,255,0.12);
  overflow: hidden;
}
.mv-loading__fill {
  height: 100%; width: 0%;
  background: linear-gradient(90deg, var(--teal), var(--teal-tint));
  border-radius: 4px;
  transition: width 0.2s ease;
}
.model-viewer-frame.is-loaded .mv-loading { opacity: 0; pointer-events: none; }

/* Contrôles personnalisés */
/* ── Boîte à outils ───────────────────────────────────────────────────────
   Un seul endroit pour toutes les commandes. Le jour/nuit flottait seul en haut
   à gauche, en pastille teal pleine : c'était la seule commande hors de la barre,
   et la plus voyante alors qu'elle est secondaire. Les commandes sont maintenant
   groupées par fonction — cadrage, vue, scène — séparées par des filets, puis
   l'instrument d'éclatement. */
.mv-controls {
  position: absolute;
  bottom: 16px; left: 50%;
  transform: translateX(-50%);
  z-index: 2;
  display: flex;
  align-items: center;
  gap: 2px;
  background: rgba(10, 10, 10, 0.72);
  backdrop-filter: blur(14px) saturate(1.2);
  -webkit-backdrop-filter: blur(14px) saturate(1.2);
  border: 1px solid rgba(255, 255, 255, 0.1);
  border-radius: var(--radius-pill);
  padding: 6px 8px;
  box-shadow: 0 12px 34px rgba(0, 0, 0, 0.5);
}
.mv-ctrl {
  width: 38px; height: 38px;
  border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  background: transparent;
  border: none;
  color: rgba(255,255,255,0.75);
  transition: background 0.2s ease, color 0.2s ease, transform 0.2s cubic-bezier(0.16,1,0.3,1);
}
.mv-ctrl { position: relative; }
.mv-ctrl:hover { background: rgba(255, 255, 255, 0.09); color: var(--white); }
/* État enclenché en teinte, pas en aplat : la pastille teal pleine sur le bouton
   de rotation en faisait l'action principale de la barre, ce qu'elle n'est pas. */
.mv-ctrl[aria-pressed="true"] {
  background: rgba(0, 203, 190, 0.14);
  color: var(--teal);
  box-shadow: inset 0 0 0 1px rgba(0, 203, 190, 0.4);
}
.mv-ctrl svg { width: 18px; height: 18px; }

/* Infobulle : elle reprend `aria-label`, donc l'intitulé lu par un lecteur
   d'écran EST celui qu'on voit — une seule source, rien à maintenir en double.
   Cinq pictogrammes sans mot demandaient de devenir devin, en particulier pour
   « réinitialiser la vue ». Désactivée au doigt : sans survol, elle resterait
   collée après un appui. */
.mv-ctrl::after {
  content: attr(aria-label);
  position: absolute;
  bottom: calc(100% + 10px);
  left: 50%;
  transform: translateX(-50%) translateY(4px);
  padding: 6px 10px;
  background: rgba(0, 0, 0, 0.94);
  border: 1px solid rgba(255, 255, 255, 0.14);
  border-radius: 7px;
  font-size: 0.6875rem;
  font-weight: 500;
  letter-spacing: 0.01em;
  color: rgba(255, 255, 255, 0.92);
  white-space: nowrap;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.18s var(--ease-soft), transform 0.18s var(--ease-soft);
}
.mv-ctrl:hover::after,
.mv-ctrl:focus-visible::after { opacity: 1; transform: translateX(-50%); }
@media (hover: none) { .mv-ctrl::after { display: none; } }

/* Bouton AR (slot="ar-button" — masqué automatiquement si non supporté) */
.mv-ar-btn {
  position: absolute;
  top: 16px; right: 16px;
  z-index: 2;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  background: rgba(0,0,0,0.55);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  border: 1px solid rgba(255,255,255,0.16);
  color: var(--white);
  font-family: var(--font);
  font-size: 0.8125rem;
  font-weight: 700;
  padding: 8px 16px;
  border-radius: var(--radius-pill);
  transition: background 0.2s ease, border-color 0.2s ease;
}
.mv-ar-btn:hover { background: rgba(0,203,190,0.28); border-color: var(--teal); }


/* Vue éclatée — désormais un segment compact DANS .mv-controls (même pill
   que les autres boutons), plutôt qu'une carte séparée : reste visible et
   utilisable en plein écran (requestFullscreen() ne cible que .model-viewer-frame,
   tout ce qui est en dehors disparaît en plein écran), et cohérent en un
   seul bloc d'UI. */
.mv-controls__sep {
  width: 1px;
  align-self: stretch;
  margin: 7px 5px;
  background: rgba(255, 255, 255, 0.13);
}
/* L'instrument d'éclatement : libellé, course, valeur. Il n'avait qu'un
   pictogramme énigmatique et un pourcentage ; le mot dit ce que la course fait. */
.mv-explode__label {
  font-size: 0.6875rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.5);
  white-space: nowrap;
}
.mv-explode {
  display: flex;
  align-items: center;
  gap: 9px;
  padding: 0 8px 0 6px;
}
.mv-explode__value {
  font-size: 0.75rem;
  font-weight: 700;
  color: var(--teal);
  min-width: 30px;
  text-align: right;
  font-variant-numeric: tabular-nums;
}
.mv-explode__slider {
  -webkit-appearance: none;
  appearance: none;
  width: 96px;
  height: 4px;
  border-radius: var(--radius-pill);
  background: linear-gradient(90deg, var(--teal) 0%, var(--teal) var(--mv-explode-pct, 0%), rgba(255,255,255,0.25) var(--mv-explode-pct, 0%), rgba(255,255,255,0.25) 100%);
  outline: none;
  cursor: pointer;
}
.mv-explode__slider::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 14px; height: 14px;
  border-radius: 50%;
  background: var(--white);
  border: 2px solid var(--teal);
  box-shadow: 0 2px 6px rgba(0,0,0,0.35);
  cursor: pointer;
  transition: transform 0.15s ease;
}
.mv-explode__slider::-webkit-slider-thumb:hover { transform: scale(1.15); }
.mv-explode__slider::-moz-range-thumb {
  width: 14px; height: 14px;
  border-radius: 50%;
  background: var(--white);
  border: 2px solid var(--teal);
  box-shadow: 0 2px 6px rgba(0,0,0,0.35);
  cursor: pointer;
  transition: transform 0.15s ease;
}
.mv-explode__slider::-moz-range-thumb:hover { transform: scale(1.15); }
.mv-explode__slider::-moz-range-track { background: transparent; }
.mv-explode__slider:disabled { opacity: 0.4; cursor: not-allowed; }
/* Le curseur était le seul élément focalisable du site sans indicateur visible :
   `outline: none` est posé sur lui pour masquer le contour natif, et rien ne le
   remplaçait. On le rend visible au clavier seulement — la souris n'en a pas
   besoin et un halo au clic ferait sale. */
.mv-explode__slider:focus-visible {
  box-shadow: 0 0 0 3px rgba(0, 203, 190, 0.45);
  border-radius: var(--radius-pill);
}

.model-viewer-frame.has-error .mv-explode { display: none; }

.mv-hint {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 0.8125rem;
  color: var(--muted);
  margin-top: 16px;
}
.mv-hint svg { flex-shrink: 0; color: var(--teal); }
/* Consigne d'usage : une formulation par mode de saisie. Le critère est `hover`
   et non la largeur d'écran — une tablette large est tactile, une petite fenêtre
   de portable ne l'est pas. Par défaut on montre la version pointeur : si le
   navigateur ne connaît pas la requête, c'est celle-là qui reste. */
.mv-hint__touch { display: none; }
@media (hover: none) {
  .mv-hint__pointer { display: none; }
  .mv-hint__touch { display: inline; }
}

/* État d'erreur — modèle indisponible (repli gracieux) */
.model-viewer-frame__fallback {
  position: absolute; inset: 0;
  z-index: 3;
  display: none;
  align-items: center; justify-content: center;
  flex-direction: column;
  gap: 12px;
  text-align: center;
  padding: 32px;
  background: rgba(0,0,0,0.82);
  backdrop-filter: blur(3px);
  color: var(--white);
}
.model-viewer-frame__fallback p { color: rgba(255,255,255,0.7); font-size: 0.9375rem; max-width: 320px; }
.model-viewer-frame.has-error .model-viewer-frame__fallback { display: flex; }
.model-viewer-frame.has-error .mv-controls,
.model-viewer-frame.has-error .mv-ar-btn { display: none; }

[data-theme="dark"] .mv-loading__label { color: rgba(255,255,255,0.5); }

@media (prefers-reduced-motion: reduce) {
  .mv-ctrl, .mv-ar-btn { transition: none; }
}

/* ─── Responsive blog + article + order ─── */
@media (max-width: 1024px) {
  .article-layout { grid-template-columns: 1fr; }
  .article-sidebar { position: static; }
  .order-process { grid-template-columns: repeat(2, 1fr); }
  .order-process::before { display: none; }
}
@media (max-width: 768px) {
  .blog__featured { grid-template-columns: 1fr; }
  .blog__grid { grid-template-columns: 1fr; }
  .guarantee-grid { grid-template-columns: 1fr; }
  .pricing-highlight { padding: 32px 24px; }
  .order-process { grid-template-columns: 1fr 1fr; }

  /* ── Visionneuse 3D sur téléphone : la barre d'outils sort du plateau ──────
     Elle était en `position: absolute` au centre-bas du cadre. Sur un écran de
     390 px le cadre fait 342 px de haut, et la barre — qui se replie alors sur
     trois rangées (boutons, boutons, curseur d'éclatement) — en occupait 122 :
     elle couvrait le produit, c'est-à-dire précisément ce qu'on est venu voir, et
     toute tentative de faire pivoter le modèle en glissant dans cette zone
     tombait sur les boutons.
     Le cadre devient une colonne : le canevas garde son format carré, la barre se
     pose DESSOUS, en flux. Elle reste un enfant de `.model-viewer-frame` — c'est
     lui qui passe en plein écran, et tout ce qui est à l'extérieur y disparaît. */
  .model-viewer-frame {
    min-height: 0;
    aspect-ratio: auto;
    display: flex;
    flex-direction: column;
    padding: 0 10px 10px;
  }
  .model-viewer-frame model-viewer {
    aspect-ratio: 1 / 1;
    height: auto;
    border-radius: var(--radius);
  }
  .mv-controls {
    position: static;
    transform: none;
    margin-top: 10px;
    border-radius: var(--radius);
    background: rgba(255,255,255,0.05);
    flex-wrap: wrap;
    justify-content: center;
    row-gap: 6px;
    max-width: none;
  }
  /* Les cibles reviennent à 40 px : hors du plateau, la place ne manque plus, et
     34 px sous le doigt restaient petits pour une commande qu'on tâtonne. */
  .mv-ctrl { width: 40px; height: 40px; }
  .mv-ctrl svg { width: 20px; height: 20px; }
  .mv-ar-btn { top: 12px; right: 12px; padding: 7px 14px; font-size: 0.75rem; }
  .mv-controls__sep { display: none; }
  /* Le curseur d'éclatement prend toute la largeur de la barre : c'est la
     commande la plus fine à manipuler au doigt, elle mérite la course la plus
     longue possible. */
  .mv-explode { flex-basis: 100%; justify-content: center; padding: 6px 4px 2px; }
  .mv-explode__slider { flex: 1; }

  /* En plein écran, le cadre occupe l'écran entier : le canevas doit alors
     prendre toute la hauteur restante au lieu de garder son carré. */
  .model-viewer-frame:fullscreen { padding: 0; justify-content: center; }
  .model-viewer-frame:fullscreen model-viewer { aspect-ratio: auto; flex: 1; }
  .model-viewer-frame:fullscreen .mv-controls { margin: 0 10px 10px; }
}

/* ── prefers-reduced-motion ── */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    /* Le délai doit tomber aussi : les entrées en fill-mode `backwards`
       maintiennent leur état initial (opacity 0) pendant toute la durée du
       délai, soit jusqu'à ~0,4 s d'écran vide pour rien. */
    animation-delay: 0.01ms !important;
    transition-duration: 0.01ms !important;
    transition-delay: 0s !important;
    scroll-behavior: auto !important;
  }
  .hero__image img { animation: none !important; }
  .hero__film { animation: none !important; transform: none; }
  .hero__shot { animation: none !important; transform: none; }
  .reveal { transition: none !important; }
}

/* ── Admin nav button ── */
.nav__admin-btn {
  display: flex; align-items: center; justify-content: center;
  width: 34px; height: 34px; border-radius: 8px;
  color: rgba(255,255,255,0.4); flex-shrink: 0;
  transition: color .2s, background .2s;
}
.nav__admin-btn:hover { color: var(--teal); background: rgba(255,255,255,0.08); }
.nav__admin-btn svg { display: block; }


/* ── Article highlight box ── */
.highlight-box {
  background: rgba(0,203,190,.06);
  border: 1px solid rgba(0,203,190,.22);
  border-left: 4px solid var(--teal);
  border-radius: 8px;
  padding: 20px 22px;
  margin: 28px 0;
}
.highlight-box p { margin: 0; color: var(--dark); line-height: 1.7; }

/* ══════════════════════════════════════════════════════════
   PALETTE OFFICIELLE — thème sombre unique
   Le site n'a plus de mode clair : [data-theme="dark"] est écrit en dur
   dans le <html> de chaque page (plus de bouton de bascule, plus de
   theme-init.js). Les tokens de :root plus haut restent la couche de base
   (charte : teal + noir + gris neutres) ; ce bloc est la déclinaison
   sombre officielle qui les recouvre.
   ══════════════════════════════════════════════════════════ */

/* ── Tokens sombres ── */
[data-theme="dark"] {
  --black:      #E6E7E8;
  --gray:       #A7A9AC;
  --muted:      #949699;   /* 4,6:1 sur #0A0A0A — #6D6E71 plafonnait à 3,3 */
  --border:     #2B2B2B;
  --light:      #121212;
  --teal-light: rgba(0, 203, 190, 0.1);
  /* En dark mode les surfaces concernées (cards, breadcrumb, code...) sont
     sombres : le teal plein y a déjà un excellent contraste. */
  --teal-text:  var(--teal);
  --shadow-sm:  0 2px 8px rgba(0,0,0,0.35);
  --shadow:     0 4px 24px rgba(0,0,0,0.45);
  --shadow-lg:  0 8px 40px rgba(0,0,0,0.55);
}

/* ── Corps de page ── */
/* `[data-theme="dark"]` seul, et non `[data-theme="dark"] html` : l'attribut est
   porté par <html> lui-même, donc la version descendante ne correspond à rien —
   le fond restait blanc. */
[data-theme="dark"] { background: #0A0A0A; }
[data-theme="dark"] body { background: transparent; }

/* ── Navigation — en dark mode le nav devient encore plus sombre ── */
[data-theme="dark"] .nav {
  background: #050505;
}
[data-theme="dark"] .nav::after {
  background: linear-gradient(90deg, transparent 0%, rgba(0,203,190,0.12) 25%, rgba(0,203,190,0.25) 50%, rgba(0,203,190,0.12) 75%, transparent 100%);
}

@media (max-width: 768px) {
  [data-theme="dark"] .nav__menu {
    background: #050505;
    border-bottom-color: rgba(255,255,255,0.06);
    box-shadow: 0 8px 32px rgba(0,0,0,0.5);
  }
}

/* ── Surfaces blanches → sombres ── */
[data-theme="dark"] .feature-card,
[data-theme="dark"] .product-card,
[data-theme="dark"] .blog-card,
[data-theme="dark"] .sidebar-card,
[data-theme="dark"] .contact-form,
[data-theme="dark"] .pricing-card,
[data-theme="dark"] .booking-frame,
[data-theme="dark"] .guarantee-item {
  background: #141414;
  border-color: #2B2B2B;
}
/* Logos noirs sur transparent (partenaires/produits) : invisibles sur fond
   sombre sans ce filtre — même traitement que le logo de nav/footer. */
[data-theme="dark"] .product-card__icon {
  filter: brightness(0) invert(1);
}

/* ── Tags / badges ── */
[data-theme="dark"] .badge-lux {
  background: #1C1C1C;
  border-color: #2B2B2B;
  color: #E6E7E8;
}
[data-theme="dark"] .blog-card__tag {
  background: rgba(0, 203, 190, 0.14);
}

/* ── FAQ ── */
[data-theme="dark"] .faq-item        { background: #141414; }
[data-theme="dark"] .faq-item__icon  { border-color: #2B2B2B; }

/* ── Formulaires ── */
[data-theme="dark"] .form-group label { color: #E6E7E8; }
[data-theme="dark"] .form-group input,
[data-theme="dark"] .form-group textarea,
[data-theme="dark"] .form-group select {
  background: #0A0A0A;
  border-color: #2B2B2B;
  color: #E6E7E8;
}

/* ── Bouton outline ── */
[data-theme="dark"] .btn--outline {
  color: #E6E7E8;
  border-color: #2B2B2B;
}

/* ── Timeline ── */
[data-theme="dark"] .timeline::before    { background: #2B2B2B; }
[data-theme="dark"] .timeline-item__dot  { background: #0A0A0A; }

/* ── Spécifications ── */
[data-theme="dark"] .spec-item:not(.spec-item--card) { border-bottom-color: #2B2B2B; }
[data-theme="dark"] .spec-item__value   { color: #E6E7E8; }

/* ── Infos contact ── */
[data-theme="dark"] .contact-info__item p,
[data-theme="dark"] .contact-info__item a { color: #E6E7E8; }

/* ── Bloc de réservation ── */
[data-theme="dark"] .booking-box {
  background: rgba(0, 203, 190, 0.06);
  border-color: rgba(0, 203, 190, 0.16);
}

/* La fenêtre de réservation : surface sombre, mais on REPOSE le filet teal. Une
   entrée dans la liste générique ci-dessus l'écraserait, sa spécificité (0,2,0)
   passant devant celle de la règle de base (0,1,0). Même piège que
   « .booking-box », documenté plus bas dans ce fichier. */
[data-theme="dark"] .booking-modal__box {
  background: #101010;
  border-color: rgba(0, 203, 190, 0.22);
}

/* ── Breadcrumb ── */

/* ── Section label shimmer — surfaces sombres en dark mode : plein éclat ── */
@supports (-webkit-background-clip: text) or (background-clip: text) {
  [data-theme="dark"] .section-label {
    background: linear-gradient(100deg, var(--teal) 20%, var(--teal-tint) 55%, var(--teal) 80%);
    background-size: 200% auto;
    -webkit-background-clip: text;
    background-clip: text;
    -webkit-text-fill-color: transparent;
  }
}

/* ── Blog ── */
[data-theme="dark"] .blog-card__title  { color: #E6E7E8; }
[data-theme="dark"] .blog-card__meta   { border-top-color: #2B2B2B; }
[data-theme="dark"] .blog__featured-img { background: #141414; }

/* ── Articles ── */
[data-theme="dark"] .article-header               { border-bottom-color: #2B2B2B; }
[data-theme="dark"] .article-header h1,
[data-theme="dark"] .article-body h2,
[data-theme="dark"] .article-body h3              { color: #E6E7E8; }
[data-theme="dark"] .article-body h2              { border-bottom-color: rgba(0,203,190,0.18); }
[data-theme="dark"] .article-meta strong          { color: #E6E7E8; }
[data-theme="dark"] .article-body code {
  background: #1C1C1C;
  border-color: #2B2B2B;
}
[data-theme="dark"] .article-body blockquote      { background: rgba(0, 203, 190, 0.07); }
[data-theme="dark"] .article-body blockquote p    { color: #E6E7E8; }
[data-theme="dark"] .highlight-box                { background: rgba(0, 203, 190, 0.05); }
[data-theme="dark"] .highlight-box p              { color: #E6E7E8; }
[data-theme="dark"] .related-articles             { border-top-color: #2B2B2B; }

/* ── Order page ── */
[data-theme="dark"] .order-step h3,
[data-theme="dark"] .guarantee-item h3   { color: #E6E7E8; }
[data-theme="dark"] .order-step__number  { border-color: #0A0A0A; }
[data-theme="dark"] .order-process::before { background: rgba(0, 203, 190, 0.14); }

/* ── Pricing ── */
[data-theme="dark"] .pricing-card__price       { color: #E6E7E8; }
[data-theme="dark"] .pricing-card__features li { color: #E6E7E8; }

/* ── Sidebar ── */
[data-theme="dark"] .sidebar-card h4 { color: #E6E7E8; }

/* ══════════════════════════════════════════════
   BARRE DE PROGRESSION DE LECTURE
   Affichée automatiquement sur les pages article.
   ══════════════════════════════════════════════ */
.reading-progress {
  position: fixed;
  top: 0; left: 0;
  height: 3px;
  width: 0%;
  background: linear-gradient(90deg, var(--teal) 0%, var(--teal-dark) 100%);
  z-index: 99999;
  pointer-events: none;
  border-radius: 0 2px 2px 0;
  transform-origin: left;
}

/* ══════════════════════════════════════════════
   BOUTON COPIER — blocs de code
   ══════════════════════════════════════════════ */
.code-copy-btn {
  position: absolute;
  top: 12px; right: 12px;
  padding: 4px 10px;
  font-size: 0.75rem;
  font-weight: 700;
  letter-spacing: 0.03em;
  border: 1px solid rgba(255,255,255,0.14);
  border-radius: 5px;
  background: rgba(255,255,255,0.06);
  color: rgba(255,255,255,0.66);
  cursor: pointer;
  transition: background 0.15s, color 0.15s, border-color 0.15s;
  font-family: var(--font);
  line-height: 1.5;
}
.code-copy-btn:hover {
  background: rgba(0,203,190,0.18);
  border-color: rgba(0,203,190,0.38);
  color: var(--teal);
}
.code-copy-btn.copied {
  background: rgba(0,203,190,0.22);
  border-color: var(--teal);
  color: var(--teal);
}

/* ══════════════════════════════════════════════
   TEMPS DE LECTURE estimé — articles
   ══════════════════════════════════════════════ */
.reading-time {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  color: var(--muted);
  font-size: 0.875rem;
}

/* ══════════════════════════════════════════════
   CONTACT MAP — Google Maps embed
   ══════════════════════════════════════════════ */
.contact-map {
  position: relative;
  height: 420px;
  overflow: hidden;
  border-top: 1px solid var(--border);
}
.contact-map--inline {
  height: auto;
  aspect-ratio: 1 / 1;
  border-top: none;
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  margin: 24px 0;
}
.contact-map__iframe {
  width: 100%;
  height: 100%;
  border: none;
  display: block;
  filter: saturate(0.9);
}
.contact-map__link {
  position: absolute;
  inset: 0;
  z-index: 1;
  display: block;
  cursor: pointer;
}
.contact-map__card {
  position: absolute;
  bottom: 24px;
  left: 24px;
  z-index: 2;
  background: var(--white, #fff);
  border-radius: var(--radius-lg);
  padding: 18px 22px;
  box-shadow: -4px 16px 40px rgba(0,0,0,0.18);
  display: flex;
  align-items: center;
  gap: 16px;
  max-width: 340px;
  pointer-events: none;
}
.contact-map__card-icon {
  flex-shrink: 0;
  width: 44px;
  height: 44px;
  background: var(--teal-light);
  border-radius: 12px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--teal);
}
.contact-map__card-body h4 {
  font-size: 0.9375rem;
  font-weight: 700;
  color: var(--black);
  margin: 0 0 3px;
}
.contact-map__card-body p {
  font-size: 0.8125rem;
  color: var(--gray);
  margin: 0 0 8px;
  line-height: 1.4;
}
.contact-map__card-cta {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  font-size: 0.8125rem;
  font-weight: 700;
  color: var(--teal-text);
}

[data-theme="dark"] .contact-map__iframe {
  filter: saturate(0.7) brightness(0.85);
}
[data-theme="dark"] .contact-map__card {
  background: #141414;
  box-shadow: -4px 16px 40px rgba(0,0,0,0.4);
}

@media (max-width: 480px) {
  .contact-map { height: 320px; }
  .contact-map__card {
    left: 12px;
    right: 12px;
    bottom: 12px;
    max-width: none;
  }
}

/* ══════════════════════════════════════════════
   ANIMATIONS AVANCÉES
   Orbs · Shimmer · Spotlight · Tilt · Magnetic
   ══════════════════════════════════════════════ */

/* ── Keyframes supplémentaires ──
   Les deux orbes de fond respirent très lentement (20-26 s) et de très peu :
   à l'échelle précédente (scale 1.25, 40-50 px, 12 s) le mouvement était
   perceptible comme une animation ; ici il ne l'est que comme une ambiance. */
@keyframes heroOrb1 {
  0%   { opacity: 0.16; transform: scale(1)    translate(0, 0); }
  100% { opacity: 0.26; transform: scale(1.10) translate(-18px, 14px); }
}
@keyframes heroOrb2 {
  0%   { opacity: 0.08; transform: scale(1)    translate(0, 0); }
  100% { opacity: 0.16; transform: scale(1.09) translate(22px, -18px); }
}
@keyframes shimmerLabel {
  0%   { background-position: 200% center; }
  100% { background-position:   0% center; }
}
/* Remplace l'ancien iconBounce (scale 1.22 → 0.93) : le rebond « jouet » est
   troqué contre une arrivée sobre, alignée sur le reveal de la carte. */
@keyframes iconIn {
  0%   { opacity: 0; transform: translateY(10px) scale(0.88); }
  100% { opacity: 1; transform: none; }
}

/* ── Hero — second orbe animé (::after) ── */
.hero::after {
  content: '';
  position: absolute;
  bottom: -100px; left: -60px;
  width: 480px; height: 480px;
  background: radial-gradient(circle, rgba(0,203,190,0.10) 0%, transparent 68%);
  pointer-events: none;
  animation: heroOrb2 26s ease-in-out infinite alternate;
  translate: 0 calc(var(--orb-y, 0px) * 1.6);
}

/* ── Page-hero — orbe de profondeur ── */
.page-hero { position: relative; overflow: hidden; }
.page-hero::after {
  content: '';
  position: absolute;
  top: -80px; right: -80px;
  width: 340px; height: 340px;
  background: radial-gradient(circle, rgba(0,203,190,0.16) 0%, transparent 70%);
  pointer-events: none;
  animation: heroOrb1 22s ease-in-out infinite alternate;
}

/* ── Section label — gradient shimmer ──
   Sur fond clair, dégradé assombri pour rester au-dessus de 4.5:1 (WCAG AA) ;
   le dégradé plein éclat (--teal/--teal-tint) n'est réutilisé que sur fond sombre. */
@supports (-webkit-background-clip: text) or (background-clip: text) {
  .section-label {
    background: linear-gradient(100deg, #006F68 20%, #00857D 55%, #006F68 80%);
    background-size: 200% auto;
    -webkit-background-clip: text;
    background-clip: text;
    -webkit-text-fill-color: transparent;
    /* 2 passages, pas `infinite` : un scintillement en boucle sur chaque label
       de chaque section attire l'œil en permanence sur une décoration — c'est
       la signature d'un template, pas d'un produit. Il joue à l'arrivée sur la
       page puis se tait. */
    animation: shimmerLabel 4s linear 2;
  }
  .hero .section-label,
  .page-hero .section-label,
  .section--dark .section-label {
    background: linear-gradient(100deg, var(--teal) 20%, var(--teal-tint) 55%, var(--teal) 80%);
    background-size: 200% auto;
    -webkit-background-clip: text;
    background-clip: text;
    -webkit-text-fill-color: transparent;
  }
}

/* ── Feature card icon — arrivée au reveal + micro-interaction au survol ── */
/* fill-mode « backwards » et non « both » : avec « both » la keyframe 100%
   (transform: none) reste appliquée après la fin de l'animation et écrase en
   permanence le transform du hover juste en dessous. « backwards » ne fige que
   l'état initial pendant le délai, ce qui suffit ici. */
.feature-card.is-visible .feature-card__icon {
  animation: iconIn 0.7s var(--ease-out)
    calc(var(--stagger-i, 0) * 0.075s + 0.18s) backwards;
}
.feature-card__icon,
.feature-card__icon svg {
  transition: transform 0.45s var(--ease-spring), background 0.3s var(--ease-soft);
}
.feature-card:hover .feature-card__icon { transform: translateY(-3px); }
.feature-card:hover .feature-card__icon svg { transform: scale(1.08); }

/* ── Card spotlight (curseur suit le hover) ── */
.feature-card::after,
.product-card::after {
  content: '';
  position: absolute;
  inset: 0;
  background: radial-gradient(
    180px circle at var(--spot-x, 50%) var(--spot-y, 50%),
    rgba(0,203,190,0.10), transparent 80%
  );
  opacity: 0;
  transition: opacity 0.35s ease;
  pointer-events: none;
  border-radius: inherit;
  z-index: 0;
}
.feature-card:hover::after,
.product-card:hover::after { opacity: 1; }

/* Contenu au-dessus du spotlight */
.feature-card > *,
.product-card > * { position: relative; z-index: 1; }

/* ── Bouton — pression tactile ── */
.btn:active {
  transform: scale(0.96) !important;
  transition: transform 0.08s ease !important;
}

/* Le bandeau de confiance a été RETIRÉ le 2026-08-26, balisage et CSS. Le client
   avait sollicité ses clients : aucun n'a accepté d'être nommé, donc il n'y aura
   pas de témoignages. C'est un refus, pas une attente, et un CSS gardé « au cas
   où » est une invitation à décommenter le balisage un jour. Même arbitrage que
   les bandes d'outils du 2026-08-20 : le motif est écarté, pas mis de côté.
   L'écart avec `.timeline`, gardée comme chemin de code, est que celle-ci l'a été
   sur décision du client. Ici la décision est inverse. */

/* ── Carte de contact — l'état AVANT le clic ──────────────────────────────
   L'iframe Google n'existe pas encore (module 19 de main.js la crée au clic).
   Ce cadre-ci est ce que le visiteur voit d'abord, et ce qu'il voit toujours
   si le JavaScript ne tourne pas : l'adresse, le bouton, et la mention de ce
   que le clic déclenche. Le bouton est en NOIR sur le teal, jamais en blanc :
   le teal de charte est une couleur claire, le blanc y plafonne à 2:1.
   ──────────────────────────────────────────────────────────────────────── */
.contact-map.is-differee {
  display: grid;
  place-items: center;
  background:
    radial-gradient(120% 100% at 50% 0%, rgba(0,203,190,.10), transparent 62%),
    var(--card);
}
.contact-map__ask {
  text-align: center;
  padding: 24px;
  max-width: 320px;
}
.contact-map__ask-pin {
  color: var(--teal);
  margin-bottom: 12px;
}
.contact-map__ask-addr {
  font-weight: 500;
  color: var(--black);
  margin-bottom: 16px;
  line-height: 1.5;
}
.contact-map__ask-btn {
  font: inherit;
  font-weight: 500;
  cursor: pointer;
  padding: 10px 20px;
  min-height: 44px;
  border: none;
  border-radius: var(--radius);
  background: var(--teal);
  color: #001A18;
  transition: background .2s ease, transform .2s ease;
}
.contact-map__ask-btn:hover {
  background: var(--teal-tint);
  transform: translateY(-1px);
}
.contact-map__ask-note {
  margin-top: 14px;
  font-size: .75rem;
  line-height: 1.5;
  color: var(--muted);
}
/* Le lien « itinéraire » reste utile avant le clic : il ne charge rien. */
.contact-map.is-differee .contact-map__open { z-index: 2; }

/* ── Contact map — chip "Itinéraire" ── */
.contact-map__open {
  position: absolute;
  bottom: 14px;
  right: 14px;
  z-index: 2;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  background: rgba(255,255,255,0.92);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  color: var(--dark);
  font-size: 0.8125rem;
  font-weight: 700;
  font-family: var(--font);
  padding: 8px 16px;
  border-radius: var(--radius-pill);
  box-shadow: 0 2px 14px rgba(0,0,0,0.18);
  transition: background 0.22s ease, transform 0.22s ease;
  text-decoration: none;
  line-height: 1;
}
.contact-map__open:hover {
  background: var(--teal);
  color: var(--dark);
  transform: translateY(-2px);
}
[data-theme="dark"] .contact-map__open {
  background: rgba(10,10,10,0.92);
  color: #E6E7E8;
}
[data-theme="dark"] .contact-map__open:hover {
  background: var(--teal);
  color: var(--dark);
}

/* ══════════════════════════════════════════════
   prefers-reduced-motion — dark mode & UX
   ══════════════════════════════════════════════ */
@media (prefers-reduced-motion: reduce) {
  .hero::before, .hero::after,
  .page-hero::after { animation: none; }
  .section-label { animation: none; background: none; -webkit-text-fill-color: var(--teal-text); color: var(--teal-text); }
  .hero .section-label, .page-hero .section-label, .section--dark .section-label { -webkit-text-fill-color: var(--teal); color: var(--teal); }
  .hero__title em { background: none; -webkit-text-fill-color: var(--teal); color: var(--teal); }
  .feature-card.is-visible .feature-card__icon { animation: none; }
}

/* ══════════════════════════════════════════════
   SKIP NAV — Accessibilité clavier
   ══════════════════════════════════════════════ */
.skip-nav {
  position: absolute;
  left: -999px;
  width: 1px; height: 1px;
  overflow: hidden;
  white-space: nowrap;
}
.skip-nav:focus {
  position: fixed;
  left: 12px; top: 12px;
  width: auto; height: auto;
  z-index: 99999;
  background: var(--teal);
  color: var(--dark);
  padding: 10px 20px;
  border-radius: var(--radius-pill);
  font-weight: 700;
  font-size: .9375rem;
  text-decoration: none;
  outline: 2px solid var(--dark);
  outline-offset: 2px;
}

/* ══════════════════════════════════════════════
   PROBLEM CALLOUT — Encadré mise en valeur
   ══════════════════════════════════════════════ */
.problem-callout {
  background: var(--white);
  border-left: 4px solid var(--teal);
  border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
  padding: 24px 32px;
  font-size: 1.0625rem;
  color: var(--dark);
  box-shadow: var(--shadow);
  margin: 28px 0;
  line-height: 1.75;
}
[data-theme="dark"] .problem-callout {
  background: #141414;
  box-shadow: 0 4px 24px rgba(0,0,0,0.4);
  color: #E6E7E8;
}

/* ══════════════════════════════════════════════
   GRID-2 — Grille 2 colonnes utilitaire
   ══════════════════════════════════════════════ */
.grid-2 {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 24px;
}
@media (max-width: 768px) {
  .grid-2 { grid-template-columns: 1fr; }
}
/* ══════════════════════════════════════════════
   MOTION SYSTEM
   Parallaxe au scroll · reveals typés · pointeur
   ──────────────────────────────────────────────
   Toute la couche de mouvement au scroll est pilotée par le module « MOTION
   ENGINE » de main.js, qui n'écrit que des variables CSS (jamais de style
   calculé) :
     --mx-y   décalage vertical de parallaxe, en px (couche par couche)
     --hero-p progression de sortie du hero, 0 → 1
     --mx-rx / --mx-ry  rotation de l'inclinaison au pointeur
   Le JS lit/écrit, le CSS décide *à quoi ça ressemble* : les amplitudes
   restent donc ajustables par media query sans toucher au script.

   Convention de signe de --mx-y (cf. main.js) : une amplitude positive fait
   « traîner » l'élément derrière le scroll (il paraît plus loin), une
   amplitude négative le fait devancer le scroll (il paraît plus près). Les
   images internes utilisent l'amplitude opposée à celle de leur cadre : c'est
   ce contre-mouvement dans un cadre fixe qui donne la profondeur.
   ══════════════════════════════════════════════ */

/* La propriété individuelle `translate` (et non `transform`) est utilisée
   partout ici : elle se compose avec les `transform` déjà posés par les
   hovers, les reveals et les animations d'entrée, sans les écraser. */
.mx-parallax { translate: 0 var(--mx-y, 0px); }

/* En plein écran, l'élément sort du flux mais garde son `translate` : le
   viewer 3D apparaîtrait décalé de la valeur de parallaxe figée au moment du
   clic (et il n'y a plus de scroll pour la remettre à zéro). */
.model-viewer-frame.is-fullscreen,
.mx-parallax:fullscreen { translate: none; }

/* Sur-dimensionnement des images clippées : donne au parallaxe interne la
   marge nécessaire pour bouger sans jamais découvrir un bord du cadre. */
.blog__featured-img { position: relative; }
.blog__featured-img img {
  --media-scale: 1.08;
  transform: scale(var(--media-scale));
  transition: transform 0.85s var(--ease-out);
}
.video__wrapper { position: relative; }

/* ── Hero — sortie au scroll ──
   Le contenu s'efface et remonte doucement pendant que le hero quitte
   l'écran, l'image reste plus longtemps (amplitude de parallaxe plus faible
   dans main.js) : c'est ce décalage qui crée la profondeur, et qui remplace
   l'ancienne animation de flottement en boucle sur l'image. */
.hero__content {
  opacity: calc(1 - var(--hero-p, 0) * 0.8);
  translate: 0 calc(var(--hero-p, 0) * -44px);
}
.hero__image { perspective: 1100px; }
/* Le cadre du film ne reçoit ni inclinaison au pointeur ni parallaxe : son
   contenu bouge déjà, un cadre mobile par-dessus se lit comme du flottement.
   Seule l'entrée par masque (mediaIn) est conservée — elle joue une fois. */

/* Inclinaison au pointeur sur le rendu produit (desktop, souris uniquement).
   L'entrée `mediaIn` est en fill-mode `backwards` — et non `both` — pour ne
   pas figer `transform` après coup et bloquer cette inclinaison. */
.hero__image img {
  transform: rotateX(var(--mx-rx, 0deg)) rotateY(var(--mx-ry, 0deg));
  transition: transform 0.5s var(--ease-soft);
}

/* ══ Reveals typés ══
   Trois variantes posées par main.js selon le type d'élément. Le texte monte,
   les cartes montent en se rapprochant, les médias se dévoilent par un
   masque : trois vitesses distinctes, une seule courbe. */

/* ── Variante « média » : masque qui remonte ──
   clip-path plutôt qu'un translate : l'image ne bouge pas, elle se découvre.
   `round` reprend le radius du cadre, sinon les angles redeviennent carrés
   pendant toute la transition. */
.reveal--media {
  --media-radius: var(--radius-lg);
  opacity: 0;
  transform: none;
  filter: none;
  clip-path: inset(0 0 26% 0 round var(--media-radius));
  transition:
    opacity   var(--dur-media) var(--ease-out) var(--reveal-delay),
    clip-path var(--dur-media) var(--ease-out) var(--reveal-delay);
}
.reveal--media.is-visible {
  opacity: 1;
  clip-path: inset(0 0 0 0 round var(--media-radius));
}
/* Le dézoom accompagne le masque : l'image « se pose » au lieu d'apparaître.
   Keyframe (fill `backwards`) et non transition : une transition de 1,35 s
   posée sur `.is-visible` resterait la transition active *après* le reveal et
   ferait durer 1,35 s le zoom au survol. Ici l'animation ne fige rien à la
   fin, le hover retrouve donc sa propre transition de 0,85 s. */
@keyframes mediaSettle {
  from { transform: scale(calc(var(--media-scale, 1) + 0.05)); }
  to   { transform: scale(var(--media-scale, 1)); }
}
.reveal--media:not(.is-visible) img {
  transform: scale(calc(var(--media-scale, 1) + 0.05));
}
.reveal--media.is-visible img {
  animation: mediaSettle 1.35s var(--ease-out) var(--reveal-delay) backwards;
}

/* Passage de lumière, une seule fois, juste après le masque. */
@keyframes mediaSheen {
  from { transform: translateX(-130%) skewX(-14deg); }
  to   { transform: translateX(130%)  skewX(-14deg); }
}
.reveal--media::after {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  background: linear-gradient(
    100deg,
    transparent 38%,
    rgba(255,255,255,0.20) 50%,
    transparent 62%
  );
  transform: translateX(-130%) skewX(-14deg);
}
.reveal--media.is-visible::after {
  animation: mediaSheen 1.5s var(--ease-soft) calc(var(--reveal-delay) + 0.3s) 1;
}

/* ── Variante « plain » : montée + fondu, sans flou ──
   Réservée aux éléments qui portent .reveal directement dans le HTML (blocs
   d'article), dont la hauteur rend un filter inutilement coûteux. */
.reveal--plain {
  transform: translateY(18px);
  filter: none;
}
.reveal--plain.is-visible {
  transform: none;
  filter: none;
}

/* ── Variante « carte » : montée + rapprochement ── */
.reveal--card {
  transform: translateY(30px) scale(0.975);
  filter: blur(4px);
}
.reveal--card.is-visible {
  transform: none;
  filter: none;
}

/* ── Variante « éventail » : la grille se range en arrivant ──
   Les cartes d'une grille arrivent légèrement de travers, en alternance, et se
   rangent. Un seul passage, à la révélation.

   POURQUOI. Procédé relevé sur linearity.io, sur leur page produit : pendant un
   épinglage, chaque carte d'une pile prend une rotation propre et un décalage de
   profondeur, puis celle du dessus se détache. Mesuré dans leurs matrices :
   rotations ALTERNÉES et CROISSANTES — 2,00° / 2,40° / 2,76° / 3,08° — et
   translateZ à −20 / −40 / −60.

   CE QUI DIFFÈRE DE CHEZ EUX, et c'est l'essentiel : aucun épinglage. L'accueil en
   a déjà un (la séquence 3D) et `caracteristiques` un autre ; un troisième coûterait
   des écrans de défilement pour un contenu qui tient dans une grille. L'éventail se
   joue donc À L'ARRIVÉE, dans la révélation que ces cartes avaient déjà : c'est la
   même durée, la même courbe, le même échelonnement — seule la pose de départ change.
   La profondeur est rendue par l'échelle et non par un translateZ, ce qui évite
   d'imposer une `perspective` au conteneur (et donc un contexte d'empilement de plus
   sur chaque grille).

   L'ANGLE EST DÉRIVÉ DE L'ÉCHELONNEMENT DÉJÀ POSÉ par le module 4 (`--stagger-i`,
   l'index dans la grille, plafonné à 5) : alternance par la parité, amplitude
   croissante de 1,6° à 3,0°, exactement le motif relevé. Rien de nouveau à écrire en
   JavaScript, et l'angle repart de zéro à chaque grille puisque l'index est calculé
   par parent.

   L'ÉTAT D'ARRIVÉE EST IDENTIQUE À CELUI DE LA VARIANTE « CARTE » (`transform: none`)
   : l'inclinaison au pointeur du module 10 écrit un `transform` EN LIGNE sur
   `.feature-card`, qui gagne donc sur cette règle, et le relâchement le remet à
   vide. L'éventail ne peut pas interférer avec le survol. */
.reveal--fan {
  --fan-sign: -1;
  transform:
    translateY(30px)
    rotate(calc(var(--fan-sign) * (1.6deg + var(--stagger-i, 0) * 0.28deg)))
    scale(0.955);
  filter: blur(4px);
}
.reveal--fan:nth-child(even) { --fan-sign: 1; }
.reveal--fan.is-visible {
  transform: none;
  filter: none;
}

/* ── Variante « groupe » : le conteneur ne bouge pas, ses enfants s'enchaînent ──
   Utilisée sur les en-têtes de section et les colonnes de texte : label puis
   titre puis paragraphe, comme une phrase qui se compose. Un reveal du bloc
   entier ferait arriver les trois d'un coup. */
.reveal--group {
  opacity: 1;
  transform: none;
  filter: none;
  transition: none;
}
.reveal--group > * {
  opacity: 0;
  transform: translateY(18px);
  filter: blur(5px);
  transition:
    opacity   var(--dur-text) var(--ease-out) var(--child-delay, 0s),
    transform var(--dur-text) var(--ease-out) var(--child-delay, 0s),
    filter    var(--dur-text) var(--ease-out) var(--child-delay, 0s);
}
.reveal--group.is-visible > * {
  opacity: 1;
  transform: none;
  filter: none;
}
/* Même précaution que riseInSoft : pas de filter sur le label à dégradé. */
.reveal--group > .section-label,
.reveal--group.is-visible > .section-label { filter: none; }

.reveal--group > *:nth-child(1) { --child-delay: 0s;    }
.reveal--group > *:nth-child(2) { --child-delay: 0.09s; }
.reveal--group > *:nth-child(3) { --child-delay: 0.18s; }
.reveal--group > *:nth-child(4) { --child-delay: 0.27s; }
.reveal--group > *:nth-child(5) { --child-delay: 0.34s; }
.reveal--group > *:nth-child(n+6) { --child-delay: 0.4s; }

/* ── Ligne de temps — le trait se dessine au scroll ──
   Un second trait, teal, se superpose au trait gris de .timeline::before et se
   remplit à mesure que la section défile (--tl-p, écrit par le moteur). C'est
   le seul effet du site réellement « scrubbé » par le scroll plutôt que
   déclenché à l'entrée : il donne un repère de progression dans la section.
   En prefers-reduced-motion le moteur ne tourne pas, --tl-p reste à 0, donc
   scaleY(0) : le trait teal est simplement absent et le trait gris subsiste. */
.timeline::after {
  content: '';
  position: absolute;
  left: 20px; top: 0; bottom: 0;
  width: 2px;
  background: linear-gradient(to bottom, var(--teal), var(--teal-dark));
  transform: scaleY(var(--tl-p, 0));
  transform-origin: top center;
  z-index: 0;   /* sous les items (cf. .timeline-item) */
}
.timeline-item__dot { transition: box-shadow 0.6s var(--ease-out) 0.25s; }

/* ── Mobile : amplitudes réduites ──
   Sur un écran court, un parallaxe calé sur le desktop devient un décalage
   visible plutôt qu'une profondeur ; et le hero passe en une colonne, donc
   sa sortie doit rester discrète. */
@media (max-width: 768px) {
  .mx-parallax { translate: 0 calc(var(--mx-y, 0px) * 0.45); }
  .hero__content {
    opacity: calc(1 - var(--hero-p, 0) * 0.5);
    translate: 0 calc(var(--hero-p, 0) * -20px);
  }
}

/* ── prefers-reduced-motion — neutralisation complète de la couche motion ── */
@media (prefers-reduced-motion: reduce) {
  .mx-parallax { translate: none; }
  .hero__content { opacity: 1; translate: none; }
  .hero__image img { transform: none; }
  /* `.reveal` de base et `.reveal--plain` inclus : sans eux, ces éléments
     restaient à opacity:0 jusqu'à ce que l'observer les atteigne, alors qu'en
     mouvement réduit il n'y a plus d'animation à attendre — autant les
     afficher d'emblée, ce qui élimine aussi tout risque qu'un contenu reste
     invisible si l'observer ne se déclenche pas. */
  .reveal,
  .reveal--plain,
  .reveal--media,
  .reveal--card,
  .reveal--fan,
  .reveal--group,
  .reveal--group > * {
    opacity: 1;
    transform: none;
    filter: none;
    clip-path: none;
    transition: none !important;
  }
  .reveal--media::after,
  .reveal--media.is-visible::after { animation: none; opacity: 0; }
  .reveal--media:not(.is-visible) img,
  .reveal--media.is-visible img {
    animation: none;
    transform: scale(var(--media-scale, 1));
  }
  .feature-card:hover .feature-card__icon,
  .feature-card:hover .feature-card__icon svg { transform: none; }
}

/* ══════════════════════════════════════════════
   PICTOGRAMMES EN LIGNE
   Le site n'utilise aucun emoji : les puces de badges, de méta d'article et
   de bannières de blog sont des SVG au trait, alignés sur le texte.
   ══════════════════════════════════════════════ */
.footer__badge-ico,
.badge-lux__ico,
.meta-ico {
  flex-shrink: 0;
  vertical-align: -0.14em;
}
.badge-lux__ico  { color: var(--teal-text); }
.footer__badge-ico { color: rgba(255,255,255,0.68); }
.blog-card__meta span {
  display: inline-flex;
  align-items: center;
  gap: 6px;
}
/* Bannière de blog : le pictogramme remplace l'emoji, en blanc translucide
   par-dessus le dégradé. */
.blog-banner__ico { color: rgba(255,255,255,0.9); }
.blog-card__banner .blog-banner__ico,
.blog-color-banner .blog-banner__ico { filter: drop-shadow(0 2px 10px rgba(0,0,0,0.25)); }

/* Listes de dimensions / caractéristiques reprises du site live (page
   « Caractéristiques techniques ») : puces teal discrètes, alignées sur le
   rythme des paragraphes. */
.specs__dims {
  list-style: none;
  margin: 18px 0 0;
  display: grid;
  gap: 8px;
}
/* Deux colonnes pour la liste de la section Raspberry, demandé le 2026-08-28.
   ON AJOUTE DES COLONNES DE GRILLE, PAS `columns`. La règle de base pose
   `display: grid` sur .specs__dims, et un conteneur de grille ignore purement
   et simplement la mise en colonnes multiple : la propriété était bien lue,
   les quatre items restaient l'un sous l'autre. Sous 640 px on revient sur une
   colonne, deux colonnes de 150 px couperaient les libellés au milieu d'un mot. */
.specs__dims--2col { grid-template-columns: 1fr 1fr; column-gap: 32px; }
@media (max-width: 640px) { .specs__dims--2col { grid-template-columns: 1fr; } }

.specs__dims li {
  position: relative;
  padding-left: 20px;
  color: var(--gray);
  font-size: 0.9375rem;
}
.specs__dims li::before {
  content: '';
  position: absolute;
  left: 4px; top: 0.62em;
  width: 6px; height: 6px;
  border-radius: 50%;
  background: var(--teal);
}

/* Figures d'article : visuel de couverture et illustrations en cours de texte,
   reprises du site live. La légende garde le texte du live sous l'image. */
.article-figure {
  margin: 32px 0;
}
.article-figure img {
  width: 100%;
  height: auto;
  border-radius: var(--radius);
  border: 1px solid #2B2B2B;
  background: #141414;
}
.article-figure figcaption {
  margin-top: 10px;
  font-size: 0.875rem;
  color: var(--muted);
}
.article-figure--cover {
  margin: 0 0 36px;
}
.article-figure--cover img {
  aspect-ratio: 1 / 1;
  object-fit: cover;
}

/* ══════════════════════════════════════════════
   CIBLES TACTILES
   WCAG 2.2 (2.5.8) impose 24x24 CSS px minimum ; au doigt on vise 44.
   Mesuré à 390 px : le hamburger faisait 32x24, la case de consentement
   13x13, le bouton « retour en haut » 35x35.
   ══════════════════════════════════════════════ */
.nav__toggle { width: 44px; height: 44px; justify-content: center; align-items: center; }
.newsletter__consent input[type="checkbox"] { width: 18px; height: 18px; }
/* La zone cliquable utile est le label entier, on lui donne de la hauteur. */
.newsletter__consent label { padding: 6px 0; }
/* Derniers cas mesurés à 390 px : les liens des cartes produit et des cartes
   de blog s'arrêtaient à 20-23 px, et la piste du curseur d'éclatement du
   modèle 3D à 4 px. Les liens en pleine phrase (réponses de FAQ, corps
   d'article) ne sont pas repris : 2.5.8 les exempte explicitement. */
.product-card a,
.blog-card__title a { display: inline-block; padding: 3px 0; }
/* `.evo-card__link` EST SORTI DE CETTE RÈGLE GROUPÉE, et elle lui faisait deux
   dégâts d'un coup. Le raccourci `padding: 3px 0` écrasait les 18 px qui séparent
   le lien du paragraphe au-dessus (le texte venait donc coller le lien), et
   `display: inline-block` écrasait son `inline-flex`, dont il a besoin pour
   centrer sa flèche et tenir son `gap`. Il garde donc ses 18 px du bloc de base et
   ne reçoit ici que la hauteur de cible manquante. Mesuré : 41 px de haut, donc
   au-delà des 24 px de WCAG 2.5.8. */
.evo-card__link { padding-bottom: 3px; }
.mv-explode__slider { height: 24px; }

/* Même mesure ailleurs : fil d'Ariane et bloc de coordonnées tombaient aussi
   sous 24 px de haut. Les cases à cocher des formulaires portent un
   `width:auto` en ligne, que seul un !important peut reprendre ; leur libellé
   entier reste de toute façon cliquable, ce qui satisfait déjà 2.5.8. */
.contact-info__item a {
  display: inline-block;
  padding: 5px 0;
}
.newsletter__consent input[type="checkbox"],
.form-group input[type="checkbox"],
label > input[type="checkbox"] {
  width: 20px !important;
  height: 20px;
  flex-shrink: 0;
}

/* Liens du pied de page. Mesurés à 390 px, ils faisaient 16 px de haut — la
   hauteur de la ligne de texte, sans zone cliquable autour — là où WCAG 2.2
   (2.5.8) demande 24 px. On ajoute 8 px de padding vertical (-> 32 px) et on
   retire d'autant l'espacement de la liste, pour que le pied de page garde sa
   hauteur. Le lien de la mention de consentement du formulaire suit. */
.footer__col ul { display: grid; gap: 0; }
.footer__col ul li { margin: 0; }
.footer__col ul a,
.footer__col ul [aria-current="page"],
.footer__legal a,
.newsletter__consent a,
.form-consent a,
.form-group label a {
  display: inline-block;
  padding: 8px 0;
}
.footer__legal { gap: 8px 24px; }

/* .nav__lang a : le padding 6x8 est désormais déclaré avec le composant
   (section NAV) — hauteur ≈31px, largeur ≈50px, au-delà du minimum de 24. */


/* ══ LA FENÊTRE DE L'INTERFACE LIVRÉE (.appwin) ════════════════════════════
   Posée le 2026-09-07, sur remise du dossier `ScreenUI/` par le client : « voici
   l'UI actuel du site pour remplacer la fictive dans "INTERFACE & API — une
   interface web et une API REST", le but étant d'avoir un rendu similaire à ce
   que tu as fait maintenant avec un petit truc dynamique ».

   CE QUI PART : `qbot-interface.jpg` / `-en.jpg`, une MAQUETTE HTML/CSS capturée
   (tools/render/interface-mockup.html), signalée comme telle depuis le
   2026-08-11 et gardée sur arbitrage du 2026-08-27 (« pour l'instant c'est du
   full préprod côté Q-Bot donc c'est moche »). L'interface existe désormais et
   elle est présentable : la maquette n'a plus de raison d'être, et avec elle part
   l'acte épinglé, dont les trois zones de projecteur étaient MESURÉES sur cette
   maquette (cf. le bloc suivant). Un projecteur ne se recale pas sur une autre
   image : ses régions visaient « la liste des étapes », « le pied de page » et
   « l'étape notification », trois objets qui n'existent pas dans les captures.

   CE QUI ARRIVE : une fenêtre d'application dont le bandeau est du BALISAGE et
   le corps une CAPTURE. Le bandeau reproduit la barre de l'application (fond
   #212429, relevé dans les captures), ses trois onglets réels — Home, Scenario
   Editor, API — et son numéro de version ; le corps montre le haut de la page
   correspondante, découpée sous la barre. Basculer d'onglet échange la capture :
   c'est le « petit truc dynamique », et il est PILOTÉ PAR LE VISITEUR.

   PAS DE DÉFILEMENT AUTOMATIQUE, ET C'EST UNE RÈGLE DU DÉPÔT. Le client a fait
   retirer les bandes d'outils défilantes le 2026-08-20 (« les carrousels sont un
   peu illisibles cognitivement ») : ce qui les rendait illisibles était le
   défilement automatique, pas le motif. Un défilement automatique demanderait de
   surcroît un bouton de pause (WCAG 2.2.2).

   LES ONGLETS GARDENT LEURS LIBELLÉS ANGLAIS, MÊME EN FRANÇAIS, parce que
   l'application est en anglais : les traduire ferait dire à la fenêtre autre
   chose que ce que le visiteur verra en s'en servant. C'est la légende, elle,
   qui est dans la langue de la page.

   PAS D'ONGLET « CREDITS ». La quatrième capture nomme sept contributeurs avec
   leur adresse de courriel : la règle du dépôt est de ne nommer personne sans
   accord écrit (arbitrage du 2026-08-26 sur les références clients). Le fichier
   est en plus tenu hors de git, cf. .gitignore.

   LA LARGEUR EST CALCULÉE, PAS CHOISIE, et c'est ce qui rend les captures
   lisibles. Les originaux sont des captures DPR 2 d'une fenêtre de 1 575 px CSS,
   donc 1 px source = 0,5 px CSS de l'application. Une découpe de 2 360 px
   affichée sur 1 180 px — la largeur plancher du conteneur — rend l'application
   à SA taille native (texte de 15 px à 15 px) et, sur un écran de densité 2,
   à 1 pixel pour 1 pixel. Au-delà de 2 000 px de fenêtre le conteneur monte à
   1 440 et l'agrandissement plafonne à 1,22, ce que le dépôt accepte (précédent
   du 2026-08-26 : on cape au-delà de 1,5, on accepte à 1,26).
   C'EST AUSSI POURQUOI LA FENÊTRE N'EST PAS DANS UNE COLONNE. Une capture de
   bureau dans la demi-colonne de 526 px de l'ancienne grille tomberait à un tiers
   de son échelle, texte à 5 px : c'est le défaut mesuré sur `qbot-2fa-flux.jpg`
   (note du 2026-09-02), et il est structurel, pas réglable.

   LES TROIS DÉCOUPES SONT LE HAUT DE LEUR PAGE, sous la barre : x [404, 2764),
   y [123, 1623) dans les originaux, soit 2 360 × 1 500 (rapport 1,573). Aucune
   n'est retouchée, aucune n'est complétée, et le contenu qui dépasse le bord bas
   est coupé net — dans un cadre qui se lit comme une fenêtre, une page qui
   continue sous le bord est ce qu'on attend, pas un défaut. Vérifié avant
   découpe : aucune couture de capture d'écran et aucun texte coupé
   horizontalement dans cette fenêtre.

   LE CORPS EST BLANC PARCE QUE LES PAGES LE SONT. Sur un site noir, une capture
   claire a besoin d'un cadre : c'est le traitement déjà écrit pour `.hero__shot`
   et `.hero__film` (angles arrondis, filet teal, halo teal doux) et il est repris
   ici aux mêmes valeurs. Aucune vignette intérieure en revanche : assombrir les
   coins d'une capture d'interface se lirait comme un filtre photo.

   PAS DE VERRE. `.appwin` n'est PAS dans la liste des surfaces sombres de la
   section « verre » : un flou d'arrière-plan derrière un corps blanc opaque ne
   sert à rien, et cette règle-là (`[data-theme="dark"] .x`, 0,2,0) écraserait le
   filet teal du cadre — le piège déjà payé sur `.booking-modal__box`. */
/* ── Les trois modes de pilotage, en rangée de trois cartes ──
   LE LIBELLÉ PASSE AU-DESSUS DE SA VALEUR, ET C'EST OBLIGATOIRE ICI.
   `.spec-item` est un flex à deux colonnes avec un libellé à `min-width: 120px`
   — juste dans une LISTE de fiche technique, faux dans une carte de 310 px : le
   libellé y prenait 120 px et il ne restait que 174 px pour le texte, soit trois
   mots par ligne. C'est exactement le défaut mesuré sur les fiches à 390 px le
   2026-08-25, et le correctif est le même, seul le seuil change : ici ce n'est
   pas la fenêtre qui manque de place mais la carte.
   Les deux sélecteurs sont à (0,2,0), donc ils passent devant `.spec-item`
   (0,1,0) ET devant la requête média à 560 px, qui est au même poids : sans
   cela l'ordre du fichier trancherait, et cette règle est écrite APRÈS. */
.modes-grid .spec-item--card { display: block; }
.modes-grid .spec-item__label { display: block; min-width: 0; margin-bottom: 8px; }

/* LA LÉGENDE EST HORS DU CADRE, d'où la <figure> qui l'englobe : le cadre est en
   `overflow: hidden` pour que ses angles arrondis découpent la capture, et une
   `<figcaption>` posée dedans se retrouverait dans la fenêtre, sur son fond
   blanc. La figure ne porte donc que la mise en page. */
.appwin { margin: 40px 0 0; }

.appwin__win {
  border-radius: var(--radius-lg);
  overflow: hidden;
  border: 1px solid rgba(0, 203, 190, 0.22);
  box-shadow: 0 30px 70px rgba(0, 0, 0, 0.45),
              0 0 60px rgba(0, 203, 190, 0.10);
  background: #212429;
}

/* ── Le bandeau ── */
.appwin__bar {
  display: flex;
  align-items: center;
  gap: 16px;
  padding: 0 16px;
  min-height: 46px;
  background: #212429;
  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}

/* Les trois pastilles de fenêtre. Neutres et de la même teinte : des feux
   tricolores seraient hors charte, et rien ici n'est cliquable. */
.appwin__dots { display: flex; gap: 6px; flex: 0 0 auto; }
.appwin__dots i {
  width: 9px;
  height: 9px;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.20);
}

.appwin__tabs {
  display: flex;
  flex-wrap: wrap;
  gap: 2px;
  flex: 1 1 auto;
  min-width: 0;
}

/* L'onglet. `padding` et non `min-height` : la cible utile fait 31 px de haut et
   44 px au moins de large, donc au-delà des 24 px de WCAG 2.5.8, et le bandeau
   n'a pas à grandir pour autant. */
.appwin__tab {
  appearance: none;
  border: 0;
  background: none;
  font-family: inherit;
  font-size: 13px;
  font-weight: 500;
  line-height: 1.2;
  letter-spacing: 0.01em;
  color: rgba(255, 255, 255, 0.62);
  padding: 9px 12px;
  border-radius: 6px 6px 0 0;
  cursor: pointer;
  position: relative;
  transition: color 0.25s var(--ease-out, ease), background-color 0.25s var(--ease-out, ease);
}
.appwin__tab:hover { color: #fff; background: rgba(255, 255, 255, 0.06); }

/* L'ONGLET ACTIF EST BLANC ET GRAS, comme dans l'application ; le trait teal est
   NOTRE affordance, pas la sienne. L'application marque son onglet courant en
   blanc gras sans aucun trait : on reproduit ce qu'elle fait et on ajoute la
   marque du site, sans repeindre son interface en teal. */
.appwin__tab[aria-selected="true"] { color: #fff; font-weight: 700; }
.appwin__tab[aria-selected="true"]::after {
  content: '';
  position: absolute;
  left: 8px;
  right: 8px;
  bottom: -1px;
  height: 2px;
  border-radius: 1px;
  background: var(--teal);
}

/* Le numéro de version, relevé dans le pied de page de l'application. Il vieillit
   avec la capture, ce qui est cohérent : les deux disent la même livraison. */
.appwin__ver {
  flex: 0 0 auto;
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 12px;
  color: rgba(255, 255, 255, 0.55);
}

/* ── Le corps ──
   `aspect-ratio` sur le corps et non sur l'image : les trois captures ont
   exactement la même taille, donc la hauteur ne bouge pas d'un onglet à l'autre
   et il n'y a AUCUN décalage de mise en page au clic. */
.appwin__body {
  position: relative;
  aspect-ratio: 2360 / 1500;
  background: #fff;
}

.appwin__panel {
  position: absolute;
  inset: 0;
}
/* L'ÉTAT AU REPOS EST UN ÉTAT COMPLET : `is-active` est écrit dans le HTML sur le
   premier panneau, donc sans JavaScript la fenêtre montre l'accueil de
   l'application, entière et légendée. Les deux autres sont masqués par le CSS et
   non par un attribut `hidden` écrit dans la page — c'est la discipline du
   module 3 (accordéon de la FAQ) : l'attribut est POSÉ PAR LE SCRIPT, jamais
   servi, pour que le balisage reste lisible par un robot. */
.appwin__panel:not(.is-active) { display: none; }

.appwin__panel img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Fondu à l'arrivée seulement. Un fondu enchaîné demanderait de garder les deux
   panneaux affichés et de différer le masquage du sortant : trois captures de
   pages blanches n'y gagneraient rien de visible pour un minuteur de plus. */
.appwin__panel.is-active { animation: appwinIn 0.3s var(--ease-out, ease) both; }
@keyframes appwinIn { from { opacity: 0; } to { opacity: 1; } }

/* La légende. Elle dit ce qu'on regarde, dans la langue de la page, et elle
   garde sa mesure de lecture. `.appwin__cap` seule pèse (0,1,0) : le sélecteur
   est doublé parce que `.container > p` (0,1,1) la précède dans ce fichier et
   gagnerait sur la largeur — le piège relevé quatre fois sur ce dépôt
   (`.usecase__fig`, `.newsletter__legal`, `.evo-card__link`, `.guide-date`). */
.appwin .appwin__cap {
  margin: 16px 0 0;
  font-size: 13.5px;
  line-height: 1.5;
  color: var(--gray);
  max-width: 78ch;
}

@media (max-width: 600px) {
  /* Le bandeau s'enroule : trois onglets, un numéro de version et les pastilles
     ne tiennent pas sur 342 px. Les pastilles partent — elles ne portent aucune
     information — et le numéro de version passe à la ligne avec les onglets. */
  .appwin__dots { display: none; }
  .appwin__bar { padding: 4px 10px; gap: 8px; flex-wrap: wrap; }
  .appwin__tab { font-size: 12.5px; padding: 8px 10px; }
}

/* ══ ACTE ÉPINGLÉ « INTERFACE & API » ══════════════════════════════════════
   PLUS AUCUNE PAGE NE PORTE `.pin-modes` DEPUIS LE 2026-09-07. La maquette
   fictive a été remplacée par les captures de l'interface livrée (bloc `.appwin`
   juste au-dessus), et l'acte est parti avec elle : SES TROIS ZONES DE PROJECTEUR
   ÉTAIENT MESURÉES SUR CETTE MAQUETTE — « la liste des étapes », « le pied de
   page », « l'étape notification » — trois objets qui n'existent nulle part dans
   les captures. Un projecteur ne se recale pas sur une autre image, et le
   commentaire ci-dessous le disait déjà : « si la maquette est refaite, il faut
   refaire ce relevé ».
   LE BLOC EST GARDÉ ET ANNOTÉ, comme `.timeline` et `.video__wrapper` : c'est le
   chemin de code d'une section épinglée à trois temps, et l'entrée
   `['.pin-modes', '--pin-p', 3]` du module 9 reste avec lui. Le supprimer est un
   arbitrage du client, pas d'une passe de remplacement d'image.
   Ce qui suit décrit l'état d'avant.

   Proposition B de la revue : le procédé n°2 de linearity.io, où le texte reste en
   place et c'est le visuel qui change. Les trois modes de pilotage — interface web,
   API REST, notification mobile — se lisaient comme trois cartes empilées qu'on
   parcourt d'un œil. Ils deviennent trois temps : la carte courante s'éclaire, et un
   projecteur se déplace sur la partie de la maquette qui la concerne.

   RIEN N'EST INVENTÉ. La maquette contient déjà les trois choses : la liste des
   étapes du scénario, l'étape « notification envoyée sur le mobile », et l'appel
   `POST /api/v1/authenticate` en pied. On ne fabrique pas deux écrans de plus.

   POURQUOI UN PROJECTEUR ET NON UN ZOOM. La première version recadrait l'image
   (translate + scale). Défaut signalé par le client : « le cadre Notification mobile
   n'est pas entier » — et c'est structurel, pas un réglage à corriger. La fenêtre de
   la maquette occupe 88,24 % de la largeur de l'image ; à l'échelle s la zone visible
   n'en fait que 1/s. Garder la fenêtre entière impose donc s ≤ 1/0,8824 = 1,13, et à
   cette échelle il ne reste plus de place pour déplacer le centre : viser le pied de
   page, à 86 % de la hauteur, coupait forcément la fenêtre. Un zoom qui tranche une
   bordure arrondie ne se lit pas comme un rapprochement mais comme une image cassée.
   Le projecteur ne touche pas à l'image : elle reste entière et à l'échelle 1 du
   premier au dernier temps. C'est l'extérieur de la région qui s'assombrit, par une
   ombre portée de 9999 px que le cadre — déjà en `overflow: hidden` — découpe.

   LES RÉGIONS SONT MESURÉES, pas réglées à l'œil : boîtes relevées dans
   tools/render/interface-mockup.html et rapportées en pourcentage de la capture. Si
   la maquette est refaite, il faut refaire ce relevé — c'est déjà arrivé une fois, à
   la passe de finition de la maquette. */
.pin-modes__track { position: relative; }
/* `align-self: start` n'est pas cosmétique : dans la grille, la colonne image
   s'étirait à la hauteur de la colonne texte, donc la boîte du cadre était plus
   haute que celle de l'image — et les pourcentages du projecteur se rapportent au
   cadre. Les deux boîtes doivent coïncider. */
.pin-modes__zoom { overflow: hidden; align-self: start; }
.pin-modes .pin-modes__zoom img { transform: none; }

.pin-modes__zoom::after {
  content: '';
  position: absolute;
  left: var(--sx, 5.5%);
  top: var(--sy, 22.9%);
  width: var(--sw, 89%);
  height: var(--sh, 56.3%);
  border: 1px solid rgba(0, 203, 190, 0.55);
  border-radius: 8px;
  box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.58), 0 0 22px rgba(0, 203, 190, 0.16);
  opacity: 0;
  pointer-events: none;
  transition: left 0.65s var(--ease-out), top 0.65s var(--ease-out),
              width 0.65s var(--ease-out), height 0.65s var(--ease-out),
              opacity 0.4s var(--ease-out);
}

/* L'ÉTAT DE BASE EST L'ÉTAT LISIBLE : les trois cartes pleinement visibles, aucun
   projecteur. L'acte n'AJOUTE l'estompe que là où il a la place de se jouer.
   L'inverse avait été écrit d'abord — estompe par défaut, annulée sous 900 px — et
   c'était faux : entre 901 et 1119 px, ou sur une fenêtre courte, aucune des deux
   règles ne s'appliquait et les cartes restaient éteintes sans que rien ne les
   rallume. Même leçon que le viewer mort de la proposition E : l'état de repos doit
   être l'état actif.
   Quand l'estompe s'applique, elle porte sur le CONTENU de la carte et non sur la
   carte : celle-ci est déjà une cible du système de révélation, dont la règle
   `.is-visible { opacity: 1 }` est plus spécifique et gagnait. */
.pin-modes__card > * { opacity: 1; }
/* L'ÉTAT ACTIF EST PORTÉ PAR LA CARTE, PAS PAR UN TRAIT POSÉ À CÔTÉ.
   Première version : un liseré de 2 px en `left: 0`, haut de toute la carte, à angles
   droits. Mesuré : 2 × 116,5 px sur une carte de 526 × 119 qui a 16 px de rayon — le
   trait contredisait donc la forme de la carte, ses extrémités tombant dans le vide
   laissé par les angles arrondis. C'est ce qui le faisait lire comme une ligne
   égarée et non comme un état.
   La carte prend maintenant son propre état : bordure teal, lavis teal très léger et
   ombre un peu plus présente. Rien ne peut plus se désaligner, puisqu'il n'y a plus
   rien à aligner.
   Le lavis passe par une ombre interne à grand étalement plutôt que par un
   `background-image` : l'ombre respecte le rayon ET s'interpole, alors qu'un dégradé
   d'arrière-plan apparaîtrait d'un coup. Les trois ombres sont déclarées dès l'état
   de repos, à opacité nulle, pour que la transition ait de quoi interpoler. */
.pin-modes__card {
  box-shadow: inset 0 0 0 200px rgba(0, 203, 190, 0),
              0 0 0 1px rgba(0, 203, 190, 0),
              0 12px 30px rgba(0, 0, 0, 0);
  transition: border-color 0.45s var(--ease-out, ease), box-shadow 0.45s var(--ease-out, ease);
}

/* ── La porte de l'acte : DEUX dimensions, et des chiffres, pas une intuition ──
   Défaut signalé par le client : « le cadre Notification mobile n'est pas entier ».
   Mesuré : la scène épinglée est aussi haute que son contenu plus ses marges, et
   quand ce total dépasse la fenêtre, le bas sort de l'écran — définitivement, car
   une scène collée ne défile pas. Sur une fenêtre de 700 px la troisième carte
   était coupée de 16 px.

   Hauteur de contenu relevée selon la largeur (français, le plus long des deux) :
       901 px de large → 698 px      1120 px → 582 px
       960 px          → 698 px      1280 px → 588 px
      1024 px          → 652 px      1440 px → 588 px
   La colonne de texte est d'autant plus haute que la fenêtre est étroite. Plutôt
   qu'un seuil unique calé sur le pire cas — qui aurait privé de l'acte un portable
   de 1440 × 780 —, l'acte ne se joue qu'à partir de 1120 px de large, où le contenu
   se stabilise à 588 px : avec des marges de 5vh il faut alors 660 px de fenêtre, et
   la porte est posée à 720 px. Reste 60 px de réserve si le texte s'allonge.
   Entre 901 et 1119 px, ou sur une fenêtre plus courte, la section reprend sa mise
   en page normale — lisible, simplement sans acte. */
@media (min-width: 1120px) and (min-height: 720px) {
  /* Trois écrans de course : un par mode, plus la marge d'arrivée et de sortie
     que ménagent les bornes resserrées du module 9. */
  .pin-modes { padding: 0; }
  .pin-modes__track { height: 300svh; }
  .pin-modes__stage {
    position: sticky;
    top: 0;
    min-height: 100svh;
    display: flex;
    align-items: center;
    /* Marges élastiques : à marges fixes de 96 px il fallait 780 px de fenêtre. */
    padding: clamp(28px, 5vh, 72px) 0;
  }
  .pin-modes__card > * { opacity: 0.45; transition: opacity 0.45s var(--ease-out, ease); }
  .pin-modes[data-panel] .pin-modes__zoom::after { opacity: 1; }
  .pin-modes[data-panel="0"] .pin-modes__card[data-mode="0"] > *,
  .pin-modes[data-panel="1"] .pin-modes__card[data-mode="1"] > *,
  .pin-modes[data-panel="2"] .pin-modes__card[data-mode="2"] > * { opacity: 1; }
  .pin-modes[data-panel="0"] .pin-modes__card[data-mode="0"],
  .pin-modes[data-panel="1"] .pin-modes__card[data-mode="1"],
  .pin-modes[data-panel="2"] .pin-modes__card[data-mode="2"] {
    border-color: rgba(0, 203, 190, 0.42);
    box-shadow: inset 0 0 0 200px rgba(0, 203, 190, 0.05),
                0 0 0 1px rgba(0, 203, 190, 0.12),
                0 12px 30px rgba(0, 0, 0, 0.32);
  }

  /* Les trois cadrages. Le premier est l'identité : la maquette entière. */
  /* Régions relevées sur la maquette, en pourcentage de la capture :
       en-tête + liste des étapes  x 6,03  y 23,48  w 87,94  h 55,19
       pied de page (l'appel API)  x 6,03  y 81,16  w 87,94  h 10,00
       3e étape (la notification)  x 7,50  y 55,09  w 85,00  h  7,86
     Chacune est élargie d'un demi-point pour que le liseré ne colle pas au texte. */
  .pin-modes[data-panel="0"] .pin-modes__zoom { --sx: 5.5%; --sy: 22.9%; --sw: 89.0%; --sh: 56.3%; }
  .pin-modes[data-panel="1"] .pin-modes__zoom { --sx: 5.5%; --sy: 80.6%; --sw: 89.0%; --sh: 11.1%; }
  .pin-modes[data-panel="2"] .pin-modes__zoom { --sx: 7.0%; --sy: 54.5%; --sw: 86.0%; --sh:  9.0%; }
  /* Le centre est décalé à 0,42 et non 0,50 : la contrainte est que le bord
     gauche de la liste (à 0,090) reste dans le champ, soit cx − 0,5/échelle
     ≤ 0,090. À 1,45 cela impose cx ≤ 0,435. Centré, le premier mot de chaque
     étape sortait du cadre. */
}

/* Sous 900 px : aucun épinglage, aucun recadrage. La leçon de la passe mobile
   tient — sur téléphone on raccourcit, on n'ajoute pas trois écrans épinglés à
   une page qui en fait déjà six — et les trois cartes se lisent l'une après
   l'autre, ce qui est exactement leur ordre naturel. */


@media (prefers-reduced-motion: reduce) {
  .pin-modes__track { height: auto; }
  .pin-modes__stage { position: static; min-height: 0; padding: 0; }
  .pin-modes__card > * { opacity: 1; }
  .pin-modes__card { box-shadow: none; border-color: var(--border); }
  .pin-modes .pin-modes__zoom::after { display: none; }
}

/* ══ INDEX COLLANT DE LA FAQ ═══════════════════════════════════════════════
   Proposition C de la revue. La FAQ était la page la plus dense du site — 1 420
   mots, seize questions — et la plus nue : aucune image, aucun mouvement de fond,
   et surtout aucun moyen de savoir ce qu'elle contient sans la parcourir en
   entier. L'index règle d'abord ce problème-là ; le mouvement (le trait qui se
   remplit, l'entrée courante qui s'allume) n'est que la lecture de la position.

   AUCUN THÈME N'EST INVENTÉ. La page n'a pas de groupes, et en fabriquer
   demanderait d'écrire des intitulés — donc du texte que le client n'a pas
   validé. Les entrées de l'index sont exactement les questions.

   Au-delà de 1024 px seulement : sur un écran de téléphone, seize entrées
   au-dessus de seize questions repliées n'aident personne, elles doublent la
   longueur de la page. C'est la même règle que partout ailleurs — sur mobile on
   raccourcit. */
.faq-layout { display: block; }
.faq-index { display: none; }

@media (min-width: 1025px) {
  /* L'INDEX EST À DROITE, LES QUESTIONS GARDENT LA GOUTTIÈRE. Première version
     avec l'index à gauche : les questions commençaient 345 px plus à droite que le
     titre de la page et que le fil d'Ariane, ce qui cassait le bord gauche sur
     lequel tout le site s'aligne. L'index passe donc en seconde colonne — c'est
     aussi la place d'un sommaire — mais il reste AVANT la liste dans le HTML, pour
     qu'au clavier on tombe dessus d'abord : il sert précisément à ne pas parcourir
     les seize questions. */
  .faq-layout {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 250px);
    gap: clamp(32px, 4vw, 64px);
    align-items: start;
  }
  .faq-index {
    display: block;
    order: 2;
    position: sticky;
    top: 104px;              /* barre de navigation (72) + respiration */
    padding-left: 18px;
  }
  /* Le trait de progression est la même grammaire que le rail de la séquence 3D :
     une ligne discrète, une portion teal qui suit la lecture. La valeur vient du
     module 9, écrite sur `.faq-layout` — donc sur un ancêtre commun, faute de quoi
     le rail ne pourrait pas lire une variable posée sur la liste, qui est sa sœur. */
  .faq-index__rail {
    position: absolute;
    left: 0; top: 4px; bottom: 4px;
    width: 2px;
    background: var(--border);
    border-radius: 2px;
    overflow: hidden;
  }
  .faq-index__rail::after {
    content: '';
    position: absolute;
    inset: 0;
    background: linear-gradient(180deg, var(--teal), var(--teal-dark));
    transform: scaleY(var(--faq-p, 0));
    transform-origin: top center;
  }
  .faq-index__list {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 2px;
    max-height: calc(100svh - 180px);
    overflow-y: auto;
    scrollbar-width: thin;
  }
  .faq-index__list a {
    display: grid;
    grid-template-columns: 22px 1fr;
    gap: 8px;
    padding: 5px 6px 5px 0;
    font-size: 0.8125rem;
    line-height: 1.35;
    color: var(--muted);
    text-decoration: none;
    transition: color 0.25s var(--ease-out);
  }
  .faq-index__num { font-size: 0.75rem; font-weight: 700; letter-spacing: 0.04em; opacity: 0.7; }
  .faq-index__list a:hover { color: var(--black); }
  .faq-index__list a:focus-visible { outline: 2px solid var(--teal); outline-offset: 2px; border-radius: 3px; }
  /* L'entrée courante : c'est un état de lecture, pas un lien actif — d'où
     `aria-current="location"` et non "page". */
  .faq-index__list a[aria-current] { color: var(--black); font-weight: 500; }
  .faq-index__list a[aria-current] .faq-index__num { color: var(--teal-text); opacity: 1; }
}

@media (prefers-reduced-motion: reduce) {
  .faq-index__rail::after { transform: scaleY(1); }
  .faq-index__list a { transition: none; }
}

/* ══ RELIEF DES ARTICLES ═══════════════════════════════════════════════════
   Proposition D de la revue. Les six articles traversaient 4 à 7 écrans avec zéro
   élément en parallaxe et trois révélations en fondu plat — mesuré. Trois gestes,
   aucun nouveau contenu, aucune image de plus.

   1. Les figures prennent le masque qui remonte (variante « media », déjà la
      signature du site pour un média). Il faut trois réglages pour qu'il tombe
      juste sur une figure d'article : le rayon du masque doit être celui de
      l'image (`--radius`) et non celui des grands cadres, sinon les angles
      redeviennent carrés pendant la transition ; la figure doit être positionnée,
      faute de quoi le passage de lumière du masque, qui est en `position:
      absolute; inset: 0`, se rapporterait à un ancêtre quelconque ; et la marge
      de sur-dimensionnement doit exister, sinon la dérive découvrirait un bord.
   2. Elles dérivent, très peu (16 contre 20 à 38 pour les autres cadres).
   3. Les titres de partie arrivent un à un, avec un trait teal qui se dessine à
      leur gauche. Le corps entier est une seule révélation : rien ne marquait le
      passage d'une partie à l'autre. */
.article-figure {
  position: relative;
  /* `overflow: hidden` est OBLIGATOIRE, pas cosmétique. Le passage de lumière de
     la variante « media » est un pseudo-élément en `inset: 0` translaté de −130 %
     : sur une figure large de 748 px il ressort à 970 px à gauche, et la page
     gagnait 341 px de débordement horizontal — mesuré, scrollWidth 1781 pour un
     viewport de 1440. Les autres cadres de cette variante (`.intro__image`,
     `.video__wrapper`) ont toujours eu cette propriété, c'est pour cela que le
     défaut ne s'était jamais montré. À vérifier pour tout nouvel élément ajouté à
     la variante. */
  overflow: hidden;
  --media-radius: var(--radius);
}

/* Le trait des titres de partie. Il se dessine de haut en bas avec l'arrivée du
   titre, puis reste : il marque la structure, il ne l'anime pas en boucle. */
.article-body h2 { position: relative; }
.article-body h2::before {
  content: '';
  position: absolute;
  left: -18px;
  top: 0.15em;
  bottom: 0.15em;
  width: 2px;
  border-radius: 2px;
  background: linear-gradient(180deg, var(--teal), var(--teal-dark));
  transform: scaleY(0);
  transform-origin: top center;
  transition: transform 0.7s var(--ease-out) 0.1s;
}
.article-body h2.is-visible::before { transform: scaleY(1); }

/* Le trait se rapproche sur les petits écrans mais ne disparaît pas : mesuré à
   390 px, la colonne de texte commence à 24 px du bord, donc un décalage de 10 px
   laisse le trait à 14 px — visible en entier. Le supprimer aurait retiré le seul
   repère de structure des pages qui en ont le plus besoin. */
@media (max-width: 900px) {
  .article-body h2::before { left: -12px; }
}
@media (max-width: 520px) {
  .article-body h2::before { left: -10px; }
}

@media (prefers-reduced-motion: reduce) {
  .article-body h2::before { transform: scaleY(1); transition: none; }
}

/* ══ MARQUES DE SECTION — ACCUEIL ══════════════════════════════════════════
   Proposition F de la revue. Procédé n°4 relevé sur linearity.io : un tracé qui
   traverse la page et relie des blocs que rien d'autre ne relie. Chez eux c'est une
   grande courbe décorative ; deux formes ont été mises en balance ici et c'est
   celle-ci qui a été retenue — un trait court à gauche de chaque libellé de
   section, le geste exact déjà employé sur les titres de partie des articles.
   Aucun composant d'interface en plus, aucune interaction nouvelle, aucun
   JavaScript : sur 11,8 écrans, chaque section s'annonce, et l'ensemble se lit
   comme une suite.

   L'ÉTAT STATIQUE EST L'ÉTAT VISIBLE, et c'est la décision qui compte ici. Sur les
   sept libellés de l'accueil, SIX ont un ancêtre révélé au défilement ; le
   septième — celui de la section Luxembourg, dont le libellé a été promu en `<h2>`
   lors d'une passe antérieure — n'en a aucun. Une marque qui partirait de
   `scaleY(0)` en attendant un `.is-visible` resterait donc invisible pour toujours
   sur cette section. C'est la classe de bug déjà rencontrée deux fois sur ce site
   (les articles rendus invisibles, le trait de la frise resté à zéro). Le tracé est
   donc une animation AJOUTÉE quand la révélation arrive, jamais une condition pour
   exister — même motif que le rail de la section évolution.

   Le libellé de la section LuxTrust est à 760 px et non 154 : cette section alterne
   les côtés sur grand écran et sa colonne de texte est à droite. La marque
   appartient au libellé, elle part donc à droite avec lui — ce n'est pas un défaut
   à corriger, c'est ce qui rend l'alternance lisible au lieu de la combattre. Sous
   900 px tout s'empile et les sept marques s'alignent : le tracé est plus net sur
   téléphone que sur bureau. */
.page-home main .section-label { position: relative; }

.page-home main .section-label::before {
  content: '';
  position: absolute;
  /* Mêmes décalages que les titres de partie des articles, mesurés : à 390 px la
     colonne commence à 24 px du bord, donc 10 px laissent la marque entière. */
  left: -18px;
  /* Hauteur EXPLICITE et centrée, plutôt que `top`/`bottom` : le libellé est un
     `inline-block` dont la boîte de padding est plus haute que sa ligne de texte, si
     bien qu'un calage par les bords donnait une marque de 24 px pour un texte de
     20,4 px — elle dépassait de 1,8 px de chaque côté. Mesuré, et c'est la même
     famille de défaut que le liseré des cartes : un accent qui ne coïncide pas avec
     ce qu'il marque. 12 px, c'est la taille de police du libellé. */
  top: 50%;
  height: 12px;
  margin-top: -6px;
  width: 2px;
  border-radius: 2px;
  background: linear-gradient(180deg, var(--teal), var(--teal-dark));
  transform-origin: top center;
}

/* Le tracé lui-même. Une seule fois : le module 4 cesse d'observer l'élément après
   avoir posé `.is-visible`, la marque ne peut donc pas se redessiner en remontant. */
@keyframes labelMark {
  from { transform: scaleY(0); }
  to   { transform: scaleY(1); }
}
.page-home main .reveal.is-visible .section-label::before {
  animation: labelMark 0.7s var(--ease-out) 0.05s both;
}

@media (max-width: 900px) {
  .page-home main .section-label::before { left: -12px; }
}
@media (max-width: 520px) {
  .page-home main .section-label::before { left: -10px; }
}

@media (prefers-reduced-motion: reduce) {
  .page-home main .reveal.is-visible .section-label::before { animation: none; }
}

/* ══ LISERÉ VIVANT ═════════════════════════════════════════════════════════
   Un anneau d'un pixel dont un segment plus clair fait le tour de la carte,
   trois fois, à son arrivée à l'écran.

   POURQUOI. Procédé relevé sur linearity.io (`.kp-glass-border.animate-border`,
   `spin 4s linear infinite` sur un carré en `aspect-ratio: 1/1`) : un dégradé
   conique tourne derrière la carte, masqué en anneau. La carte a l'air sous
   tension sans que rien ne bouge dans sa mise en page.

   CE QUI DIFFÈRE DE CHEZ EUX. Chez eux la rotation est infinie. Ici trois tours
   puis arrêt : ce dépôt a déjà tranché ce point pour le chatoiement des libellés
   (ramené d'`infinite` à 2 itérations) au motif qu'une boucle permanente est une
   signature de gabarit. Le survol relance le mouvement — c'est la même animation
   qui passe en `infinite`, donc elle reprend à l'angle courant sans saut.

   TROIS CARTES, UNE PAR PAGE : le prix (commandez / order), la prise de
   rendez-vous (contact) et la génération actuelle (accueil). Pas plus : l'effet
   ne désigne quelque chose que s'il ne désigne qu'une chose.

   MÉCANIQUE — un seul pseudo-élément, aucun enfant, aucun `overflow: hidden`.
   Le dégradé est peint sur toute la boîte, un masque à deux couches n'en garde
   que le cadre. Quatre pièges, tous rencontrés :
   - l'anneau est en `inset: -1px`, donc exactement sur la bordure existante, et
     celle-ci passe en transparent — sinon on lit deux traits parallèles. Ce
     passage en transparent est sous `@supports` : sans `mask-composite`, la
     carte garde sa bordure au lieu de n'avoir plus rien du tout.
   - l'abrégé `mask` remet `mask-composite` à `add`. Les abrégés doivent donc
     être déclarés AVANT les longues, jamais après.
   - `[data-theme="dark"] .booking-box` (0,2,0) fixe déjà une couleur de
     bordure : la remettre à transparent demande la même spécificité, sinon la
     règle est simplement ignorée. Idem pour `.evo-card:hover`.
   - `--ring-a` doit être enregistrée par `@property` pour être interpolable.
     Sans elle l'animation ne fait rien et l'anneau reste à son angle de départ,
     ce qui reste un état lisible : un arc clair sur un anneau discret.

   Mouvement réduit : la règle globale ramène toute animation à 0,01 ms et une
   itération — l'anneau s'arrête donc à 360°, c'est-à-dire à son point de
   départ. Rien à neutraliser ici. */
@property --ring-a {
  syntax: '<angle>';
  inherits: false;
  initial-value: 0deg;
}

@keyframes ringTurn { to { --ring-a: 360deg; } }

.pricing-highlight,
.booking-box,
.evo-card--current { position: relative; }

.pricing-highlight::before,
.booking-box::before,
.evo-card--current::before {
  content: '';
  position: absolute;
  inset: -1px;
  border-radius: inherit;
  padding: 1px;                     /* épaisseur de l'anneau */
  background: conic-gradient(from var(--ring-a),
    rgba(0,203,190,0.14)  0    58%,
    rgba(0,203,190,0.38) 72%,
    var(--teal)          80%,
    rgba(0,203,190,0.38) 88%,
    rgba(0,203,190,0.14) 96% 100%);
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
          mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
          mask-composite: exclude;
  pointer-events: none;
}

/* La bordure d'origine s'effacerait sous l'anneau : elle ne cède la place que
   là où l'anneau existe vraiment. */
@supports (mask-composite: exclude) or (-webkit-mask-composite: xor) {
  [data-theme="dark"] .booking-box,
  .evo-card--current,
  .evo-card--current:hover { border-color: transparent; }
}

.pricing-highlight.is-visible::before,
.booking-box.is-visible::before,
.evo-card--current.is-visible::before { animation: ringTurn 4s linear 3; }

.pricing-highlight:hover::before,
.booking-box:hover::before,
.evo-card--current:hover::before { animation: ringTurn 4s linear infinite; }

/* ══ NOTE D'ARTICLE ════════════════════════════════════════════════════════
   Un encart en tête des six articles, sous leur ligne de méta.

   POURQUOI. Les articles datent du 2 mars 2023 et décrivent le produit tel qu'il
   était : ils parlent de récupérer la valeur d'un token, et l'un d'eux annonce
   même la prise en charge prochaine de tous les tokens physiques du marché. Le
   produit ne fonctionne plus ainsi (cf. la section sur le mécanisme ADB). Les
   réécrire aurait voulu dire toucher à leur titre, donc à leur URL et à leur
   référencement, pour des publications datées : le client a choisi la note.

   Elle est en `border-left` teal et non dans un cadre complet : un cadre en tête
   d'article se lit comme un avertissement de sécurité, alors qu'il s'agit d'une
   simple mise à jour de contexte. */
.article-note {
  display: flex;
  gap: 12px;
  align-items: flex-start;
  margin: 0 0 30px;
  padding: 14px 18px;
  border-left: 2px solid var(--teal);
  border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
  background: rgba(0, 203, 190, 0.06);
  font-size: 0.9375rem;
  line-height: 1.6;
  color: var(--gray);
}

.article-note svg {
  flex-shrink: 0;
  margin-top: 3px;
  color: var(--teal);
}

.article-note a {
  color: var(--teal-text);
  text-decoration: underline;
  text-underline-offset: 2px;
}

@media (max-width: 520px) {
  .article-note { padding: 12px 14px; font-size: 0.875rem; }
}

/* Un lien EN LIGNE dans le corps de texte de la page À propos. La règle de base
   du site est `a { color: inherit; text-decoration: none }`, faite pour les liens
   de navigation et les cartes, où c'est le bloc entier qui est cliquable et où le
   contexte suffit à le dire. Au milieu d'une phrase elle rend le lien
   indistinguable du texte : même couleur, aucun soulignement, donc rien ne le
   signale (WCAG 1.4.1, et ici il n'y a même pas de différence de couleur à
   compenser). Même traitement que `.article-note a`, qui est la convention déjà
   établie pour ce cas. Scopé au corps de texte : la règle ne doit pas atteindre
   le bouton « Visiter q-leap.eu », qui n'est pas dans un <p>. */
.about-section__content p a {
  color: var(--teal-text);
  text-decoration: underline;
  text-underline-offset: 2px;
}

/* ══ SECTION API ═══════════════════════════════════════════════════════════
   Bloc de code hors article, liste de faits à coches, et lignes de fiche dont
   le libellé est un point d'entrée HTTP.

   Le bloc de code reprend l'apparence de `.article-body pre` plutôt que d'en
   inventer une seconde : même fond, même rayon, même graisse. Il est en
   `position: relative` parce que le bouton « Copier » du module 7 se pose en
   absolu dedans, et ce module vise désormais `.code-block pre` en plus des
   articles.

   Le libellé d'une ligne de fiche est normalement en capitales espacées, ce qui
   est illisible pour une URL (`GET /SCENARIOS/:ID/EXECUTE`). La variante `--api`
   le remet en minuscules, en police à chasse fixe, et lui laisse la place d'un
   chemin complet. */
.code-block {
  position: relative;
  align-self: start;
  /* Deux lignes exigées par la variante « média » de la révélation, à laquelle ce
     bloc est rattaché (module 4) :
     - `overflow: hidden` contient le passage de lumière, qui est un `::after`
       translaté de −130 % à +130 %. Sans lui il s'échappe de son cadre : c'est ce
       qui avait provoqué 341 px de débordement horizontal sur les figures
       d'article, seul média du site à ne pas avoir cette propriété ;
     - `--media-radius` aligne les angles du masque sur ceux du `pre`. Par défaut la
       variante prend `--radius-lg`, et le masque arrondirait plus que le cadre
       pendant toute la durée du dévoilement. */
  overflow: hidden;
  --media-radius: var(--radius);
}

.code-block pre {
  background: #141414;
  color: #E6E7E8;
  padding: 24px;
  border: 1px solid #2B2B2B;
  border-radius: var(--radius);
  overflow-x: auto;
  margin: 0;
  font-size: .8125rem;
  line-height: 1.7;
}

.code-block pre code {
  background: none;
  border: none;
  padding: 0;
  color: inherit;
  font-family: 'Courier New', monospace;
}

.api-facts {
  list-style: none;
  margin: 0;
  display: flex;
  flex-direction: column;
  gap: 14px;
}

.api-facts li {
  display: flex;
  align-items: flex-start;
  gap: 11px;
  font-size: 0.9375rem;
  line-height: 1.6;
}

.api-facts svg {
  flex-shrink: 0;
  margin-top: 4px;
  color: var(--teal);
}

.spec-item--api .spec-item__label {
  text-transform: none;
  letter-spacing: 0;
  font-family: 'Courier New', monospace;
  font-size: 0.8125rem;
  color: var(--teal-text);
  min-width: 218px;
}

@media (max-width: 720px) {
  .spec-item--api .spec-item__label { min-width: 0; }
  .code-block pre { padding: 18px; font-size: .75rem; }
}

/* Deux colonnes pour une liste de faits longue (l'éditeur de scénarios en compte
   six) : en une seule colonne, six lignes à coche font un mur. */
.api-facts--deux {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 16px 44px;
}
/* UNE SEULE COLONNE SOUS 1200 PX, et le seuil est mesuré, pas choisi. Sur deux
   colonnes la cellule tombe à 466 px à 1024 px de large : les deux premières
   lignes passent alors sur deux lignes de texte, les autres non, et comme une
   rangée de grille prend la hauteur de sa plus haute cellule, l'inégalité se
   voit sur DEUX points au lieu d'un. Signalé par le client le 2026-09-01.
   En une colonne l'item dispose de 1132 px et tout tient sur une ligne.
   Le seuil de 760 px d'origine ne changeait rien à cette bande-là. */
@media (max-width: 1199px) {
  .api-facts--deux { grid-template-columns: 1fr; }
}

/* ══ CAS D'USAGE ═══════════════════════════════════════════════════════════
   Cinq situations, chacune en deux volets : le blocage, puis ce que Q-Bot en
   fait. Deux colonnes sur grand écran, empilées en dessous.

   Le volet « blocage » est en retrait de couleur et le volet « avec Q-Bot »
   porte le liseré teal : c'est le seul indice visuel de lecture, et il suffit,
   là où deux fonds différents auraient fait un damier sur cinq blocs.

   PLUS AUCUNE PAGE N'EMPLOIE CE MOTIF. Relevé le 2026-09-03 : de toute la
   famille, seule `.usecase__cat` subsiste dans le balisage, huit fois, sur les
   deux pages « Comment ça marche ». Les cinq volets sont devenus quatre cartes
   courtes le 2026-09-02, à la demande du client.
   LE BLOC EST GARDÉ ET NON SUPPRIMÉ, comme `.timeline` et `.video__wrapper` :
   c'est un motif que le client peut redemander, et il porte deux choses qu'on
   ne réécrirait pas de mémoire, le liseré qui se dessine (procédé n°4 de
   linearity.io, proposition M du 2026-08-19) et la ligne de chiffre-clé.
   Conséquence à connaître : le correctif du 2026-09-02 sur
   `.usecase .usecase__fig` (les deux mots collés) porte sur du balisage qui a
   disparu le même jour, au commit suivant. Il reste juste, il ne s'applique
   plus à rien. */
.usecase {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 30px 34px;
  background: var(--white);
}

.usecase + .usecase { margin-top: 18px; }

.usecase__cat {
  display: block;
  font-size: 0.6875rem;
  font-weight: 700;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--teal-text);
  margin-bottom: 6px;
}

.usecase h3 { font-size: 1.0625rem; margin-bottom: 18px; }

.usecase__grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 24px 40px;
}

.usecase__lab {
  display: block;
  font-size: 0.6875rem;
  font-weight: 700;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--muted);
  margin-bottom: 7px;
}

/* Le liseré du volet « solution » est un pseudo-élément et non une bordure, parce
   qu'il se DESSINE à l'arrivée du bloc : une bordure ne se met pas à l'échelle.

   POURQUOI CE GESTE. Procédé n°4 relevé sur linearity.io, où un tracé de 7 139 px
   se dessine au défilement en `stroke-dashoffset`. Ici à l'échelle d'un bloc, et
   surtout il dit quelque chose : la réponse arrive après le problème. Le
   vocabulaire existe déjà dans cette feuille (les marques de titre des articles et
   le rail de la section évolution se dessinent en `scaleY`).

   DEUX TEMPS ET PAS TROIS. Le bloc monte, puis le liseré se trace 0,28 s plus tard,
   quand le bloc est posé. Faire en plus arriver les deux volets en cascade
   demanderait la variante « groupe », qui immobilise le conteneur : le dépôt note
   qu'un élément de groupe dont le parent est lui-même révélé se retrouve animé deux
   fois et repart de zéro au milieu de sa propre arrivée. Sur cinq blocs empilés,
   trois temps feraient de toute façon du bruit.

   L'ÉTAT AU REPOS EST LE LISERÉ ENTIER. L'animation n'est ajoutée que par
   `.is-visible`, donc sans JavaScript, sans révélation ou en mouvement réduit, le
   trait est simplement là. C'est la règle du dépôt, apprise sur le trait de la
   section évolution qui restait invisible. */
.usecase__part--sol { position: relative; padding-left: 16px; }

.usecase__part--sol::before {
  content: '';
  position: absolute;
  left: 0;
  top: 0;
  bottom: 0;
  width: 2px;
  border-radius: 2px;
  background: linear-gradient(180deg, var(--teal), var(--teal-dark));
}

@keyframes usecaseRule { from { transform: scaleY(0); } to { transform: scaleY(1); } }

/* Le retard suit l'échelonnement du bloc : le trait se dessine toujours juste après
   que SON bloc s'est posé, pas après le premier de la pile. */
.usecase.is-visible .usecase__part--sol::before {
  transform-origin: top center;
  animation: usecaseRule 0.6s var(--ease-out) calc(var(--reveal-delay, 0s) + 0.28s) both;
}

.usecase__part--sol .usecase__lab { color: var(--teal-text); }
.usecase p { font-size: 0.9375rem; margin: 0; }
/* ---- Le chiffre-clé d'un cas d'usage ----
   RÉTABLI LE 2026-09-02, ET C'ÉTAIT LE DÉFAUT SIGNALÉ À LA REVIEW : « 1 appelpar
   verrou 2FA dans la chaîne » et « 0intervention humaine, à toute heure ». Ce
   n'était pas une faute de frappe dans le texte mais une règle CSS disparue. Le
   balisage est `<b>0</b><span>intervention…</span>` ; sans mise en forme, les
   deux éléments restent en ligne et se collent, à l'écran comme au copier-coller.
   Le bloc avait été supprimé par erreur le 2026-08-28, avec le CSS de
   l'épinglage des cas d'usage, alors que les dix paragraphes qui l'emploient
   sont restés dans les deux pages. Un retrait de mise en page ne se contrôle pas
   sur « la page s'affiche encore » : il se contrôle sur ce que le texte rendu
   dit, et c'est un lecteur humain qui l'a vu, deux passes d'audit plus tard.
   LE CORRECTIF EST ICI ET NON DANS LE BALISAGE : ajouter une espace entre `</b>`
   et `<span>` masquerait le symptôme (elle serait mangée par le `display: flex`
   de la ligne) sans rendre au chiffre sa taille ni au libellé sa nuance. */
.usecase .usecase__fig {
  display: flex;
  align-items: baseline;
  gap: 14px;
  margin: 24px 0 0;
  padding-top: 18px;
  border-top: 1px solid var(--border);
}
.usecase__fig b {
  font-size: clamp(1.75rem, 2.6vw, 2.25rem);
  font-weight: 700;
  line-height: 1;
  letter-spacing: -0.02em;
  color: var(--teal);
  white-space: nowrap;
}
.usecase__fig span {
  font-size: 0.9375rem;
  line-height: 1.45;
  color: var(--gray);
}

.usecase code { font-family: 'Courier New', monospace; font-size: 0.875em; color: var(--teal-text); }

[data-theme="dark"] .usecase { background: #141414; border-color: #2B2B2B; }

@media (max-width: 760px) {
  .usecase { padding: 24px 22px; }
  .usecase__grid { grid-template-columns: 1fr; gap: 22px; }
  .usecase__part--sol { padding-left: 14px; }
}

/* ══ COCHES QUI SE DESSINENT ════════════════════════════════════════════════
   Les quinze arguments à coche des pages Caractéristiques n'avaient aucune
   entrée. Leur coche se trace maintenant, du talon vers la pointe.

   POURQUOI. C'est le procédé n°4 de linearity.io, le tracé qui se dessine en
   `stroke-dashoffset`, à 16 px au lieu de 7 139. C'est le seul procédé relevé
   chez eux que ce site n'employait pas encore.

   LE SENS DU TRACÉ EST DANS LE BALISAGE, PAS ICI. La polyligne d'une coche allait
   de la pointe longue (20,6) vers le talon (4,12) : dessinée dans cet ordre, elle
   se construisait à l'envers de la façon dont une main la trace. Les points ont
   donc été retournés dans les 30 SVG concernés. Aucune règle CSS ne peut corriger
   cela : un motif de tirets se décale, il ne s'inverse pas. Le décalage négatif ne
   change rien non plus, le motif ayant une période de deux fois la longueur.

   LONGUEUR DE LA POLYLIGNE : (4,12) → (9,17) → (20,6), soit 7,07 + 15,56 = 22,63
   unités du viewBox. `stroke-dasharray: 23` couvre donc le tracé entier, et un
   décalage de 23 le cache complètement.

   L'ÉTAT AU REPOS EST LA COCHE ENTIÈRE : décalage 0. L'animation n'est ajoutée que
   par `.is-visible`, donc sans JavaScript, sans révélation ou en mouvement réduit,
   les quinze coches sont simplement là. */
.api-facts svg polyline {
  stroke-dasharray: 23;
  stroke-dashoffset: 0;
}

@keyframes tickDraw {
  from { stroke-dashoffset: 23; }
  to   { stroke-dashoffset: 0; }
}

/* Le retard suit l'échelonnement de la ligne, et l'index repart de zéro à chaque
   liste puisqu'il est calculé par parent : quinze coches qui se dessinent ensemble
   feraient du bruit, quatre ou six qui s'enchaînent se lisent. */
.api-facts li.is-visible svg polyline {
  animation: tickDraw 0.5s var(--ease-out) calc(var(--reveal-delay, 0s) + 0.08s) both;
}


/* ══ PLAFONDS DE LECTURE ═══════════════════════════════════════════════════
   Le conteneur pouvant aller jusqu'à 1440 px, deux blocs de prose devenaient trop
   longs à lire. Au-delà d'environ 80 caractères par ligne, l'œil perd la ligne
   suivante.

   LE PLAFOND ÉTAIT ÉCRIT EN PIXELS ALORS QUE LA CIBLE EST EN CARACTÈRES, ET IL
   NE LA TENAIT PAS. Relevé à 1440 px, sur 24 paragraphes de réponse de FAQ et
   14 de politique de confidentialité :

     | bloc                        | avant           | après          |
     | réponse de FAQ (15 px)      | 758 px,  93 car | 658 px, 79 car |
     | réponse de FAQ, EN          | 758 px,  89 car | 658 px, 80 car |
     | politique (16 px)           | 780 px,  99 car | 629 px, 81 car |
     | politique, EN               | 780 px,  98 car | 629 px, 79 car |

   D'OÙ LE `ch`, ET CE N'EST PAS UNE COQUETTERIE D'UNITÉ. Un plafond en pixels
   est juste pour UNE taille de police : `.container > p` porte de la prose à
   15 px (86 car. à 760 px) et à 17 px (77 car. à la même largeur), donc aucune
   valeur en pixels ne convient aux deux. En `ch` le plafond suit la police et
   les deux tombent sous 80.

   LES DEUX VALEURS DIFFÈRENT PARCE QUE LA PROSE DIFFÈRE : le registre juridique
   a des mots plus longs, donc moins d'espaces, donc plus de caractères pour la
   même largeur. À 78ch la politique remonterait à 90 car. Les valeurs sont
   mesurées, pas déduites l'une de l'autre.

   Le plafond porte sur les PARAGRAPHES et non sur leur conteneur : la colonne, elle,
   doit continuer de suivre la grille, sinon l'index collant de la FAQ et les deux
   volets d'un cas d'usage se décaleraient. */
.faq-item__answer p,
.faq-item__answer li { max-width: 78ch; }

/* Plus employé depuis le 2026-09-02 (cf. le bandeau des volets plus haut).
   Laissé en pixels : sans page pour le porter, il n'y a rien à mesurer, et une
   valeur en `ch` posée à l'aveugle vaudrait moins que celle-ci, qui a été
   mesurée quand le motif existait. */
.usecase__part p { max-width: 620px; }

/* Un paragraphe posé DIRECTEMENT dans le conteneur occupe toute sa largeur, donc il
   s'allonge avec lui. Mesuré à 1920 px : « Q-Bot ne nécessite aucune installation… »
   sur l'accueil tenait 139 caractères par ligne, et la phrase de conclusion de
   l'éditeur de scénarios 170. Les paragraphes encadrés (cartes, grilles, volets) ne
   sont pas concernés : leur colonne les borne déjà. */
.container > p { max-width: 78ch; }


/* ══ MATRICE DE COMPATIBILITÉ ══════════════════════════════════════════════
   Remplace les bandes d'outils qui défilaient en boucle. Le client les trouve
   « illisibles cognitivement », et il a raison : une liste qui bouge ne se lit pas,
   elle se subit — on ne peut ni la parcourir des yeux, ni relire un nom qu'on vient
   de voir passer, ni vérifier si le sien y est. Or c'est exactement pour ça qu'on
   vient dans cette section. Le défilement rendait la liste entière VISIBLE, pas
   LISIBLE, et c'est la lisibilité qui compte ici.

   Ce qui la remplace n'est pas le pavé de pastilles d'avant (il se repliait sur
   quatre à cinq lignes en désordre à 390 px) mais une grille régulière : chaque
   entrée occupe une case de largeur égale, donc les noms s'alignent en colonnes et
   se parcourent d'un coup d'œil. `auto-fill` avec un plancher de 190 px décide le
   nombre de colonnes, il n'est pas codé en dur.

   CE QUI EST PREMIUM ICI N'EST PAS UNE ANIMATION, c'est le tracé : un liseré teal
   sur le bord gauche de chaque case (le même vocabulaire que le filet des cartes de
   la séquence et des titres de partie), une case qui se soulève de 2 px au survol,
   et une entrée fourre-tout distinguée par un trait pointillé — « toute app 2FA
   Android » n'est pas un produit, c'est une porte ouverte, et le pointillé le dit
   sans un mot de plus. Le liseré se dessine une fois à la révélation, en cascade ;
   au repos il est entier, donc sans JavaScript ou en mouvement réduit la grille est
   simplement là. */
.compat {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
  gap: 14px;
}
.compat__head {
  font-size: 1.0625rem;
  margin: 0 0 18px;
  padding-bottom: 12px;
  border-bottom: 1px solid var(--border);
}
.compat__item {
  position: relative;
  display: flex;
  align-items: center;
  min-height: 54px;
  padding: 12px 16px 12px 20px;
  border: 1px solid var(--border);
  border-radius: 12px;
  background: linear-gradient(135deg, rgba(255, 255, 255, 0.045), rgba(255, 255, 255, 0.012));
  color: var(--black);
  font-size: 0.9375rem;
  font-weight: 500;
  line-height: 1.35;
  overflow: hidden;
  transition:
    transform 0.35s cubic-bezier(0.16, 1, 0.3, 1),
    border-color 0.35s ease,
    background 0.35s ease;
}
/* Le liseré. État au repos = entier : c'est la règle du dépôt, l'animation est
   ajoutée par `.is-visible`, jamais défaite par une autre règle. */
.compat__item::before {
  content: '';
  position: absolute;
  left: 0; top: 0; bottom: 0;
  width: 2px;
  background: linear-gradient(180deg, var(--teal), var(--teal-dark));
  transform-origin: top center;
}
@keyframes compatMark {
  from { transform: scaleY(0); }
  to   { transform: scaleY(1); }
}
.compat__item.is-visible::before {
  animation: compatMark 0.55s var(--ease-out) both;
  animation-delay: calc(var(--stagger-i, 0) * 60ms);
}
.compat__item:hover {
  transform: translateY(-2px);
  border-color: var(--teal-tint);
  background: linear-gradient(135deg, rgba(0, 203, 190, 0.10), rgba(255, 255, 255, 0.015));
}
/* L'entrée ouverte : ce n'est pas un produit de plus dans la liste, c'est ce qui
   reste après la liste. */
.compat__item--open {
  border-style: dashed;
  color: var(--teal-text);
}
.compat__item--open::before { display: none; }
@media (max-width: 520px) {
  .compat { grid-template-columns: 1fr; gap: 12px; }
  .compat__item { min-height: 48px; padding: 11px 14px 11px 18px; }
}
@media (prefers-reduced-motion: reduce) {
  .compat__item.is-visible::before { animation: none; }
}


/* ══ CAS D'USAGE : LES CINQ CAS ═════════════════════════════════════════════
   Une liste. La section a été épinglée cinq écrans (un cas à l'écran, séquence
   pilotée par le moteur de mouvement via `--ucs-p` et `data-panel`, index de
   pastilles dans la gouttière, halo, rail de progression). RETIRÉ LE 2026-08-28
   à la demande du client : « pas d'étape par étape en scrollytelling, juste les
   uns au-dessus des autres ».
   Le motif est écarté, pas mis de côté : ses ~240 lignes partent avec lui, comme
   les bandes d'outils du 2026-08-20. L'entrée du moteur de mouvement disparaît
   aussi, sinon il continuerait d'écrire une progression que personne ne lit. */
.usecases { display: grid; gap: 32px; }
@media (max-width: 640px) { .usecases { gap: 24px; } }

/* ── La bande d'exemples qui glisse ────────────────────────────────────────
   Procédé de la section « Everyone creates » de linearity.io : le bloc est épinglé
   et sa piste défile latéralement pendant qu'on continue à descendre.

   LA COURSE EST EXACTEMENT CE QUI DÉPASSE, donc le rapport est de un pour un : la
   section est haute d'un écran plus le débordement, et la piste se translate de ce
   même débordement. On ne détourne pas le défilement, on le réoriente. C'est aussi
   ce qui évite le défaut classique du genre, une section qui retient le lecteur
   plus longtemps que ce qu'elle a à montrer.

   TOUT EST EN UNITÉS DE FENÊTRE, JAMAIS EN POURCENTAGE. Un pourcentage se résout
   sur le bloc conteneur de l'élément qui s'en sert, et ce piège a déjà coûté trois
   corrections sur ce dépôt (`--sc-gut-scene`, le calage de la carte de séquence, la
   marge de la carte de cas d'usage). Ici la gouttière, la piste et le débordement
   sont tous exprimés en `vw` et en pixels.

   La première carte part sur la gouttière du site, et la dernière s'arrête sur la
   gouttière opposée : la bande commence et finit alignée sur le texte, comme le
   reste de la page. */
/* PLUS AUCUNE PAGE N'EMPLOIE CE BLOC DEPUIS LE 2026-09-02. La bande d'exemples
   d'appel qui glissait latéralement (procédé de linearity.io, posée le
   2026-08-20) a quitté la page « comment ça marche » : le client l'a transformée
   en page de découverte, où les exemples de code sont désormais des accordéons
   placés APRÈS l'appel à l'action. Le bloc est gardé, comme `.timeline` : le
   procédé n'est pas rejeté, c'est sa place qui a changé. Ne pas le réintroduire
   sans demander. */
.ucs-api {
  --api-card: 460px;
  --api-gap: 20px;
  --api-n: 5;
  --api-gut: max(24px, calc((100vw - var(--container)) / 2 + 24px));
  --api-track: calc(var(--api-n) * var(--api-card)
                    + (var(--api-n) - 1) * var(--api-gap) + var(--api-gut));
  --api-over: max(0px, calc(var(--api-track) - 100vw + var(--api-gut)));
  height: calc(100svh + var(--api-over));
}
.ucs-api__pin {
  position: sticky;
  top: 0;
  height: 100svh;
  /* Un halo qui glisse avec la piste : sans lui, un écran entier de fond noir sous
     un titre et trois cadres sombres. Il se déduit de `--api-p`, donc aucune
     écriture JavaScript de plus. */
  background:
    radial-gradient(48% 44% at calc(78% - var(--api-p, 0) * 46%) 62%,
                    rgba(0, 203, 190, 0.13), transparent 68%);
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 8px;
  overflow: hidden;
}
.ucs-api__pin .section-header { margin-bottom: 0; }
.ucs-api__viewport {
  overflow: hidden;
  /* Les cartes se dissolvent aux deux bords au lieu d'être coupées net. C'est ce
     détail qui fait la différence entre « une liste qui dépasse de l'écran » et
     « une bande qui continue » : la coupe franche dit qu'il y a un cadre, le fondu
     dit qu'il y a une suite. */
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 3%, #000 92%, transparent);
          mask-image: linear-gradient(90deg, transparent, #000 3%, #000 92%, transparent);
}
.ucs-api__track {
  display: flex;
  align-items: stretch;
  gap: var(--api-gap);
  width: max-content;
  padding-left: var(--api-gut);
  transform: translate3d(calc(-1 * var(--api-p, 0) * var(--api-over)), 0, 0);
  /* UNE INERTIE COURTE, ET C'EST TOUT CE QUI MANQUAIT. Le moteur écrit la
     progression sans lissage, donc la piste suivait le défilement au pixel : avec
     une molette, qui avance par crans, cela se voit comme des saccades. 0,28 s en
     sortie exponentielle transforme les crans en glissement sans jamais décrocher
     du doigt (au pavé tactile, l'écart est de l'ordre de l'image). C'est le même
     rôle que le lissage du moteur de mouvement sur la parallaxe, mais écrit en CSS
     là où il est utile. */
  transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
  will-change: transform;
}
/* LE BOUTON « COPIER » PASSE DANS UN BANDEAU, il n'est plus posé SUR le code.
   Signalé : les lignes de code passaient dessous. C'était structurel — le bouton
   était en absolu dans le `pre`, donc au-dessus du texte, et il défilait avec lui
   dès qu'une ligne dépassait. Un rembourrage à droite n'aurait rien réglé : sur un
   bloc à défilement horizontal, la ligne repasse sous le bouton dès qu'on fait
   glisser. Le bandeau supprime le problème par construction, et il donne au bloc
   l'allure d'un panneau : le nom de l'outil à gauche, l'action à droite. */
.ucs__snippet {
  margin: 0;
  width: var(--api-card);
  display: flex;
  flex-direction: column;
  border: 1px solid var(--glass-line);
  border-radius: var(--radius);
  overflow: hidden;
  background: var(--glass-strong);
  -webkit-backdrop-filter: var(--glass-blur);
          backdrop-filter: var(--glass-blur);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.055);
  transition: border-color 0.5s var(--ease-out), transform 0.5s var(--ease-out);
}
/* Le sélecteur porte le thème parce que la règle générale du verre en fait autant
   (`[data-theme="dark"] .code-block pre`) : à poids égal c'est l'ordre qui tranche, et
   le bloc du verre est écrit plus loin dans le fichier. Sans cela, un rectangle plus
   sombre réapparaissait à l'intérieur du cadre. Et le `pre` doit remplir la carte,
   sinon le verre du cadre déborde autour du code. */
/* `align-self: stretch` annule le `align-self: start` de la règle de base de
   `.code-block` : dans la colonne flex de la figure, il faisait rétrécir le bloc à la
   largeur de son texte, et le verre du cadre débordait autour du code. */
[data-theme="dark"] .ucs__snippet .code-block { display: flex; align-self: stretch; }
[data-theme="dark"] .ucs__snippet .code-block pre {
  flex: 1;
  background: transparent;
  border: 0;
  border-radius: 0;
  -webkit-backdrop-filter: none;
          backdrop-filter: none;
}
/* La carte en cours de lecture. `data-panel` est écrit par le moteur de mouvement,
   qui découpe la course en autant de parts qu'il y a de cartes. Le relief est porté
   par la figure, qui est le cadre depuis que le bandeau existe. */
.ucs-api[data-panel="0"] .ucs__snippet:nth-child(1),
.ucs-api[data-panel="1"] .ucs__snippet:nth-child(2),
.ucs-api[data-panel="2"] .ucs__snippet:nth-child(3),
.ucs-api[data-panel="3"] .ucs__snippet:nth-child(4),
.ucs-api[data-panel="4"] .ucs__snippet:nth-child(5) {
  border-color: rgba(0, 203, 190, 0.34);
  /* Pas d'ombre ici : sur un fond noir, une grande ombre floue autour d'un cadre
     sombre se lit comme un rectangle plus clair, pas comme un relief. Le liseré et
     les trois pixels de soulèvement suffisent à désigner la carte courante. */
  transform: translateY(-3px);
}
.ucs__snippet .code-block { flex: 1; }
.ucs__snippet figcaption {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 9px 10px 9px 16px;
  border-bottom: 1px solid var(--glass-line);
  background: rgba(255, 255, 255, 0.022);
  font-size: 0.6875rem;
  font-weight: 700;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--teal-text);
  margin-bottom: 0;
}
/* Dans le bandeau, le bouton reprend sa place dans le flux. */
.ucs__snippet figcaption .code-copy-btn { position: static; flex: none; }
/* Le panneau de code de la bande des cas d'usage, réemployé dans le flux d'un
   guide : là-bas sa largeur vient du rail (`--api-card`), ici il prend celle de sa
   colonne. Deux déclarations, tout le reste du panneau est déjà écrit. */
.code-panel { width: auto; max-width: 760px; }

/* Date de mise à jour d'un guide. L'audit RosoAI la compte parmi les quatre
   marques du format qui se fait citer, avec la réponse autonome de 40 à 60 mots
   sous chaque titre-question. Elle vit dans le page-hero, dont les <p> portent
   déjà une couleur fixe : on n'y touche pas, on ne change que l'échelle. */
/* Sélecteur doublé : `.page-hero p` vaut (0,1,1) et écrasait la marge d'une classe
   seule (0,1,0). Même piège que `.usecase__fig` et `.newsletter__legal`. */
.page-hero .guide-date { font-size: 0.8125rem; margin-top: 6px; }

/* LA SIGNATURE D'UN GUIDE ET CELLE D'UN ARTICLE PORTENT UN LIEN QUI NE SE VOYAIT
   PAS. Relevé le 2026-08-27 : `.article-meta a` (les quatre articles signés) et
   `.guide-date a` (les seize guides) prenaient exactement la couleur de leur
   paragraphe, sans soulignement — donc rien, pas même la couleur, ne disait qu'il
   y avait un lien vers le profil de l'auteur. C'est le motif déjà retenu pour les
   libellés de fiche technique : on garde la couleur du texte, qui porte le
   contraste, et le soulignement pointillé teal fait l'affordance. */
.guide-date a,
.article-meta a {
  color: inherit;
  text-decoration: underline;
  text-decoration-style: dotted;
  text-underline-offset: 3px;
  text-decoration-color: rgba(0, 203, 190, 0.55);
  transition: color 0.18s ease, text-decoration-color 0.18s ease;
}
.guide-date a:hover, .guide-date a:focus-visible,
.article-meta a:hover, .article-meta a:focus-visible {
  color: var(--teal);
  text-decoration-color: currentColor;
}

/* Schéma dans le CORPS d'un guide. Les six schémas ne vivaient que sur les cartes
   de l'index de blog ; un schéma posé dans le guide aide à comprendre ET donne aux
   moteurs de réponse un objet à citer (audit RosoAI n°4, 2026-08-26).

   LA LARGEUR N'EST PAS UN GOÛT, C'EST LA RÉSOLUTION DE LA SOURCE. Les schémas sont
   sortis en 900 px par `tools/render/shoot-guide-thumbs.py` : à 450 px d'affichage
   ils tombent à 1,00 sur un écran de densité 2, donc aucun agrandissement. Les
   afficher plus large ne montrerait que du détail qui n'existe pas dans le fichier,
   défaut déjà relevé sur la photo LuxTrust le 2026-08-26.

   ALIGNÉ À GAUCHE, jamais centré : un bloc plus étroit que sa colonne doit toucher
   une gouttière, c'est la règle du dépôt. Le `max-width` cède la place à `100%`
   sous 500 px, où la colonne est plus étroite que la source. */
.guide-figure {
  margin: 32px 0 0;
  max-width: 450px;
}
.guide-figure img {
  display: block;
  width: 100%;
  height: auto;
  border-radius: var(--radius);
  border: 1px solid #2B2B2B;
  background: #141414;
}
.guide-figure figcaption {
  margin-top: 12px;
  font-size: 0.875rem;
  line-height: 1.6;
  /* `--gray` et non `--muted` : la légende est du texte qui compte, un assistant la
     lit, et elle est posée sur le fond de page comme `.ucs__note` juste en dessous. */
  color: var(--gray);
}
/* La figure ouvre la section : le corps qui la suit reprend le rythme des 32 px. */
.guide-figure + p,
.guide-figure + ul,
.guide-figure + figure { margin-top: 32px; }

.ucs__note {
  margin-top: 20px;
  max-width: 760px;
  font-size: 0.875rem;
  /* `--gray` et non `--muted` : c'est un paragraphe posé sur le fond de page, donc
     sur la boule quand elle passe dessous. Calculé : au coeur de la boule le fond
     monte à 21 % de teal, où `--gray` tient 5,9:1 et `--muted` 4,7 seulement. */
  color: var(--gray);
}

/* ══ LA BOULE ET SA TRAÎNE ═════════════════════════════════════════════════
   Procédé relevé sur linearity.io : un halo traverse lentement le fond, et il
   continue même quand le visiteur ne bouge pas. Le calque ambiant du site
   (`body::before/::after`) dérive avec la PROGRESSION dans la page ; à l'arrêt il
   est immobile. Celui-ci dérive avec le TEMPS, ce qui est autre chose : la page
   respire au lieu d'attendre.

   TROIS CALQUES, UNE SEULE ANIMATION. Le même parcours, décalé de 2,4 s et de
   4,8 s, avec des tailles et des opacités décroissantes : c'est ce décalage qui
   fait la traîne, sans une deuxième trajectoire à écrire.

   CE QUI EST ANIMÉ EST UNIQUEMENT `transform`, donc composité : aucun repeint. Le
   piège est documenté juste au-dessus, sur le calque ambiant — déplacer le centre
   d'un dégradé radial repeint toute la surface et coûtait 14 % des images par
   seconde sur l'accueil.

   POUR L'INSTANT SUR LA SEULE PAGE DES CAS D'USAGE : le balisage n'existe que là.
   L'accueil a déjà son canevas 3D et ses deux orbes de hero, et y ajouter un
   troisième mouvement de fond serait de trop. */
/* CE QUI RENDAIT LA BOULE INVISIBLE : LES FONDS OPAQUES. Elle est en `z-index: -1`,
   donc derrière tout ce qui peint un fond. Relevé sur la page : l'en-tête de page
   (320 px de noir plein), la section de l'API (3 001 px de `#121212`) et le pied de
   page la masquaient entièrement — soit la moitié de la page, dont tout le haut, là
   où le visiteur arrive. D'où « je ne vois aucun halo ».
   Sur les pages à boule, ces deux blocs deviennent donc translucides : le fond de
   page (`#0A0A0A`) reste visible dessous, la bande grise garde sa nuance (2,2 % de
   blanc donne `#101010`, à deux points de l'ancien `#121212`), et la boule passe.
   Le pied de page reste opaque : c'est la fin, et une lumière qui traverse le pied
   d'une page ne sert plus rien. */
/* LE HALO PLUS LUMINEUX SE PAIE EN GRIS PLUS CLAIRS, et c'est un échange, pas un
   réglage. Un fond plus clair sous un texte gris clair, c'est moins de contraste :
   il n'y a aucun moyen de contourner cela (un mode de fusion éclaircit aussi le fond
   sous le texte). Le halo demandé plus lumineux monte à 0,366 d'opacité cumulée, où
   `--gray` d'origine (#A7A9AC) tomberait à 3,9:1. Les deux gris secondaires de CETTE
   page montent donc d'un cran : 5,8:1 et 5,2:1 au coeur du halo, et la hiérarchie
   avec les titres (#E6E7E8) reste lisible. Contrôlé en forçant le fond de page à la
   couleur du coeur. */
body:has(.orbz) {
  --gray:  #C9CBCE;
  --muted: #BFC1C4;
}

body:has(.orbz) .page-hero { background: transparent; }
body:has(.orbz) .section--gray { background: rgba(255, 255, 255, 0.022); }

/* UN SEUL SYSTÈME DE FOND PAR PAGE. Le calque ambiant du site (`body::before` et
   `::after`) est neutralisé là où la boule existe : sinon les halos s'additionnent,
   et là où ils se superposent tous le fond monte à 30 % de teal, ce qui fait tomber
   le texte gris clair à 3,7:1 (calculé, pas estimé). La boule est un calque ambiant
   plus vivant, pas un calque de plus. */
body:has(.orbz)::before,
body:has(.orbz)::after { content: none; }

.orbz {
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  overflow: hidden;
}
.orbz span {
  position: absolute;
  top: -18vh;
  left: -14vw;
  /* 58vw et non 46 : à opacité plafonnée, c'est la TAILLE qui fait la présence. Un
     halo de 660 px sur un écran de 1440 se lit comme une nuance ; à 835 px il se lit
     comme une lumière. */
  width: 68vw;
  height: 68vw;
  border-radius: 50%;
  /* LES OPACITÉS SONT UN BUDGET, ET LE PLAFOND EST ATTEINT. Les calques se
     superposent, donc l'opacité résultante vaut 1 − ∏(1−ai). Le facteur limitant
     n'est pas le gris mais LE TEAL SUR LE TEAL : les libellés de section et les
     catégories des cas sont en `#00CBBE`, et à mesure que le fond tend vers cette
     même couleur, leur contraste s'effondre deux fois plus vite que celui d'un gris.
     Calculé : 5,4:1 à 0,30 d'opacité cumulée, 4,5:1 à 0,366, 3,9:1 à 0,42. Le
     maximum lisible est donc 0,37, et non un chiffre plus haut qu'on choisirait.

     Ce qui reste possible sans dépasser le plafond, c'est de REDISTRIBUER : un coeur
     un peu moins fort, une décroissance beaucoup plus lente et une boule plus large
     donnent une surface éclairée nettement plus grande à luminosité de crête égale.
     C'est ce qui est fait ici, plus une traîne le long du bord gauche : le pire
     recouvrement (coeur de la boule sur la traîne) vaut 0,363, soit 4,53:1 pour le
     teal. */
  background: radial-gradient(closest-side,
              rgba(0, 203, 190, 0.22), rgba(0, 203, 190, 0.17) 45%,
              rgba(0, 203, 190, 0.07) 68%, transparent 84%);
  will-change: transform;
  animation: orbzDrift 24s cubic-bezier(0.45, 0, 0.55, 1) infinite;
}
/* La traîne : plus petite, plus pâle, et en retard. */
.orbz span:nth-child(2) {
  width: 38vw; height: 38vw;
  background: radial-gradient(closest-side, rgba(0, 203, 190, 0.07), rgba(0, 203, 190, 0.04) 60%, transparent 82%);
  animation-delay: -2.4s;
}
.orbz span:nth-child(3) {
  width: 30vw; height: 30vw;
  background: radial-gradient(closest-side, rgba(0, 203, 190, 0.035), transparent 80%);
  animation-delay: -4.8s;
}
/* Un parcours fermé, en pourcentages de la fenêtre : il traverse la page en
   diagonale, remonte par la droite et revient. Les valeurs sont volontairement
   larges — un halo qui reste au centre ne se remarque pas, et un halo qui va vite
   se remarque trop. */
/* SIX ÉTAPES ET NON QUATRE, et 24 s au lieu de 34. Le client demande des passages
   plus fréquents. Deux façons de l'obtenir : accélérer, ou allonger le parcours à
   durée égale. On fait les deux à moitié — la vitesse apparente reste celle d'une
   ambiance (le hero de l'accueil avait dû être ralenti à 22-26 s pour cette raison),
   mais le parcours traverse maintenant la fenêtre trois fois par tour au lieu
   d'une. */
@keyframes orbzDrift {
  0%   { transform: translate3d(4vw, 10vh, 0) scale(1); }
  17%  { transform: translate3d(58vw, 26vh, 0) scale(1.1); }
  33%  { transform: translate3d(84vw, 68vh, 0) scale(0.95); }
  50%  { transform: translate3d(38vw, 88vh, 0) scale(1.06); }
  67%  { transform: translate3d(2vw, 58vh, 0) scale(0.98); }
  83%  { transform: translate3d(46vw, 34vh, 0) scale(1.12); }
  100% { transform: translate3d(4vw, 10vh, 0) scale(1); }
}
/* ── La traîne du bord gauche ───────────────────────────────────────────────
   Demandée en plus de la boule. C'est une ellipse haute et étroite qui longe le bord
   gauche et remonte et descend sur son propre cycle, plus lent que celui de la boule
   (38 s contre 24) : les deux ne sont donc jamais en phase, et la page ne prend jamais
   un rythme régulier. C'est ce qui distingue une ambiance d'une animation.
   Son opacité (0,09) est calculée pour que le pire recouvrement avec le coeur de la
   boule reste sous le plafond de lisibilité du teal. */
.orbz b {
  position: absolute;
  left: -16vw;
  top: -10vh;
  width: 34vw;
  height: 92vh;
  border-radius: 50%;
  background: radial-gradient(closest-side,
              rgba(0, 203, 190, 0.09), rgba(0, 203, 190, 0.05) 52%, transparent 82%);
  will-change: transform;
  animation: orbzEdge 38s cubic-bezier(0.42, 0, 0.58, 1) infinite alternate;
}
@keyframes orbzEdge {
  from { transform: translate3d(0, -14vh, 0) scaleY(0.92); }
  to   { transform: translate3d(6vw, 46vh, 0) scaleY(1.12); }
}

/* Sur téléphone la boule occuperait presque tout l'écran : on la réduit et on
   ralentit encore, pour qu'elle reste un fond et pas un événement. */
@media (max-width: 700px) {
  .orbz span { width: 108vw; height: 108vw; animation-duration: 32s; }
  /* La traîne du bord n'a plus de bord à longer sur un écran de téléphone : elle
     ferait un voile sur toute la largeur. */
  .orbz b { display: none; }
  .orbz span:nth-child(2) { width: 80vw; height: 80vw; }
  .orbz span:nth-child(3) { width: 64vw; height: 64vw; }
}
@media (prefers-reduced-motion: reduce) {
  /* Elles ne bougent plus, mais elles restent : c'est de la lumière, pas une
     animation décorative, et la page en a besoin. */
  .orbz b { animation: none; transform: translate3d(3vw, 16vh, 0); }
  .orbz span { animation: none; transform: translate3d(30vw, 30vh, 0); }
  .orbz span:nth-child(2) { transform: translate3d(58vw, 62vh, 0); }
  .orbz span:nth-child(3) { transform: translate3d(14vw, 74vh, 0); }
}


/* ══ VERRE ══════════════════════════════════════════════════════════════════
   Les surfaces de carte passent du noir plein au verre sombre : un fond
   translucide plus un flou d'arrière-plan. Le halo qui dérive derrière la page
   traverse alors les cartes, en flou, ce qui donne la couleur et la profondeur
   demandées.

   POURQUOI DU VERRE SOMBRE ET NON DU VERRE CLAIR. Le glassmorphisme habituel pose
   du blanc translucide. Calculé sur ce site : une carte en blanc à 4,5 % posée sur
   le coeur du halo donne un fond à `#115C58`, où le teal des catégories tombe à
   3,8:1 et le gris sourd à 4,3 — sous le seuil AA, et ce sont précisément les deux
   couleurs qui vivent DANS les cartes. Une base sombre à 62 % fait l'inverse : le
   fond de carte reste à `#0F2A28` au pire, le teal tient 7,5:1 et le gris sourd
   8,4, et l'aurore se voit quand même puisque 38 % du halo passe au travers.

   LE FLOU EST CE QUI FAIT LE VERRE, pas la transparence : sans lui on verrait le
   halo net derrière un voile, ce qui ressemble à une erreur d'opacité. Avec lui, la
   carte a une épaisseur. `saturate(130%)` compense la désaturation du flou.

   REPLI SANS `backdrop-filter` : fond opaque, comme avant. La propriété est
   supportée partout depuis longtemps, mais une carte translucide sans flou serait
   moins lisible qu'une carte opaque, donc le repli est explicite. */
:root {
  --glass:        rgba(20, 20, 20, 0.62);
  --glass-strong: rgba(16, 16, 16, 0.72);
  --glass-line:   rgba(255, 255, 255, 0.10);
  --glass-blur:   blur(18px) saturate(130%);
}

[data-theme="dark"] .usecase,
[data-theme="dark"] .feature-card,
[data-theme="dark"] .guarantee-item,
[data-theme="dark"] .pricing-card,
[data-theme="dark"] .faq-item,
[data-theme="dark"] .product-card,
[data-theme="dark"] .spec-item--card,
[data-theme="dark"] .sidebar-card,
[data-theme="dark"] .contact-map__card,
[data-theme="dark"] .problem-callout,
[data-theme="dark"] .blog-card,
[data-theme="dark"] .compat__item,
[data-theme="dark"] .flow2fa,
[data-theme="dark"] .roi__panel {
  background: var(--glass);
  border-color: var(--glass-line);
  -webkit-backdrop-filter: var(--glass-blur);
          backdrop-filter: var(--glass-blur);
  /* Le liseré de lumière d'un pixel en haut : c'est lui qui donne l'arête. */
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.055);
}
/* Les blocs de code sont plus opaques : on y lit du texte à 13 px en chasse fixe,
   c'est la surface la moins tolérante de la page. */
[data-theme="dark"] .code-block pre,
[data-theme="dark"] .article-body pre {
  background: var(--glass-strong);
  border-color: var(--glass-line);
  -webkit-backdrop-filter: var(--glass-blur);
          backdrop-filter: var(--glass-blur);
}
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  [data-theme="dark"] .usecase,
  [data-theme="dark"] .feature-card,
  [data-theme="dark"] .guarantee-item,
  [data-theme="dark"] .pricing-card,
  [data-theme="dark"] .faq-item,
  [data-theme="dark"] .product-card,
  [data-theme="dark"] .spec-item--card,
  [data-theme="dark"] .sidebar-card,
  [data-theme="dark"] .contact-map__card,
  [data-theme="dark"] .problem-callout,
  [data-theme="dark"] .blog-card,
  [data-theme="dark"] .compat__item,
  [data-theme="dark"] .flow2fa,
  [data-theme="dark"] .roi__panel,
  [data-theme="dark"] .code-block pre,
  [data-theme="dark"] .article-body pre { background: #141414; }
}


/* ══ CALCULATEUR DE ROI ══════════════════════════════════════════════════════
   Le bloc qui remplace le prix sur commandez / order. Arbitrage client du
   2026-08-24 : le tarif n'est plus prononcé, il est retourné en ROI.

   CE QU'IL AFFICHE, ET CE QU'IL N'AFFICHE PAS. Deux curseurs, deux chiffres du
   visiteur, un résultat en HEURES puis en JOURNÉES DE TEST. Jamais d'euros :
   convertir demanderait un coût horaire, donc soit un troisième curseur (le
   client a tranché pour deux), soit une hypothèse inventée de notre part. Et
   aucun tarif Q-Bot n'apparaissant, la page ne peut pas promettre un délai de
   rentabilisation, et c'est précisément ce qui la rend défendable.

   L'ÉTAT AU REPOS EST L'ÉTAT COMPLET. Les valeurs du HTML (3 testeurs, 35 min,
   36 h 45, 5,3 journées) sont exactement celles que produit le calcul avec les
   positions par défaut des curseurs, et `--roi-p` est écrite en style en ligne
   pour que la portion remplie du rail soit juste elle aussi. Sans JavaScript le
   bloc se lit donc correctement, il ne réagit simplement plus.

   LE RAIL REMPLI, DEUX MOTEURS, DEUX MÉCANIQUES. Firefox a
   `::-moz-range-progress`, qui remplit tout seul. WebKit et Chromium n'ont rien
   d'équivalent : la portion teal y est un dégradé sur la piste, dont la borne
   est la propriété `--roi-p` écrite par le module 18. Les deux blocs de
   pseudo-éléments ne peuvent PAS être groupés dans une même règle : un sélecteur
   inconnu invalide la déclaration entière pour le moteur qui ne le connaît pas.

   CIBLE TACTILE. Le rail visible fait 4 px, mais l'`<input>` en fait 24 : c'est
   la hauteur de l'élément qui est cliquable, pas celle de la piste peinte. La
   poignée fait 20 px. WCAG 2.5.8 est donc tenu sans agrandir le trait. */
.roi {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 32px;
  align-items: stretch;
}
.roi__panel {
  background: var(--white);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  padding: 32px;
}

.roi__field + .roi__field { margin-top: 32px; }
.roi__label {
  display: block;
  font-size: 0.8125rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--teal-text);
  margin-bottom: 12px;
}
.roi__row { display: flex; align-items: center; gap: 20px; }
.roi__val {
  flex: 0 0 auto;
  min-width: 84px;
  text-align: right;
  font-size: 1.125rem;
  font-weight: 700;
  color: var(--black);
  font-variant-numeric: tabular-nums;
}
.roi__hint { font-size: 0.875rem; color: var(--muted); margin: 24px 0 0; }

/* ---- le curseur ---- */
.roi__range {
  flex: 1 1 auto;
  min-width: 0;
  height: 24px;
  background: transparent;
  -webkit-appearance: none;
          appearance: none;
  cursor: pointer;
}
.roi__range:focus-visible { outline: 2px solid var(--teal); outline-offset: 6px; border-radius: 4px; }

.roi__range::-webkit-slider-runnable-track {
  height: 4px;
  border-radius: 2px;
  background:
    linear-gradient(90deg,
      var(--teal) 0 var(--roi-p, 0%),
      rgba(255, 255, 255, 0.16) var(--roi-p, 0%) 100%);
}
.roi__range::-webkit-slider-thumb {
  -webkit-appearance: none;
  margin-top: -8px;
  width: 20px; height: 20px;
  border: none;
  border-radius: 50%;
  background: var(--teal);
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.55);
}
.roi__range::-moz-range-track {
  height: 4px;
  border-radius: 2px;
  background: rgba(255, 255, 255, 0.16);
}
.roi__range::-moz-range-progress {
  height: 4px;
  border-radius: 2px;
  background: var(--teal);
}
.roi__range::-moz-range-thumb {
  width: 20px; height: 20px;
  border: none;
  border-radius: 50%;
  background: var(--teal);
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.55);
}

/* ---- le résultat ---- */
.roi__result { display: flex; flex-direction: column; align-items: flex-start; }
.roi__lead {
  font-size: 0.8125rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--teal-text);
  margin-bottom: 8px;
}
.roi__figure {
  font-size: clamp(2.5rem, 5.5vw, 3.75rem);
  font-weight: 700;
  color: var(--teal-text);
  line-height: 1;
  margin: 0;
  font-variant-numeric: tabular-nums;
}
.roi__unit { font-size: 0.9375rem; color: var(--gray); margin: 8px 0 0; }
.roi__equiv { margin: 24px 0 0; }
.roi__equiv strong { color: var(--black); font-variant-numeric: tabular-nums; }
/* La formule est affichée à dessein : un grand nombre sans son calcul est un
   argument publicitaire, avec son calcul c'est une mesure. En chasse fixe pour
   qu'elle se lise comme une opération et pas comme une phrase. */
.roi__formula {
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 0.8125rem;
  color: var(--muted);
  margin: 8px 0 0;
}
.roi__result .btn { margin-top: auto; }
.roi__result .roi__formula + .btn { margin-top: 32px; }
.roi__note { font-size: 0.9375rem; color: var(--gray); margin: 32px 0 0; max-width: 68ch; }

@media (max-width: 900px) {
  .roi { grid-template-columns: 1fr; gap: 24px; }
  .roi__panel { padding: 28px 24px; }
}
