/* ==========================================================================
   styles.css — TOUTE l'apparence du site
   --------------------------------------------------------------------------
   Theme repris de la maquette Figma "Portfolio_Explorations" (noeud 2309:3835).
   Les valeurs de couleur, de taille et d'espacement viennent directement du
   fichier Figma : elles ne sont pas approximees.

   Organisation du fichier, de haut en bas :
     1. Variables (couleurs, espacements, typo)  <- MODIFIEZ ICI EN PRIORITE
     2. Remise a zero et reglages de base
     3. Utilitaires (accessibilite, conteneurs)
     4. Loader et portail du prenom
     5. En-tete, navigation, barre de progression
     6. Page d'accueil
     7. Cartes de projet
     8. Pages d'etude de cas (+ navigation laterale collante)
     9. Pages editoriales (A propos, article)
    10. Pied de page
    11. Transitions de page
    12. Responsive (ecrans etroits)
    13. Preferences utilisateur (mouvement reduit, impression)
   ========================================================================== */


/* ==========================================================================
   1. VARIABLES
   --------------------------------------------------------------------------
   Une "variable CSS" (ou custom property) se declare avec deux tirets et
   se lit avec var(). Declarees sur :root, elles sont disponibles partout.
   Interet : changer le bleu du site = changer UNE ligne ci-dessous.
   ========================================================================== */
:root {

  /* ---- COULEURS DE MARQUE, RELEVEES DANS FIGMA -------------------------
     Ces deux valeurs sont exactement celles de la maquette. Tout le reste
     du fichier en decoule.

     NOTE DE CONFORMITE (decision assumee, documentee une seule fois) :
     le blanc sur ce bleu donne un contraste de 4,19:1, et le vert lime
     3,13:1. Le seuil WCAG AA pour du texte courant est 4,5:1. Le texte de
     32 px du heros passe (seuil 3:1 pour les grandes tailles), mais les
     libelles de 16 px — navigation, etiquettes, pastilles — sont en dessous.
     C'est un choix esthetique explicite : la fidelite a la maquette prime.
     Si vous changez d'avis, #1571EF rend tout conforme en assombrissant la
     luminosite de 2 %, a teinte et saturation identiques.                */
  --blue:        #2078F0;   /* fond de tout le site */
  --lime:        #BEF007;   /* accent : mots surlignes dans le heros */

  /* ---- NEUTRES --------------------------------------------------------- */
  --white:       #FFFFFF;
  --deep:        #10233F;   /* bleu tres fonce, pour le texte sur fond blanc */

  /* ---- LES DEUX THEMES -------------------------------------------------
     Le site a deux ambiances, et TOUT le reste du fichier ne parle qu'en
     variables — jamais en couleurs fixes. Basculer une page d'un theme a
     l'autre revient donc a changer un seul attribut sur <html>, ce que fait
     app.js a chaque rendu :

       html[data-theme="brand"]  fond bleu, texte blanc   (accueil, listes)
       html[data-theme="light"]  fond blanc, texte fonce  (etudes de cas)

     Les etudes de cas sont sur fond blanc parce que ce sont des pages
     longues, faites pour etre lues d'un bout a l'autre. Les valeurs
     ci-dessous sont celles du theme de marque ; le bloc du theme clair les
     redefinit juste apres.                                                */
  --bg:          #2078F0;                /* fond de page */
  --ink:         #FFFFFF;                /* texte courant */
  --ink-soft:    rgba(255,255,255,.78);  /* texte secondaire */
  --ink-faint:   rgba(255,255,255,.60);  /* legendes, sur-titres */
  --line:        rgba(255,255,255,.24);  /* filets et separateurs */
  --line-soft:   rgba(255,255,255,.14);
  --veil:        rgba(0,0,0,.05);        /* fond de la pilule de navigation */
  --veil-strong: rgba(0,0,0,.12);        /* blocs mis en avant */
  /* #0555C0 : bleu du pied de page sur https://marvinsrd.com/index (demande
     utilisateur, "aussi sombre que sur..."), pas une derivation de --bg — ses
     canaux ne s'assombrissent pas dans les memes proportions, donc color-mix
     ne le reproduirait pas. Jeton distinct de --bg (et non un remplacement)
     pour que le theme clair, ci-dessous, puisse garder son pied de page
     blanc sans le redefinir explicitement a chaque fois. */
  --foot-bg:     #0555C0;

  /* ---- LA GRILLE DE FOND DE L'ACCUEIL ----------------------------------
     Le quadrillage pose sous la page d'accueil (dessine section 6). Deux
     jetons parce que la maille et la teinte se regardent l'une l'autre : une
     maille plus serree multiplie les traits a l'ecran et demande donc un
     trait plus pale, sinon le bleu vire au gris.

     --grid-line est ici, dans le bloc de MARQUE, et non dans :root : c'est du
     blanc translucide, il n'a de sens que sur le bleu. La grille ne sort
     jamais de l'accueil, aucun theme clair n'a donc a la redefinir.

     .09 et non les .14 de --line-soft : un filet, on en voit un a la fois ;
     une grille, on en voit des centaines d'un coup. Reutiliser le jeton du
     filet aurait donne un papier millimetre, pas une trame.

     ESSAYE A .14, REPOSE A .09. L'argument etait qu'un pointille ne pose que
     la moitie de son encre (8px de tiret pour 16px de periode) et que .14 lui
     rendait donc le poids d'une ligne pleine a .09. C'est vrai sur le papier,
     mais a l'ecran la trame se met a concurrencer le contenu : on la regarde
     au lieu de la traverser. Une trame de fond a le droit d'etre a la limite
     du visible — c'est meme sa definition. .18 avait ete ecarte d'emblee,
     franchement millimetre.                                                */
  --grid-line:   rgba(255,255,255,.09);
  /* 96px et non les 32px du premier jet : la trame est passee en pointilles
     (section 6), et un pointille sur une maille serree fait grouiller l'ecran
     — trois fois plus de tirets que de traits pleins auparavant. En espaçant
     la maille, chaque ligne redevient lisible en tant que ligne. Toujours un
     multiple du rythme de 8px, et de --grid-period ci-dessous pour que le
     motif retombe juste a chaque croisement.
     CE 96 APPARTIENT AU POINTILLE. L'ancienne trame en traits pleins est
     conservee, desactivee, en fin de section 6 : si on la rallume, il faut
     REMETTRE 32px ici — sa note explique pourquoi. */
  --grid-cell:   32px;
  /* Le pointille lui-meme. --grid-period est la distance d'un cycle complet,
     donc AUSSI la distance dont on translate pour boucler (voir gridMarchX /
     gridMarchY, section 6) : les deux ne peuvent pas diverger puisqu'il n'y a
     qu'une valeur. Tiret et vide egaux, c'est le pointille le plus neutre.
     Les deux masques sont des jetons parce qu'ils ne different que par leur
     angle : les ecrire deux fois, c'est se garantir d'en retoucher un seul. */
  --grid-dash:   8px;
  --grid-period: 16px;
  --grid-dash-x: repeating-linear-gradient(90deg, #000 0 var(--grid-dash), transparent var(--grid-dash) var(--grid-period));
  --grid-dash-y: repeating-linear-gradient(0deg,  #000 0 var(--grid-dash), transparent var(--grid-dash) var(--grid-period));
  /* LA VITESSE SE LIT --grid-period / --grid-march, et c'est le seul calcul a
     faire pour la regler : une periode entiere parcourue en une duree.
     Le premier reglage — 16px en 6s, soit 2,7px par seconde — etait invisible :
     un tiret mettait trois secondes a parcourir sa propre longueur, ce qui est
     sous le seuil ou l'oeil voit un mouvement plutot qu'une image fixe.
     A 1.5s on est a 10,7px/s : chaque tiret avance de sa longueur en huit
     dixiemes de seconde, ce qui se voit sans happer le regard.
     Descendre vers 1s accelere encore, monter vers 3s repasse dans le
     subliminal. */
  --grid-march:  1.5s;

  /* --accent porte tout ce qui doit ressortir : barres de progression,
     section active, chiffres cles, anneau de focus. Lime sur le bleu, bleu
     sur le blanc — le lime sur blanc serait illisible (1,34:1). */
  --accent:      #BEF007;
  --accent-ink:  #16220A;                /* texte pose SUR l'accent */

  /* Les pastilles d'etiquette s'inversent d'un theme a l'autre. */
  --tag-bg:      #FFFFFF;
  --tag-fg:      #2078F0;

  /* Le cadre blanc derriere les captures d'ecran. Sur fond blanc il
     disparaitrait, on lui donne donc un liser. */
  --frame-bg:    #FFFFFF;
  --frame-line:  transparent;

  /* ---- TYPOGRAPHIE ------------------------------------------------------
     Raleway, imposee par la maquette. La pile de secours evite un saut de
     mise en page si le telechargement echoue : Trebuchet MS a des largeurs
     de caracteres proches de Raleway.                                     */
  --font: 'Raleway', 'Trebuchet MS', -apple-system, BlinkMacSystemFont,
          'Segoe UI', Roboto, sans-serif;
  --mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;

  /* Graisses utilisees dans la maquette. Les nommer evite les nombres
     magiques disperses dans le fichier. */
  --w-light:    300;
  --w-regular:  400;
  --w-medium:   500;
  --w-semibold: 600;

  /* Echelle typographique. Les tailles de reference viennent de Figma :
     16 px pour l'interface, 24 px pour les sous-titres, 32 px pour le heros.
     clamp(min, souhaite, max) = "jamais plus petit que min, jamais plus grand
     que max, et fluide entre les deux". vw = 1% de la largeur de fenetre.
     C'est ce qui rend le site responsive SANS media query par taille.     */
  --fs-hero:    clamp(1.55rem, 3.1vw, 2rem);      /* 32px de la maquette */
  --fs-h1:      clamp(1.6rem, 3.4vw, 2.25rem);
  --fs-h2:      clamp(1.25rem, 2.2vw, 1.5rem);    /* 24px */
  --fs-h3:      1.125rem;
  --fs-lead:    clamp(1.05rem, 1.6vw, 1.25rem);
  --fs-body:    1rem;                             /* 16px */
  --fs-small:   0.9375rem;
  --fs-tiny:    0.8125rem;

  /* Interlignes releves dans Figma */
  --lh-nav:  1.45;
  --lh-body: 1.6;

  /* ---- ESPACEMENTS -----------------------------------------------------
     Echelle limitee : garde le rythme vertical coherent d'une page a l'autre.
     Les valeurs cles de la maquette : 8, 16, 24, 40, 80.                  */
  --s1: 0.25rem;  --s2: 0.5rem;   --s3: 0.75rem;  --s4: 1rem;
  --s5: 1.5rem;   --s6: 2rem;     --s7: 3rem;     --s8: 5rem;
  --s9: 7rem;
  --gutter: clamp(1.5rem, 6.25vw, 5rem);   /* 80px sur large ecran */

  /* ---- DIVERS ---------------------------------------------------------- */
  --radius-pill: 40px;   /* la pilule de navigation */
  --nav-pad-x:   40px;   /* le retrait interne de la pilule, de la maquette */
  /* La largeur maximale du conteneur .wrap (section 2), d'ou la pilule de
     navigation tire la sienne : sa boite de contenu mesure cette valeur moins
     les deux gouttieres, c'est-a-dire exactement la colonne ou vit le contenu.
     Un jeton parce que deux endroits en dependent et qu'ils doivent bouger
     ensemble. */
  --wrap-max:    1280px;
  --radius-tag:  20px;   /* les etiquettes */
  --radius:      14px;
  --radius-sm:   8px;
  --shadow:      0 10px 30px -12px rgba(0,0,0,.35);
  --shadow-lg:   0 24px 60px -20px rgba(0,0,0,.45);

  /* ---- LE VERRE, LA MATIERE ---------------------------------------------
     Le flou et l'ombre des objets qui flottent AU-DESSUS d'une page au lieu
     d'y etre poses : la croix de fermeture et la barre de sections d'une
     etude de cas, qui doivent rester la meme matiere a la retouche pres.
     Ils sont ici, dans :root, et non dans le theme clair ou ils vivaient :
     l'ombre sert aussi a la barre d'onglets du bas sur mobile (section 12),
     qui est sur le bleu de marque, et une variable ne se lit pas depuis un
     theme qu'on n'a pas. Le flou les a suivis pour ne pas separer deux
     valeurs qui se lisent ensemble — la barre d'onglets, elle, en demande un
     bien plus epais et le declare sur place.
     La TEINTE du verre depend en revanche du theme : voir --glass dans le
     bloc clair. */
  --glass-blur:   blur(14px);
  --glass-shadow: 0 2px 12px -4px rgba(16, 35, 63, .28);
  --head-h:      82px;   /* 16px de texte + 24px de marge haute et basse */
  --back-h:      38px;   /* hauteur du bouton "Retour", fixe sous l'en-tete */
  --measure:     68ch;   /* largeur de lecture confortable */

  /* Courbe d'animation : demarre vite, se pose doucement. */
  --ease: cubic-bezier(.2, .7, .2, 1);

  /* Ce qu'il reste du RESTE de la page quand on survole une vignette
     d'accueil (section 7 bis). Un jeton parce que c'est le seul reglage de
     l'effet : tout le monde recule de la meme quantite, et la retoucher se
     fait ici plutot qu'en six endroits. .35 laisse lire ce qu'on ecarte —
     descendre vers .2 le change en projecteur, monter vers .5 rend l'effet
     a peine perceptible sur un bleu deja sature.
     L'EFFET EST DESACTIVE (voir la note en tete de la section 7 bis). Les
     deux jetons restent declares : --showcase-ms sert encore a la pilule de
     navigation (section 5), et un jeton que l'on supprime est un reglage que
     l'on doit retrouver le jour ou l'on rallume. */
  --showcase-dim: .35;
  --showcase-ms:  .3s;

  /* Le balayage lime qui presente le prenom au retour du portail (section 6).
     La duree vient de la maquette Lottie d'origine : 24 images a 60/s pour
     pousser la barre, 24 pour la retirer, soit deux fois 400ms.
     Le retard, lui, doit laisser passer `pageIn` (.38s, section 11) : la page
     finit d'arriver, PUIS le prenom est presente. Les deux gestes lances
     ensemble se marcheraient dessus — on ne verrait ni l'un ni l'autre. */
  --hello-wipe:  .8s;
  --hello-delay: .42s;
}

/* ---- LE THEME CLAIR, applique aux etudes de cas ----------------------
   Ce bloc ne redefinit QUE les couleurs. Typographie, espacements et rayons
   restent ceux de :root ci-dessus : une etude de cas doit avoir la meme
   respiration et la meme voix que le reste du site, seulement sur un autre
   fond. Redeclarer ici les tailles serait le meilleur moyen de les voir
   diverger a la premiere modification.                                    */
/* Deux selecteurs pour le meme jeu de couleurs :
     html[data-theme="light"]  — l'ancien fonctionnement, page entiere ;
     .theme-light              — le meme theme applique a UN SEUL element.
   Le second sert aux etudes de cas : depuis qu'elles s'ouvrent en fiche
   par-dessus la page (voir section 11), <html> doit rester en bleu de marque
   — c'est ce bleu qu'on apercoit sur les bords et sous la fiche — tandis que
   la fiche elle-meme, et elle seule, passe en clair. Poser le theme sur
   <html> comme avant repeindrait aussi le calque du dessous. */
/* INDISPENSABLE quand .theme-light est portee par un element et non par
   <html>. Redefinir --ink ne suffit pas : `color` s'HERITE, et <body> a
   deja calcule la sienne avec les jetons de marque (blanc). Tout ce qui ne
   redeclare pas sa couleur — le titre d'une etude de cas, les titres de la
   section "projet suivant" — heriterait donc du blanc de <body> et
   deviendrait invisible sur le fond blanc de la fiche. Cette ligne fait
   repartir tout le sous-arbre de la bonne encre. */
.theme-light { color: var(--ink); }

html[data-theme="light"],
.theme-light {
  --bg:          #FFFFFF;
  --ink:         #10233F;
  /* Ces deux gris sont mes valeurs, pas celles de la maquette : ils sont donc
     calibres au seuil WCAG AA plutot qu'a l'oeil. */
  --ink-soft:    #4A5B75;   /* 6,90:1 sur blanc */
  --ink-faint:   #68778F;   /* 4,54:1 sur blanc */
  --line:        rgba(16,35,63,.16);
  --line-soft:   rgba(16,35,63,.09);
  --veil:        rgba(16,35,63,.04);
  --veil-strong: rgba(32,120,240,.07);
  --foot-bg:     var(--bg);        /* etudes de cas : pied de page blanc, inchange */
  --warning-tint: rgba(218,30,40,.08);

  --accent:      #2078F0;
  --accent-ink:  #FFFFFF;

  --tag-bg:      #2078F0;
  --tag-fg:      #FFFFFF;

  --frame-bg:    #FFFFFF;
  --frame-line:  rgba(16,35,63,.12);

  /* Ombres plus discretes : sur fond blanc, une ombre marquee salit. */
  --shadow:      0 8px 24px -12px rgba(16,35,63,.25);
  --shadow-lg:   0 20px 50px -20px rgba(16,35,63,.30);

  /* ---- LA TEINTE DU VERRE -----------------------------------------------
     Deux objets seulement flottent AU-DESSUS d'une etude de cas : la croix de
     fermeture et, sur mobile, la barre de sections. Ils ne sont pas dans la
     page, ils sont poses dessus — d'ou le blanc translucide qui, avec le flou
     de --glass-blur, laisse deviner le texte qui passe en dessous plutot que
     de le masquer.

     Seule la COULEUR est ici : le flou et l'ombre sont remontes dans :root
     (section 1), parce que la barre d'onglets de l'accueil reprend l'ombre et
     qu'elle, elle est sur le bleu de marque. Sa teinte a elle n'est pas un
     jeton — c'est un noir a 14 % declare sur place, section 12.            */
  --glass:        rgba(255, 255, 255, .72);
  /* Il y avait ici --glass-0, le meme blanc en transparence totale, arrivee des
     degrades qui estompaient les bords de la barre flottante. Ces degrades sont
     devenus un masque pose sur la liste (section 12) : un masque efface, il ne
     peint pas, il n'a donc pas de couleur d'arrivee. Le jeton n'avait plus
     aucun usage. */
}


/* ==========================================================================
   2. REMISE A ZERO ET BASES
   ========================================================================== */

/* box-sizing: border-box -> la largeur declaree inclut padding et bordure.
   Sans ca, une boite de 200px avec 20px de padding mesure 240px. */
*, *::before, *::after { box-sizing: border-box; }

h1,h2,h3,h4,p,figure,blockquote,dl,dd,ul,ol { margin: 0; }
ul,ol { padding: 0; list-style: none; }

html {
  scroll-behavior: smooth;
  /* Evite que l'en-tete collant recouvre le titre vise par un lien d'ancrage. */
  scroll-padding-top: calc(var(--head-h) + var(--s5));
  -webkit-text-size-adjust: 100%;
  background: var(--bg);   /* evite un flash de couleur au rebond de scroll */
}

body {
  margin: 0;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--font);
  font-size: var(--fs-body);
  font-weight: var(--w-regular);
  line-height: var(--lh-body);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  /* Evite les "orphelins" (un mot seul en fin de paragraphe). */
  text-wrap: pretty;
}

/* Quand le loader ou le portail est affiche, on bloque le defilement du fond. */
body.is-locked { overflow: hidden; }

img, picture, svg, video { display: block; max-width: 100%; height: auto; }

a { color: inherit; text-decoration-thickness: 1px; text-underline-offset: 3px; }

/* :focus-visible ne s'active qu'au clavier, pas au clic de souris : on garde
   un anneau utile sans le halo parasite a la souris. */
:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
  border-radius: 4px;
}

/* ...SAUF SUR #main, ET C'ETAIT LA "LIGNE VERTE".
   paint() (app.js) appelle main.focus() a chaque rendu pour qu'un lecteur
   d'ecran annonce la nouvelle page au lieu de continuer l'ancienne. Or
   :focus-visible ne regarde pas COMMENT l'element a reçu le focus, mais si la
   derniere interaction de la personne etait au clavier : valider le portail
   avec la touche Entree suffit. L'anneau se posait alors autour de #main —
   1265px de large sur 3478 de haut sur l'accueil, donc on n'en voyait jamais
   que le bord SUPERIEUR : un filet vert pleine largeur sous l'en-tete, qui
   s'en allait au premier defilement avec le reste de la boite. Reproduit
   exactement : Tab, puis main.focus(), et le filet revient.
   (Passer par le portail a la souris — le bouton "Skip this" — ne le declenche
   pas : c'est pour ça qu'il ne se voyait pas a tous les chargements.)

   L'eteindre ici ne coute rien en accessibilite. #main porte tabindex="-1" :
   il n'est pas atteignable au clavier, donc cet anneau n'a jamais signale une
   position de navigation — il cernait la page entiere, ce qui n'indique rien.
   Le focus lui-meme, celui qui sert aux lecteurs d'ecran, est conserve. */
#main:focus,
#main:focus-visible { outline: none; }

h1,h2,h3 { line-height: 1.2; font-weight: var(--w-medium); }
h1,h2 { text-wrap: balance; }

button { font: inherit; color: inherit; cursor: pointer; }


/* ==========================================================================
   3. UTILITAIRES
   ========================================================================== */

/* Visible pour les lecteurs d'ecran, invisible a l'oeil. On ne met PAS
   display:none, qui retirerait le texte de l'arbre d'accessibilite. */
.sr-only {
  position: absolute; width: 1px; height: 1px;
  padding: 0; margin: -1px; overflow: hidden;
  clip-path: inset(50%); white-space: nowrap; border: 0;
}

.skip-link {
  position: fixed; top: var(--s3); left: var(--s3); z-index: 200;
  transform: translateY(-160%);
  background: var(--accent); color: var(--accent-ink);
  padding: var(--s3) var(--s4); border-radius: var(--radius-sm);
  text-decoration: none; font-size: var(--fs-small); font-weight: var(--w-semibold);
  transition: transform .2s var(--ease);
}
.skip-link:focus { transform: translateY(0); }

/* Conteneur : marges laterales fluides valant 80px sur grand ecran,
   exactement comme la maquette.
   --wrap-max est un jeton (section 1) parce que la pilule de navigation s'y
   accorde : son CONTENU tombe exactement sur la COLONNE de ce conteneur, soit
   cette largeur moins les deux gouttieres. Deux endroits, une valeur. */
.wrap {
  width: 100%; max-width: var(--wrap-max);
  margin-inline: auto;
  padding-inline: var(--gutter);
}
.wrap--narrow { max-width: 860px; }
/* Aussi large que .site-head (qui n'a pas de max-width, juste le gutter) —
   utilise sur le corps d'une etude de cas, voir pageCase(). */
.wrap--wide { max-width: none; }

.rule { height: 1px; background: var(--line-soft); border: 0; margin: 0; }

.kicker {
  font-size: var(--fs-tiny); text-transform: uppercase;
  letter-spacing: .14em; font-weight: var(--w-semibold); color: var(--ink-faint);
}

/* Badge "brouillon" : signale les textes a relire. Disparait en retirant
   isDraft dans content.js. */
.draft-badge {
  display: inline-flex; align-items: center; gap: var(--s2);
  background: var(--accent); color: var(--accent-ink);
  font-size: var(--fs-tiny); font-weight: var(--w-semibold);
  text-transform: uppercase; letter-spacing: .08em;
  padding: var(--s1) var(--s3); border-radius: 999px;
}

/* ---- Boutons ---- */
.btn {
  display: inline-flex; align-items: center; justify-content: center;
  gap: var(--s2);
  padding: var(--s3) var(--s5);
  /* Pilule (999px) remplacee par le rayon "squircle" du bouton submit de
     meganyap.me/onboarding (14px, demande utilisateur) — --radius portait
     deja cette meme valeur ailleurs sur le site (cartes, etc.), on la
     reutilise plutot que de redefinir 14px en dur ici. */
  border-radius: var(--radius); border: 1px solid transparent;
  font-size: var(--fs-small); font-weight: var(--w-semibold);
  text-decoration: none; white-space: nowrap;
  transition: background .18s var(--ease), color .18s var(--ease),
              border-color .18s var(--ease), transform .18s var(--ease);
}
/* Le bouton principal reprend la pastille de la maquette : blanc plein,
   texte bleu. */
.btn--primary { background: var(--ink); color: var(--bg); }
.btn--primary:hover { background: var(--accent); color: var(--accent-ink); transform: translateY(-1px); }
.btn--ghost { background: transparent; color: var(--ink); border-color: var(--line); }
.btn--ghost:hover { border-color: var(--ink); background: var(--veil); }
/* Fleche des CTA externes (arrowUpRightIcon) : .btn est deja inline-flex
   avec un `gap`, donc le texte et l'icone s'espacent tout seuls. Meme petit
   saut au survol que .u-arrow-link plus bas (coherence de langage visuel),
   sans le fondu d'opacite : un bouton n'a pas de soulignement a estomper,
   son icone est deja pleinement visible au repos. Saut court (1px) : assez
   pour se voir, pas assez pour distraire. */
.btn__icon { width: 14px; height: 14px; flex: none; transition: transform .2s var(--ease); }
.btn:hover .btn__icon, .btn:focus-visible .btn__icon { transform: translate(1px, -1px); }

/* ---- Liens soulignes reutilisables --------------------------------------
   Deux composants partages par tout le site (heros, gists, notes de bas de
   page, corps des etudes de cas) : le soulignement part estompe et rejoint
   la pleine opacite au survol — repris de jakub.kr (.article-underline),
   l'effet tient tout entier dans l'animation de `text-decoration-color`, la
   couleur du texte ne bouge pas. `color-mix` derive le ton estompe de
   `currentColor` plutot que d'une couleur figee, donc ca marche pareil en
   texte blanc (heros, pied de page) qu'en texte sombre (etudes de cas).

   .u-underline   : l'ancre ne contient QUE du texte -> le soulignement peut
                    vivre directement dessus.
   .u-arrow-link  : l'ancre contient un <span> de texte PUIS, en option, un
                    <span class="u-arrow-link__icon"> avec une fleche SVG a
                    l'interieur. Le soulignement doit alors vivre sur le
                    span de texte, jamais sur l'ancre : un <a> a un
                    soulignement par defaut du navigateur qui, pose sur
                    l'ancre, continuerait de se dessiner a travers la
                    fleche (un `text-decoration: none` local sur la fleche
                    ne supprime jamais le trait d'un ancetre). La fleche
                    suit le meme rythme, estompee -> pleine opacite, PLUS un
                    petit saut court (1px) vers le coin superieur droit. Sa
                    taille suit le texte (1em) : elle grandit et retrecit
                    avec lui. Utilisable meme sans fleche (le span d'icone
                    est optionnel) : Petal/Fit-Plans dans le heros partagent
                    ce composant avec Gekko sans en avoir une.
                    `display: inline-flex; align-items: center` sur l'ancre
                    (pas `vertical-align` sur l'icone) : un <svg> inline a un
                    espace de descendance implicite sous sa ligne de base qui
                    le decale et l'eloigne du texte quand on le cale a l'œil
                    plutot qu'en centrant les deux boites l'une contre
                    l'autre. Ca reste "en ligne" dans la phrase : le
                    conteneur flex est lui-meme un bloc en ligne, donc il se
                    comporte comme un seul mot insecable au fil du texte. */
.u-underline {
  text-decoration: underline;
  text-decoration-thickness: 1.5px;
  text-underline-offset: 4px;
  text-decoration-color: color-mix(in srgb, currentColor 35%, transparent);
  transition: text-decoration-color .2s ease-out;
}
.u-underline:hover, .u-underline:focus-visible { text-decoration-color: currentColor; }

.u-arrow-link { display: inline-flex; align-items: center; text-decoration: none; }
.u-arrow-link > span:first-child {
  text-decoration: underline;
  text-decoration-thickness: 1.5px;
  text-underline-offset: 4px;
  text-decoration-color: color-mix(in srgb, currentColor 35%, transparent);
  transition: text-decoration-color .2s ease-out;
}
.u-arrow-link:hover > span:first-child,
.u-arrow-link:focus-visible > span:first-child { text-decoration-color: currentColor; }
.u-arrow-link__icon {
  display: inline-flex; align-items: center; margin-left: 3px;
  opacity: .35;
  transition: opacity .2s ease-out, transform .2s var(--ease);
}
.u-arrow-link__icon svg { display: block; width: 1em; height: 1em; }
.u-arrow-link:hover .u-arrow-link__icon,
.u-arrow-link:focus-visible .u-arrow-link__icon { opacity: 1; transform: translate(1px, -1px); }


/* ==========================================================================
   4. LOADER ET PORTAIL DU PRENOM
   ========================================================================== */

.loader {
  position: fixed; inset: 0; z-index: 150;
  display: grid; place-items: center;
  background: var(--blue);
  transition: opacity .5s var(--ease), visibility .5s;
}
/* visibility:hidden (et pas seulement opacity:0) retire l'element du flux de
   clics : sans ca, un calque invisible bloquerait la souris. */
.loader.is-done { opacity: 0; visibility: hidden; }

.loader__mark {
  width: 168px; height: 2px;
  background: rgba(255,255,255,.22);
  border-radius: 2px;
  /* Plus d'overflow:hidden ici (retire pour la lueur ci-dessous, voir sa
     note) : sans lui, la piste ne rogne plus rien — a 2px de haut et 2px de
     rayon, elle n'avait de toute facon presque rien a rogner (la barre est un
     simple rectangle, jamais plus large que la piste puisque sa largeur est
     un pourcentage de celle-ci). */
}
.loader__bar {
  position: relative;
  display: block; height: 100%; width: 0%;
  /* Blanc plutot que lime (demande utilisateur). */
  background: var(--ink);
  /* Loader et portail restent sur le bleu de marque : ils s'affichent
     avant toute page, donc avant qu'un theme soit choisi.
     La largeur est pilotee en JS (pourcentage reel d'images chargees), donc
     pas d'animation infinie qui mentirait sur la progression. */
  transition: width .3s var(--ease);
}
/* Lueur inspiree d'une reference Dribbble
   (demande utilisateur) : la reference fait courir un point lumineux flou le
   long d'un contour, plutot qu'illuminer toute la forme d'un coup — transpose
   ici en un halo colle a la pointe d'avancement de la barre (right:0), et non
   sur la barre entiere (cette derniere approche avait deja ete essayee puis
   retiree, demande utilisateur). Le flou d'un box-shadow deborde dans toutes
   les directions ; sur une piste de 2px de haut, un overflow:hidden l'aurait
   quasi entierement rogne verticalement — d'ou sa suppression sur
   .loader__mark ci-dessus. */
.loader__bar::after {
  content: '';
  position: absolute;
  top: 0; right: 0;
  width: 14px; height: 100%;
  background: var(--ink);
  box-shadow: 0 0 8px 1px rgba(255,255,255,.55), 0 0 20px 4px rgba(255,255,255,.3);
}

.gate {
  position: fixed; inset: 0; z-index: 140;
  display: grid; place-items: center;
  padding: var(--gutter);
  background: var(--blue);
}
.gate[hidden] { display: none; }

.gate__card {
  width: 100%; max-width: 620px;
  animation: rise .6s var(--ease) both;
}
@keyframes rise {
  from { opacity: 0; transform: translateY(14px); }
  to   { opacity: 1; transform: translateY(0); }
}

/* Le portail reprend la typographie du heros : meme accueil, meme voix. */
.gate__hi {
  font-size: var(--fs-h2); font-weight: var(--w-light);
  margin-bottom: var(--s2);
}
.gate__q {
  font-size: var(--fs-hero); font-weight: var(--w-regular);
  margin-bottom: var(--s7);
  white-space: pre-line;   /* respecte le \n present dans content.js */
}
.gate__label {
  display: block; color: var(--ink-soft);
  font-size: var(--fs-h3); margin-bottom: var(--s2);
}
.gate__input {
  width: 100%; padding: var(--s4);
  /* Fond assombri a nouveau (REVERT, demande utilisateur) : le fond blanc a
     faible alpha essaye juste avant est retire, on revient au noir a
     faible alpha d'origine. */
  background: rgba(0,0,0,.08);
  /* Pas de filet au repos (demande utilisateur) : `transparent` plutot que
     `none`, pour que la largeur de la boite ne bouge pas quand :hover/:focus
     lui redonnent une couleur. */
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  color: #FFFFFF; font: inherit; font-size: 1.25rem;
  transition: border-color .18s var(--ease), background .18s var(--ease);
}
.gate__input::placeholder { color: rgba(255,255,255,.45); }
/* Le survol garde son fond legerement plus clair, mais plus de filet
   (REVERT, demande utilisateur) : seul le focus (au clic/clavier) en pose
   un desormais. */
.gate__input:hover { background: rgba(0,0,0,.12); }
.gate__input:focus {
  outline: none;
  /* Blanc legerement transparent plutot que lime (demande utilisateur). */
  border-color: rgba(255,255,255,.5);
  background: rgba(0,0,0,.14);
}
/* .is-invalid est posee par app.js quand le prenom ne passe pas le filtre. */
.gate__input.is-invalid { border-color: #FFC7C7; }

.gate__error { color: #FFD9D9; font-size: var(--fs-small); margin-top: var(--s2); }
.gate__actions { display: flex; flex-wrap: wrap; gap: var(--s3); margin-top: var(--s5); }
/* Remplissage expansif inspire de components.janustiu.com ("Expanding fill
   button"), simplifie (demande utilisateur) : la reference suit le curseur
   (pointerenter/pointerleave, coordonnees en custom properties) — retire ici,
   l'origine est toujours le bas du bouton (demande utilisateur — pas le
   centre), souris comme clavier, ce qui evite tout JS. overflow: hidden
   decoupe le cercle aux coins arrondis du bouton ; position: relative
   l'ancre. Applique aux deux boutons du portail — Submit et Skip (demande
   utilisateur) — chacun avec la couleur de son propre survol d'origine. */
.gate__actions .btn--primary,
.gate__actions .btn--ghost {
  position: relative;
  overflow: hidden;
}
/* Le texte doit peindre au-dessus du cercle : un z-index explicite (pas
   l'ordre du DOM ni un ::before/::after) pour que ca reste vrai quel que soit
   lequel des deux vient en premier dans la feuille. */
.gate__actions .btn--primary .btn__label,
.gate__actions .btn--ghost .btn__label { position: relative; z-index: 1; }
/* Le cercle : ancre au centre horizontalement mais 20px sous le bord bas du
   bouton (demande utilisateur — pas colle a ce bord, ni au centre). Carre
   force par aspect-ratio (jamais une ellipse quel que soit le ratio
   largeur/hauteur du bouton), assez grand (300% de la largeur) pour depasser
   tres largement la distance jusqu'aux coins hauts, les plus eloignes de
   cette ancre, une fois deplie. */
.gate__actions .btn--primary::before,
.gate__actions .btn--ghost::before {
  content: '';
  position: absolute;
  top: calc(100% + 20px); left: 50%;
  width: 300%; aspect-ratio: 1;
  border-radius: 50%;
  transform: translate(-50%, -50%) scale(0);
  transition: transform .5s var(--ease);
  z-index: 0;
  pointer-events: none;
}
/* Couleur = l'ancien fond au survol de chacun (demande utilisateur) : Submit
   reste lime du debut a la fin, seule la teinte s'assombrit legerement en se
   deployant ; Skip reprend le voile translucide qu'il prenait deja au
   survol (voir plus bas : son fond instantane d'origine est neutralise, le
   cercle en est desormais la seule version, animee). */
.gate__actions .btn--primary::before { background: color-mix(in srgb, var(--lime) 85%, black); }
.gate__actions .btn--ghost::before { background: var(--veil); }
.gate__actions .btn--primary { background: var(--lime); color: #16220A; border: 1px solid rgba(255,255,255,.5); }
.gate__actions .btn--primary:hover,
.gate__actions .btn--primary:focus-visible {
  transform: translateY(-1px);
}
.gate__actions .btn--primary:hover::before,
.gate__actions .btn--primary:focus-visible::before,
.gate__actions .btn--ghost:hover::before,
.gate__actions .btn--ghost:focus-visible::before {
  transform: translate(-50%, -50%) scale(1);
}
/* Pas de filet sur "Skip" (demande utilisateur), y compris au survol — sinon
   .btn--ghost:hover lui en redonnerait un (border-color: var(--ink)). Fond
   transparent au survol aussi : le cercle ci-dessus remplace desormais le
   voile plein instantane de .btn--ghost:hover. */
.gate__actions .btn--ghost,
.gate__actions .btn--ghost:hover { border-color: transparent; background: transparent; }

/* Meme principe que .hero__hello (section 6, keyframe helloWipe reutilise
   ici), rejoue a l'arrivee sur le portail. Meme duree qu'--hello-wipe (.8s,
   demande utilisateur — le passage a .5s a ete revert), mais les deux
   balayages continuent de se chevaucher : --gate-wipe-delay est fixe a .3s
   (demande utilisateur — pas 100%, ni un pourcentage de la duree), donc
   gate__q demarre bien avant que celui de gate__hi soit fini.
   Barre en bleu de fond eclairci ici (demande utilisateur), ni blanc ni lime
   comme sur l'accueil — --gate-wipe eclaircit var(--blue), la meme constante
   que le fond du portail (pas var(--bg), qui changerait de theme ailleurs).
   width: fit-content ramene chaque boite a la largeur de son texte (tout en
   restant des blocs empiles, contrairement a inline-block), comme
   align-self: flex-start le fait pour .hero__hello — sinon la barre
   balaierait toute la largeur de la carte au lieu du seul texte. */
.gate__hi.is-revealing,
.gate__q.is-revealing {
  --gate-wipe: color-mix(in srgb, var(--blue) 75%, white);
  --gate-wipe-dur: .8s;
  --gate-wipe-delay: .3s;
  width: fit-content;
  position: relative;
}
.gate__hi.is-revealing { animation: gateReveal var(--gate-wipe-dur) both; }
.gate__hi.is-revealing::after {
  content: '';
  position: absolute;
  inset: -.14em -.3em;
  border-radius: 2px;
  background: var(--gate-wipe);
  pointer-events: none;
  animation: helloWipe var(--gate-wipe-dur) both;
}
.gate__q.is-revealing { animation: gateReveal var(--gate-wipe-dur) var(--gate-wipe-delay) both; }
.gate__q.is-revealing::after {
  content: '';
  position: absolute;
  inset: -.14em -.3em;
  border-radius: 2px;
  background: var(--gate-wipe);
  pointer-events: none;
  animation: helloWipe var(--gate-wipe-dur) var(--gate-wipe-delay) both;
}
/* Pas de teinte attenuee ici (contrairement a --hello-ink) : le texte du
   portail est deja a pleine opacite au repos, `inherit` retombe donc
   directement sur la bonne couleur. */
@keyframes gateReveal {
  0%, 49.9% { color: transparent; }
  50%, 100% { color: inherit; }
}

.gate__hint {
  /* Deplace juste sous le champ (demande utilisateur) : margin-top reduit a
     --s2, comme .gate__error juste en dessous — c'est desormais une note de
     champ, plus une mention de bas de formulaire. */
  margin-top: var(--s2);
  color: var(--ink-faint);
  font-size: var(--fs-tiny); line-height: 1.5;
  max-width: 46ch;
}


/* ==========================================================================
   5. EN-TETE, NAVIGATION, PROGRESSION
   ========================================================================== */

.progress {
  position: fixed; top: 0; left: 0; right: 0; height: 2px; z-index: 130;
  background: transparent; pointer-events: none;
}
.progress__fill {
  height: 100%; width: 0%;
  background: var(--accent);
}
/* SAUF SUR UNE FICHE QUI PORTE SA PROPRE NAV. Depuis que la progression est
   portee par la nav elle-meme — le rail vertical le long de la liste sur le
   bureau (section 8b), l'anneau au debut de la pilule sur mobile (section 12)
   — ce filet mesurait la meme chose une seconde fois, et depuis le bord haut
   de la FENETRE, alors que la fiche commence plus bas et que l'en-tete qu'il
   souligne est inerte. Deux traits pour une seule valeur se lisent comme deux
   valeurs.
   :has(.cs-nav) et non body.is-overlay seul : une fiche sans processus (un
   texte, une etude sans sections) n'a ni rail ni anneau, et le filet y reste
   son unique reperage. Meme condition que celle qui rallume la croix de
   fermeture sur mobile, section 12.
   Masque et non retire : setupScrollProgress continue d'y ecrire (et de poser
   .is-scrolled sur l'en-tete) sans avoir a connaitre le mode fiche, et le
   filet reprend son role des qu'on revient sur une page normale. */
body.is-overlay:has(.cs-nav) .progress { display: none; }

/* L'EN-TETE N'EST PLUS QU'UN SUPPORT : il place la pilule et la rend
   collante, rien de plus. Il portait un aplat --bg pleine largeur et un filet
   d'un pixel, avec la pilule posee au milieu — deux surfaces pour un seul
   objet, et le flou de la pilule ne servait a rien puisque l'aplat derriere
   elle etait opaque. Le fond et le filet sont partis ; la pilule, elle, prend
   maintenant toute la largeur et porte tout, y compris le nom.
   La grille a trois colonnes est partie avec eux : il ne reste qu'un enfant,
   qui se centre tout seul. C'est la pilule qui a repris la grille, et donc le
   centrage optique des entrees (voir .site-nav).

   LE RETRAIT LATERAL EST LA GOUTTIERE MOINS LE PADDING DE LA PILULE. Ce qui
   doit s'aligner sur la colonne de contenu, ce n'est pas le bord du verre :
   c'est le nom a gauche et Contact a droite, les deux seules choses qu'on lit.
   Ils sont a --nav-pad-x du bord de la pilule ; on recule donc la pilule
   d'autant pour que son contenu, et non sa boite, tombe sur la colonne. Le
   verre deborde alors de 40px de chaque cote, ce qui est voulu.
   Toujours positif ici : cette regle ne vaut qu'au-dessus de 860px, ou la
   gouttiere (6.25vw) fait au moins 53.75px, donc 13.75px apres retrait. Sous
   860px la section 12 remet ce padding a zero et la pilule devient fixe. */
.site-head {
  position: sticky; top: 0; z-index: 120;
  padding: var(--s5) calc(var(--gutter) - var(--nav-pad-x));
}

.brand {
  justify-self: start;
  font-size: var(--fs-body); font-weight: var(--w-medium);
  line-height: var(--lh-nav); letter-spacing: -0.08px;
  text-decoration: none; white-space: nowrap;
}

/* LA PILULE, QUI EST MAINTENANT LA BARRE ENTIERE. Valeurs de Figma reprises
   telles quelles : fond noir a 5 %, flou d'arriere-plan de 35px, rayon 40px,
   ecart de 40px entre les entrees, padding 16px/40px. backdrop-filter floute
   ce qui se trouve DERRIERE l'element : depuis que l'en-tete n'a plus d'aplat,
   c'est vraiment le contenu de la page qui defile dessous et s'y devine.

   Trois colonnes, comme l'en-tete en avait : le nom a gauche, les entrees au
   centre, Resume et Contact a droite. 1fr auto 1fr et non space-between —
   les deux pistes souples etant egales, la piste du milieu tombe au centre
   exact de la barre quels que soient les groupes qui l'encadrent. Avec
   space-between, le centre aurait glisse des que le nom ou le groupe de
   droite change de longueur.

   SON CONTENU MESURE LA COLONNE DE CONTENU, PAS SA BOITE. Elle a d'abord
   couru d'un bord a l'autre de l'en-tete, soit bien au-dela des textes et des
   cartes qu'elle surplombe : une barre plus large que tout ce qu'elle
   contient et tout ce qu'elle annonce. On l'a ensuite ramenee a la colonne de
   .wrap, mais alors c'etait le verre qui s'y alignait, et le nom se retrouvait
   40px en dedans des titres qu'il surplombe. Ce qui doit s'aligner, c'est ce
   qui se lit : le nom a gauche, Contact a droite. Le max-width vaut donc la
   colonne PLUS les deux retraits internes, de sorte que la boite de contenu de
   la pilule — et non son bord — tombe exactement sur .wrap. Le verre depasse
   de --nav-pad-x de chaque cote, ce qui est voulu et discret.
   Calcule et non recopie : c'est la formule meme de la colonne de .wrap
   (section 2), augmentee du padding declare deux lignes plus bas. Y ecrire
   1200px figerait le resultat pour une seule taille d'ecran, la gouttiere
   etant fluide. Au-dessous de --wrap-max le retrait de l'en-tete suffit seul,
   ce max-width devenant plus grand que la place disponible.

   Sous 860px cette meme liste quitte l'en-tete et se pose en barre d'onglets
   au bas de la fenetre : elle y repasse en flex, perd le nom et le groupe de
   droite, et garde le meme etat actif. Le max-width ci-dessous ne la gene pas
   — a 390px il vaut plus de 1300px, soit bien plus que la barre. Voir
   section 12. */
.site-nav {
  max-width: calc(var(--wrap-max) - 2 * var(--gutter) + 2 * var(--nav-pad-x));
  margin-inline: auto;
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  gap: var(--s4);
  padding: 16px var(--nav-pad-x);
  background: var(--veil);
  -webkit-backdrop-filter: blur(35px);
  backdrop-filter: blur(35px);
  border-radius: var(--radius-pill);
  /* Les deux changements d'etat de la pilule, declares ensemble parce qu'une
     seule propriete `transition` peut vivre sur un element : la seconde
     ecraserait la premiere en silence.
       background — voir .is-on-light juste apres ;
       opacity    — le recul du survol d'une vignette (section 7 bis). Cet
                    effet-la est DESACTIVE : plus rien ne touche a l'opacite
                    de la pilule, la ligne ne fait donc rien pour l'instant.
                    On la laisse en place — elle est inerte, et elle doit etre
                    ici (et non dans la section 7 bis) le jour ou l'on rallume
                    l'effet, pour la raison ecrite juste au-dessus.
     Et surtout PAS backdrop-filter : animer un flou force le navigateur a le
     recalculer image par image, et la barre saccade. */
  transition: background .22s var(--ease),
              opacity var(--showcase-ms) var(--ease);
}

/* ---- QUAND LA PILULE PASSE SUR UN VISUEL CLAIR ------------------------
   Le probleme : le verre ne pese que --veil, un noir a 5 %. Sur le bleu de
   marque, ça suffit — le libelle blanc y tient a 3,60:1, ce que la note de
   conformite de la section 1 assume deja. Sur la capture creme d'une carte,
   le meme libelle tombe a 1,13:1 : il disparait. La section 12 avait deja
   rencontre exactement ça pour la barre du bas et l'avait resolu en alourdi-
   ssant sa matiere en permanence (noir a 14 %, flou de 60px) — elle, elle
   passe sur les visuels a chaque defilement. La pilule de l'en-tete, elle,
   est sur du bleu uni la plupart du temps : lui donner la meme matiere fixe
   la noircirait sur toute la page pour un probleme qui n'existe qu'a certains
   endroits.

   ON NE PEUT PAS S'EN SORTIR AVEC UN SEUL FILTRE. brightness() est
   MULTIPLICATIF : ramener le blanc (255) a un niveau lisible, c'est diviser
   le bleu de marque par le meme facteur. Aucun filtre n'est neutre sur
   #2078F0 et sombre sur #FFFFFF — il faut donc deux etats, et quelqu'un pour
   dire lequel s'applique. C'est setupNavContrast() (app.js) qui le dit.

   15 % EST UN CHOIX D'APPARENCE, ASSUME, ET IL NE REND PAS LA NORME. Il faut
   l'ecrire une bonne fois pour que personne ne relise cette regle en croyant
   le probleme regle :

     sur du blanc pur, ce voile donne un fond a L≈0.714, et le libelle blanc a
     82 % d'opacite y mesure 1,29:1 — contre 1,13:1 sans lui. Le seuil AA est
     a 4,5:1. Autrement dit, le gain est de l'ordre du centieme : sur une
     capture claire, le libelle reste illisible.

   Le voile qui tenait la norme etait a 66 % (fond L≈0.128, libelle a 4,54:1).
   Entre les deux, il n'y a pas de reglage heureux : c'est une droite, et
   chaque point d'opacite rendu a la transparence se paie en contraste.

   SI ON VEUT LES DEUX — une pilule qui laisse voir le visuel ET un libelle
   lisible — le voile plat n'est pas le bon outil. Il faut ASSOMBRIR LE VISUEL
   au lieu de le RECOUVRIR, c'est-a-dire ajouter brightness() au
   backdrop-filter de cet etat-ci (et de lui seul) :

     .site-nav.is-on-light {
       background: rgba(6, 22, 43, .15);
       backdrop-filter: blur(35px) saturate(1.4) brightness(.45);
     }

   ça mesure 4,52:1 sur du blanc tout en gardant ce voile a 15 % : on voit
   alors le visuel, sombre, au lieu d'une plaque bleu nuit. Le prix est que la
   transition ne peut plus porter sur `background` seul — deux listes de
   filtres de longueurs differentes ne s'interpolent pas, il faudrait declarer
   `saturate(1) brightness(1)` sur la regle de base pour qu'elles se
   correspondent, et animer un backdrop-filter coute cher. Piste ouverte, pas
   prise.

   #06162B et non du noir : c'est le bleu nuit du fond de scene des fiches
   (.underlay, section 11 bis). Un noir pur virerait la capture creme au brun.

   Deux classes (0,2,0) : la section 12 redeclare `background` sur .site-nav
   (0,1,0) pour la barre du bas. Cette regle-ci gagne donc dans les deux mises
   en page, quel que soit l'ordre des blocs — l'etat vaut donc AUSSI pour la
   barre d'onglets de l'accueil sur mobile, sans une ligne de plus.

   MAIS IL N'Y FAIT PRESQUE RIEN, ET C'EST ARITHMETIQUE. La section 12 avait
   deja alourdi cette barre-la en permanence, precisement parce qu'elle passe
   sur les visuels a chaque defilement : elle porte #00000025, soit un noir a
   14,5 %. Un voile a 15 % vient donc REMPLACER un fond de meme poids par un
   autre — bleu nuit au lieu de noir, un demi-point d'opacite d'ecart. La
   classe se pose bien sous 860px, elle ne change simplement rien a l'oeil.
   C'est a 66 % que l'etat se voyait sur mobile.                           */
.site-nav.is-on-light { background: rgba(6, 22, 43, .15); }
/* Les deux groupes ne sont que des colonnes : ils rangent leurs liens en
   rangee et se collent chacun a son bord. Le nom, lui, est un enfant direct
   de la grille et porte deja son justify-self. */
.site-nav__links { justify-self: center; display: flex; align-items: center; gap: 40px; }
.site-nav__end   { justify-self: end;    display: flex; align-items: center; gap: var(--s5); }

/* Le nom est entre dans la pilule, donc .site-nav a s'applique a lui — et le
   passait a 82 % d'opacite comme une entree de menu. Il n'en est pas une : il
   nomme le site, il ne designe pas une destination parmi d'autres. Tout le
   reste de .site-nav a (taille, graisse, interligne, approche) est deja ce
   que .brand declarait de son cote, il n'y a donc que cette ligne a reprendre. */
.site-nav .brand { opacity: 1; }
.site-nav a {
  position: relative;
  text-decoration: none; color: var(--ink);
  font-size: var(--fs-body); font-weight: var(--w-medium);
  line-height: var(--lh-nav); letter-spacing: -0.08px;
  white-space: nowrap;
  opacity: .82;
  transition: opacity .18s var(--ease);
}
.site-nav a:hover { opacity: 1; }

/* Soulignement lime de la page courante. On anime en scaleX plutot qu'en
   width : transform peut etre delegue au GPU, c'est plus fluide. */
.site-nav a::after {
  content: ''; position: absolute; left: 0; right: 0; bottom: -6px;
  height: 2px; background: var(--accent);
  transform: scaleX(0); transform-origin: left;
  transition: transform .25s var(--ease);
}
/* [aria-current] sans valeur : le soulignement marque aussi bien la page
   affichee ("page", pose par markActiveNav) que la section traversee sur
   l'accueil ("location", pose par setupHomeNavSpy). Deux etats differents
   pour un lecteur d'ecran, la meme marque a l'oeil. */
.site-nav a[aria-current] { opacity: 1; }
.site-nav a[aria-current]::after { transform: scaleX(1); }

/* Resume et Email ne sont pas des pages : ils n'ont jamais aria-current, donc
   jamais le soulignement ci-dessus — et le survol n'en revele plus non plus
   (demande utilisateur : retire) : l'opacite (.site-nav a:hover) et l'effet
   magnetique (setupMagneticNavLinks) suffisent desormais a les marquer. */
/* Le soulignement blanc a 35% de .u-arrow-link (couleur estompee de
   currentColor, blanc ici puisque --ink vaut #FFFFFF sur le theme brand) est
   retire ici (demande utilisateur) : ces deux liens ne portent plus aucun
   trait, ni a l'etat courant ni au survol, meme traitement que .foot
   .u-arrow-link. */
.site-nav__end .u-arrow-link > span:first-child { text-decoration: none; }

/* HOME N'EXISTE QUE DANS LA BARRE DU BAS. Ici, dans l'en-tete, le nom a
   gauche ramene deja a l'accueil (meme ancre, #hello) : l'entree ferait la
   meme chose une seconde fois, a 40px de distance. Sous 860px le nom est
   masque avec l'en-tete et elle devient au contraire le seul chemin — la
   section 12 la rallume. Symetrique de #nav-contact, qui fait le trajet
   inverse. */
#nav-home { display: none; }

/* Il y avait ici le bouton hamburger et ses deux barres qui se croisaient en
   X a l'ouverture. Il commandait un tiroir plein ecran qui descendait de
   l'en-tete ; la pilule etant desormais posee en permanence au bas de la
   fenetre sur mobile (section 12), il n'y avait plus rien a ouvrir. Un
   bouton "menu" a cote d'un menu deja visible, c'est une question a laquelle
   la reponse est deja affichee. Retire du HTML et du JS avec lui. */

/* ---- Lien retour persistant ----
   Place SOUS l'en-tete plutot qu'en bas d'ecran : c'est la que l'oeil cherche
   un retour, et il ne masque plus le contenu en fin de page. */
.back-link {
  position: fixed; z-index: 118;
  left: var(--gutter);
  top: calc(var(--head-h) + var(--s3));
  display: inline-flex; align-items: center; gap: var(--s2);
  height: var(--back-h);
  padding-inline: var(--s5);
  background: var(--veil); color: var(--ink);
  border: 1px solid var(--line);
  border-radius: 999px;
  text-decoration: none; font-size: var(--fs-small); font-weight: var(--w-semibold);
  -webkit-backdrop-filter: blur(20px); backdrop-filter: blur(20px);
  transition: transform .2s var(--ease), background .2s var(--ease);
}
.back-link[hidden] { display: none; }
/* Demande utilisateur : le bouton "Back" flottant reprend la forme de celui
   de la page gap (sans filet, squircle var(--radius) plutot que la pilule
   bordee) — UNIQUEMENT sur la page About (body.route-about, voir paint()
   dans app.js). Les autres pages qui partagent #back-link (etude de cas sans
   nav laterale) gardent l'habillage d'origine ci-dessus. */
body.route-about .back-link { border: none; border-radius: var(--radius); }
.back-link:hover { transform: translateX(-3px); background: var(--veil-strong); }
.back-link span:first-child { transition: transform .2s var(--ease); }
.back-link:hover span:first-child { transform: translateX(-2px); }
/* Meme raison que .cs-nav__back-icon (section 8b) : un svg inline garde par
   defaut un espace de descendance sous sa ligne de base, qui le decale vers
   le bas malgre l'align-items:center du lien. */
.back-link__icon { display: block; width: 1em; height: 1em; }


/* ==========================================================================
   6. PAGE D'ACCUEIL
   --------------------------------------------------------------------------
   Le heros fait 400px de haut dans la maquette, contenu centre verticalement,
   avec 80px de marge laterale. min-height plutot que height : si la phrase
   passe sur cinq lignes en francais, le bloc grandit au lieu de deborder.
   ========================================================================== */

/* ---- LE QUADRILLAGE DE FOND ------------------------------------------
   Une trame de 32px sous toute la page d'accueil, en-tete et pied de page
   compris. Elle donne au bleu plat une echelle et une texture, sans rien
   ajouter a lire.

   DEUX DEGRADES CSS, PAS UNE IMAGE NI UN SVG. `linear-gradient(couleur 1px,
   transparent 1px)` n'est pas un vrai degrade : c'est une bascule nette au
   premier pixel, donc une ligne. Repetee par background-size, elle pave la
   page. Le second degrade fait la meme chose a 90 degres pour les colonnes.
   Cout : zero requete, zero octet telecharge, zero noeud dans le DOM, et une
   teinte qui reste un var() — donc modifiable en une ligne section 1. Un SVG,
   meme en data-URI, aurait fallu l'encoder, l'ecrire en dur et le rendre
   sourd aux jetons ; une image aurait ete un fichier a charger pour dessiner
   ce que le moteur de rendu sait faire seul.

   `body:has(.home)`, ET NON .home : la classe est posee sur le <div> que
   pageHome() glisse dans <main>, qui ne descend ni jusqu'au pied de page (son
   frere dans #page) ni jusqu'a la bande de l'en-tete (#page commence 103px
   plus bas, l'en-tete etant dans le flux). Peindre sur <body> couvre la
   hauteur entiere du document ; le :has() fait de <main> l'INTERRUPTEUR sans
   qu'app.js ait une classe de plus a poser et a retirer.

   `:not(.is-overlay)` : quand une etude de cas est ouverte, la photo de
   l'accueil (#underlay) contient un CLONE du DOM, classe .home comprise, et
   le :has() continuerait donc de matcher. Le calque est opaque, on ne verrait
   rien — mais peindre une grille sous une page qui n'est plus a l'ecran est
   un piege pour la prochaine lecture. On l'eteint explicitement.

   DEUX PSEUDO-ELEMENTS, ET NON LE FOND DE <body> COMME AVANT. Des traits
   POINTILLES ne se dessinent pas avec un degrade : un linear-gradient ne varie
   que sur UN axe, or une ligne horizontale en pointilles demande les deux — un
   trait de 1px tous les --grid-cell en y, ET une alternance trait/vide en x.
   Aucune combinaison de background-size ou de background-repeat n'y arrive
   (essayees : la tuile finit toujours par peindre des COLONNES de la hauteur
   d'une cellule au lieu d'un filet).
   On separe donc les deux familles de traits, chacune sur son pseudo-element
   avec son propre masque : ::before porte les horizontales, masquees en x ;
   ::after les verticales, masquees en y. Un masque s'applique a l'element
   entier, pas a une couche de fond — d'ou deux elements et non deux couches.
   Les mettre sur <body> lui-meme etait exclu : le masque aurait decoupe le
   CONTENU de la page avec la grille.

   POURQUOI PAS UN SVG en data-URI, qui ferait ça d'une ligne (stroke-dasharray) :
   il faudrait y ecrire la couleur en dur, donc perdre --grid-line, et c'est
   precisement ce qu'on avait refuse en posant la grille (note ci-dessus).

   L'ANIMATION EST UNE TRANSLATION, PAS UN background-position ANIME.
   Deplacer un fond fait repeindre toute la surface a chaque image ; `translate`
   est delegue au compositeur. Et surtout le rendu est le bon : les horizontales
   sont IDENTIQUES le long de x, donc les glisser en x ne deplace pas les lignes
   — seuls les tirets defilent. Idem en y pour les verticales. C'est l'effet
   recherche, obtenu sans que rien d'autre ne bouge.
   La boucle est raccord parce qu'on translate d'exactement une periode : au
   dernier keyframe le motif se superpose a lui-meme, la reprise est invisible.
   inset negatif d'une cellule : la translation ne doit jamais decouvrir de
   bord vide, et une cellule est plus large qu'une periode.

   position: absolute (et non fixed) — la grille defile avec la page au lieu
   de rester posee sur la fenetre. Le calque est ANCRE SUR #page ET NON SUR
   <body> : #page est deja `position: relative`, et surtout <body> ne peut
   pas porter `overflow: clip` sans consequence — c'est le meme element que
   `body.is-locked { overflow: hidden }` (section 2) utilise pour bloquer le
   defilement de TOUTE la fenetre pendant le loader/portail, via la
   propagation CSS du overflow de <body> vers la racine <html>. Poser un
   overflow non-visible sur <body> de façon permanente aurait donc bloque le
   defilement du site entier, pas seulement le debord de la grille. #page,
   lui, est un conteneur de decoupage ordinaire : lui donner `overflow: clip`
   ne declenche aucune propagation, et sa hauteur suit deja celle du contenu
   en flux normal (il contient tout, jusqu'au pied de page), ce qui couvre
   toute la page sans --hero-min/min-height explicite ici.

   z-index: -1 — et non 0. La grille est maintenant un enfant de #page, au
   meme titre que le contenu de la page (hero, cartes...). Dans l'ordre de
   peinture d'un contexte d'empilement (CSS2 Appendix E), le contenu en flux
   normal non positionne (etape 3) passe AVANT les descendants positionnes a
   z-index 0 (etape 6) : a z-index 0, la grille se peindrait donc AU-DESSUS
   des cartes, pas dessous. A z-index -1 (etape 2), elle passe avant l'etape
   3 et reste bien derriere tout le contenu. #page a besoin de son propre
   z-index (ici 1, deja pose ci-dessous) pour former son propre contexte
   d'empilement — sinon -1 la ferait passer derriere le fond de <body> au
   lieu de derriere son propre contenu. */
#page { position: relative; z-index: 1; }

/*
body:not(.is-overlay):has(.home) #page {
  overflow: clip;
}

body:not(.is-overlay):has(.home) #page::before,
body:not(.is-overlay):has(.home) #page::after {
  content: '';
  position: absolute;
  inset: calc(-1 * var(--grid-cell));
  z-index: -1;
  pointer-events: none;
}

body:not(.is-overlay):has(.home) #page::before {
  background-image: linear-gradient(var(--grid-line) 1px, transparent 1px);
  background-size: 100% var(--grid-cell);
  -webkit-mask-image: var(--grid-dash-x);
          mask-image: var(--grid-dash-x);
  animation: gridMarchX var(--grid-march) linear infinite;
}

body:not(.is-overlay):has(.home) #page::after {
  background-image: linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
  background-size: var(--grid-cell) 100%;
  -webkit-mask-image: var(--grid-dash-y);
          mask-image: var(--grid-dash-y);
  animation: gridMarchY var(--grid-march) linear infinite;
}

@keyframes gridMarchX { to { translate: var(--grid-period) 0; } }
@keyframes gridMarchY { to { translate: 0 var(--grid-period); } }
*/


/* ---- L'ANCIENNE GRILLE, CONSERVEE ET DESACTIVEE ----------------------
   La trame d'origine : traits PLEINS, maille de 32px, immobile, peinte
   directement sur le fond de <body>. C'est elle que remplacent les deux
   pseudo-elements ci-dessus.

   REVENIR EN ARRIERE — TROIS GESTES, ET IL FAUT LES TROIS :
     1. decommenter la regle ci-dessous ;
     2. commenter les trois regles qui precedent (le ::before/::after commun,
        puis chacun des deux) ainsi que gridMarchX / gridMarchY ;
     3. remettre --grid-cell a 32px en section 1.
   Le point 3 n'est pas cosmetique, et l'oublier ferait passer un retour en
   arriere pour un bug : 96px a ete choisi POUR le pointille (voir la note du
   jeton). Des traits pleins tous les 96px donnent une trame trop lache, ou
   l'on ne lit plus une grille mais quelques lignes perdues.
   --grid-line, lui, n'a pas a bouger : il est reste a .09, la valeur d'origine.

   Les jetons du pointille (--grid-dash, --grid-period, les deux masques,
   --grid-march) deviennent alors inutilises. Les laisser declares ne coute
   rien et evite d'avoir a les retrouver au retour.

   CE QUE L'ON REPERD EN REVENANT. Un fond de <body> defile avec la page,
   d'ou le min-height: 100vh de la regle : <html> porte deja son propre
   `background` (section 2), donc le fond de <body> ne se propage PAS au
   canevas et s'arreterait a la hauteur du contenu — sur un grand ecran, la
   grille cesserait en plein milieu de la fenetre. La version en pseudo-
   elements est `position: fixed` et n'a pas ce probleme.
   background-position: -1px -1px decale la trame d'un pixel pour que la
   PREMIERE ligne tombe sur le bord de la page ; sans lui, le haut et la
   gauche montrent une cellule tronquee.
   En echange, c'est une seule regle, sans masque ni animation. */
body:not(.is-overlay):has(.home) {
  background-image:
    linear-gradient(var(--grid-line) 1px, transparent 1px),
    linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
  background-size: var(--grid-cell) var(--grid-cell);
  background-position: -1px -1px;
  min-height: 100vh;
}

/* --hero-min est un jeton et non 400px ecrits ici : le media 860px doit
   l'augmenter d'autant qu'il augmente le retrait haut (voir section 12), et
   deux nombres qui doivent bouger ensemble ne peuvent pas etre ecrits deux
   fois. */
.hero {
  display: flex; flex-direction: column; justify-content: center;
  --hero-min: 400px;
  min-height: var(--hero-min);
  padding-block: var(--s7);
}
.hero__text { display: flex; flex-direction: column; gap: var(--s5); }

/* "Hey [prenom], nice to meet you!" — 24px Regular, le prenom en SemiBold. */
.hero__hello {
  font-size: var(--fs-h2);
  font-weight: var(--w-regular);
  position: relative;          /* le referentiel de la barre ci-dessous */
  /* Ramene le paragraphe a la largeur de son texte. Sans cette ligne il
     s'etire sur toute la colonne (comportement par defaut d'un enfant de
     flex), et la barre balaierait plusieurs centaines de pixels de vide a
     droite de la phrase. Le texte etant deja cale a gauche, rien ne bouge
     a l'ecran ; seule la boite change. */
  align-self: flex-start;
  /* Meme opacite que .hero__statement (demande utilisateur) : "Hey" et
     "nice to meet you!" restent dans le meme registre attenue, mais PAS le
     prenom saisi au portail — voir .hero__hello b juste en dessous.
     --hello-ink plutot qu'un `color` direct : @keyframes helloInk (juste en
     dessous) doit pouvoir y retomber a la fin du balayage de presentation
     sans passer par `color: inherit` (qui prendrait la couleur PLEINE de
     l'ancetre, pas cette teinte attenuee). */
  --hello-ink: color-mix(in srgb, var(--ink) 70%, transparent);
  color: var(--hello-ink);
}
.hero__hello b {
  font-weight: var(--w-semibold);
  /* Le prenom (pose dans un <b>, voir renderHello() dans app.js) reste
     pleinement lumineux, contrairement au reste de la phrase ci-dessus. */
  color: var(--ink);
}

/* ---- Le balayage qui presente le prenom -------------------------------
   Rejoue le geste de la maquette Lottie : un rectangle lime pousse depuis la
   gauche jusqu'a couvrir toute la ligne, puis se retire par la droite en
   decouvrant le prenom. Il ne joue QU'UNE FOIS, au premier affichage du
   prenom apres le portail (voir state.helloReveal dans app.js).

   POURQUOI DEUX ANIMATIONS ET NON UNE SEULE. Pour que la barre PRESENTE le
   prenom, il faut que le prenom soit invisible avant son passage — sinon on
   voit un texte deja la, qu'un rectangle efface puis redecouvre. Or la barre
   est un ::after, donc un ENFANT du paragraphe : masquer le texte par
   l'opacite emporterait la barre avec lui. Le texte est donc masque par sa
   COULEUR (transparent), que la barre ne suit pas puisqu'elle se peint avec
   un background. La bascule est instantanee et placee a mi-parcours, a
   l'instant precis ou la barre est a pleine largeur : le texte reprend sa
   couleur DERRIERE la barre, personne ne peut le voir arriver. */
.hero__hello.is-revealing {
  animation: helloInk var(--hello-wipe) var(--hello-delay) both;
}
/* Le <b> du prenom a sa PROPRE couleur de repos depuis peu (var(--ink), plus
   claire que --hello-ink du reste de la phrase — demande utilisateur) : il
   n'herite donc plus automatiquement du `color: transparent` anime sur le
   paragraphe. Meme balayage, meme duree/delai, mais vers var(--ink) plutot
   que --hello-ink, pour que le prenom reste cache puis revele EN MEME TEMPS
   que le reste — sinon il serait visible avant que la barre ne passe. */
.hero__hello.is-revealing b {
  animation: helloInkName var(--hello-wipe) var(--hello-delay) both;
}
.hero__hello.is-revealing::after {
  content: '';
  position: absolute;
  /* La barre deborde legerement le texte. Une boite de ligne s'arrete aux
     jambages et au blanc lateral des glyphes : reglee a `inset: 0`, la barre
     laisserait depasser le bas des "y" et la premiere lettre pendant la
     fraction de seconde ou elle est censee tout couvrir. */
  inset: -.14em -.3em;
  background: var(--accent);
  pointer-events: none;
  animation: helloWipe var(--hello-wipe) var(--hello-delay) both;
}

/* Une seule propriete animee, trois etapes : largeur nulle collee a gauche,
   pleine largeur, largeur nulle collee a droite. C'est exactement ce que fait
   la maquette, ou le rectangle grandit vers la droite puis voit son bord
   GAUCHE rattraper son bord droit.
   Les deux courbes en viennent aussi : la barre arrive en freinant (elle se
   pose sur le prenom), elle repart en accelerant (elle s'en va). Une courbe
   unique sur les deux moities donnerait un aller-retour mou. */
@keyframes helloWipe {
  0%   { clip-path: inset(0 100% 0 0); animation-timing-function: cubic-bezier(.5, 0, 0, 1); }
  50%  { clip-path: inset(0 0 0 0);    animation-timing-function: cubic-bezier(.5, 0, 1, 1); }
  100% { clip-path: inset(0 0 0 100%); }
}

/* 49.9 puis 50 : la couleur revient d'un coup. L'interpolation dure 0,08ms,
   soit rien du tout, et elle se produit de toute facon sous la barre. */
@keyframes helloInk {
  0%, 49.9% { color: transparent; }
  50%, 100% { color: var(--hello-ink); }
}
@keyframes helloInkName {
  0%, 49.9% { color: transparent; }
  50%, 100% { color: var(--ink); }
}

.hero__block { display: flex; flex-direction: column; gap: var(--s5); }
.hero__lines { display: flex; flex-direction: column; gap: var(--s2); }

/* "I'm Marvin," — 32px Regular. */
.hero__name {
  font-size: var(--fs-hero);
  font-weight: var(--w-regular);
  line-height: 1.25;
  /* Pleine opacite (demande utilisateur) — contrairement a .hero__statement,
     qui reste attenue en dessous. */
  color: var(--ink);
}

/* La phrase principale — 32px Medium, largeur bloquee a 755px dans Figma. */
.hero__statement {
  font-size: var(--fs-hero);
  font-weight: var(--w-regular);
  line-height: 1.25;
  max-width: 755px;
  /* Un vrai `opacity` sur .hero__statement estomperait aussi ses enfants
     (l'opacite d'un parent plafonne celle des descendants, meme a opacity:1
     dessus) — les mots en lime et les liens doivent rester pleinement
     visibles. On dilue donc directement la couleur du texte courant, et on
     rend aux enfants qui doivent y echapper leur propre couleur pleine
     ci-dessous. */
  color: color-mix(in srgb, var(--ink) 70%, transparent);
}
/* Desktop seulement (demande utilisateur) : a 755px, la 3e ligne ("I
   previously worked at Petal, Fit-Plans, and Gekko↗.") depasse tout juste
   (~764px de large avec les liens) et perd "Gekko" seul sur une 4e ligne.
   Un peu plus de marge suffit a la faire tenir entiere, sans toucher aux
   deux lignes du dessus (bien plus courtes). Inutile sur mobile : la
   largeur de l'ecran y contraint deja tout, cette regle n'y changerait
   rien. */
@media (min-width: 861px) {
  .hero__statement { max-width: 790px; }
}
/* Les mots accentues, en blanc plein (demande utilisateur — plus en lime) :
   sans cela ils heriteraient du 70% attenue de .hero__statement ci-dessus. */
.hero__statement .accent { color: var(--ink); }
/* Les liens (Petal/Fit-Plans/Gekko) passent au lime (demande utilisateur —
   plus blancs) avec une graisse Medium ; leur soulignement (qui suit
   currentColor) suit la meme teinte. */
.hero__statement .u-arrow-link { color: var(--accent); font-weight: var(--w-medium); }
/* La fleche de Gekko, elle, reste blanche (REVERT demande utilisateur) :
   sans cette regle, `stroke="currentColor"` suivrait le lime pose juste
   au-dessus comme le soulignement. Inchangee sinon — opacite, transition et
   saut au survol restent ceux de .u-arrow-link__icon. */
.hero__statement .u-arrow-link .u-arrow-link__icon { color: var(--ink); }
/* Les employeurs sont soulignes et pointent chacun vers une etude de cas ou
   (Gekko) un site externe : voir .u-arrow-link (plus bas, section "liens
   soulignes reutilisables") pour le soulignement estompe -> plein et la
   fleche. Gekko seul a une fleche (seg.href dans renderStatement) : elle
   signale qu'il quitte le site, contrairement a Petal/Fit-Plans qui restent
   internes — mais TOUS passent par .u-arrow-link (Petal/Fit-Plans n'ont
   simplement pas de span d'icone a l'interieur). */
.hero__statement .u-arrow-link { white-space: nowrap; }
/* Petal et Gekko seuls gardent un soulignement (Fit-Plans le retire plus bas
   via .fitplans-experiment) : trait et decalage plus genereux qu'ailleurs sur
   le site, demandes ici specifiquement. (0,3,1) : l'emporte sur le (0,2,1)
   de la regle .u-arrow-link > span:first-child generique, peu importe l'ordre
   dans la feuille. */
.hero__statement .u-arrow-link > span:first-child {
  text-decoration-thickness: 2px;
  text-underline-offset: 6px;
}

/* Le lien vers l'article sur les deux ans : meme composant .u-arrow-link,
   avec ses propres taille/graisse de texte et son alignement dans la
   colonne flex du heros. */
.hero__gap {
  align-self: flex-start;
  font-size: var(--fs-body); font-weight: var(--w-medium);
  /* Moins lumineux au repos (demande utilisateur) : meme opacite que le
     texte courant de .hero__statement, pour rester dans le meme registre
     visuel que le reste du heros. */
  color: color-mix(in srgb, var(--ink) 70%, transparent);
}
/* Plus de soulignement (demande utilisateur) — le contraste plein/attenue
   du libelle fait desormais lui-meme le signal de survol. Meme specificite
   (0,2,1) que .u-arrow-link > span:first-child, mais plus bas dans la
   feuille : l'emporte. */
.hero__gap > span:first-child { text-decoration: none; }
.hero__gap:hover,
.hero__gap:focus-visible { color: var(--ink); }
/* La fleche est passee avant le libelle (demande utilisateur) : seule la
   marge change de cote pour garder le meme espacement visuel entre les deux
   — opacite et saut au survol restent ceux de .u-arrow-link__icon,
   inchanges. `margin-right` rejoint la liste `transition` (sinon cette regle,
   plus specifique, ecraserait celle de .u-arrow-link__icon) pour que
   l'espacement fleche/texte s'anime lui aussi au survol (demande
   utilisateur) : quelques pixels de plus entre les deux. */
.hero__gap .u-arrow-link__icon {
  margin-left: 0; margin-right: 3px;
  transition: opacity .2s ease-out, transform .2s var(--ease), margin-right .2s var(--ease);
}
.hero__gap:hover .u-arrow-link__icon,
.hero__gap:focus-visible .u-arrow-link__icon { margin-right: 7px; }

/* EXPERIMENT (demande utilisateur) : un surlignage clair et translucide au
   survol seulement, inspire de alvinn.design/projects/kitchen-experiment —
   assez sombre pour rester lisible derriere le texte blanc, assez leger pour
   ne pas l'ecraser. Le padding est deja reserve au repos (fond transparent)
   et compense par une marge negative egale : la surbrillance apparait sans
   decaler le mot ni ses voisins ("Petal," ne bouge pas quand le fond
   apparait). Le lien "deux ans" (.gap-experiment) porte desormais le meme
   traitement que Petal/Fit-Plans/Gekko (demande utilisateur) — il l'avait eu
   un temps puis perdu (REVERT), il est repris ici. Regles separees (plutot
   qu'une seule classe partagee) pour garder chacune modifiable/revertible
   independamment. */
.petal-experiment,
.fitplans-experiment,
.gekko-experiment,
.gap-experiment {
  padding-inline: 4px;
  margin-inline: -4px;
  border-radius: 4px;
  font-style: normal;
  transition: background-color .2s ease-out;
}
.petal-experiment:hover,
.fitplans-experiment:hover,
.gekko-experiment:hover,
.gap-experiment:hover { background-color: rgba(230, 230, 230, .3); }
/* Soulignement retire en permanence sur les trois (pas seulement au survol),
   pour que la boite au survol soit le seul signal. ANCIEN COMPORTEMENT DE
   PETAL, GARDE EN MEMOIRE POUR REVENIR EN ARRIERE au besoin : le soulignement
   estompe (herite de .u-arrow-link) restait visible au repos, et seul le
   survol le retirait via `.petal-experiment:hover > span:first-child
   { text-decoration: none; }`. Pour restaurer ce comportement sur Petal :
   supprimer "petal-experiment" de la regle unconditionnelle juste en dessous
   et decommenter la ligne suivante.
   .petal-experiment:hover > span:first-child { text-decoration: none; } */
.petal-experiment > span:first-child,
.fitplans-experiment > span:first-child,
.gekko-experiment > span:first-child { text-decoration: none; }

/* EFFET MAGNETIQUE (Petal/Fit-Plans/Gekko + le lien "deux ans", tous les
   quatre) : voir setupMagneticHeroLinks() dans app.js, qui pose `transform`
   en ligne a chaque evenement pointeur. Cette regle-ci ne fait que lisser ce
   changement — c'est elle qui donne le petit rebond ("damped spring" du
   prompt d'origine) a l'aller comme au retour au centre.
   Redeclare tout le raccourci `transition` (et pas juste
   `transition-property: transform`) : sinon cette regle, plus
   specifique/plus bas que la regle de base ci-dessus, ecraserait aussi sa
   transition `background-color` — .gap-experiment en a desormais une aussi
   (voir plus haut), il rejoint donc le groupe au lieu d'avoir sa propre
   regle `transform` seul. */
.petal-experiment,
.fitplans-experiment,
.gekko-experiment,
.gap-experiment {
  /* Rebond reduit (demande utilisateur) : le deuxieme point de controle
     descend de 1.56 a 1.15, un depassement plus discret qu'avant plutot
     qu'un aller-retour tres marque. */
  transition: background-color .2s ease-out, transform .5s cubic-bezier(.34, 1.15, .64, 1);
}
/* Focus clavier : un leger soulevement fixe plutot qu'un suivi du pointeur
   (qui n'existe pas au clavier) — le contour vient deja de la regle
   :focus-visible globale (voir plus haut dans ce fichier). */
.petal-experiment:focus-visible,
.gap-experiment:focus-visible,
.fitplans-experiment:focus-visible,
.gekko-experiment:focus-visible { transform: translateY(-2px); }

/* Les sections de la page d'accueil. 80px de respiration verticale. */
.section { padding-block: var(--s8); }
.section__head { margin-bottom: var(--s6); }
/* "Work" / "Side quests" (les deux seuls .section__head du site, tous deux
   des h2) : meme taille que la phrase du heros, --fs-hero plutot que
   --fs-h1 (demande utilisateur). */
.section__head h1,
.section__head h2 { font-size: var(--fs-hero); margin-bottom: var(--s3); font-weight: var(--w-medium); }
.section__head p { color: var(--ink-soft); max-width: var(--measure); }


/* ==========================================================================
   7. CARTES DE PROJET
   --------------------------------------------------------------------------
   La maquette n'encadre pas les cartes : l'image est posee directement sur le
   bleu, le texte dessous. Deux colonnes, 80px d'ecart.
   ========================================================================== */

.cards {
  display: grid;
  gap: var(--s8) var(--s8);
  /* Deux colonnes tant que la place le permet, une seule ensuite.
     auto-fit + minmax remplace une dizaine de media queries. */
  grid-template-columns: repeat(auto-fit, minmax(min(420px, 100%), 1fr));
}

.card {
  display: flex; flex-direction: column; gap: var(--s4);
  text-decoration: none; color: inherit;
}

/* aspect-ratio releve dans Figma (1280/1072). Il garantit que toutes les
   vignettes ont la meme hauteur sans fixer de pixels. */
.card__media {
  position: relative;
  aspect-ratio: 1280 / 1072;
  overflow: hidden;
  background: rgba(0,0,0,.12);
  border-radius: var(--radius-sm);
  display: flex;
  align-items: center;
  justify-content: center;
}
.card__media img,
.card__media svg {
  width: 100%; height: 100%;
  object-fit: cover;
  transition: transform .5s var(--ease);
}
.card:hover .card__media img,
.card:hover .card__media svg { transform: scale(1.03); }

/* Vignette de la carte "Scheduling constraints" : sa video est cadree en
   portrait (voir .cs__hero-media plus bas), donc object-fit: cover la
   recadrerait au lieu de la montrer en entier. #e9e1f9 est relevee sur
   constraint-limit.jpg, la meme couleur de fond que la video elle-meme —
   voir le commentaire complet sur .cs__hero-media .figure__frame. */
.card[data-slug="constraints"] .card__media { background: #e9e1f9; }

/* Meme raison, pour la vignette "Licence management" (licence-hero.mp4) :
   video cadree en portrait comme constraints ci-dessus, meme traitement
   (contain, reduite a 92% pour laisser respirer le fond autour). #eeeaf5 est
   la couleur de fond de la video elle-meme (voir .cs__hero-media plus bas).
   MORTE depuis la fusion (branche petal-merged-projects) : la carte d'accueil
   du projet fusionne porte data-slug="petal-controls" et une image statique,
   plus la video licence-hero.mp4. Laissee en place (facile a retirer si la
   branche est abandonnee) plutot que supprimee. */
.card[data-slug="licence-management"] .card__media { background: #eeeaf5; }

/* Bible app : le Lottie (ecran de telephone) n'a pas de fond propre a
   reprendre — #f0f0f0, le meme gris clair que .cs-brands, sert de fond a la
   vignette. */
.card[data-slug="bible-app"] .card__media { background: #f0f0f0; }

/* Meme raison que le lilas de "Scheduling constraints" ci-dessus : la video
   n'a de quoi peindre qu'une fois decodee (preload="metadata" ne charge que
   l'entete), et .card__media n'est que 12% opaque (rgba(0,0,0,.12), pensee
   pour un <img> qui decode en un instant). Le temps que la video demarre —
   surtout sur la page d'accueil, ou la grille de fond est juste derriere —
   la carte laissait voir cette grille par transparence. #581938 est la
   couleur moyenne du fichier (salle eclairee au magenta), releve sur
   plusieurs images de salsa-cover.mp4. */
.card[data-slug="salsa"] .card__media { background: #581938; }

/* Meme raison, pour la vignette Soundcloud (soundcloud-hero.mp4) : #f3591c
   est la meme moyenne de coins que .cs__hero-media[data-slug="soundcloud"]
   plus bas, releve sur son poster. */
.card[data-slug="soundcloud"] .card__media { background: #f3591c; }

/* Hoot : meme GIF que la couverture (hoot-hero.gif) sur la vignette
   d'accueil — trop petit/anime pour object-fit: cover comme les autres
   vignettes, donc contain et reduit, sur le meme fond #2d254b qu'en
   couverture (voir .cs__hero-media[data-slug="hoot"] plus bas). */
.card[data-slug="hoot"] .card__media { background: #2d254b; }
.card[data-slug="hoot"] .card__media img {
  width: 55%; height: 55%; object-fit: contain;
}

/* Yabara : meme traitement que Hoot ci-dessus — logo sur fond uni
   (yabara-hero.webp), reduit et centre sur son propre gris fonce (#2F2F2F,
   nouvelle version du logo — demande explicite, remplace l'ancien bleu
   #314466) plutot qu'etire en pleine vignette. */
.card[data-slug="yabara"] .card__media { background: #2F2F2F; }
.card[data-slug="yabara"] .card__media img {
  width: 55%; height: 55%; object-fit: contain;
}

.card__body { display: flex; flex-direction: column; gap: var(--s4); }
.card__title-block { display: flex; flex-direction: column; gap: var(--s2); }

/* Le nom du client, 16px Regular. */
.card__client { font-size: var(--fs-body); font-weight: var(--w-regular); }

/* Le titre du cas, 24px Regular. Il RESTE BLANC au survol : il passait au
   vert d'accent, ce qui en faisait le signal de survol de la carte. Ce role
   avait ete repris par le recul de tout le reste de la page (section 7 bis) —
   et le vert, lui, restait allume sur la carte survolee pendant que ses
   voisines s'effaçaient, donc deux signaux pour un seul geste.
   La transition sur `color` part avec la regle : plus rien ne fait varier
   cette couleur, une transition n'aurait plus rien a interpoler.

   ATTENTION, LE RAISONNEMENT CI-DESSUS N'EST PLUS COMPLET : le recul de la
   section 7 bis est maintenant desactive. Le seul signal de survol restant
   sur une carte est donc l'echelle de 1.03 (plus bas dans cette section). Ca
   suffit — le geste reste lisible — mais si l'on juge la carte trop muette,
   c'est ici qu'on rallume le vert, sans avoir a toucher a la section 7 bis.
   REVENIR EN ARRIERE : `transition: color .18s var(--ease)` ici, plus
   `.card:hover .card__title { color: var(--accent); }`. */
.card__title {
  font-size: var(--fs-h2);
  font-weight: var(--w-regular);
  line-height: 1.3;
}
/* Les chiffres marquants du titre passent en SemiBold, comme dans Figma
   ("...reducing rules from 24 to 9"). */
.card__title b { font-weight: var(--w-semibold); }

/* Les etiquettes : pastilles blanches, texte bleu, 8px d'ecart. */
.card__tags { display: flex; flex-wrap: wrap; gap: var(--s2); }
.tag {
  display: inline-flex; align-items: center;
  padding: 4px 8px;
  background: var(--tag-bg); color: var(--tag-fg);
  border-radius: var(--radius-tag);
  font-size: var(--fs-body); font-weight: var(--w-semibold);
  line-height: 1.35; white-space: nowrap;
}

/* Les etiquettes de la grille d'accueil restent cachees au repos et
   n'apparaissent qu'au survol de la carte — gate (hover: hover) : sur
   tactile :hover reste accroche apres un tap, la carte resterait sans
   etiquettes visibles une fois relachee. */
@media (hover: hover) and (pointer: fine) {
  #main .home .card .card__tags {
    opacity: 0;
    transform: translateY(6px);
    transition: opacity .22s var(--ease), transform .22s var(--ease);
  }
  #main .home .card:hover .card__tags {
    opacity: 1;
    transform: translateY(0);
  }
}


/* ==========================================================================
   7 bis. LE SURVOL "VITRINE" DES VIGNETTES D'ACCUEIL
   --------------------------------------------------------------------------
   LE RECUL "VITRINE" EST ACTIF ; LA PASTILLE RESTE DESACTIVEE. Le code de
   cette derniere est conserve entier, avec ses explications, et se rallume
   par UN SEUL geste :

     - LA PASTILLE : decommenter l'appel `setupCursorPill();` dans paint()
       (app.js). Tout le CSS de la pastille ci-dessous est DEJA en place et
       n'a pas eu besoin d'etre commente : chacune de ses regles exige soit la
       classe has-card-cursor, soit un element .card-cursor, et le script est
       le seul a produire l'une et l'autre. Sans lui, elles ne designent rien.

     - LE RECUL "VITRINE" peut au besoin etre re-desactive en recommentant les
       deux regles ci-dessous (la transition, puis le :has() qui pose
       --showcase-dim). Rien d'autre a toucher.

   Le reste de cette note decrit les deux effets tels qu'ils jouent une fois
   rallumes ; elle reste donc valable telle quelle.

   Survoler une vignette fait reculer TOUT LE RESTE de la page : les autres
   cartes, le heros, les intitules de section, le pied de page et la pilule de
   navigation. La vignette visee ne bouge pas — c'est le decor qui s'efface
   autour d'elle. L'echelle de 1.03 posee sur .card:hover (section 7) continue
   de jouer par-dessus, les deux se cumulent ; le titre, lui, ne passe plus au
   vert (voir sa note en section 7).

   Le meme survol remplace aussi le pointeur par une pastille "View case
   study" : voir .card-cursor a la fin de cette section.

   POURQUOI SIX SELECTEURS ET NON UN VOILE PAR-DESSUS LA PAGE.
   La solution evidente — un calque de la couleur du fond qu'on fait monter en
   opacite, la carte survolee passant au-dessus — est ici IMPOSSIBLE. Les
   cartes vivent dans #page, qui porte `overflow: clip` et un z-index de 125
   (section 11 bis) : c'est un contexte d'empilement, donc rien de ce qu'il
   contient ne peut passer devant un calque pose a l'exterieur, quel que soit
   son z-index. Il aurait fallu mettre le voile DANS #page, ou il n'aurait
   couvert ni l'en-tete ni le fond de la fenetre. On eclaircit donc chaque
   bloc separement.

   ET NON PAS UNE OPACITE SUR UN PARENT COMMUN, pour la meme famille de
   raisons : `opacity` s'applique au sous-arbre APLATI, un enfant ne peut pas
   la reprendre a son compte. Eclaircir .home puis rendre sa pleine valeur a
   la carte survolee ne marche pas — il n'existe pas de "opacity: 1 quand
   meme".

   LA LISTE EST COMPLETE, et on peut le verifier : pageHome() ne produit que
   le heros, puis deux sections faites d'un .section__head et d'une grille de
   .card. Ces trois selecteurs couvrent donc 100% du contenu de l'accueil ;
   restent le pied de page et la pilule, qui vivent en dehors de #main.

   .card:not(:hover) et non :not(:has(.card__media:hover)) : le survol remonte
   aux ancetres, donc survoler la vignette survole deja sa carte. Les deux
   ecritures designent le meme element, la premiere sans imbriquer un :has().

   PORTEE. `#main .home` exclut d'un coup deux faux positifs : les cartes
   "projet suivant" d'une etude de cas (meme composant, meme classe, rendu par
   le meme projectCard()) et le clone de l'accueil pose dans #underlay pendant
   qu'une fiche est ouverte.

   (hover: hover) and (pointer: fine) fait le vrai travail du garde-fou : sur
   un ecran tactile, :hover se declenche au TOUCHER et RESTE accroche jusqu'au
   toucher suivant — la page resterait eteinte apres un tap sur une vignette.
   La largeur minimale n'est la que parce que l'effet a ete demande pour le
   bureau seul ; c'est la borne de la barre d'onglets (section 12).
   ========================================================================== */

@media (hover: hover) and (pointer: fine) and (min-width: 861px) {
  /* La transition est posee au REPOS, pas dans l'etat survole : declaree sur
     l'etat d'arrivee seulement, elle n'aurait rien a interpoler au retour et
     la page se rallumerait d'un coup. */
  #main .home .hero,
  #main .home .section__head,
  #main .home .card,
  .site-foot { transition: opacity var(--showcase-ms) var(--ease); }
  /* .site-nav a la sienne dans la section 5 : elle transitionne deja son
     fond, et une seconde declaration `transition` effacerait la premiere. */

  body:has(#main .home .card:hover) :is(
    #main .home .hero,
    #main .home .section__head,
    #main .home .card:not(:hover),
    .site-foot,
    .site-nav
  ) { opacity: var(--showcase-dim); }

  /* ---- LA PASTILLE QUI REMPLACE LE POINTEUR ---------------------------
     Le pointeur systeme s'efface sur la vignette et une pastille "View case
     study" prend sa place, centree sur lui et le suivant a la trace.

     LE `cursor: none` EST CONDITIONNE A LA PRESENCE DU JAVASCRIPT, via la
     classe que setupCardCursor pose sur <body>. C'est tout l'interet de la
     poser : si le script ne tourne pas — erreur en amont, module non charge —
     la regle ne s'applique pas et l'on garde un pointeur ordinaire. L'ecrire
     sans condition aurait donne, dans ce cas, une vignette sur laquelle le
     pointeur disparait sans que rien ne le remplace.

     C'est CSS, et non le script, qui decide quand l'effet joue : la pastille
     n'est visible que dans ce media et sous ce :has(). Le script se contente
     de fournir des coordonnees. Redimensionner la fenetre sous 861px eteint
     donc tout — pastille ET cursor:none — sans que le script ait a surveiller
     quoi que ce soit, et sans risque de voir les deux gardes diverger. */
  /* LES DEUX FAMILLES DE CIBLES, ici et dans CURSOR_PILL_TARGETS (app.js).
     L'accueil : les vignettes de projet, pastille "View case study".
     Les etudes de cas : les visuels agrandissables, pastille "Zoom".

     :not(.is-zoomed) — une fois l'image agrandie on ne l'ouvre plus, on la
     PROMENE (cursor: grab, section 8) : la pastille mentirait, et masquer le
     pointeur pendant un glisser-deposer est exactement ce qu'il ne faut pas
     faire. La classe va et vient au clic, bien apres le montage du script,
     d'ou une condition CSS et non une condition JS.
     :not(--mobile-only) — ces visuels-la ne sont agrandissables que sous
     700px (zoomableClass, app.js), donc jamais dans ce media. */
  /* .cursor-pill-idle est pose par le script quand le DEFILEMENT a fait glisser
     le contenu sous un pointeur immobile : :hover n'est alors plus reevalue par
     le navigateur et designe encore le visuel d'avant (voir setupCursorPill,
     app.js §8 quater). Il coupe les deux regles a la fois — la pastille ET le
     masquage du pointeur — parce que c'est precisement ensemble qu'elles
     tournaient a vide : une etiquette figee au milieu de l'ecran, et plus
     aucun pointeur visible dessous.
     Ecrit DANS les deux selecteurs et non comme une regle qui les annule
     ensuite : un :has() prend la specificite de son argument le plus fort,
     donc (1,4,1) ici, qu'une regle d'annulation ordinaire ne pourrait pas
     battre sans surenchere. */
  body.has-card-cursor:not(.cursor-pill-idle) :is(
    #main .home .card__media,
    #main .zoomable-media:not(.is-zoomed):not(.zoomable-media--mobile-only)
  ) { cursor: none; }

  body:not(.cursor-pill-idle):has(#main .home .card__media:hover) .card-cursor,
  body:not(.cursor-pill-idle):has(#main .zoomable-media:not(.is-zoomed):not(.zoomable-media--mobile-only):hover) .card-cursor {
    opacity: 1; scale: 1;
  }
}

/* La pastille vit en dehors du media : le script l'ajoute au document quelle
   que soit la largeur, et ce sont les deux regles ci-dessus qui l'allument.
   Hors du media elle reste donc a opacity 0, inerte et invisible.

   ENFANT DE <body>, ET C'EST OBLIGATOIRE. Dans #page elle serait prise dans
   son `overflow: clip` et dans son contexte d'empilement (section 11 bis) :
   rognee au bord de la fiche, et incapable de passer devant l'en-tete.

   translate ET scale EN PROPRIETES SEPAREES, pas un `transform` unique. Le
   script ecrit la position (via --cc-x / --cc-y) pendant que le CSS anime
   l'apparition : deux auteurs pour une seule propriete `transform`
   s'ecraseraient l'un l'autre a chaque image. Ce sont deux proprietes
   independantes, elles se composent sans se marcher dessus.
   Les pourcentages de `translate` se calculent sur la boite de l'element :
   `- 50%` la centre donc sur le pointeur quelle que soit la longueur du
   libelle, qu'on n'a jamais a mesurer.

   pointer-events: none, sinon la pastille se glisse sous le pointeur, devient
   elle-meme la cible du survol, la vignette cesse d'etre survolee, la regle
   :has() tombe, la pastille disparait — et le cycle recommence a l'infini. */
.card-cursor {
  position: fixed; left: 0; top: 0; z-index: 160;
  pointer-events: none;
  translate: calc(var(--cc-x, 50vw) - 50%) calc(var(--cc-y, 50vh) - 50%);
  scale: .82;
  opacity: 0;
  padding: 10px 16px;
  border-radius: var(--radius-pill);
  background: var(--accent); color: var(--accent-ink);
  font-size: var(--fs-small); font-weight: var(--w-semibold);
  line-height: 1.2; white-space: nowrap;
  /* Capitales posees ICI et non dans les chaines de content.js : la casse est
     une decision d'apparence. L'approche est elargie parce qu'une capitale
     serree se lit mal — c'est le reglage habituel de tout texte tout en
     capitales, et il n'y en a qu'un a tenir puisque les deux libelles
     partagent cette regle. */
  text-transform: uppercase; letter-spacing: .06em;
  box-shadow: var(--glass-shadow);
  transition: opacity .16s var(--ease), scale .16s var(--ease);
}


/* ==========================================================================
   8. PAGES D'ETUDE DE CAS
   ========================================================================== */

/* --cs-nav-inset : largeur de la colonne de nav laterale + son ecart (voir
   .cs__body plus bas, 190px + gap) — reprise par l'intro/le selecteur d'un
   projet fusionne (.cs__cases-intro/.cs__case-switch) pour aligner leur
   TEXTE sur la colonne de contenu (celle du titre/tagline en dessous) tout
   en gardant leur boite (donc leur fond) pleine largeur — le padding ne
   retire rien au fond, seul le contenu se decale. Repassee a 0 sous 1000px
   (section 12) : la nav laterale y quitte la grille, la colonne de contenu
   commence donc au bord gauche comme tout le reste. */
.cs { padding-bottom: var(--s9); --cs-nav-inset: calc(190px + clamp(var(--s6), 5vw, var(--s8))); }

/* ---- 8a. L'en-tete : la partie "30 secondes" ----
   Tout ce qu'un recruteur presse doit lire tient ici, avant le premier scroll.
   La marge haute degage le bouton "Retour", qui est fixe au-dessus — SAUF
   quand cette etude de cas a sa propre nav laterale (.cs-nav) : le lien
   retour vit alors dans cette nav (voir pageCase()/setupBackLink()) et ne
   flotte plus par-dessus l'en-tete, donc plus besoin de lui reserver de
   place ici. */
.cs__head {
  padding-block: calc(var(--back-h) + var(--s6)) var(--s6);
  /* Decale la cible d'ancrage quand cet en-tete sert de section "Overview"
     dans la nav laterale (voir pageCase()), pour que le titre ne finisse
     pas sous l'en-tete collant du site — meme reglage que .cs-sec. */
  scroll-margin-top: calc(var(--head-h) + var(--s4));
}
.cs:has(.cs-nav) .cs__head { padding-block: var(--s6); }
.cs__client { font-size: var(--fs-body); margin-bottom: var(--s2); color: var(--ink-soft); }
.cs__title { font-size: var(--fs-h1); font-weight: var(--w-medium); margin-bottom: var(--s4); }
.cs__tagline {
  font-size: var(--fs-lead); color: var(--ink-soft);
  max-width: var(--measure); margin-bottom: var(--s6);
  font-weight: var(--w-light);
}

/* Intro + selecteur d'un projet fusionne (project.cases, content.js) sont
   des enfants DIRECTS de `body` (.wrap wrap--wide dans pageCase()), places
   AVANT `grid` (.cs__body, qui porte la nav laterale + .cs__content) —
   au-dessus des DEUX colonnes, pas seulement de la colonne de contenu :
     1. Largeur : .wrap--wide annule le max-width, donc `body` est aussi
        large que nav+contenu ensemble — c'est ce qui permet a
        .cs__cases-intro et .cs__case-switch de reculer leur bord gauche
        jusqu'a la colonne de contenu (margin-left: --cs-nav-inset, voir les
        deux regles plus bas) tout en gardant un bord DROIT pleine largeur.
     2. Collant sur toute la hauteur : le bloc englobant d'un element
        position:sticky est la boite de son PARENT DIRECT. `body` ne
        contient que [intro, switch, grid] : sa hauteur = celle de `grid`
        (qui s'etire pour egaler .cs__content, en-tete + sections) — assez
        haute pour que .cs__case-switch (plus bas) reste colle en haut sur
        TOUT le defilement.
   `body` porte donc en ligne (pageCase()) le padding-top "degager la croix"
   que .cs__head porte d'habitude (var(--s7) — voir body.is-overlay
   .cs__head plus bas) ; .cs__head lui-meme repasse a son padding-top normal
   (var(--s6), aussi pose en ligne) puisqu'il n'est plus le premier element
   de la page.

   white-space: pre-line (meme mecanique que .cs__overview-intro) : le \n du
   texte source devient un saut de ligne visuel sans faire deux <p>. Fond
   teinte #E9F2FE (demande utilisateur : "put it in a blue block, a bit like
   the outcome block or .cs-sec__term") — meme couleur dediee que
   .cs-sec__aside/.cs-sec__term ci-dessous plutot que --veil-strong (reserve
   au bloc "mis en avant" .pair__out/.todo), pour rester dans la meme famille
   visuelle qu'un encart de contexte plutot qu'un resultat chiffre.
   margin-left (pas padding-left comme avant) + width en consequence : EXACT
   MEME TECHNIQUE que .cs__case-switch juste en dessous, et pour la meme
   raison — une boite maintenant VISIBLE (fond colore) doit commencer a la
   colonne de contenu, pas juste inset son texte avec un fond qui continuerait
   de deborder sur la colonne de nav laterale. En dessous de 1000px
   --cs-nav-inset vaut 0 (section 12, la nav laterale n'existant plus) :
   marge et largeur retombent seules a leurs valeurs pleine largeur. */
/* Icone d'info ajoutee devant le texte (demande utilisateur), sur le modele
   du bandeau "info" de carbondesignsystem.com/components/select/usage —
   meme fond bleu tres clair et meme esprit, mais SANS son filet gauche
   colore (demande utilisateur : "no border left") et sans le contour ici
   deja fourni par le fond plein. flex plutot que du texte seul : l'icone
   doit rester calee sur la premiere ligne (align-items: flex-start) meme
   quand le texte (white-space: pre-line, deux lignes chez Petal) en fait
   deux. */
.cs__cases-intro {
  margin-left: var(--cs-nav-inset); width: calc(100% - var(--cs-nav-inset));
  margin-bottom: var(--s5);
  display: flex; align-items: flex-start; gap: var(--s3);
  background: #E9F2FE; border-radius: var(--radius-sm); padding: var(--s5);
  color: var(--ink);
}
.cs__cases-intro-text { white-space: pre-line; }
/* Contour seul (l'icone Lucide fournie a deja fill="none"), jamais un disque
   plein — et bleu (var(--accent), le bleu du theme clair des etudes de cas),
   pas la couleur du texte courant. */
.cs__cases-intro-icon {
  flex: none;
  width: 20px; height: 20px;
  color: var(--accent);
  /* Aligne le centre optique de l'icone sur la premiere ligne de texte,
     plutot que son coin haut sur le sommet de la ligne. */
  margin-top: .1em;
}

/* Selecteur de cas pour un projet fusionne (branche petal-merged-projects,
   voir content.js/pageCase() dans app.js) : bascule entre les deux etudes de
   cas repliees dans une seule fiche. Forme plate et sticky inspiree de
   data-framer-name="Project Nav" sur diana.lu/hyper#app (demande explicite,
   sans pilule ni filet, collee pile au sommet — top: 0, pas d'ecart — des
   qu'elle l'atteint en defilant), mais habillage typographique repris de
   .cs-nav a juste en dessous plutot que du site de reference : meme famille
   de composant (choisir/reperer une entree dans une etude de cas), donc
   memes regles de couleur/etat plutot qu'un vocabulaire visuel importe.
   Fond opaque (var(--bg), le blanc du theme clair de la fiche) pour qu'un
   titre de section ne vienne pas "flotter" dans la barre une fois figee
   au-dessus du contenu qui defile dessous.
   margin-left: var(--cs-nav-inset) (au lieu d'un padding) + width en
   consequence : la boite elle-meme commence a la colonne de contenu plutot
   que de la couvrir avec juste du padding. Sinon, une fois collee en haut
   (top:0, .cs-nav en dessous colle lui aussi mais plus bas — voir .cs-nav
   plus haut), elle passait par-dessus le lien "Back" de la nav laterale
   (z-index 20) et le rendait inclable — demande explicite : ne pas aller
   jusqu'au bord gauche sur bureau. width: calc(100% - inset) compense la
   marge pour que le bord DROIT reste, lui, sur le bord de `body` (pas de
   perte de la largeur pleine a droite). En dessous de 1000px --cs-nav-inset
   vaut 0 (voir media query plus bas) : marge et largeur retombent a leurs
   valeurs pleine largeur normales, la nav laterale n'existant plus.
   overflow-x:auto + les onglets en nowrap (ci-dessous) : filet de securite
   sur tres petit ecran plutot qu'un retour a la ligne qui doublerait la
   hauteur de la barre collante. Barre de defilement masquee (scrollbar-width/
   ::-webkit-scrollbar) : le glissement tactile reste possible, seule
   l'indication visuelle — inutile ici, la barre est courte et evidente —
   disparait. */
.cs__case-switch {
  position: sticky; top: 0; z-index: 20;
  display: flex; gap: var(--s5);
  margin-left: var(--cs-nav-inset); width: calc(100% - var(--cs-nav-inset));
  overflow-x: auto; scrollbar-width: none; -ms-overflow-style: none;
  background: var(--bg);
  padding-block: var(--s4);
  margin-bottom: var(--s5);
}
.cs__case-switch::-webkit-scrollbar { display: none; }
/* Ni pilule de fond ni trait au survol : avec la boite calee sur la colonne
   de contenu (ci-dessus), le moindre padding/bordure horizontal sur un
   onglet decale son texte du meme cran par rapport au titre juste en
   dessous (ex. "Service exclusion" ne serait plus aligne avec "Services
   exclusion") — demande explicite. Couleur seule, memes regles que
   .cs-nav a : le texte reste au ras du bord de la boite, donc du titre,
   dans tous les etats. gap: var(--s5) sur .cs__case-switch (ci-dessus) fait
   tout le travail d'espacement entre onglets a la place du padding.
   white-space:nowrap : le libelle d'un cas ("Licence management") reste sur
   une ligne plutot que de doubler la hauteur de la barre sur mobile. */
.cs__case-tab {
  position: relative;
  padding-block: var(--s2);
  text-decoration: none; color: var(--ink-faint);
  font-size: var(--fs-small); white-space: nowrap;
  transition: color .2s var(--ease);
}
.cs__case-tab:hover { color: var(--ink); }
.cs__case-tab.is-active { color: var(--accent); font-weight: var(--w-semibold); }
/* Trait de soulignement de l'onglet actif — meme mecanique que .site-nav
   a::after (scaleX, GPU-friendly) : position:absolute ne touche a aucun
   padding/bordure de la boite, donc l'alignement du texte avec le titre
   juste en dessous (voir commentaire plus haut) reste intact. */
.cs__case-tab::after {
  content: ''; position: absolute; left: 0; right: 0; bottom: -6px;
  height: 2px; background: var(--accent);
  transform: scaleX(0); transform-origin: left;
  transition: transform .25s var(--ease);
}
.cs__case-tab.is-active::after { transform: scaleX(1); }

/* La fiche d'identite : role, duree, equipe, outils. */
/* Sans filets au-dessus et en dessous : le padding et la marge suffisent a
   detacher le bloc, et deux traits pleine largeur en haut d'une etude de cas
   la decoupaient en tranches avant meme qu'on ait commence a lire. Le retrait
   vertical reste, lui — c'est lui qui tient le rythme, pas les traits. */
.gist {
  display: grid; gap: var(--s5);
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  padding-block: var(--s5);
  margin-bottom: var(--s7);
}
.gist dt {
  font-size: var(--fs-tiny); text-transform: uppercase; letter-spacing: .1em;
  color: var(--ink-faint); font-weight: var(--w-semibold); margin-bottom: var(--s1);
}
.gist dd { font-size: var(--fs-small); }
/* Le lien "Company" (Petal, etc.) reprend la couleur des liens de la nav
   principale : estompee au repos (meme .82 que .site-nav a), pleine (donc
   plus foncee) au survol — au lieu du ink plein herite par defaut ici.
   Le soulignement de .u-arrow-link est aussi affine : 1.5px convient a un
   lien isole dans une phrase, trop epais colle au texte tres court d'un
   simple nom d'entreprise dans la fiche d'identite. */
.gist dd .u-arrow-link { color: color-mix(in srgb, var(--ink) 82%, transparent); }
.gist dd .u-arrow-link:hover,
.gist dd .u-arrow-link:focus-visible { color: var(--ink); }
.gist dd .u-arrow-link > span:first-child { text-decoration-thickness: 1px; }

/* Probleme / Resultat cote a cote : la structure narrative minimale. */
.pair { display: grid; gap: var(--s6); grid-template-columns: 1fr 1fr; margin-bottom: var(--s7); }
.pair h2 { font-size: var(--fs-h3); margin-bottom: var(--s3); font-weight: var(--w-semibold); }
.pair p { color: var(--ink-soft); }
/* .cs__overview-intro (voir hideOverviewHeadings dans pageCase(), app.js) :
   le paragraphe d'intro bare accepte lui aussi **gras** via emphasize(), mais
   en SemiBold plutot que le <b> navigateur par defaut (700) — meme reglage
   que .hero__hello b / .card__title b. */
.cs__overview-intro b { font-weight: var(--w-semibold); }
/* Le resultat est mis en avant par un voile sombre et un filet lime. */
.pair__out {
  background: var(--veil-strong);
  border-radius: var(--radius-sm);
  padding: var(--s5);
}
.pair__out p { color: var(--ink); white-space: pre-line; }

/* Les chiffres marquants : ce que le recruteur retiendra. */
.stats__title { font-size: var(--fs-h3); font-weight: var(--w-semibold); margin-bottom: var(--s4); }
.stats {
  display: grid; gap: var(--s5);
  grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
  margin-bottom: var(--s7);
}
.stat__n {
  /* Plancher a 3.2rem (51.2px, >= 50px demandes) quel que soit le viewport —
     meme mesure clamp(rem, vw, rem) qu'avant, valeurs doublees. */
  font-size: clamp(3.2rem, 6.4vw, 4.4rem); font-weight: var(--w-semibold);
  line-height: 1.05; color: var(--accent);
  /* Aligne les chiffres sur une meme largeur quand plusieurs se suivent. */
  font-variant-numeric: tabular-nums;
  margin-bottom: var(--s2);
}
.stat__l { font-size: var(--fs-small); color: var(--ink-soft); line-height: 1.4; }

/* Meme composant, cite au fil d'une section (s.stats, voir content.js) plutot
   qu'en en-tete : chiffres beaucoup plus petits pour ne pas concurrencer les
   .stat de l'apercu, et une marge resserree puisqu'ils vivent entre deux
   paragraphes plutot que sous leur propre titre "Impacts". */
.stats--sec { margin-block: var(--s6); max-width: var(--measure); }
.stats--sec .stat__n { font-size: clamp(1.6rem, 2.6vw, 2rem); margin-bottom: 0; }
.stats--sec .stat__l { margin-top: 2px; }

.cs__cta { display: flex; flex-wrap: wrap; gap: var(--s3); margin-bottom: var(--s7); }
/* s.cta (voir app.js) : meme bouton, mais attache a un paragraphe au fil
   d'une section plutot qu'a l'en-tete de la fiche. */
.cs-sec__cta { margin-top: var(--s4); }

/* ---- 8b. Le corps : nav laterale collante + sections ---- */
.cs__body {
  display: grid;
  grid-template-columns: 190px minmax(0, 1fr);
  gap: clamp(var(--s6), 5vw, var(--s8));
  align-items: start;
}

.cs-nav {
  position: sticky;
  /* Le lien retour vit maintenant DANS cette nav (premiere entree, voir
     .cs-nav__back plus bas) plutot que de flotter fixe au-dessus — l'offset
     n'a donc plus qu'a degager l'en-tete collant. */
  top: calc(var(--head-h) + var(--s6));
  /* Si un jour il y a 20 sections, la nav defile elle-meme au lieu de
     deborder de l'ecran. */
  max-height: calc(100vh - var(--head-h) - var(--s8));
  overflow-y: auto;

  /* Deux colonnes : le rail de progression, puis la liste. Une grille plutot
     qu'un flex parce que le rail est le DERNIER enfant dans le DOM (.cs-nav__prog,
     voir pageCase) et doit s'afficher en PREMIER : grid-column/grid-row le
     placent sans toucher au balisage, et sans wrapper supplementaire.
     Le rail s'etire sur toute la hauteur de la liste tout seul — les deux
     partagent la rangee 2, et align-items vaut stretch par defaut. */
  display: grid;
  grid-template-columns: 2px minmax(0, 1fr);
  column-gap: var(--s3);
}
/* Projet fusionne (project.cases, .cs__case-switch plus haut) : la nav
   laterale et la barre d'onglets sont deux boites collantes cote a cote
   (colonnes differentes, donc jamais l'une sur l'autre — voir .cs__case-switch),
   mais avec l'offset "degager l'en-tete" ci-dessus le lien "Back" restait plus
   bas que le texte des onglets une fois toutes deux figees. Un offset plus
   petit, cale sur le retrait interne de la barre d'onglets (padding-block de
   .cs__case-switch + de .cs__case-tab), aligne les deux textes sur la meme
   ligne — demande explicite. */
.cs:has(.cs__case-switch) .cs-nav { top: var(--s4); }
/* Le lien retour, en premiere position dans la nav laterale — meme famille
   visuelle que .back-link (le bouton flottant utilise ailleurs sur le site)
   mais en flux normal, pas fixe : il defile et colle avec le reste de
   .cs-nav plutot que de rester plaque en haut de l'ecran. */
/* Lien discret, pas un bouton/pilule : une nette separation avec la liste
   qui suit (marge genereuse) suffit a le distinguer, sans chrome propre
   (demande explicite).
   Il occupe la rangee du haut, a cheval sur les deux colonnes : le rail ne
   commence qu'avec la liste. justify-self l'empeche d'etre etire sur toute la
   largeur de la grille — il doit rester de la largeur de son texte, sinon sa
   zone cliquable deborde tres au-dela du libelle. C'est aussi sa marge basse
   qui espace les deux rangees, la grille n'ayant pas de row-gap. */
.cs-nav__back {
  /* grid-column: 1/-1 (demande utilisateur) : aligne le bloc sur la colonne
     du rail (.cs-nav__prog, colonne 1) plutot que sur celle de la liste juste
     en dessous. justify-self reste au defaut (stretch) : la zone de survol
     doit rester aussi large que les entrees de la liste en dessous, pas
     retrecie a la largeur du texte. display:flex (et non inline-flex) : un
     inline-flex ignorerait align-items/gap sur ses propres enfants (icone +
     libelle). */
  grid-column: 1 / -1;
  display: flex; align-items: center; gap: var(--s2);
  margin-bottom: var(--s6);
  /* Meme padding que .cs-nav a ci-dessous : les deux blocs de survol
     partagent donc le meme bord gauche. */
  padding: var(--s2) var(--s3);
  border-radius: var(--radius-sm);
  text-decoration: none; color: var(--ink-soft);
  font-size: var(--fs-small); font-weight: var(--w-regular);
  transition: color .2s var(--ease), background .2s var(--ease);
}
.cs-nav__back:hover { color: var(--ink); background: var(--veil); }
/* display:block sur le <svg> : un svg inline garde par defaut un espace de
   descendance sous sa ligne de base (comme une image), qui le decale vers le
   bas malgre l'align-items:center du conteneur flex ci-dessus. */
.cs-nav__back-icon { display: block; width: 1em; height: 1em; }
/* La fleche seule s'eloigne du libelle au survol (et non plus tout le lien) :
   elle pointe vers la gauche, donc "s'eloigne" = translate vers la gauche. */
.cs-nav__back > span:first-child { transition: transform .2s var(--ease); }
.cs-nav__back:hover > span:first-child { transform: translateX(-3px); }

/* DESACTIVE SUR BUREAU SEULEMENT (essai, retour possible) : la croix
   (#overlay-close, toujours visible sur ce gabarit) suffit a quitter la
   fiche, donc le lien "Back" de la nav laterale est masque. Scope au bureau
   express : sous 1000px .cs-nav__back devient la pastille de fermeture
   elle-meme (section 12, la vraie croix y est masquee) — on n'y touche pas,
   c'est la SEULE sortie sur ce gabarit. */
@media (min-width: 1001px) {
  /* .cs-nav a.cs-nav__back (0,2,1) et non .cs-nav__back seul (0,1,0) :
     .cs-nav a plus bas (0,1,1) est plus specifique que la classe nue et lui
     rendrait son display:flex sinon (meme piege que la note sur
     l'align-items un peu plus bas). */
  .cs-nav a.cs-nav__back { display: none; }
}

/* .cs-nav__pill et .cs-nav__ring n'existent que pour la barre flottante du
   mobile (section 12). Ici, la colonne laterale n'en veut pas :
   - display: contents efface le conteneur SANS toucher au balisage. Ses
     enfants redeviennent des elements de la grille de .cs-nav, donc le
     grid-column/grid-row de .cs-nav ol ci-dessous continue de le placer, et
     le rail garde sa colonne. Sans cela, le conteneur serait auto-place dans
     la grille et pousserait tout le reste.
   - l'anneau est masque : le rail vertical dit deja la meme chose, en mieux
     ici, puisqu'il longe la liste et designe donc AUSSI ou l'on en est. */
.cs-nav__pill { display: contents; }
.cs-nav__ring { display: none; }

.cs-nav ol { grid-column: 2; grid-row: 2; display: grid; gap: 2px; }
.cs-nav a {
  display: flex; align-items: baseline; gap: var(--s3);
  padding: var(--s2) var(--s3);
  border-radius: var(--radius-sm);
  text-decoration: none; color: var(--ink-faint);
  font-size: var(--fs-small);
  transition: color .2s var(--ease), background .2s var(--ease);
}
.cs-nav a:hover { color: var(--ink); background: var(--veil); }
/* .cs-nav a.cs-nav__back (0,1,1) plutot que .cs-nav__back seul (0,1,0) :
   sinon l'align-items:baseline de .cs-nav a ci-dessus l'emporte et decale
   l'icone sous le centrage vertical du libelle "Back". */
.cs-nav a.cs-nav__back { align-items: center; }
/* .is-active est posee par app.js via le scroll-spy : c'est elle qui suit la
   section actuellement lue.
   L'entree active ne porte QUE de la couleur et de la graisse : ni pilule de
   fond, ni filet a sa gauche. Le fond gris est reserve au survol — quand
   l'entree active en portait un aussi, survoler une autre entree donnait deux
   pilules a l'ecran et on ne savait plus laquelle disait "vous etes ici".
   C'est le rail continu a gauche de la liste qui porte maintenant le reperage
   (.cs-nav__prog plus bas) : il ne se contente pas de designer l'entree en
   cours, il montre le chemin parcouru dans toute l'etude de cas. */
.cs-nav a.is-active {
  color: var(--accent); font-weight: var(--w-semibold);
}

/* La progression propre a chaque section n'existe QUE sur la barre flottante
   du mobile (section 12). En colonne, le sommaire tient tout entier a l'ecran
   et le rail juste a cote (.cs-nav__prog) dit deja ou l'on en est dans le
   processus : une seconde barre par entree n'ajouterait qu'un bruit visuel. Le
   balisage est pose dans tous les cas (pageCase) et masque ici — c'est le
   mobile qui le revele, plutot que le JS qui construirait deux nav
   differentes. */
.cs-nav__sprog { display: none; }

/* La progression d'ensemble : un rail vertical le long de la liste, et non
   plus une barre horizontale posee dessous. Meme balisage, meme calcul
   (updateProgress) — seule l'orientation change, d'ou height et non width sur
   .cs-nav__bar, cote JS comme ici.
   Le rail est retreci de la hauteur du padding vertical d'une entree, de sorte
   qu'il commence a la premiere ligne de texte et finisse a la derniere, plutot
   qu'a la zone cliquable qui deborde des deux cotes. */
.cs-nav__prog { grid-column: 1; grid-row: 2; }
.cs-nav__track {
  width: 2px; margin-block: var(--s2);
  height: calc(100% - 2 * var(--s2));
  background: var(--line-soft); border-radius: 1px; overflow: hidden;
}
.cs-nav__bar { width: 100%; height: 0%; background: var(--accent); border-radius: 1px; }
/* Le pourcentage chiffre disparait : le rail est desormais visible en
   permanence a cote de la liste, et il dit la meme chose sans occuper de
   ligne. La valeur reste annoncee aux lecteurs d'ecran — updateProgress tient
   aria-valuenow a jour sur .cs-nav__track, qui porte role="progressbar". */
.cs-nav__pct { display: none; }

/* ---- 8c. Une section de processus ---- */
.cs-sec {
  padding-block: 0 var(--s8);
  /* Decale la cible d'ancrage pour que le titre ne finisse pas sous l'en-tete. */
  scroll-margin-top: calc(var(--head-h) + var(--s4));
}
/* Le titre de section a la meme typographie que .gist dt (section 8a) : une
   etiquette petite, capitales, interlettree, plutot qu'un gros titre. Les deux
   jouent le meme role — nommer ce qui suit sans se mettre devant — et doivent
   donc bouger ensemble : toucher a l'un sans l'autre casserait la paire.
   Seule la marge basse reste celle de la section (--s4, pas --s1) : une etape
   de processus a besoin de plus d'air qu'un couple terme/definition. */
.cs-sec__title {
  font-size: var(--fs-tiny); text-transform: uppercase; letter-spacing: .1em;
  color: var(--ink-faint); font-weight: var(--w-semibold);
  margin-bottom: var(--s4);
}
/* --ink-faint et non --ink-soft : le corps des etudes de cas s'eclaircit d'un
   cran. C'est le PLANCHER — 4,54:1 sur blanc, tout juste au-dessus des 4,5:1
   exiges par WCAG AA pour du texte courant. Le cran suivant ne serait plus
   conforme. */
/* La seconde ligne du titre : elle reprend telle quelle la typographie que
   .cs-sec__title portait avant de devenir une etiquette — --fs-h2, poids
   moyen, encre pleine. Le titre au-dessus devient alors un sur-titre, et c'est
   cette ligne-ci qui fait le vrai titre.
   Deux usages : s.headline dans une section de processus (un <p>), et les
   titres Probleme / Resultat / Impacts de l'apercu (des <h2>, section 8a).
   L'ancetre est .cs et non .cs-sec, sinon l'apercu — qui vit dans .cs__head,
   hors de toute .cs-sec — n'y aurait pas droit.
   Deux classes (0,2,0) et non la classe seule (0,1,0) : les deux usages sont
   deja vises par une regle a (0,1,1) — .cs-sec p pour le <p>, .pair h2 pour
   les <h2> — qui l'emporterait. Le poids des classes tranche avant celui des
   elements, donc (0,2,0) passe devant. Meme piege que .cs-sec__card-title. */
.cs .cs-sec__headline {
  max-width: var(--measure); margin-bottom: var(--s4);
  font-size: var(--fs-h2); font-weight: var(--w-medium); color: var(--ink);
  /* C'est un <p>, donc il herite de l'interligne de lecture (1.6). Celui des
     titres est 1.2 (voir h1,h2,h3) : sans ca, une seconde ligne de titre
     s'aererait comme un paragraphe. */
  line-height: 1.2;
}
/* Le sur-titre se rapproche de la ligne qu'il annonce : les --s4 qui separent
   un titre de son corps de texte feraient, ici, deux blocs sans rapport.
   Le second selecteur couvre s.collapsed (voir pageCase(), app.js) : le
   sur-titre y est suivi d'un <details class="text-drawer"> dont s.headline
   est devenu le declencheur, plutot que de rester son voisin direct. */
.cs-sec__title:has(+ .cs-sec__headline),
.cs-sec__title:has(+ .text-drawer > summary > .cs-sec__headline) { margin-bottom: var(--s2); }
.cs-sec p { max-width: var(--measure); margin-bottom: var(--s4); color: var(--ink-faint); }
.cs-sec p:last-child { margin-bottom: 0; }
/* Hoot/Analysis : le paragraphe qui annonce les deux profils doit coller a
   la liste qui les enumere juste en dessous, plutot que de garder l'espace
   normal d'un paragraphe suivi d'un autre paragraphe. */
#sec-analysis p:has(+ .cs-sec__list) { margin-bottom: 0; }
/* s.numbered (voir app.js) : pastille numerotee violette accolee a un
   paragraphe, meme style que .cs-timeline__constraint-num — pour faire echo
   a un numero deja present dans une figure au-dessus (ex. Admin model). */
.cs-sec__num-row { display: flex; align-items: flex-start; gap: var(--s3); }
.cs-sec__num {
  flex: 0 0 auto; width: 28px; height: 28px; border-radius: 50%; margin-top: 2px;
  background: #7F5DDD; color: #fff; font-size: var(--fs-small); font-weight: var(--w-semibold);
  display: flex; align-items: center; justify-content: center; line-height: 1;
}
/* Meme mesure/couleur que .cs-sec p, avec les puces natives ul : liste a
   puces generique (voir s.bullets dans content.js, et p.list des items de
   moreDrawer), a la place d'un paragraphe unique. */
.cs-sec__list {
  max-width: var(--measure); margin: 0 0 var(--s4); padding-left: 1.2em;
  color: var(--ink-faint);
  /* Le reset global (ul,ol { list-style:none } plus haut dans ce fichier)
     retire les puces par defaut : on les restaure ici, cette liste-ci n'est
     pas decorative. */
  list-style: disc;
}
.cs-sec__list li { margin-bottom: 0; }

/* Rangee de logos (voir s.brandsAfter dans content.js, ex. Hoot/Exploration,
   les concurrents benchmarkes) — couleurs, fond et rayon copies a l'identique
   de .brands sur la page source (marvinsrd.com/en/hoot-project /
   portfolio-46df44.webflow.css) : fond gris clair, coins arrondis, logos
   attenues, deux d'entre eux (Glovo, Please — voir `gray` dans content.js)
   aussi passes en niveaux de gris comme sur l'original. */
/* width: fit-content : la boite epouse les logos (equivalent du "Hug
   contents" d'un auto-layout Figma) plutot que de s'etirer sur toute la
   largeur de la colonne comme un bloc normal. flex-wrap: nowrap + le
   flex:1 1 0/min-width:0 des logos ci-dessous font le reste : sous 700px,
   au lieu de retomber sur deux lignes, les quatre logos se compressent
   ensemble pour tenir sur une seule. */
.cs-brands {
  display: flex; flex-wrap: nowrap; justify-content: center; align-items: center;
  gap: var(--s4);
  padding: 10px 14px;
  border-radius: 10px;
  background-color: #f0f0f0;
  margin: var(--s2) 0 var(--s6);
  width: fit-content; max-width: 100%;
}
.cs-brands img { flex: 1 1 0; min-width: 0; max-width: 90px; height: auto; opacity: .5; }
.cs-brands img.cs-brands__gray { filter: grayscale(100%); }
/* Espace supplementaire avant un titre qui suit directement la rangee de
   logos (ex. Hoot/Exploration, "Gathering of the user needs") — la marge
   normale d'un cs-sec__headline suppose un paragraphe juste au-dessus, pas
   un encart avec son propre fond. */
.cs-brands + .cs-sec__headline { margin-top: var(--s6); }

/* Post-its mauves (voir b.postits dans content.js, ex. Hoot/Exploration, les
   3 constats du sondage) — meme fond, ombre et rotation par index que
   .post-it/._1/._2/._3 sur la page source. Classe dediee et non
   .cs-sec__card : cette palette (mauve, pivotee) n'a pas vocation a
   deteindre sur les cartes neutres utilisees ailleurs (ex. Constraints/
   design, les 4 principes). */
.cs-postits {
  display: flex; flex-wrap: wrap; justify-content: flex-start; align-items: center;
  gap: var(--s6);
  margin: var(--s5) 0 var(--s6);
}
.cs-postit {
  flex: 1 1 180px; max-width: 220px; min-height: 200px;
  padding: 28px 18px 18px;
  background-color: #b368e2;
  box-shadow: 0 4px 4px rgba(0, 0, 0, .25);
}
.cs-postit:nth-child(1) { transform: rotate(4deg); }
.cs-postit:nth-child(2) { transform: rotate(-1deg); }
.cs-postit:nth-child(3) { transform: rotate(9deg); }
.cs-postits .cs-postit__title { margin-bottom: 8px; font-size: 19px; line-height: 1.1; font-weight: var(--w-semibold); color: #333; }
.cs-postits .cs-postit__body { font-size: 14px; line-height: 1.3; color: #333; }

/* Tableau de post-its par categorie (voir postitBoardMarkup() dans app.js,
   b.postitBoard dans content.js, ex. Hoot/Analysis) — 4 colonnes egales,
   memes couleurs et texte que .postit-board sur la page source. Une seule
   colonne sous 700px (meme repli que .cs-sec__cards plus bas), pas 4
   colonnes ecrasees a l'illisible. */
.cs-postit-board {
  /* minmax(0, 1fr), pas 1fr nu : voir la note sur .cs__body (§8b) — un
     texte de post-it un peu long forcerait sinon toute la piste (et la
     grille) a deborder plutot que de s'enrouler sur ses propres lignes. */
  display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--s5);
  margin: var(--s5) 0 var(--s6); max-width: var(--measure);
}
.cs-postit-col__title {
  margin-bottom: 10px; font-size: var(--fs-small); font-weight: var(--w-semibold);
}
/* Fond blanc, aucun rayon/ombre au repos : c'est litteralement l'etat mesure
   sur la page source (voir la note "strict identical" de la conversation) —
   l'ombre n'apparait que sous 480px la-bas, reprise ici a la meme largeur. */
.cs-mini-postit {
  display: flex; align-items: center; justify-content: center;
  min-height: 90px; padding: 10px; margin-bottom: var(--s3);
  font-size: 14px; line-height: 1.2; text-align: center;
  /* Etat de depart de l'apparition : --fly-x/--fly-y/--fly-r sont tirees au
     hasard par post-it (voir postitBoardMarkup() dans app.js), pour que
     chacun arrive d'une direction differente — l'esprit du vol aleatoire de
     Webflow, sans chercher a copier des valeurs figees qui n'auraient plus
     de sens hors de son moteur d'interactions. transition-delay, pose la
     aussi en inline, echelonne l'arrivee au lieu de tout poser d'un bloc. */
  opacity: 0; transform: translate(var(--fly-x, 0), var(--fly-y, 0)) rotate(var(--fly-r, 0deg));
  transition: opacity .5s var(--ease), transform .5s var(--ease);
}
.cs-mini-postit.is-visible { opacity: 1; transform: translate(0, 0) rotate(0deg); }
.cs-postit-board .cs-mini-postit p { max-width: none; margin-bottom: 0; color: #333; }
@media (prefers-reduced-motion: reduce) {
  .cs-mini-postit { transition: none; }
}
@media (max-width: 700px) {
  /* Meme mise en page qu'au bureau (4 colonnes cote a cote, jamais 1 seule) :
     sur marvinsrd.com/en/hoot-project, .postit-board devient un simple flex
     row + overflow: auto sous 991px — les colonnes gardent leur largeur, et
     c'est la piste entiere qui defile horizontalement. Ici, memes colonnes
     grid mais en largeur fixe (les 1fr du bureau n'ont plus de conteneur
     assez large pour se repartir, donc elles deborderaient) : c'est ce
     debordement, combine a overflow-x, qui cree le defilement. */
  .cs-postit-board {
    grid-template-columns: repeat(4, 176px);
    gap: var(--s3);
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
    padding-bottom: var(--s2);
  }
  /* width: 100% explicite (pas juste laisser width:auto) : .cs-mini-postit
     est display:flex, et un conteneur flex EN FLUX NORMAL ne se cale pas
     forcement sur la largeur disponible de son parent comme un bloc classique
     — mesure : sans cette regle il debordait sa propre colonne de ~20px
     (le min-content de son texte). width:100% force le cadrage exact sur la
     colonne (176px), sans quoi son overflow visuel recreait le meme "vide"
     entre deux post-its que max-width en creait par defaut (voir plus bas).
     Pas de max-width sur .cs-mini-postit ici : un post-it plus etroit que sa
     colonne (ex. max-width: 88%) laisse un vide DANS la colonne (le post-it
     est en flux normal, cale a gauche) qui s'ajoute au gap entre colonnes —
     l'ecart visible entre deux post-its devient alors bien plus grand que le
     gap lui-meme. Le post-it "reduit" est donc la colonne elle-meme (176px
     au lieu de 200px) : il remplit exactement sa colonne, et le seul espace
     entre deux post-its reste le gap ci-dessus. */
  .cs-mini-postit { width: 100%; box-sizing: border-box; }
}
@media (max-width: 480px) {
  .cs-mini-postit.is-visible { box-shadow: 0 2px 4px rgba(0, 0, 0, .25); }
}

/* Cartes des principes de conception (voir s.list dans content.js,
   Constraints/design) : une grille de 4 plutot qu'une liste a puces —
   titre en gras + description, sur un fond legerement teinte. */
.cs-sec__cards {
  display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--s3);
  margin: 0 0 var(--s4);
}
.cs-sec__card {
  padding: var(--s4); border-radius: var(--radius-sm);
  background: rgba(234, 234, 249, .5);
}
/* .cs-sec__card .cs-sec__card-title (0,2,0) plutot que .cs-sec__card-title
   seul (0,1,0) : sinon perd face a .cs-sec p (0,1,1), qui matche aussi ce
   <p>, et son margin-bottom:16px reapparait sous le titre. */
.cs-sec__card .cs-sec__card-title { font-weight: var(--w-semibold); color: var(--ink); margin-bottom: 0; }
.cs-sec__card-body { color: var(--ink-soft); font-size: var(--fs-small); margin-bottom: 0; }
@media (max-width: 700px) {
  .cs-sec__cards { grid-template-columns: 1fr 1fr; }
}

/* Bloc "definitions" a cote du texte (voir s.aside dans content.js,
   Services exclusion/Design) : texte a gauche, encart teinte a droite.
   Couleur dediee (pas --veil-strong, qui reste au bloc "mis en avant"
   .pair__out/.todo) : #E9F2FE fixe pour tous les .cs-sec__aside/.cs-sec__term,
   quel que soit le projet. */
.cs-sec__row { display: flex; gap: var(--s6); align-items: flex-start; margin-bottom: var(--s4); }
.cs-sec__row-text { flex: 2 1 0; min-width: 0; }
.cs-sec__row-text p:last-child { margin-bottom: 0; }
.cs-sec__aside {
  flex: 1 1 0; min-width: 0;
  background: #E9F2FE; border-radius: var(--radius-sm); padding: var(--s5);
  display: flex; flex-direction: column; gap: var(--s4);
}
.cs-sec__aside p { margin: 0; color: var(--ink); font-size: var(--fs-small); }
/* .cs-sec__row-media : meme colonne que .cs-sec__aside (flex:1), mais pour
   un media plutot qu'un encart teinte (voir s.rowMedia dans content.js) —
   pas de fond/padding, et le margin-block de .media-single/.media-grid est
   remis a zero (meme raison que .cs-sec__media-pair) pour que la figure ne
   se decale pas verticalement par rapport au texte a cote. */
.cs-sec__row-media { flex: 1 1 0; min-width: 0; }
.cs-sec__row-media .media-single,
.cs-sec__row-media .media-grid { margin-block: 0; }
/* --media-left : variante de .cs-sec__row pour s.rowMedia (image posee
   avant le texte dans le markup, donc deja a gauche) — seul le centrage
   vertical a besoin d'une regle, .cs-sec__row restant flex-start pour
   s.aside. Selecteur compose pour battre .cs-sec__row a specificite egale
   quel que soit l'ordre des regles. */
.cs-sec__row.cs-sec__row--media-left { align-items: center; }
@media (max-width: 700px) {
  .cs-sec__row { flex-direction: column; }
}

/* Blocs terme/definition alignes horizontalement, dans le flux normal du
   texte (voir s.terms dans content.js, Licence management/Definitions) —
   <dl> plutot que .cs-sec__aside : contrairement au bloc ci-dessus, ce
   contenu n'est pas en marge du texte mais insere entre deux paragraphes,
   donc pas d'.aside semantique ici. Meme teinte #E9F2FE que .cs-sec__aside
   pour rester coherent visuellement. */
.cs-sec__terms { display: flex; gap: var(--s5); margin: var(--s2) 0 var(--s5); }
.cs-sec__term {
  flex: 1 1 0; min-width: 0; margin: 0;
  background: #E9F2FE; border-radius: var(--radius-sm); padding: var(--s5);
}
.cs-sec__term dt { font-weight: var(--w-semibold); color: var(--ink); }
/* white-space: pre-line : un \n dans t.body (app.js) devient un saut de
   ligne visuel sans avoir a decouper la definition en plusieurs <dd> — meme
   mecanique que .cs__overview-intro/.cs__cases-intro. */
.cs-sec__term dd {
  margin: var(--s2) 0 0; color: var(--ink); font-size: var(--fs-small);
  white-space: pre-line;
}
/* .cs-sec__list imbriquee dans une definition (t.body = {intro, list, outro},
   voir termBody() dans app.js) : la liste generique vise le corps de section
   (--ink-faint, mesure de lecture pleine largeur) — a l'interieur de l'encart
   teinte et etroit d'un .cs-sec__term, elle doit reprendre la couleur/taille
   deja posees sur dd (specificite (0,2,0), bat le (0,1,0) de .cs-sec__list). */
.cs-sec__term .cs-sec__list { color: inherit; max-width: none; margin: var(--s2) 0; }
@media (max-width: 700px) {
  .cs-sec__terms { flex-direction: column; }
}

/* Encart "Result" isole en fin de section (voir s.result dans content.js,
   Licence management/Context) — meme teinte #E9F2FE que .cs-sec__term
   ci-dessus (meme famille visuelle : un constat court mis en avant), mais un
   champ dedie {title, text} plutot qu'une rangee de definitions.
   Titre et texte colles, margin:0 des deux cotes (demande explicite : pas
   d'espace entre le titre et le texte) — a la difference de .cs-sec__title,
   qui se rapproche de la ligne qu'il annonce mais garde tout de meme --s2. */
.cs-result {
  background: #E9F2FE; border-radius: var(--radius-sm); padding: var(--s5);
  margin-bottom: var(--s4);
}
.cs-result__title {
  font-size: var(--fs-tiny); text-transform: uppercase; letter-spacing: .1em;
  font-weight: var(--w-semibold); color: var(--ink-faint); margin: 0;
}
.cs-result__text { color: var(--ink); margin: 0; }

/* Encart d'avertissement (voir s.callout dans content.js, Services
   exclusion/Release) : icone + texte sur fond teinte. */
.cs-callout {
  display: flex; gap: var(--s3); align-items: flex-start;
  background: var(--warning-tint); border-radius: var(--radius-sm);
  padding: var(--s4) var(--s5); margin-bottom: var(--s4);
}
.cs-callout p { margin: 0; color: var(--ink-soft); }
.cs-callout__icon { flex-shrink: 0; width: 20px; height: 20px; margin-top: 2px; color: #DA1E28; }

/* Timeline de processus (voir s.timeline dans content.js, Licence
   management/Design trials) : inspiree du gabarit repere sur bevyip.com/about
   (grid grid-cols-1 md:grid-cols-[max-content_1px_1fr] md:gap-x-10), sans la
   colonne de date (jamais remplie dans notre cas -> retiree entierement, pas
   gardee vide, pour que le trait reste au bord gauche de la section) ni le
   bouton/etat hover de la source. Le trait vertical tient sur la hauteur
   de sa ligne de grille grace au stretch par defaut de `align-items`, et le
   padding-bottom partage entre la colonne trait et la colonne contenu fait
   que le trait couvre aussi l'espacement avant l'entree suivante. */
.cs-timeline { margin-block: var(--s6); }
.cs-timeline__row {
  display: grid; grid-template-columns: 1fr; row-gap: var(--s5);
}
.cs-timeline__row:last-child .cs-timeline__line-col,
.cs-timeline__row:last-child .cs-timeline__content { padding-bottom: 0; }
.cs-timeline__line-col { display: none; position: relative; }
/* Absolument positionnee sur toute la hauteur de .cs-timeline__line-col (y
   compris son padding-bottom) plutot que height:100% (qui ne remplit que la
   content-box et laissait un trou visible au niveau du padding, a chaque
   jonction entre deux entrees) : le trait reste continu d'un bout a l'autre
   de la timeline. */
.cs-timeline__line { position: absolute; top: 0; bottom: 0; left: 50%; width: 1px; transform: translateX(-50%); background: var(--line); }
/* Point plein marquant le debut de chaque entree (voir reference utilisateur
   : carte "stream of thought"), aligne sur la 1ere ligne du titre/paragraphe
   grace au meme top que le haut de .cs-timeline__content (siblings de grille,
   stretch par defaut). */
.cs-timeline__line-col::before {
  content: ''; position: absolute; top: 3px; left: 50%; transform: translateX(-50%);
  width: 7px; height: 7px; border-radius: 50%; background: var(--ink-soft);
  z-index: 1;
}
.cs-timeline__content {
  display: flex; flex-direction: column; gap: var(--s5); min-width: 0;
  padding-bottom: var(--s7);
}
.cs-timeline__thumb {
  display: block; flex-shrink: 0; width: 100%; aspect-ratio: 1 / 1;
  border-radius: var(--radius-sm); background: var(--veil);
  overflow: hidden;
}
/* Quand .cs-timeline__thumb est un <picture> (voir item.image dans
   content.js) plutot que le div placeholder gris : l'image remplit le
   meme cadre carre, recadree par le haut (le contenu utile — barre de
   titre, champ — est toujours en haut de la capture). */
.cs-timeline__thumb img { width: 100%; height: 100%; object-fit: cover; object-position: top; display: block; }
/* Entree avec une vraie capture (.cs-timeline__row--figure, voir item.image
   dans content.js) : garde son ratio naturel plutot que d'etre recadree dans
   le cadre carre des placeholders (comme les .figure du reste de l'etude de
   cas), et passe au-dessus du texte plutot qu'a cote. */
.cs-timeline__row--figure .cs-timeline__thumb { aspect-ratio: auto; }
.cs-timeline__row--figure .cs-timeline__thumb img { height: auto; }
/* item.imageAfter (voir app.js) : figure inseree apres les paragraphes d'une
   entree plutot qu'a cote/au-dessus — meme traitement ratio-naturel que
   .cs-timeline__row--figure, mais porte par la figure elle-meme (elle vit a
   l'interieur de .cs-timeline__text, pas au niveau de la row). */
.cs-timeline__thumb--auto { aspect-ratio: auto; margin-bottom: var(--s4); }
.cs-timeline__thumb--auto img { height: auto; }
/* .zoomable-media (voir setupZoomableMedia() dans app.js) : clic pour
   zoomer n'importe quelle image (item.imageAfterZoomable dans content.js
   pose la classe sur la figure imageAfter du moment, mais le bloc ci-dessous
   ne depend d'aucune structure cs-timeline — reutilisable sur n'importe
   quelle image du site en posant juste la classe sur son conteneur direct)
   — inspire de l'ancien portfolio (marvinsrd.com, .img-container.old) :
   l'image passe de 100% a 230% de la largeur de son cadre, qui devient
   alors scrollable (overflow:auto) pour la parcourir, plutot qu'un vrai
   lightbox plein ecran. max-width:none pose en permanence (pas seulement
   sur .is-zoomed) : le reset generique `img { max-width: 100% }` (section 1)
   n'est pas transitionnable, donc le laisser revenir a 100% au dezoom
   plafonnerait la largeur animee des la premiere frame et coupait net
   l'animation retour au lieu de la laisser rejouer en sens inverse. Une fois
   zoomee, glissable a la souris (setupZoomableMedia() pilote scrollLeft au
   pointermove) — le tactile garde le scroll natif d'overflow:auto ci-dessous,
   qui suffit deja au doigt. width:100% pose ici (pas seulement dans des
   contextes comme .cs-timeline__thumb qui le fixent deja) : sans lui, une
   image sans autre regle de largeur ambiante (ex. .figure__frame, qui n'en
   impose aucune) se retrouve affichee a sa taille intrinseque une fois
   max-width:none applique, debordant de son cadre et de la page entiere si
   elle est plus large que la colonne. overflow:auto pose seulement sur
   .is-zoomed (pas sur .zoomable-media de base) : au repos rien ne deborde
   (l'image fait exactement 100% du cadre), donc pas besoin ; le poser en
   permanence marquait deja l'element comme conteneur scrollable aux yeux du
   navigateur meme sans contenu a faire defiler, ce qui a deja perturbe la
   reconnaissance tactile d'un simple tap dans un carrousel (glissement
   horizontal natif du meme geste). */
.zoomable-media { cursor: zoom-in; }
.zoomable-media.is-zoomed { cursor: grab; overflow: auto; }
.zoomable-media.is-zoomed:active { cursor: grabbing; }
.zoomable-media img {
  max-width: none;
  width: 100%;
  transition: width .3s var(--ease);
}
.zoomable-media.is-zoomed img {
  width: 230%;
  -webkit-user-drag: none; user-select: none;
}
/* --zoom-scale-mobile (inline style posee par item.imageAfterZoomScaleMobile
   dans content.js, voir app.js) : une image precise a besoin d'un zoom plus
   fort que le 230% par defaut une fois sur mobile (colonne trop etroite pour
   lire son contenu meme zoomee normalement) — desktop garde 230% en
   ignorant la variable en dehors de ce media query. */
@media (max-width: 700px) {
  .zoomable-media.is-zoomed img { width: var(--zoom-scale-mobile, 230%); }
}
/* .cs-sec__media-pair .figure__frame.zoomable-media (voir s.zoomable dans
   content.js, ex. Services exclusion/exclusion-2-before-en) : compound
   selector necessaire — .cs-sec__media-pair .figure__frame img plus bas
   dans ce fichier a la meme specificite (0,2,1) que .zoomable-media.
   is-zoomed img ci-dessus et l'ecraserait sinon a l'ordre du fichier,
   figeant width/height a 100%. height:auto (pas 100%) laisse l'image
   grandir uniformement plutot que de l'etirer dans la hauteur fixe du
   cadre 997/936. */
.cs-sec__media-pair .figure__frame.zoomable-media.is-zoomed img { width: 230%; height: auto; }
@media (max-width: 700px) {
  .cs-sec__media-pair .figure__frame.zoomable-media.is-zoomed img { width: var(--zoom-scale-mobile, 230%); }
}
/* .cs-sec__media-pair .cs-carousel__panel .zoomable-media (voir item.zoomable
   dans content.js, carrousel Services exclusion/Concept) : meme collision,
   cette fois avec .cs-sec__media-pair .cs-carousel__panel img (0,2,1) plus
   bas dans ce fichier. Meme raison pour height:auto (cadre 997/936 fixe). */
.cs-sec__media-pair .cs-carousel__panel .zoomable-media.is-zoomed img { width: 230%; height: auto; }
@media (max-width: 700px) {
  .cs-sec__media-pair .cs-carousel__panel .zoomable-media.is-zoomed img { width: var(--zoom-scale-mobile, 230%); }
}
/* .cs-carousel__panel picture (voir carouselMarkup() dans app.js) : sans
   regle a elle, elle reste inline par defaut et .zoomable-media.overflow
   (base rule) y est donc ignore (overflow ne s'applique pas a un inline non
   remplace) — sans quoi le glisser-deplacer une fois zoomee n'aurait aucun
   cadre scrollable. display:block seulement une fois zoomee (pas en
   permanence) : au repos, .cs-carousel__panel img { height:100% } traverse
   deja la picture inline pour se caler sur la hauteur du panneau (aspect-ratio
   997/936) ; poser display:block dessus en permanence en ferait un nouveau
   bloc conteneur SANS hauteur propre, cassant cette chaine (image ecrasee a
   0px). height:100% ici la retablit explicitement pour l'etat zoome. */
.cs-carousel__panel .zoomable-media.is-zoomed { display: block; height: 100%; }
/* .cs-mockups .figure__frame.zoomable-media (voir s.mockups + s.zoomable
   dans content.js, ex. Constraints/constraints-solution-list) : meme
   collision que ci-dessus mais avec .cs-mockups .figure img (0,2,1 aussi,
   plus bas dans ce fichier). Pas de height a corriger ici : contrairement
   a .cs-sec__media-pair, .cs-mockups n'impose pas de hauteur fixe. */
.cs-mockups .figure__frame.zoomable-media.is-zoomed img { width: 230%; }
@media (max-width: 700px) {
  .cs-mockups .figure__frame.zoomable-media.is-zoomed img { width: var(--zoom-scale-mobile, 230%); }
}
/* .zoomable-media--ripple : modificateur optionnel, a poser en plus de
   .zoomable-media — ondulation lente et permanente autour du cadre, invite
   discrete a cliquer avant toute interaction. box-shadow deborde du cadre
   sans etre rogne par le overflow:auto ci-dessus (l'overflow d'un element ne
   s'applique qu'a son contenu/ses descendants, jamais a son propre
   box-shadow) : pas besoin d'un wrapper supplementaire. Coupee (pas juste
   ralentie) une fois zoomee — deja compris que c'est cliquable, la garder
   aurait juste distrait pendant le glisser-deplacer. prefers-reduced-motion
   la neutralise deja via la regle generique en fin de fichier
   (animation-duration a .01ms). */
@keyframes zoomable-media-ripple {
  0%   { box-shadow: 0 0 0 0 rgba(127, 93, 221, .35); }
  100% { box-shadow: 0 0 0 14px rgba(127, 93, 221, 0); }
}
.zoomable-media--ripple { animation: zoomable-media-ripple 2.6s ease-out infinite; }
.zoomable-media--ripple.is-zoomed { animation: none; }
/* .figure__frame.zoomable-media.is-zoomed (zoomableClass() dans app.js pose
   les deux classes sur le meme div pour une figure) : compound selector
   necessaire car .figure__frame { overflow: hidden } (plus bas dans ce
   fichier) a la meme specificite (0,1,0) que .zoomable-media.is-zoomed
   { overflow: auto } ci-dessus et le battrait a l'ordre du fichier sinon.
   Scope a .is-zoomed comme la regle de base : au repos, hidden ne pose
   aucun probleme (rien ne deborde). */
.figure__frame.zoomable-media.is-zoomed { overflow: auto; }
/* Desktop uniquement : le cadre garde EXACTEMENT sa taille au repos une fois
   zoome — sur desktop, un cadre qui grandit (en largeur) ou dont la hauteur
   suit celle, bien plus grande, de l'image zoomee deplace tout le contenu en
   dessous (la mise en page "bouge"). --zoom-h0 (voir setupZoomableMedia()
   dans app.js) : hauteur au repos figee en px juste avant le zoom, pour que
   le cadre ne suive plus l'auto-height de son image agrandie. La largeur
   n'a besoin d'aucune regle ici : elle ne bougeait deja pas (c'est l'image a
   l'interieur qui grandit, voir .zoomable-media.is-zoomed img plus bas), le
   probleme etait uniquement la hauteur. Scrollable dans les 4 directions —
   glisser-deplacer voir setupZoomableMedia() — puisque l'image agrandie
   (230%) deborde desormais le cadre aussi bien en largeur qu'en hauteur.
   Scrollbar cachee : au repos le cadre n'a pas de scrollbar (rien ne
   deborde), donc pas besoin de scoper plus large que .is-zoomed. Mobile
   (hors de ce media query) garde le comportement d'origine : le cadre suit
   l'image et grandit avec elle — pas de mise en page a proteger la, le
   visiteur scrolle deja toute la page verticalement au doigt.

   `.zoomable-media--grows` (voir mediaMarkup()/m.zoomGrows dans app.js) :
   contre-exception demandee pour les deux schemas de la page gap ("What it
   changed about how I work" — the-junior-trap.svg, what-i-didnt-enjoy.svg) —
   la comparaison utilisateur etait .cs-timeline__thumb (licence management,
   imageAfterZoomable), qui n'a jamais eu ce gel de hauteur et grandit deja
   normalement au zoom. Pour ces deux figures isolees (.media-single, rien en
   dessous dans le meme bloc qui devrait rester stable), le cadre reprend donc
   son `height: auto` d'origine : la largeur ne bouge toujours pas (regle
   au-dessus, .zoomable-media.is-zoomed img reste a 230%), seule la hauteur
   suit l'image agrandie — d'ou aucun scroll vertical necessaire, seulement le
   defilement horizontal deja permis par `overflow: auto` (ligne au-dessus). */
@media (min-width: 701px) {
  .figure__frame.zoomable-media.is-zoomed:not(.zoomable-media--grows) {
    height: var(--zoom-h0, auto);
    scrollbar-width: none;
    -ms-overflow-style: none;
  }
  .figure__frame.zoomable-media.is-zoomed:not(.zoomable-media--grows)::-webkit-scrollbar { display: none; }
}
/* .zoomable-media--mobile-only (voir zoomableClass() dans app.js, flag
   'mobile') : juste un curseur "pas cliquable" hors mobile — le gel du clic
   lui-meme vit cote JS, dans setupZoomableMedia(). Meme seuil que
   .cs-carousel__arrow (mobile <=700px) ailleurs dans ce fichier. */
@media (min-width: 701px) {
  .zoomable-media--mobile-only { cursor: auto; }
}
.cs-timeline__title { font-weight: var(--w-semibold); color: var(--ink); margin-bottom: var(--s2); }
.cs-timeline__text p { margin-bottom: var(--s3); }
.cs-timeline__text p:last-child { margin-bottom: 0; }
/* item.constraints (voir app.js) : liste de paragraphes alignes sur une
   colonne commune, chacun precede d'une pastille numerotee violette — reprend
   le violet #7F5DDD des pastilles 1-4 deja presentes dans le diagramme Figma
   place juste au-dessus (imageAfter), pour une lecture continue diagramme ->
   liste. `n:null` (ex. Privacy) : pastille gardee dans le flux mais rendue
   invisible plutot que retiree, pour que tous les paragraphes s'alignent
   verticalement sur la meme colonne malgre l'absence de numero. */
.cs-timeline__constraints {
  list-style: none; margin: var(--s2) 0 0; padding: 0;
  display: flex; flex-direction: column; gap: var(--s4);
}
.cs-timeline__constraint { display: flex; align-items: flex-start; gap: var(--s3); }
.cs-timeline__constraint-body { min-width: 0; }
/* c.body (voir app.js) : plusieurs paragraphes dans un seul item numerote
   (ex. "Naming the button") — meme espacement que .cs-timeline__text p. */
.cs-timeline__constraint-body p { margin: 0 0 var(--s3); }
.cs-timeline__constraint-body p:last-child { margin-bottom: 0; }
/* c.list (voir app.js) : reprend .cs-sec__list telle quelle, mais sa marge
   basse par defaut (var(--s4)) doit etre annulee en fin d'item — sinon elle
   s'ajoute au `gap` deja pose par .cs-timeline__constraints entre chaque
   <li>, doublant l'espacement sous ce dernier constraint. */
.cs-timeline__constraint-body .cs-sec__list:last-child { margin-bottom: 0; }
/* Espace avant ce qui suit la liste de contraintes (ex. imagesAfter) —
   uniquement sur le dernier item pour ne pas doubler le `gap` deja pose
   par .cs-timeline__constraints entre chaque <li>. */
.cs-timeline__constraint:last-child .cs-timeline__constraint-body { margin-bottom: var(--s4); }
.cs-timeline__constraint-num {
  flex: 0 0 auto; width: 28px; height: 28px; border-radius: 50%;
  background: #7F5DDD; color: #fff; font-size: var(--fs-small); font-weight: var(--w-semibold);
  display: flex; align-items: center; justify-content: center; line-height: 1;
}
.cs-timeline__constraint--unnumbered .cs-timeline__constraint-num { visibility: hidden; }
@media (min-width: 701px) {
  /* max-content colonne retiree (etait toujours vide -> ne servait qu'a
     decaler le trait du bord gauche de la section via son column-gap) :
     le trait est maintenant colle au bord, grille a 2 colonnes. */
  .cs-timeline__row { grid-template-columns: 1px 1fr; column-gap: var(--s7); row-gap: 0; }
  .cs-timeline__line-col { display: grid; grid-column: 1; justify-items: center; padding-bottom: var(--s7); }
  .cs-timeline__content { grid-column: 2; flex-direction: row; gap: var(--s6); padding-bottom: var(--s7); }
  .cs-timeline__thumb { width: 160px; }
  .cs-timeline__row--figure .cs-timeline__content { flex-direction: column; }
  .cs-timeline__row--figure .cs-timeline__thumb { width: 100%; max-width: 860px; }
  /* item.imageAfter vit dans .cs-timeline__text (jamais dans une row a 2
     colonnes thumb+texte) : toujours pleine largeur de la colonne de texte,
     pas la largeur fixe 160px des vignettes placeholder ci-dessus. */
  .cs-timeline__thumb--auto { width: 100%; }
}
/* item.tight (voir app.js/content.js) : resserre l'espace apres cette entree
   — pour un paragraphe de l'entree suivante qui commente directement l'image
   qui precede (ex. Licence management/Admin model, la legende du tableau de
   roles), plutot que le padding-bottom standard entre deux entrees. */
.cs-timeline__row--tight .cs-timeline__content,
.cs-timeline__row--tight .cs-timeline__line-col { padding-bottom: var(--s3); }
/* item.noLine (voir app.js/content.js) : timeline reutilisee comme simple
   groupe figure + liste numerotee (Fit-Plans/Design, une image annotee par
   entree plutot qu'un recit etape-par-etape) — pas de trait ni de puce de
   depart, l'image et sa legende occupent seules la largeur de la colonne. */
.cs-timeline__row--no-line .cs-timeline__line-col { display: none; }
@media (min-width: 701px) {
  .cs-timeline__row--no-line { grid-template-columns: 1fr; }
  .cs-timeline__row--no-line .cs-timeline__content { grid-column: 1; }
}
/* item.constraintsDark (voir app.js/content.js) : pastilles noires plutot que
   le violet #7F5DDD habituel, pour reprendre la couleur des numeros deja
   incrustes dans la capture au-dessus (Fit-Plans/Design) plutot que d'en
   introduire une seconde. */
.cs-timeline__constraints--dark .cs-timeline__constraint-num { background: #000; }
/* item.constraintsLoose (voir app.js/content.js) : agrandit l'espace apres le
   dernier constraint d'un item, quand ce qui suit (ex. imagesAfter) merite
   plus de respiration que le var(--s4) par defaut. */
.cs-timeline__constraints--loose .cs-timeline__constraint:last-child .cs-timeline__constraint-body { margin-bottom: var(--s6); }

/* Etiquettes couleur inline (voir moreDrawer.items[].body, "Mapping the 24
   constraints") : reprend le code couleur utilise dans la maquette Figma
   source (temps/taches/membres) pour rendre visible d'un coup d'oeil ce que
   chaque groupe de contraintes partage. */
.cs-tag-list { margin: 0 0 var(--s4); padding-left: 1.2em; color: var(--ink-soft); list-style: disc; }
.cs-tag-list li { margin-bottom: 0; }
.cs-tag {
  display: inline-flex; align-items: center; padding: 2px 4px;
  border-radius: 3px; font-size: var(--fs-small);
}
.cs-tag--green  { background: #D7FFD7; color: #37A137; }
.cs-tag--purple { background: #F2D9FF; color: #7057B2; }
.cs-tag--red    { background: #FFDAC7; color: #C34E3B; }

/* Les visuels extraits des PDF. Cadre blanc : les maquettes claires ont
   besoin d'un fond neutre pour ne pas se dissoudre dans le bleu. */
.figure { margin-block: var(--s6); }
.figure__frame {
  background: var(--frame-bg);
  border: 1px solid var(--frame-line);
  border-radius: var(--radius);
  padding: var(--s4);
  overflow: hidden;
}
.figure img { border-radius: var(--radius-sm); }
/* `bare` (voir s.bare dans content.js / figureFor() dans app.js) : meme
   suppression de cadre que .cs-mockups/.cs-more, mais utilisable sur une
   figure isolee (pas forcement dans un regroupement .cs-mockups). */
.figure--bare .figure__frame { padding: 0; border: none; border-radius: 0; background: none; }
/* .cs-scroll-frame (voir s.type === 'scrollFrame' dans figureFor(), app.js) :
   une image locale bien plus haute que large, montree a travers une fenetre
   dont la hauteur est fixee par `aspect-ratio` (--frame-ratio, calcule depuis
   les pixels source de l'image, pas une valeur ronde) — pour montrer une
   capture de page entiere sans l'etaler pleine hauteur dans la colonne de
   lecture. Desktop uniquement : sous 701px la fenetre disparait (aspect-
   ratio: auto, overflow: visible) et l'image s'affiche en entier comme une
   figure normale — sur mobile, tout se scrolle deja verticalement au doigt,
   une seconde fenetre scrollable imbriquee dans la page n'apporte rien et ne
   ferait que capturer le geste.

   `overflow: auto` des le repos (pas seulement une fois zoomee) : la fenetre
   est deja scrollable au chargement — molette/trackpad/tactile — pour
   parcourir le reste de la page source sans avoir a cliquer d'abord.
   Scrollbar cachee (mais le scroll fonctionne bien, voir scrollbar-width/
   -webkit-scrollbar ci-dessous) : visible, elle aurait double le rendu fige
   d'une vraie capture d'ecran. `zoomableClass()` pose EN PLUS .zoomable-media
   sur ce meme div quand s.zoomable est vrai (ex. Landing page, pas Recruiter
   section/yabara-recruiter-home qui n'a plus cette option) — memes regles
   .zoomable-media/.is-zoomed que .figure__frame plus haut (curseur zoom-in,
   image a 230% une fois zoomee, glisser-deplacer souris en plus du scroll
   natif), le clic vient alors s'AJOUTER au scroll passif deja actif plutot
   que le debloquer. Le cadre lui-meme ne bouge pas d'un pixel dans un cas
   comme dans l'autre : `aspect-ratio` le rend insensible a la taille de son
   contenu, contrairement au `height: auto` de .figure__frame (qui a besoin
   de --zoom-h0 en JS pour la meme garantie). */
.cs-scroll-frame {
  aspect-ratio: auto;
  overflow: visible;
  border: 1px solid var(--frame-line);
  border-radius: var(--radius);
}
.cs-scroll-frame picture,
.cs-scroll-frame img { display: block; width: 100%; height: auto; }
@media (min-width: 701px) {
  .cs-scroll-frame {
    aspect-ratio: var(--frame-ratio);
    overflow: auto;
    scrollbar-width: none;
    -ms-overflow-style: none;
  }
  .cs-scroll-frame::-webkit-scrollbar { display: none; }
}
/* .cs-dash-frame (voir s.type === 'dashFrame' dans figureFor(), app.js,
   Yabara/Recruiter section) : variante de .cs-scroll-frame a DEUX images
   independantes — la sidebar (node Figma 387:1154) et le reste du dashboard
   (node 389:1644, une page entiere) — plutot qu'un seul export recadre +
   calque de masquage (approche precedente, remplacee car elle demandait de
   recalculer une echelle a chaque zoom). Un simple flex row suffit : `side`
   ne scrolle jamais (flex-basis fixe, image en object-fit: cover pour
   absorber le leger ecart entre son propre ratio et celui de la colonne),
   `main` prend le reste de la largeur et scrolle seul (memes regles
   overflow/scrollbar cachee que .cs-scroll-frame ci-dessus). Pas de ratio
   propre a .cs-dash-frame__side : sa hauteur vient du stretch flex normal
   (cross-axis d'une flex row, meme hauteur que --frame-ratio sur le parent)
   et son image en object-fit: cover absorbe l'ecart avec son propre ratio
   source.
   Sous 701px, .cs-dash-frame__mobile (s.mobileImage dans figureFor()) prend
   sa place : une image unique, pleine largeur, sans la sidebar - demande
   utilisateur explicite plutot que le side+main empile par defaut (sidebar
   ecrasee en pleine largeur au-dessus d'un contenu deux fois plus long, peu
   lisible). Les deux blocs restent dans le DOM en permanence des deux
   cotes du breakpoint ; display:none retire l'inactif de l'arbre
   d'accessibilite (pas besoin d'aria-hidden manuel). */
.cs-dash-frame {
  display: flex; flex-direction: column;
  border: 1px solid var(--frame-line);
  border-radius: var(--radius);
  overflow: hidden;
}
.cs-dash-frame picture,
.cs-dash-frame img { display: block; width: 100%; height: auto; }
.cs-dash-frame__mobile {
  border: 1px solid var(--frame-line);
  border-radius: var(--radius);
  overflow: hidden;
}
.cs-dash-frame__mobile picture,
.cs-dash-frame__mobile img { display: block; width: 100%; height: auto; }
@media (min-width: 701px) {
  .cs-dash-frame {
    flex-direction: row;
    aspect-ratio: var(--frame-ratio);
  }
  .cs-dash-frame__side {
    flex: 0 0 24%;
    overflow: hidden;
  }
  .cs-dash-frame__side picture,
  .cs-dash-frame__side img { height: 100%; object-fit: cover; object-position: top; }
  .cs-dash-frame__main {
    flex: 1 1 auto;
    overflow-y: auto; overflow-x: hidden;
    scrollbar-width: none;
    -ms-overflow-style: none;
  }
  .cs-dash-frame__main::-webkit-scrollbar { display: none; }
  .cs-dash-frame__mobile { display: none; }
}
@media (max-width: 700px) {
  .cs-dash-frame { display: none; }
}

/* Yabara, Recruiter section : image d'anonymisation (node Figma 391:2234)
   empilee sous le dashboard via s.below (voir belowMarkup()/anonScoreMarkup()
   dans app.js), avec un tooltip (node 377:26729) au survol/focus du badge de
   score deja "cuit" dans l'image (pas de calque HTML par-dessus, juste un
   bouton invisible positionne dessus). aspect-ratio (pas de hauteur fixe) :
   le bouton et le tooltip sont positionnes en % (cfg.hotspot, voir
   content.js), donc doivent suivre EXACTEMENT le ratio source de l'image
   quelle que soit la largeur de colonne — un img en display:block width:100%
   sans aspect-ratio explicite sur le parent laisserait un ecart d'arrondi
   pendant le chargement. */
.cs-anon {
  position: relative;
  aspect-ratio: var(--anon-ratio);
}
.cs-anon picture,
.cs-anon img { display: block; width: 100%; height: 100%; object-fit: contain; }
.cs-anon__hot {
  position: absolute;
  margin: 0; padding: 0;
  background: none; border: none; border-radius: 50%;
  cursor: pointer;
}
.cs-anon__hot:focus-visible { outline: 2px solid #2F2F2F; outline-offset: 2px; }
/* Tooltip positionne en absolute (pas au-dessus de .cs-anon__hot en flow) :
   left/top en % (voir anonScoreMarkup()) placent son BORD superieur gauche,
   translateX(-50%) recentre sur l'axe horizontal du badge — inutile sur Y,
   le tooltip doit descendre SOUS le badge, pas se centrer dessus. */
.cs-anon__tooltip {
  position: absolute;
  transform: translateX(-50%);
  width: min(256px, 85vw);
  background: #FFFFFF;
  border-radius: 6px;
  padding: 16px;
  box-shadow: 0 12px 32px rgba(12, 12, 13, .18), 0 2px 8px rgba(12, 12, 13, .08);
  opacity: 0;
  pointer-events: none;
  transition: opacity .15s var(--ease);
  z-index: 5;
}
.cs-anon__hot:hover + .cs-anon__tooltip,
.cs-anon__hot:focus-visible + .cs-anon__tooltip {
  opacity: 1;
  pointer-events: auto;
}
.cs-anon__tooltip-title {
  margin: 0 0 11px;
  font-family: 'Inter', 'Segoe UI', sans-serif;
  font-size: 13px; color: #303030;
}
.cs-anon__tooltip-title strong { font-weight: 600; }
.cs-anon__crit-list { display: flex; flex-direction: column; gap: 10px; }
.cs-anon__crit-row { display: flex; gap: 16px; }
.cs-anon__crit-row--single .cs-anon__crit { flex: 0 0 auto; }
.cs-anon__crit {
  flex: 1; min-width: 0;
  display: flex; flex-direction: column; gap: 5px;
}
.cs-anon__crit-label {
  display: flex; align-items: center; gap: 5px;
  font-family: 'Inter', 'Segoe UI', sans-serif;
  font-size: 10px; color: #5A5A5A;
  white-space: nowrap;
}
.cs-anon__crit-icon { width: 11px; height: 11px; flex-shrink: 0; }
.cs-anon__crit-pill {
  display: inline-flex; align-items: center; gap: 5px;
  width: fit-content;
  padding: 2px 6px;
  border-radius: 6px;
  font-family: 'Inter', 'Segoe UI', sans-serif;
  font-size: 12px;
}
.cs-anon__crit-pill--pass { background: #EBFFEE; color: #02542D; }
.cs-anon__crit-pill--fail { background: #FEE9E7; color: #900B09; }
.cs-anon__crit-pill-icon { width: 11px; height: 11px; flex-shrink: 0; }

.figure figcaption {
  margin-top: var(--s3);
  font-size: var(--fs-small); color: var(--ink-faint);
  line-height: 1.5; max-width: var(--measure);
}
.figure__note {
  display: inline-block; margin-top: var(--s2);
  font-size: var(--fs-tiny); color: var(--ink-faint);
  font-style: italic;
}

/* ---- Medias distants (videos et captures hebergees sur Contra) ----
   Les videos sont des captures d'ecran animees : fond sombre pour qu'elles
   ne se dissolvent pas dans le blanc de la page, et pas de padding, l'image
   doit aller bord a bord. */
/* Le liser est indispensable ici : une des animations a un fond lilas tres
   clair qui, sans bordure, se fondrait dans le blanc de la page. */
.figure--remote .figure__frame {
  padding: 0;
  background: var(--veil-strong);
  border: 1px solid var(--frame-line);
}
.figure--remote video,
.figure--remote img,
.figure--remote dotlottie-wc { display: block; width: 100%; border-radius: var(--radius); }

/* dotlottie-wc ne rapporte pas le ratio natif du Lottie a sa boite hote (son
   <canvas> interne demarre a un ratio par defaut ~2:1), donc sans ce ratio
   explicite elle s'affiche ecrasee, bien plus courte que les videos voisines.
   1185/993 est le ratio commun aux 3 fichiers Lottie des contraintes
   (Blocking-complete lottie.json, Protection-(hollow).json, Spacing
   lottie.json) : a ajuster si un futur Lottie utilise un autre canevas.
   Le cadre garde aussi son fond/liser pour les videos (fond fonce opaque,
   pas de transparence a couvrir) mais pas pour ces Lottie : leurs
   illustrations laissent de l'espace transparent autour de l'icone, donc le
   fond/liser du cadre restait visible comme une bordure — on les retire. */
.figure--lottie dotlottie-wc { aspect-ratio: 1185 / 993; }
.figure--lottie .figure__frame { background: none; border: none; }

/* Un groupe de plusieurs medias : grille auto-adaptative, comme les cartes.
   Ils se rangent cote a cote sur large ecran et s'empilent sur telephone,
   sans media query dediee.

   align-items: start est le reglage important. Les trois animations n'ont
   pas le meme format — deux paysages et un portrait. Par defaut, une grille
   etire toutes les cellules d'une rangee a la hauteur de la plus haute, ce
   qui aurait laisse deux grands vides sous les paysages. Ici chacune garde
   sa hauteur naturelle. */
.media-grid {
  display: grid; gap: var(--s5);
  grid-template-columns: repeat(auto-fit, minmax(min(260px, 100%), 1fr));
  align-items: start;
  margin-block: var(--s6);
}
.media-grid .figure { margin-block: 0; }
.media-single { margin-block: var(--s6); }

/* Yabara, section "Candidate section" : 4 captures (home/apply/company/status)
   en grille 2x2 fixe plutot que l'auto-fit generique de .media-grid ci-dessus
   (qui passerait a 3+ colonnes sur un ecran large) — demande utilisateur.
   Scope par id de section (#sec-candidate-section, voir pageCase() dans
   app.js : id="sec-${s.id}") plutot qu'une modif de .media-grid lui-meme,
   qui est partagee par toutes les autres sections/case studies. */
#sec-candidate-section .media-grid {
  grid-template-columns: repeat(2, 1fr);
}
@media (max-width: 700px) {
  #sec-candidate-section .media-grid { grid-template-columns: 1fr; }
}

/* Liseret gris (demande utilisateur) sur la home candidat et son image
   "below" (recompenses) — pas les autres figures de la section. `bare: true`
   (voir content.js) retire deja le cadre standard de .figure__frame
   (fond/liser/padding), donc la bordure est posee directement sur l'<img>
   plutot que sur .figure__frame. Cible par attribut src (pas de classe
   dediee par item de figureAfter) : la source webp de la <picture> ne
   change pas l'attribut src du <img>. */
#sec-candidate-section img[src$="yabara-candidate-home.png"],
#sec-candidate-section img[src$="yabara-candidate-rewards.png"] {
  border: 1px solid var(--frame-line);
}

/* Bible app, section "Screens" : 4 planches zoomables, chacune pleine
   largeur plutot qu'en grille auto-fit (qui les compressait a 4 par ligne,
   ~349px, illisibles sans zoomer) — demande utilisateur. Meme pattern de
   scope par id que #sec-candidate-section ci-dessus. */
#sec-screens .media-grid {
  grid-template-columns: 1fr;
}

/* Yabara, section "Admin - Back-office" : les 4 captures du carrousel
   partagent la meme largeur de fenetre mais des hauteurs de page tres
   differentes (voir content.js). Sans ratio commun, .cs-carousel__panel
   img { height:100% } (regle generique, styles.css plus bas) etirerait
   chaque image a la hauteur du panneau le plus haut — object-fit:contain
   + un --frame-ratio calque sur la plus haute capture (yabara-admin-
   candidates, 1400x963) evite de deformer ou rogner les 3 autres. Le fond
   blanc de .cs-carousel__stage (--frame-bg) se fond avec le fond des
   captures elles-memes, donc le letterboxing latéral reste quasi invisible. */
#sec-admin-backoffice .cs-carousel__stage { aspect-ratio: 1400 / 963; }
/* border (demande utilisateur, liseret gris discret) : meme couleur que
   .figure__frame ailleurs dans le fichier (--frame-line), posee sur
   l'image plutot que sur .cs-carousel__stage — le stage est deja plus
   grand que l'image la ou une capture plus courte laisse du letterboxing
   (voir commentaire ci-dessus), un bord sur le stage encadrerait donc du
   vide blanc en plus de l'image sur ces captures-la. */
#sec-admin-backoffice .cs-carousel__panel img {
  width: 100%; height: 100%; object-fit: contain;
  border: 1px solid var(--frame-line);
}
/* zoomable: 'mobile' (content.js) sur ces 4 panneaux (meme mecanisme que
   Services exclusion/Concept, voir .cs-sec__media-pair .cs-carousel__panel
   .zoomable-media.is-zoomed img plus haut) — mais ici la regle ci-dessus
   utilise un ID, donc (1,1,1), qui l'emporte sur .zoomable-media.is-zoomed
   img (0,2,1) A TOUTE largeur d'ecran : cliquer pour zoomer posait bien
   .is-zoomed, mais l'image restait figee a width/height:100%, zoom inerte.
   Compound selector sous le meme ID pour retrouver assez de classes (1,3,1)
   et regagner la course. height:auto (pas 100%), comme .cs-sec__media-pair
   : laisse l'image grandir uniformement au lieu de l'etirer dans le
   --frame-ratio fixe du stage. */
#sec-admin-backoffice .cs-carousel__panel .zoomable-media.is-zoomed img {
  width: 230%; height: auto;
}
@media (max-width: 700px) {
  #sec-admin-backoffice .cs-carousel__panel .zoomable-media.is-zoomed img { width: var(--zoom-scale-mobile, 230%); }
}

/* Maquettes du rule-builder (constraints, section design) : pleine largeur,
   empilees, sans cadre — ce sont deja des captures d'ecran nettes qui n'ont
   pas besoin du fond/liser/marge des .figure standard. */
.cs-mockups { margin-block: var(--s6); }
.cs-mockups .figure { margin-block: var(--s5); }
.cs-mockups .figure:first-child { margin-top: 0; }
.cs-mockups .figure:last-child { margin-bottom: 0; }
.cs-mockups .figure__frame { padding: 0; border: none; border-radius: 0; background: none; }
.cs-mockups .figure img { width: 100%; border-radius: var(--radius-sm); }

/* Le media d'ouverture d'une etude de cas : pleine largeur, juste sous
   le sous-titre (voir pageCase() dans app.js — deplace ici volontairement,
   au lieu du bas de l'en-tete, pour ancrer la page des l'arrivee). */
.cs__hero-media { margin-top: var(--s6); margin-bottom: var(--s7); }
.cs__hero-media .figure { margin-block: 0; }
/* Format impose en 16:9 : la largeur ne change pas, seule la hauteur est
   reduite par rapport au ratio natif de la video. object-fit: contain (et
   non cover) : l'animation doit rester visible en entier.
   La video (assets/media/constraint-limit.mp4) est cadree en portrait
   (524x600, ratio 0.87) : dans une boite 16:9, contain laisse donc de
   larges bandes de part et d'autre plutot qu'en haut/bas. #e9e1f9 est la
   couleur de fond relevee sur toute la hauteur du bord gauche de
   constraint-limit.jpg (la premiere frame de cette meme video, utilisee
   comme poster juste en dessous) — la moyenne des quatre coins seuls
   donnait une teinte legerement plus grise, faussee par l'anti-aliasing
   au tout bord de l'image. En reprenant cette couleur ici plutot que
   --veil-strong, les bandes se fondent avec le fond de la video elle-meme
   au lieu de trancher avec — l'illusion que la video occupe toute la
   largeur de la boite. */
.cs__hero-media .figure__frame { background: #e9e1f9; }
.cs__hero-media .figure--remote video {
  aspect-ratio: 16 / 9;
  object-fit: contain;
}
/* Soundcloud : video carree (soundcloud-hero.mp4, source 1087x1087), pas de
   cadre 16:9 a lui imposer. #f3591c est la moyenne des quatre coins de son
   poster, pour que le fond ne tranche pas pendant le decodage. */
.cs__hero-media[data-slug="soundcloud"] .figure__frame { background: #f3591c; }
.cs__hero-media[data-slug="soundcloud"] .figure--remote video {
  aspect-ratio: 1 / 1;
}
/* Licence management : meme principe que le lilas de "Scheduling constraints"
   plus haut (video cadree en portrait, 758x776, contenue dans la boite 16:9
   par defaut ci-dessus). #eeeaf5 est la couleur de fond de licence-hero.mp4
   elle-meme (relevee sur sa premiere image) : les bandes de part et d'autre
   se fondent avec la video plutot que de trancher avec. */
.cs__hero-media[data-slug="licence-management"] .figure__frame { background: #eeeaf5; }
/* Hoot : la couverture est le GIF du logo (assets/img/hoot-hero.gif, 640x360)
   plutot qu'une capture — l'afficher a sa taille naturelle en pleine largeur
   le ferait paraitre etire et grossier. On le garde donc petit et centre
   dans une bande 21:9, le fond du cadre reprenant #2d254b (releve sur le GIF
   lui-meme) pour qu'il semble flotter sur son propre fond plutot que d'etre
   pose sur un cadre etranger. */
.cs__hero-media[data-slug="hoot"] .figure__frame {
  background: #2d254b;
  aspect-ratio: 21 / 9;
  display: flex; align-items: center; justify-content: center;
}
.cs__hero-media[data-slug="hoot"] img {
  width: clamp(160px, 40%, 320px); border-radius: 0;
}
/* Yabara : meme traitement que Hoot ci-dessus (voir .card[data-slug="yabara"]
   plus haut) — le logo (yabara-hero.webp) flotte, petit, sur son propre gris
   fonce #2F2F2F (nouvelle version du logo — demande explicite, remplace
   l'ancien bleu #314466) plutot que d'etre etire en pleine largeur. */
.cs__hero-media[data-slug="yabara"] .figure__frame {
  background: #2F2F2F;
  aspect-ratio: 21 / 9;
  display: flex; align-items: center; justify-content: center;
}
.cs__hero-media[data-slug="yabara"] img {
  width: clamp(160px, 40%, 320px); border-radius: 0;
}
/* Hero passe en Lottie (assets/media/constraint-limit.json, meme 524x600
   que l'ancienne video) : dotlottie-wc ne supporte pas object-fit comme
   <video> (voir la note pres de .figure--lottie plus haut — son <canvas>
   interne se contente de remplir sa boite hote, quitte a l'ecraser), donc
   le "contain" dans la boite 16:9 se refait a la main : le cadre devient la
   boite 16:9 (position:relative), et le <dotlottie-wc> a l'interieur garde
   son propre ratio natif et se centre dessus.
   Ceci annule aussi le fond/liser retire par .figure--lottie (pense pour
   les Lottie d'icones transparentes du carrousel de contraintes) : ce
   Lottie-ci a un fond opaque comme la video qu'il remplace. */
.cs__hero-media .figure--lottie .figure__frame {
  position: relative;
  aspect-ratio: 16 / 9;
  border: 1px solid var(--frame-line);
}
.cs__hero-media .figure--lottie dotlottie-wc {
  position: absolute; top: 50%; left: 50%;
  transform: translate(-50%, -50%);
  width: auto; max-width: 100%;
}
.cs__hero-media[data-slug="constraints"] .figure--lottie dotlottie-wc {
  aspect-ratio: 524 / 600;
  height: 100%;
  /* meme liser sombre au bord inferieur que sur l'ancienne video (voir
     .card__media dotlottie-wc plus bas) : le fichier source le porte aussi. */
  clip-path: inset(0 0 4% 0);
}
/* Bible app : Lottie d'un ecran de telephone (bible-app-hero.json, 375x812,
   ratio 0.46) plutot que la video 524x600 de Constraints ci-dessus. Pas de
   fond propre a reprendre (ecran expose sur fond transparent) : #f0f0f0, le
   meme gris clair que .cs-brands plus haut, sert de fond au cadre. Reduit a
   80% (au lieu des 100% de Constraints) pour rester entoure de gris plutot
   que de remplir la hauteur du cadre 16:9. */
.cs__hero-media[data-slug="bible-app"] .figure__frame { background: #f0f0f0; }
.cs__hero-media[data-slug="bible-app"] .figure--lottie dotlottie-wc {
  aspect-ratio: 375 / 812;
  height: 80%;
}


/* ---- Contraintes, section "Solution" : les deux captures (mockups,
   content.js) -------------------------------------------------------------
   Aucune regle dediee necessaire : ce sont des figures locales normales
   (figureFor(), meme cadre blanc que Mapping/Benchmark) regroupees dans la
   .media-grid generique — sa grille auto-adaptative (voir plus haut) les
   place cote a cote sur desktop et les empile sur mobile sans media query
   propre. Les 4 illustrations animees qui occupaient jadis media[0] sont le
   carrousel ci-dessous (.lottie-carousel). */

/* ---- Contraintes, section "Design" : carrousel des 4 illustrations
   animees (voir lottieCarouselMarkup()/setupLottieCarousel() dans app.js,
   figma node 34:817) --------------------------------------------------
   Meme raison qu'au-dessus pour rester hors de mediaMarkup()/mediaGroup() :
   un seul panneau visible a la fois n'est pas une grille statique, c'est un
   etat expose/interactif. Scene sombre a gauche (mode d'affichage d'origine
   des Lottie de contraintes, voir .figure--remote plus haut), libelles a
   droite empiles verticalement — reprend la mise en page a deux colonnes
   du node Figma plutot que les proportions au pixel pres. */
.lottie-carousel {
  /* Colonne de scene volontairement bridee (420px) plutot que 1fr : a
     pleine largeur de colonne (~1000px) la scene depassait 800px de haut,
     bien plus grande que les autres figures de la section. */
  display: grid; grid-template-columns: minmax(0, 420px) auto; gap: var(--s5);
  align-items: start; margin-block: var(--s6);
}
.lottie-carousel__stage {
  position: relative;
  background: var(--veil-strong);
  border: 1px solid var(--frame-line);
  border-radius: var(--radius);
  overflow: hidden;
}
/* Panneaux alignes cote a cote dans un track qui glisse via transform —
   meme structure que .cs-carousel__track (voir plus bas), pour la meme
   animation de glissement entre deux constraintes plutot qu'un cut net.
   Le glissement (setupSwipe() dans app.js) est ecoute ICI, pas sur la scene :
   les fleches/puces vivent en dehors du track (enfants de la scene), donc un
   pointerdown dessus ne peut pas etre capture par le glissement — c'est ce
   qui les rendait inertes a la souris quand l'ecoute portait sur la scene
   entiere. pan-y laisse le defilement vertical de la page passer, seul l'axe
   horizontal est intercepte par le JS ; les deux regles suivantes empechent
   le navigateur de demarrer un drag-image natif (fantome semi-transparent)
   ou une selection de texte pendant le geste. */
.lottie-carousel__track {
  display: flex; height: 100%;
  transition: transform .45s cubic-bezier(.4, 0, .2, 1);
  touch-action: pan-y;
  -webkit-user-drag: none;
  user-select: none;
  cursor: grab;
}
.lottie-carousel__track:active { cursor: grabbing; }
/* aspect-ratio deplace du <dotlottie-wc> vers .lottie-carousel__panel
   lui-meme (qui porte aussi position:relative) : c'est ce qui donne au
   panneau une boite de taille fixe et independante de son contenu, pour
   pouvoir agrandir le <dotlottie-wc> au-dela de 100% ci-dessous sans que le
   panneau (et donc .lottie-carousel__stage, qui prend sa hauteur du panneau
   visible) ne s'effondre. flex: 0 0 100% (pas de [hidden]/display:none comme
   avant) : les 4 panneaux restent tous en flux, cote a cote, seul le
   transform du track les deplace — necessaire pour l'animation de
   glissement, et effet de bord utile : dotlottie-wc a toujours une taille
   reelle des le depart, plus besoin du contournement data-src->src pour
   eviter un <canvas> fige a 300x150 (voir le commentaire au-dessus de
   lottieCarouselMarkup() dans app.js), meme si la promotion est conservee
   pour ne pas lancer les 4 animations en boucle simultanement. */
.lottie-carousel__panel { flex: 0 0 100%; min-width: 0; display: block; position: relative; overflow: hidden; aspect-ratio: 1185 / 993; }
.lottie-carousel__panel dotlottie-wc { display: block; width: 100%; height: 100%; }
/* Sur les 3 animations autres que "Blocking" (index 0), le sujet
   (losanges/etiquette) n'occupe que la portion centrale du canevas source
   (1185x993) — Blocking est la seule a etendre son propre decor (le plan
   quadrille) jusqu'aux bords. Resserrer le cadrage sur leur sujet en
   agrandissant le <dotlottie-wc> LUI-MEME (position absolute, 140%,
   recadre par l'overflow:hidden du panneau lui-meme — necessaire en plus de
   celui de .lottie-carousel__stage, sans quoi le debordement de 20% bavait
   sur le panneau voisin deja charge dans le track cote a cote) plutot que via
   un CSS transform:scale : dotlottie-wc dimensionne son <canvas> interne a
   sa propre boite reelle (ResizeObserver), donc l'agrandir "en vrai" fait
   redessiner l'animation a une resolution plus grande, nette — alors qu'un
   transform:scale ne fait qu'etirer au pixel pres le rendu deja calcule a
   la taille d'origine (flou, surtout perceptible sur les traits fins des
   losanges). */
.lottie-carousel__panel:not([data-index="0"]) dotlottie-wc {
  position: absolute; top: 50%; left: 50%;
  width: 140%; height: 140%;
  transform: translate(-50%, -50%);
}
.lottie-carousel__tabs {
  display: flex; flex-direction: column; gap: var(--s4);
  padding-top: var(--s2);
}
.lottie-carousel__tab {
  font: inherit; font-size: var(--fs-body); font-weight: var(--w-semibold);
  color: var(--ink-faint);
  background: none; border: none; padding: 0; text-align: left;
  cursor: pointer; transition: color .18s var(--ease);
}
.lottie-carousel__tab:hover { color: var(--accent); }
.lottie-carousel__tab.is-active { color: var(--accent); }

/* ---- Deux figures cote a cote, meme gabarit (voir s.image + s.carousel
   ensemble dans content.js, Services exclusion/Concept) : le "Before" et le
   carrousel "After" partagent la meme largeur ET la meme hauteur pour
   s'aligner. Le ratio des sources (997x936, meme export Figma pour les 5
   images) pilote la hauteur des deux colonnes — pas de mesure JS. */
.cs-sec__media-pair {
  display: grid; grid-template-columns: 1fr 1fr; gap: var(--s5);
  margin-block: var(--s6); align-items: start;
}
.cs-sec__media-pair .figure,
.cs-sec__media-pair .cs-carousel { margin-block: 0; }
.cs-sec__media-pair .figure__frame,
.cs-sec__media-pair .cs-carousel__stage { aspect-ratio: 997 / 936; }
.cs-sec__media-pair .figure__frame picture { display: block; height: 100%; }
.cs-sec__media-pair .figure__frame img,
.cs-sec__media-pair .cs-carousel__panel img {
  width: 100%; height: 100%; object-fit: cover;
}
@media (max-width: 700px) {
  .cs-sec__media-pair { grid-template-columns: 1fr; }
}

/* ---- Carrousel d'images statiques (voir carouselMarkup()/
   setupImageCarousel() dans app.js, s.carousel dans content.js) : les
   panneaux vivent tous cote a cote dans .cs-carousel__track, qui glisse via
   transform (voir goTo() dans setupImageCarousel()) — c'est ce qui donne
   l'animation de glissement, plutot qu'un simple hidden/is-active qui
   couperait net d'une image a l'autre. Fleches en vis-a-vis + puces
   incrustees en bas de la scene (pas sous la figure) : cf.
   amylalai.com/stitch-fromhome. */
.cs-carousel { margin-block: var(--s6); }
.cs-carousel__stage {
  position: relative; overflow: hidden; border-radius: var(--radius-sm);
  background: var(--frame-bg);
}
.cs-carousel__track {
  display: flex; height: 100%;
  transition: transform .45s cubic-bezier(.4, 0, .2, 1);
  /* Glissable (setupSwipe() dans app.js) : voir le meme trio de regles sur
     .lottie-carousel__track plus haut pour le detail de chacune. */
  touch-action: pan-y;
  -webkit-user-drag: none;
  user-select: none;
  cursor: grab;
}
.cs-carousel__track:active { cursor: grabbing; }
.cs-carousel__panel { flex: 0 0 100%; min-width: 0; }
.cs-carousel__panel img, .cs-carousel__panel video {
  display: block; width: 100%; height: 100%; -webkit-user-drag: none;
}
.cs-carousel__arrow {
  position: absolute; top: 50%; transform: translateY(-50%); z-index: 2;
  display: flex; align-items: center; justify-content: center;
  width: 40px; height: 40px; border-radius: 50%;
  background: rgba(16, 35, 63, .45); border: none;
  color: #FFFFFF; cursor: pointer; box-shadow: var(--shadow);
  transition: background .18s var(--ease), transform .18s var(--ease);
}
.cs-carousel__arrow:hover { background: rgba(16, 35, 63, .65); }
.cs-carousel__arrow--prev { left: var(--s4); }
.cs-carousel__arrow--next { right: var(--s4); }
.cs-carousel__arrow-icon { width: 18px; height: 18px; }
/* Fleches masquees par defaut, revelees au survol de la scene — desktop
   uniquement (hover:hover + pointer:fine exclut le tactile, qui n'a pas de
   survol pour les decouvrir et se repose sur le glissement/les puces).
   Partagees par .cs-carousel (Services exclusion) et .lottie-carousel
   (Contraintes), d'ou les deux selecteurs de scene. :focus-visible garde les
   fleches visibles au clavier meme sans survol. */
@media (hover: hover) and (pointer: fine) {
  .cs-carousel__arrow {
    opacity: 0;
    transition: opacity .18s var(--ease), background .18s var(--ease), transform .18s var(--ease);
  }
  .cs-carousel__stage:hover .cs-carousel__arrow,
  .lottie-carousel__stage:hover .cs-carousel__arrow,
  .cs-carousel__arrow:focus-visible {
    opacity: 1;
  }
}
.cs-carousel__arrow--prev .cs-carousel__arrow-icon { transform: rotate(90deg); }
.cs-carousel__arrow--next .cs-carousel__arrow-icon { transform: rotate(-90deg); }
.cs-carousel figcaption {
  margin-top: var(--s3); font-size: var(--fs-small); color: var(--ink-faint);
}
/* Puces incrustees : pilule sombre translucide pour rester lisibles quelle
   que soit la capture affichee dessous (fond blanc ou bleu clair selon
   l'etape). */
.cs-carousel__dots {
  position: absolute; left: 50%; bottom: var(--s3); transform: translateX(-50%); z-index: 2;
  display: flex; align-items: center; gap: var(--s3);
  padding: 6px 10px; border-radius: var(--radius-pill);
  background: rgba(16, 35, 63, .45); backdrop-filter: blur(4px);
}
.cs-carousel__dot {
  width: 7px; height: 7px; border-radius: 50%; padding: 0;
  background: rgba(255, 255, 255, .5); border: none; cursor: pointer;
  transition: background .18s var(--ease), transform .18s var(--ease);
}
.cs-carousel__dot:hover { background: rgba(255, 255, 255, .8); }
.cs-carousel__dot.is-active { background: #FFFFFF; transform: scale(1.3); }
/* Mobile : fleches retirees, seules les puces restent (glissement au doigt
   comme controle principal — voir setupSwipe() dans app.js) — partage par
   .cs-carousel et .lottie-carousel, memes classes de fleche. */
@media (max-width: 700px) {
  .cs-carousel__arrow { display: none; }
}

/* ---- Variante du carrousel pour Hoot/Design (slider "Events"/"Item
   voting") : chaque panneau porte son image ET son texte sur fond violet,
   comme le slider_container/commande_repas2 de marvinsrd.com/en/hoot-project
   — equivalent maison (flex + tokens du site) plutot qu'une copie du hack
   Webflow, qui laissait deborder des images tres larges du conteneur.
   Fleches recolorees pour matcher le rond mauve clair de la page source. */
.cs-carousel--hoot .cs-carousel__stage { background: #2d254b; }
.cs-carousel--hoot .cs-carousel__panel {
  display: flex; align-items: stretch; aspect-ratio: 16 / 9;
}
/* picture, pas img : l'<img> vit desormais TOUJOURS dans un <picture>
   (carouselMarkup() dans app.js — item.src comme item.image), lui-meme deja
   a width:100% de son cadre (regle generique .cs-carousel__panel img plus
   haut). Mettre aussi img ici appliquerait 55%/65% par-dessus les 55%/65%
   deja poses sur picture (55% de 55% ≈ 30% du panneau) — c'est ce qui
   causait l'ecart visible entre l'image et le texte. */
.cs-carousel--hoot .cs-carousel__panel picture,
.cs-carousel--hoot .cs-carousel__panel video {
  flex: 0 0 55%; width: 55%; height: 100%;
}
.cs-carousel--hoot .cs-carousel__panel img,
.cs-carousel--hoot .cs-carousel__panel video {
  object-fit: contain;
}
.cs-carousel--hoot .cs-carousel__content {
  flex: 1 1 auto; min-width: 0;
  display: flex; flex-direction: column; justify-content: center;
  gap: var(--s2); padding: var(--s5) calc(52px + var(--s6)) var(--s5) var(--s6);
}
.cs-carousel--hoot .cs-carousel__label {
  margin: 0; font-size: var(--fs-tiny); text-transform: uppercase;
  letter-spacing: .08em; font-weight: var(--w-semibold); color: rgba(255, 255, 255, .5);
}
.cs-carousel--hoot .cs-carousel__text {
  margin: 0; font-size: var(--fs-h2); font-weight: 700; line-height: 1.3;
  color: rgba(255, 255, 255, .9);
}
/* item.wide (ex. "Item voting", capture large deux-ecrans) : l'image gagne
   de la place au detriment du seul padding de .cs-carousel__content, pas de
   la colonne de texte elle-meme (padding-left reduit, tout le reste egal). */
.cs-carousel--hoot .cs-carousel__panel--wide picture,
.cs-carousel--hoot .cs-carousel__panel--wide video {
  flex-basis: 65%; width: 65%;
}
.cs-carousel--hoot .cs-carousel__panel--wide .cs-carousel__content {
  padding-left: var(--s4);
}
.cs-carousel--hoot .cs-carousel__arrow {
  background: #f0e1f9; color: #2d254b; box-shadow: none;
  width: 52px; height: 52px;
}
.cs-carousel--hoot .cs-carousel__arrow:hover { background: #e0b7f8; }
/* Chaine UI Kit -> carrousel -> 2 images -> texte collee sans espace, comme
   sur marvinsrd.com/en/hoot-project — voir b.note/b.panel dans app.js.
   L'image UI Kit (s.media[0], sans maxWidth desormais : meme largeur pleine
   colonne que le carrousel) est reperee par :has(+ .cs-carousel--hoot),
   seul media-single directement suivi du carrousel dans cette section. */
.media-single:has(+ .cs-carousel--hoot),
.media-single:has(+ .cs-carousel--hoot) .figure {
  margin-bottom: 0;
}
.media-single:has(+ .cs-carousel--hoot) .figure__frame {
  border-radius: var(--radius-sm) var(--radius-sm) 0 0; border: none; background: none;
}
.media-single:has(+ .cs-carousel--hoot) .figure--remote img {
  border-radius: var(--radius-sm) var(--radius-sm) 0 0;
}
.cs-carousel--hoot .cs-carousel__stage { border-radius: 0; }
.cs-carousel--hoot { margin-top: 0; margin-bottom: 0; }
.cs-carousel--hoot + .media-single,
.cs-carousel--hoot + .media-single .figure {
  margin-block: 0;
}
/* Le cadre standard de .figure--remote (fond bleute, liseré, coins arrondis)
   casserait le fondu violet continu avec le bloc "Meal Ordering" qui suit —
   la capture porte deja son propre fond #2d254b. Coins carres aussi sur
   l'image elle-meme : .figure--remote img (styles.css plus haut) porte son
   propre border-radius, distinct de celui de .figure__frame. */
.cs-carousel--hoot + .media-single .figure__frame {
  border-radius: 0; border: none; background: none;
}
.cs-carousel--hoot + .media-single .figure--remote img {
  border-radius: 0;
}
@media (max-width: 700px) {
  .cs-carousel--hoot .cs-carousel__panel { flex-direction: column; aspect-ratio: auto; }
  .cs-carousel--hoot .cs-carousel__panel picture,
  .cs-carousel--hoot .cs-carousel__panel video {
    width: 100%; height: 220px; flex: 0 0 auto;
  }
  .cs-carousel--hoot .cs-carousel__panel img,
  .cs-carousel--hoot .cs-carousel__panel video {
    object-fit: contain;
  }
  .cs-carousel--hoot .cs-carousel__content { padding: var(--s4) var(--s5); }
  .cs-carousel--hoot .cs-carousel__text { font-size: var(--fs-lead); }
}

/* ---- Note et panneau violets pour Hoot/Design, equivalents de
   slider_subcontent / img-container ui programmes sur
   marvinsrd.com/en/hoot-project — meme habillage texte que le carrousel
   ci-dessus mais hors carrousel (pas de fleches/puces). Voir b.note/b.panel
   dans app.js (pageCase, bloc s.after). */
.cs-hoot-note {
  background: #2d254b;
  padding: var(--s5) var(--s6); margin-block: 0;
  display: flex; flex-direction: column; gap: var(--s2);
}
/* Compound selectors : .cs-sec p (0,1,1) bat un simple .cs-hoot-note__text
   (0,1,0) — voir "The .cs-sec p specificity trap" dans CLAUDE.md. */
.cs-hoot-note .cs-hoot-note__label,
.cs-hoot-panel__content .cs-hoot-panel__label {
  margin: 0; font-size: var(--fs-tiny); text-transform: uppercase;
  letter-spacing: .08em; font-weight: var(--w-semibold); color: rgba(255, 255, 255, .5);
}
.cs-hoot-note .cs-hoot-note__text,
.cs-hoot-panel__content .cs-hoot-panel__text {
  margin: 0; max-width: none; font-size: var(--fs-h2); font-weight: 700; line-height: 1.3;
  color: rgba(255, 255, 255, .9);
}
.cs-hoot-panel {
  margin: 0; background: #2d254b; border-radius: 0 0 var(--radius-sm) var(--radius-sm);
  overflow: hidden; display: flex; align-items: stretch; aspect-ratio: 16 / 9;
}
.cs-hoot-panel__media {
  flex: 0 0 55%; width: 55%; height: 100%;
  display: flex; align-items: center; justify-content: center;
}
/* Au zoom, cale a gauche plutot que centre : justify-content: center
   centrait l'image agrandie dans son cadre devenu scrollable, et le
   navigateur ouvre alors ce scroll a mi-chemin (moitie du debordement de
   chaque cote) — le bord GAUCHE de l'image restait donc hors champ tant
   qu'on n'avait pas pense a scroller vers la gauche. flex-start ancre le
   bord gauche au bord du cadre : rien a scroller pour le voir, seul le
   reste de l'image (a droite) demande de faire defiler. */
.cs-hoot-panel__media.is-zoomed { justify-content: flex-start; }
/* flex-shrink: 0 est necessaire : .cs-hoot-panel__media est display:flex, et
   .zoomable-media.is-zoomed { overflow: auto } (plus bas dans ce fichier)
   annule le min-width:auto par defaut d'un enfant flex — sans ce
   flex-shrink, le flex-shrink:1 implicite reduisait l'image zoomee
   (width:230% pose par .zoomable-media.is-zoomed img) jusqu'a la faire
   retenir dans le cadre, annulant tout l'effet de zoom au clic. */
.cs-hoot-panel__media img { width: 100%; height: 100%; object-fit: contain; flex-shrink: 0; }
/* height: auto au zoom, meme principe que .cs-sec__media-pair plus bas dans
   ce fichier : height: 100% (regle de base ci-dessus) plafonne l'image a la
   hauteur du cadre, et object-fit: contain se cale alors sur CETTE hauteur
   (l'image est deja plus large que haute) — elargir seulement la largeur
   (width: 230%, .zoomable-media.is-zoomed img plus bas) ne faisait donc que
   rajouter du vide violet a droite, l'image affichee restant a l'identique.
   height: auto laisse la hauteur suivre le ratio de l'image une fois la
   largeur agrandie, et c'est bien elle qui grandit visuellement. */
.cs-hoot-panel__media.is-zoomed img { height: auto; }
.cs-hoot-panel__content {
  flex: 1 1 auto; min-width: 0;
  display: flex; flex-direction: column; justify-content: center;
  gap: var(--s2); padding: var(--s5) var(--s6);
}
@media (max-width: 700px) {
  .cs-hoot-note { padding: var(--s4) var(--s5); }
  /* border-radius deja pose sur .cs-hoot-panel (regle de base ci-dessus) :
     sur mobile .cs-hoot-panel__content (colonne) devient visuellement le
     dernier bloc empile, mais le radius reste sur .cs-hoot-panel lui-meme
     (fond + overflow: hidden partages par media/content, qui n'ont pas de
     fond propre) — rien a repeter ici, juste la mise en colonne. */
  .cs-hoot-panel { flex-direction: column; aspect-ratio: auto; }
  .cs-hoot-panel__media { width: 100%; height: 220px; flex: 0 0 auto; }
  /* Au zoom, le cadre suit la hauteur de l'image plutot que de rester a
     220px : sans ca l'image grandissait (height: auto sur l'img ci-dessus)
     mais restait coincee dans un cadre fixe, seulement consultable via un
     scroll interne — .cs-hoot-panel__content, plus bas dans le flux colonne
     (aspect-ratio: auto ci-dessus), est repousse d'autant. */
  .cs-hoot-panel__media.is-zoomed { height: auto; }
  .cs-hoot-panel__content { padding: var(--s4) var(--s5); }
  .cs-hoot-panel__text { font-size: var(--fs-lead); }
}

/* ---- Bloc de cloture Hoot ("morehoot" sur marvinsrd.com/en/hoot-project) :
   statement + texte + lien vers le fichier de presentation, hors de la
   chaine violette flush ci-dessus (dernier bloc de la derniere section,
   donc "fin de l'etude" au sens litteral). Voir b.cta dans app.js. */
.cs-hoot-cta { margin-top: var(--s7); }
.cs-hoot-cta .cs-hoot-cta__statement {
  margin: 0 0 var(--s3); font-size: var(--fs-h2); font-weight: 700; line-height: 1.3;
}
.cs-hoot-cta .cs-hoot-cta__text { margin: 0 0 var(--s5); }

/* ---- Le meme carrousel dans l'article (voir le media `type: 'carousel'` de
   "Lines and lanes" dans content.js). Les deux schemas Figma des deux slides
   font tous deux 990x962 (meme noeud source, meme export 2x) : le ratio de la
   scene est donc celui-la, pas un compromis comme pour Services exclusion
   (997/936, deux captures du meme ecran). L'image remplit alors la scene EN
   ENTIER, sans bande laterale — c'est ce qui permet aux fleches (posees en
   `left`/`right` sur la scene, voir .cs-carousel__arrow plus haut) de tomber
   SUR l'image plutot que dans une marge vide de part et d'autre.
   `object-fit: contain` reste pose : si un jour une des deux images change
   pour un format different, la scene contient toujours le schema entier au
   lieu de le rogner, elle ne redevient simplement plus invisible tant que les
   deux formats matchent. */
.article__block .cs-carousel__stage { aspect-ratio: 495 / 481; }
.article__block .cs-carousel__panel img, .article__block .cs-carousel__panel video {
  height: 100%; object-fit: contain;
}

/* ---- La paire de references dans "Writing it down" (voir le 2e `media` de
   content.js — la carte de pas et "Dancing Made Easy"). .media-grid generique
   (plus haut) range ses figures en colonnes EGALES (`1fr` chacune), ce qui va
   bien pour des captures du meme ecran mais etire ou ratatine deux images de
   formats differents a des LARGEURS egales, donc des HAUTEURS differentes —
   l'effet inverse de ce qui est demande ici.
   Passe donc en ligne flex : chaque figure garde la largeur que lui donne son
   image (`flex: 0 0 auto`, pas de stretch), et c'est l'IMAGE qui recoit une
   hauteur fixe plutot que le `width: 100%` habituel de .figure--remote img.
   275px, PAS la hauteur native de la carte de pas (365x415) : a 415 les deux
   images cote a cote demandent ~993px + le gap, largement plus que les 700px
   de la colonne (.wrap--narrow moins les gouttieres) — elles retomberaient
   sur deux lignes au lieu de rester cote a cote. A 275 : ~242px (carte) +
   ~416px (illustration) + 24px de gap = ~682px, sous les 700px. L'illustration
   est exportee en 2x (1014x670) pour rester nette a cette taille affichee
   (retrecissement depuis le fichier, jamais un agrandissement). Si l'une des
   deux images change de format, refaire ce calcul et ajuster 275px ici.
   Scope a [data-slug="salsa"] (et non .article__block generique) : sans ce
   scope la regle attrapait aussi la paire Yabara de l'article "2 years gap"
   (memes classes .article__block .media-grid .figure__frame), ou sa hauteur
   fixe (0,3,1) l'emportait sur .zoomable-media.is-zoomed img (0,2,1) — le
   zoom desktop restait donc inerte sur ces deux images. */
.cs--article[data-slug="salsa"] .media-grid {
  display: flex; flex-wrap: wrap; align-items: flex-start;
}
.cs--article[data-slug="salsa"] .media-grid .figure { flex: 0 0 auto; max-width: 100%; }
.cs--article[data-slug="salsa"] .media-grid .figure__frame img {
  width: auto; height: 275px; max-width: 100%;
}
/* Sous 700px, `flex-wrap` fait deja retomber les deux figures chacune sur sa
   propre ligne — mais height:275px/width:auto ci-dessus leur donne alors
   chacune une largeur DIFFERENTE (la hauteur commune qui les alignait cote a
   cote n'a plus de sens une fois empilees). Demande utilisateur : aligner la
   largeur de la seconde (Dancing Made Easy) sur la premiere (JustSalsa),
   donc les deux a 100% de la colonne plutot qu'a une hauteur commune. */
@media (max-width: 700px) {
  /* Le pourcentage vit sur .figure (pas sur l'img comme sur desktop) : mettre
     width:80% directement sur l'img aurait ete circulaire si .figure__frame
     avait du s'ajuster a son contenu (fit-content depend de l'image, l'image
     depend de fit-content). Ici .figure__frame reste un bloc qui remplit
     .figure, et l'img remplit le frame a 100% — donc le frame (fond + liseré
     de .figure--remote) colle exactement au bord de l'image reduite, au lieu
     de deborder a la largeur pleine colonne. */
  .cs--article[data-slug="salsa"] .media-grid .figure { flex: 0 0 auto; width: 80%; }
  .cs--article[data-slug="salsa"] .media-grid .figure__frame img { width: 100%; height: auto; }
}

/* ---- Services exclusion, section "Fixes" : demo interactif "modal
   behavior" (voir exclModalMarkup()/setupExclModal() dans app.js, s.modal
   dans content.js) -----------------------------------------------------
   Remplace l'ancienne capture exclusion-4-test-en. Meme traitement que le
   sentence builder des contraintes juste en dessous : widget autonome,
   fidelite aux couleurs/tailles/police EXACTES de la maquette Figma (get_
   design_context sur les nodes 114:9541 et 114:9958, "Core modal", fichier
   "Claude portfolio image generation") plutot qu'aux tokens du site —
   Roboto, encre #434C5C, bleu interactif #2E8BE0 (Primary/60), bandeau
   degrade #3B6CC9 -> #224B96 (Global/Tertiary), liseres #C2D3FF
   (Secondary/85) / #D7E2FF (Secondary/90), entete de tableau #E0EEFF
   (Primary/93), icone d'avertissement #F3B248. Coefficient d'echelle 1.6
   applique a toutes les valeurs de la maquette (400px de large dans
   Figma -> 640px ici) pour rester lisible a taille d'ecran normale. Coins
   carres partout (pas de border-radius sur la carte/l'entete/le pied) :
   fidele a la maquette, qui n'en a nulle part sur ce composant. */
.excl-modal-frame {
  margin-block: var(--s6);
  background: #E9F2FE;
  padding: clamp(16px, 4vw, 40px);
  display: flex; justify-content: center;
  border-radius: var(--radius);
  overflow: hidden;
}
.excl-modal {
  width: 100%; max-width: 640px;
  background: #FFFFFF;
  font-family: 'Roboto', 'Segoe UI', sans-serif;
}
/* Desktop only : `zoom` plutot que `transform: scale()` — zoom participe a
   la mise en page (la boite se redimensionne réellement, comme un Scale (K)
   dans Figma) alors que transform ne redimensionne que visuellement et
   laisse un vide reserve autour, ce qui aurait casse le centrage du cadre. */
@media (min-width: 861px) {
  .excl-modal { zoom: 0.75; }
}
.excl-modal__header {
  display: flex; align-items: center; position: relative;
  height: 42px; padding: 0 17px;
  background: linear-gradient(90deg, #3B6CC9, #224B96);
  color: #FFFFFF;
}
/* flex:1 + text-align:center centre horizontalement sur toute la largeur de
   l'entete (pas seulement l'espace restant a cote du bouton fermer, qui
   reste positionne independamment) ; align-items:center sur l'entete centre
   verticalement. */
/* .excl-modal .excl-modal__title (0,2,0) plutot que .excl-modal__title seul
   (0,1,0) : sinon perd face a .cs-sec p (0,1,1), voir CLAUDE.md — meme piege
   que celui deja documente pour .cbuild__preview-title, ici particulierement
   visible puisque .cs-sec p imposerait --ink-soft a la place du blanc voulu
   sur fond bleu. Meme raison pour heading/desc/learn-more/count/warning
   plus bas : tous des <p>. */
.excl-modal .excl-modal__title { flex: 1; text-align: center; color: #FFFFFF; font-size: 13.5px; font-weight: 500; margin: 0; }
.excl-modal__close {
  position: absolute; right: 17px; top: 50%; transform: translateY(-50%);
  display: flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; padding: 0; margin: 0;
  background: none; border: none; border-radius: 6px;
  color: inherit; cursor: pointer;
  transition: background .18s var(--ease);
}
.excl-modal__close:hover { background: rgba(255, 255, 255, .15); }
.excl-modal__close-icon { width: 16px; height: 16px; }

/* Zone F7F9FD sous l'entete (voir .excl-modal-frame plus haut, meme
   couleur : celle de la maquette Figma "en dessous du conteneur") : abrite
   le fil d'etapes (HORS du conteneur blanc, voir plus bas) et le
   conteneur lui-meme. padding-bottom: 17px, meme valeur que les autres
   espacements de la modale — le conteneur (donc .excl-modal__rows, quelle
   que soit sa position de defilement) se termine toujours a 17px du pied,
   cet espace faisant partie de la surface et non du contenu qui defile. */
.excl-modal__surface {
  background: #F7F9FD;
  padding: 14px 17px 17px;
  display: flex; flex-direction: column; gap: 14px;
}
/* Fil d'etapes : seule "Excluded services" est active (is-active) — le
   comportement decrit par le node Figma ne concerne que cette premiere
   etape, inutile de simuler une navigation qui n'existe pas dans la
   source. Meme couleur d'encre pour les 4 libelles (voir la maquette) :
   seul le remplissage du cercle numerote distingue l'etape active. */
.excl-modal__stepper {
  display: flex; align-items: center; justify-content: center; flex-wrap: wrap;
  gap: 8px; font-size: 11px; color: #434C5C;
}
.excl-modal__step { display: inline-flex; align-items: center; gap: 8px; white-space: nowrap; }
.excl-modal__step.is-active { font-weight: 500; }
.excl-modal__step-n {
  display: inline-flex; align-items: center; justify-content: center;
  width: 20px; height: 20px; border-radius: 50%; flex-shrink: 0;
  background: #A0A5AD; color: #FFFFFF; font-size: 10px;
}
.excl-modal__step.is-active .excl-modal__step-n { background: #224B96; }
.excl-modal__step-sep { width: 32px; height: 1px; flex-shrink: 0; background: rgba(58, 108, 201, .25); }

/* Le conteneur blanc (equivalent du frame "Content" de la maquette) : seul
   element du corps de la modale a porter un fond et une ombre — le reste de
   .excl-modal__surface reste visible en #F7F9FD tout autour. */
.excl-modal__container {
  background: #FFFFFF;
  box-shadow: 0 1px 5px rgba(58, 108, 201, .15);
}

.excl-modal__titre { padding: 21px 17px 18px; border-bottom: 1px solid #C2D3FF; }
.excl-modal .excl-modal__heading { font-size: 17px; font-weight: 500; color: #434C5C; margin: 0 0 8px; }
.excl-modal .excl-modal__desc { font-size: 12px; line-height: 17px; color: #434C5C; margin: 0 0 8px; }
.excl-modal .excl-modal__learn-more { font-size: 12px; line-height: 17px; color: #2E8BE0; margin: 0; }

.excl-modal__toolbar {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--s3); flex-wrap: wrap; padding: 10px 17px;
}
.excl-modal .excl-modal__count { font-size: 13.5px; color: #434C5C; margin: 0; }
/* Decoratif — aucun <input> : la barre de recherche n'est pas fonctionnelle
   dans la maquette source (annotation "Searchbar doesnt work" sur le node
   Figma), un vrai champ inerte induirait l'utilisateur en erreur. Icone
   apres le texte (pas avant) : ordre exact de la maquette. */
.excl-modal__search {
  display: inline-flex; align-items: center; justify-content: space-between; gap: 8px;
  width: 230px; max-width: 100%;
  padding: 8px; border: 1px solid #C2D3FF; border-radius: 5px;
  font-size: 12px; color: #A1A6AE;
}
.excl-modal__search-icon { width: 13px; height: 13px; flex-shrink: 0; }

/* padding-left = padding de .excl-modal__row (17px) + largeur de la case
   (17px) + son gap avec le texte (24px) : aligne "Service name" tel qu'il
   apparait au-dessus avec .excl-modal__row-name en dessous. */
.excl-modal__table-head {
  padding: 10px 17px 10px 58px;
  background: #E0EEFF;
  font-size: 10px; color: #434C5C; margin: 0;
}
/* Seule partie scrollable de la modale (le reste — entete, fil d'etapes,
   titre, barre d'outils, pied — reste fixe) : hauteur limitee, coupee a mi-
   hauteur d'une ligne (210px = 4 lignes de 47px + ~22px de la 5e) plutot que
   pile sur une limite de ligne, pour signaler qu'il y a d'autres services
   plus bas sans avoir besoin de la barre de defilement (masquee dans les 3
   moteurs : scrollbar-width, -ms-overflow-style, ::-webkit-scrollbar — le
   defilement reste possible, juste sans son indicateur visuel). L'espace de
   17px avant le pied vient de .excl-modal__surface (voir plus haut), pas
   d'un padding ici : il reste donc constant, que la liste soit scrollee ou
   non. */
.excl-modal__rows {
  max-height: 210px; overflow-y: auto;
  scrollbar-width: none; -ms-overflow-style: none;
}
.excl-modal__rows::-webkit-scrollbar { display: none; }
.excl-modal__row {
  display: flex; align-items: center; gap: 24px;
  height: 47px; padding: 0 17px;
  border-bottom: 1px solid #D7E2FF; cursor: pointer;
  transition: background .15s var(--ease);
}
.excl-modal__row:last-child { border-bottom: none; }
.excl-modal__row:hover { background: #FAFBFE; }

/* Case a cocher reproduisant le composant "Select" de la maquette (carre
   arrondi, pas la case native du navigateur) : l'<input> reste le vrai
   controle (focus clavier, evenement change pour setupExclModal()), rendu
   invisible mais toujours cliquable/tactile ; .excl-modal__checkbox est sa
   seule representation visuelle, pilotee par le combinateur + adjacent. */
.excl-modal__checkbox-wrap { position: relative; width: 17px; height: 17px; flex-shrink: 0; }
.excl-modal__checkbox-input { position: absolute; inset: 0; margin: 0; opacity: 0; cursor: pointer; }
.excl-modal__checkbox {
  position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
  border: 1px solid #2E8BE0; border-radius: 5px; background: #FFFFFF; pointer-events: none;
}
.excl-modal__checkbox-icon { width: 11px; height: 11px; color: #FFFFFF; opacity: 0; }
.excl-modal__checkbox-input:checked + .excl-modal__checkbox { background: #2E8BE0; }
.excl-modal__checkbox-input:checked + .excl-modal__checkbox .excl-modal__checkbox-icon { opacity: 1; }
.excl-modal__checkbox-input:focus-visible + .excl-modal__checkbox { outline: 2px solid #2E8BE0; outline-offset: 2px; }

.excl-modal__row-text { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.excl-modal__row-name { font-size: 12px; font-weight: 500; color: #434C5C; }
.excl-modal__row-code { font-size: 12px; font-weight: 400; color: #434C5C; }
/* Rappel affiche quand la ligne est decochee (donc exclue) — voir
   setupExclModal() : tag.hidden = cb.checked. Texte italique nu, sans
   badge/fond : fidele a la maquette (aucun style de puce sur ce texte). */
.excl-modal__row-tag {
  flex-shrink: 0; margin-left: auto; font-size: 12px; font-style: italic;
  color: #434C5C; text-align: right; white-space: nowrap;
}
/* Force-break hidden by default (desktop keeps the tag on one line) — shown
   only in the mobile breakpoint below, where the two-line tag frees up
   horizontal space for .excl-modal__row-text. */
.excl-modal__row-tag-break { display: none; }

.excl-modal__footer {
  display: flex; align-items: center; justify-content: space-between;
  flex-wrap: wrap; gap: var(--s3); padding: 17px;
  box-shadow: 0 -2px 4px rgba(58, 108, 201, .15);
}
.excl-modal .excl-modal__warning {
  display: flex; align-items: center; gap: 8px; margin: 0;
  font-size: 12px; color: #434C5C;
}
/* Specificite egale (0,0,1,0) que la regle [hidden] du navigateur, mais les
   styles auteur l'emportent toujours sur ceux du navigateur : sans cette
   regle explicite, le display:flex ci-dessus resterait actif malgre
   [hidden] (meme piege que .cbuild__illu-layer[hidden], voir CLAUDE.md). */
.excl-modal__warning[hidden] { display: none; }
/* Pas de `color` : le SVG fourni peint son propre remplissage (#F3B248),
   il n'utilise pas currentColor — voir warningTriangleIcon() dans app.js. */
.excl-modal__warning-icon { width: 11px; height: 11px; flex-shrink: 0; }
.excl-modal__actions { display: flex; align-items: center; gap: 8px; margin-left: auto; }
.excl-modal__cancel {
  background: none; border: none; font: inherit; font-size: 12px;
  color: #2E8BE0; cursor: pointer; padding: 8px;
}
.excl-modal__next {
  background: #2E8BE0; color: #FFFFFF; border: none; border-radius: 2px;
  font: inherit; font-size: 12px; font-weight: 500; cursor: pointer;
  padding: 8px 17px; transition: background .18s var(--ease);
}
.excl-modal__next:hover { background: #224B96; }

@media (max-width: 700px) {
  .excl-modal__toolbar { flex-direction: column; align-items: flex-start; }
  .excl-modal__table-head { padding-left: 17px; }
  .excl-modal__row { gap: 12px; }
  .excl-modal__row, .excl-modal__row * { font-size: 10px; }
  .excl-modal__row-tag-break { display: block; }
  .excl-modal__step-sep { display: none; }
}

/* ---- Yabara, section "Recruiter section" : carte candidat + modale --------
   Widget autonome (voir candidateCardMarkup()/setupCandidateCard() dans
   app.js), empile SOUS la figure "Sommaire des candidatures" via s.below
   (content.js) + .cs-sec__stack (voir juste en dessous) plutot qu'en pleine
   largeur sous tout le .media-grid — demande utilisateur : la carte doit
   "coller" sous le sommaire, du meme cote que le dashboard. Couleurs/tailles
   reprises telles quelles des nodes Figma 387:1493 (carte) et 387:1327
   (modale), memes valeurs figees que .excl-modal plus haut plutot que les
   tokens sitewide (--radius, --ink...) : ce sont des maquettes d'ecran
   produit, pas des composants du site lui-meme.
   .cand-demo reprend le fond #FBFBFB + le cadrage centre de
   .excl-modal-frame ; .cand-demo__stage est le seul ancetre positionne
   (position: relative) de .cand-card ET .cand-modal. max-width/padding
   reduits pour tenir dans la colonne plus etroite qu'avant plutot que la
   pleine largeur du .media-grid.
   min-height/zoom : revu suite a une demande separee ("make the modal stick
   inside .cand-demo entirely, without increasing .cand-demo's size") — la
   modale (panneau + son propre padding de 16px) mesure ~555px de haut en
   unites locales (avant zoom) une fois posee sur toute la largeur de
   .cand-demo__stage (max-width local 460px), largement plus que les ~290px
   de la carte au repos qui dictaient min-height avant. Comme .cand-modal
   couvre exactement .cand-demo__stage (inset: 0) et PAS .cand-demo, c'est
   min-height qui doit couvrir la modale (avec un peu de marge, 570px) pour
   qu'elle ne deborde plus jamais de son cadre. Pour ne pas agrandir
   .cand-demo pour autant (min-height*zoom = hauteur reelle de la scene),
   zoom descend a 0.41 : min-height*zoom (570*0.41 ~= 234px) reste sous la
   hauteur actuelle de .cand-demo (~240px) — la carte au repos parait donc
   plus petite qu'avant, consequence assumee de "reduce the component". */
.cs-sec__stack { display: flex; flex-direction: column; gap: var(--s5); }
.cand-demo {
  background: #FBFBFB;
  border-radius: var(--radius);
  padding: 0;
  display: flex; justify-content: center;
  /* padding: 0 (pas le clamp(12px,3vw,24px) d'avant) — demande utilisateur :
     rendre a la modale/carte l'espace qu'occupait le padding plutot que de le
     garder comme marge morte, .cand-demo restant globalement a la meme
     taille (voir zoom sur .cand-demo__stage ci-dessous, augmente d'autant). */
}
.cand-demo__stage {
  position: relative;
  width: 100%; max-width: 460px;
  min-height: 570px;
  display: flex; align-items: center; justify-content: center;
  /* zoom plutot que transform: scale() : meme choix que .excl-modal (voir
     plus haut) — zoom participe a la mise en page (.cand-demo, en display:
     flex sans hauteur propre, retrecit avec lui) alors que transform
     laisserait un vide reserve a la taille pre-reduction tout autour. Toutes
     les tailles de .cand-card/.cand-modal restent celles du node Figma
     (memes proportions, voir plus bas) — c'est ce seul zoom qui reduit
     l'ensemble en gardant le ratio, inchange par la reduction de
     .cand-demo/.cand-demo__stage ci-dessus (min-height/max-width bornent
     l'espace reserve, pas la taille des enfants).
     0.75 (pas 0.49) : demande utilisateur — aligner le bas de .cand-demo
     (colonne de droite, sous "Applications summary") sur le bas de l'image
     d'anonymisation (colonne de gauche, sous le dashboard), en grossissant
     le composant plutot qu'en ajoutant un espace mort sous la carte.
     min-height reste a 570px (deja assez grand pour la modale, ~555px de
     haut en unites locales, voir plus haut — le rapport modale/min-height
     ne depend pas du zoom, augmenter zoom ne fait donc jamais deborder la
     modale hors de .cand-demo__stage) ; seul zoom change, mesure a la
     largeur de colonne courante (~470px) pour que min-height*zoom (570*0.75
     ~= 428px) egale la hauteur totale de la colonne de gauche (dashboard +
     image d'anonymisation empiles, 632.9 mesures navigateur) moins la
     figure "Applications summary" et l'ecart entre les deux figures
     empilees (632.9 - 180.9 - 24). Comme le dashboard et l'image
     d'anonymisation sont en aspect-ratio (donc fluides avec la largeur de
     colonne) alors que min-height/zoom ici sont des px fixes, cet
     alignement n'est exact qu'a la largeur de bureau testee — pas une
     grille totalement fluide, mais coherent avec le reste des reglages de
     .cand-demo (deja tous en px fixes, jamais recalcules en fonction de la
     largeur de colonne). */
  zoom: 0.75;
}
.cand-card {
  width: 231px;
  background: #FFFFFF;
  border: 1px solid rgba(178, 178, 178, .5);
  border-radius: 5px;
  padding: 16px;
  display: flex; flex-direction: column; gap: 24px;
  font-family: 'Inter', 'Segoe UI', sans-serif;
  transition: box-shadow .2s var(--ease);
}
/* Etat hover du node Figma 387:1493 ("Component 2", type="hover") : memes
   bordure/fond, seule une ombre portee a deux couches s'ajoute (aucune
   ombre au repos) — valeurs reprises telles quelles (voir les Effect
   "Drop Shadow" du node), pas les tokens --shadow/--shadow-lg sitewide. */
.cand-card:hover {
  box-shadow: 0 16px 32px rgba(12, 12, 13, .1), 0 4px 4px rgba(12, 12, 13, .05);
}
.cand-card__top { display: flex; align-items: flex-start; justify-content: space-between; }
.cand-card__avatar { display: block; width: 64px; height: 64px; border-radius: 50%; overflow: hidden; }
.cand-card__avatar img { display: block; width: 100%; height: 100%; object-fit: cover; }
.cand-card__bookmark {
  display: flex; align-items: center; justify-content: center;
  width: 36px; height: 36px; padding: 0;
  background: rgba(239, 239, 239, .6);
  border: none; border-radius: 60px;
  color: #303030;
  cursor: pointer;
}
.cand-card__bookmark:hover { background: #DCDCDC; }
.cand-card__bookmark-icon { width: 20px; height: 20px; }
.cand-card__text { display: flex; flex-direction: column; gap: 8px; }
/* Compound selectors (.cand-card .cand-card__id, etc.) : .cs-sec p (0,1,1)
   bat sinon un simple .cand-card__id (0,1,0) — voir "The .cs-sec p
   specificity trap" dans CLAUDE.md — et lui impose color: var(--ink-faint)
   et surtout margin-bottom: var(--s4), un espace qui n'a rien a voir avec le
   gap:8px de .cand-card__text voulu ici. */
.cand-card .cand-card__id { margin: 0; font-size: 20px; font-weight: 500; color: #303030; }
.cand-card .cand-card__role { margin: 0; font-size: 16px; font-weight: 500; color: #303030; }
.cand-card .cand-card__loc { margin: 0; display: flex; align-items: center; gap: 8px; font-size: 16px; color: #303030; }
.cand-card__loc-icon { width: 20px; height: 20px; flex-shrink: 0; }
.cand-card__cta {
  width: 100%; padding: 8px;
  display: flex; align-items: center; justify-content: center;
  background: #FFFFFF;
  border: 1px solid #D9D9D9;
  border-radius: 4px;
  font-family: inherit; font-size: 16px; color: #303030;
  cursor: pointer;
}
.cand-card__cta:hover { background: #FAFAFA; }

/* .cand-modal : couvre toute .cand-demo__stage (jamais tout le viewport —
   la modale reste a l'interieur du cadre de demo, voir la note plus haut).
   Pas de fond assombri : la maquette source n'a pas de calque backdrop, la
   modale flotte directement sur #FBFBFB — c'est le centrage de .cand-card
   ET .cand-modal sur le MEME point (voir .cand-demo__stage) qui masque la
   carte, le panneau (506px) etant plus grand qu'elle (231px) dans les deux
   directions. [hidden] coupe le rendu tant qu'elle n'a jamais ete ouverte,
   .is-open pilote l'apparition (opacity + leger scale du panneau), classe
   ajoutee une frame apres avoir leve [hidden] pour que la transition rejoue
   a chaque ouverture (meme technique que .cs-timeline__constraint plus haut
   dans ce fichier). 300ms/ease-out : duree et easing donnes tels quels par
   l'annotation Figma du node 387:1326 ("Animation de l'interaction"). */
.cand-modal {
  position: absolute; inset: 0;
  display: flex; align-items: center; justify-content: center;
  padding: 16px;
  opacity: 0;
  pointer-events: none;
  transition: opacity .3s ease-out;
}
.cand-modal.is-open { pointer-events: auto; }
.cand-modal[hidden] { display: none; }
.cand-modal.is-open { opacity: 1; }
.cand-modal__panel {
  position: relative;
  width: 100%; max-width: 506px;
  background: #FFFFFF;
  border-radius: 8px;
  padding: 24px;
  box-shadow: 0 4px 2px rgba(0, 0, 0, .25);
  font-family: 'Inter', 'Segoe UI', sans-serif;
  transform: scale(.96);
  transition: transform .3s ease-out;
}
.cand-modal.is-open .cand-modal__panel { transform: scale(1); }
.cand-modal__close {
  position: absolute; top: 16px; right: 16px;
  width: 40px; height: 40px; padding: 8px;
  background: none; border: none; border-radius: 50%;
  color: #2F2F2F;
  cursor: pointer;
  transition: background .15s var(--ease);
}
.cand-modal__close:hover { background: #F0F0F0; }
.cand-modal__close-icon { width: 24px; height: 24px; }
.cand-modal__title { margin: 0 0 8px; font-size: 32px; font-weight: 400; color: #000000; }
/* .cand-modal .cand-modal__desc (pas juste .cand-modal__desc) : meme piege
   de specificite que .cand-card__id plus haut — .cs-sec p (0,1,1) imposerait
   sinon color: var(--ink-faint) et margin-bottom: var(--s4) a la place du
   gris et des 24px voulus ici. */
.cand-modal .cand-modal__desc { margin: 0 0 24px; font-size: 20px; line-height: 1.5; color: #5A5A5A; }
.cand-modal__search {
  display: flex; align-items: center; gap: 12px;
  height: 54px; padding: 0 16px; margin-bottom: 24px;
  background: #F4F4F4;
  border: 1px solid rgba(178, 178, 178, .5);
  border-radius: 8px;
  color: #757575; font-size: 16px;
  transition: background .15s var(--ease);
}
/* "Search has hover, but doesnt work" (annotation Figma du node 387:1326) :
   visuel seulement, la barre reste decorative (aria-hidden, voir
   candidateCardMarkup() dans app.js) — le hover confirme juste qu'il y a
   quelque chose la sans laisser croire qu'on peut y taper. */
.cand-modal__search:hover { background: #EFEFEF; }
.cand-modal__search-icon { width: 24px; height: 24px; flex-shrink: 0; }
.cand-modal__checks { display: flex; flex-direction: column; gap: 16px; padding-left: 16px; margin-bottom: 24px; }
/* "All checkboxes clickable" (meme annotation) : hover sur toute la ligne
   (pas seulement la case) — padding/margin negatif pour etendre la zone de
   fond sans decaler le contenu ni la colonne de cases. */
.cand-modal__check {
  display: flex; align-items: center; gap: 16px; cursor: pointer;
  padding: 6px 8px; margin: -6px -8px;
  border-radius: 6px;
  transition: background .15s var(--ease);
}
.cand-modal__check:hover { background: #F4F4F4; }
.cand-modal__check-input { position: absolute; opacity: 0; width: 24px; height: 24px; margin: 0; cursor: pointer; }
.cand-modal__check-box {
  display: flex; align-items: center; justify-content: center;
  width: 24px; height: 24px; flex-shrink: 0;
  background: #FFFFFF;
  border: 1px solid #757575;
  border-radius: 4px;
}
.cand-modal__check-icon { width: 14px; height: 14px; color: #FFFFFF; opacity: 0; }
.cand-modal__check-input:checked + .cand-modal__check-box { background: #2F2F2F; border-color: #2F2F2F; }
.cand-modal__check-input:checked + .cand-modal__check-box .cand-modal__check-icon { opacity: 1; }
.cand-modal__check-input:focus-visible + .cand-modal__check-box { outline: 2px solid #2F2F2F; outline-offset: 2px; }
.cand-modal__check-label { font-size: 16px; color: #303030; }
.cand-modal__actions { display: flex; gap: 8px; }
.cand-modal__cancel, .cand-modal__save {
  flex: 1; height: 54px; padding: 16px;
  display: flex; align-items: center; justify-content: center;
  border-radius: 8px; font-family: inherit; font-size: 16px;
  cursor: pointer;
}
.cand-modal__cancel { background: #FFFFFF; border: 1px solid rgba(178, 178, 178, .5); color: #303030; font-weight: 500; }
.cand-modal__cancel:hover { background: #FAFAFA; }
.cand-modal__save { background: #2F2F2F; border: none; color: #FFFFFF; font-weight: 600; }
.cand-modal__save:hover { background: #1A1A1A; }
@media (max-width: 700px) {
  .cand-modal__title { font-size: 24px; }
  .cand-modal__desc { font-size: 16px; }
}

/* ---- Contraintes, section "Design" : demo interactif "sentence builder" --
   Widget autonome (voir constraintBuilderMarkup()/setupConstraintBuilder()
   dans app.js), rendu APRES figureFor(s) dans pageCase() plutot que via
   media[]/mediaGroup() : il n'a donc jamais la classe .media-grid/.figure et
   n'est concerne par aucune des regles :has() ci-dessus.

   Fidelite exacte a la maquette Figma (fichier "Claude portfolio image
   generation", section "Constraint selection module", node 94:5342) : sur
   demande explicite, ce widget reprend les couleurs, tailles et polices
   EXACTES de la maquette (violet #371495 etc., police Roboto) plutot que
   les tokens du site (--accent bleu, Raleway) — c'est la seule zone du site
   qui deroge au design system, deliberement, pour ce widget precis. */
.constraint-builder { margin-block: var(--s6); font-family: 'Roboto', 'Segoe UI', sans-serif; }
/* Legende sous le widget, meme traitement que .figure figcaption (police du
   site, pas Roboto — coherent avec les legendes des mockups juste au-dessus). */
.cbuild__caption {
  margin-top: var(--s3);
  font-size: var(--fs-small); color: var(--ink-faint);
  line-height: 1.5; font-family: var(--font);
}

/* Le cadre degrade (image de fond identique a la maquette) dans lequel la
   carte blanche flotte, comme dans la presentation Figma d'origine. */
.cbuild__frame {
  background-image: url('../assets/img/constraint-builder-bg.png');
  background-size: cover;
  background-position: center;
  border-radius: 20px;
  padding: clamp(20px, 4vw, 56px);
}
/* Carte reduite a 70% de sa taille d'origine (largeur, police, illustration,
   espacements — chaque valeur ci-dessous est la valeur d'origine * .7),
   centree dans le cadre degrade. zoom aurait ete plus simple, mais un bloc
   width:auto rempli quand meme 100% du cadre (shrink-to-fit resout a la
   largeur disponible des que le contenu non enveloppe est plus large qu'elle) :
   d'ou un width explicite plutot qu'un simple zoom. */
.cbuild__card {
  width: 60%;
  margin-inline: auto;
  background: #fff;
  border: 1px solid #eaeaf9;
  border-radius: 11px;
  padding: 17px;
  color: #303030;
}
.cbuild__card-title { font-size: 14px; font-weight: 500; margin-bottom: 11px; }

/* Deux colonnes : la phrase a gauche, l'apercu (illustration + recap) a
   droite, dans un ratio proche de celui de la maquette (550:332) — replie a
   une colonne sous 860px (regle ajoutee dans le bloc @media existant, pas un
   nouveau point de rupture). */
.cbuild__layout { display: grid; grid-template-columns: 5fr 3.5fr; gap: 20px; align-items: start; }

.cbuild__sentence {
  font-size: 11px; line-height: 1.7; color: #303030;
  display: flex; flex-wrap: wrap; align-items: center; gap: 7px;
}
/* Force "the task [...] from [...]" sur sa propre ligne, quelle que soit la
   largeur disponible : un item flex de largeur 100% et hauteur nulle avant
   lui suffit a le pousser a la ligne suivante (flex-wrap), sans dependre du
   retour a la ligne naturel qui variait avec la largeur du champ physicien/
   contrainte. */
.cbuild__break { flex-basis: 100%; height: 0; }
.cbuild__field { display: inline-flex; align-items: center; position: relative; }
.cbuild__field--constraint { position: relative; }
.cbuild__field-chevron {
  position: absolute; right: 7px; top: 50%; transform: translateY(-50%);
  width: 14px; height: 14px; pointer-events: none;
}

.cbuild__select {
  font: inherit; font-size: 11px;
  color: #371495;
  background: #f7f3fe;
  border: 2px solid transparent;
  border-radius: 3px;
  padding: 6px 25px 6px 11px;
  appearance: none;
  cursor: pointer;
  transition: border-color .18s var(--ease), background .18s var(--ease);
}
.cbuild__select:hover { background: #eee4fc; }
.cbuild__select:focus-visible { outline: none; border-color: #371495; background: #fff; }
.cbuild__trigger {
  display: inline-flex; align-items: center;
  background: #f7f3fe; border: 2px solid transparent; border-radius: 3px;
  padding: 6px 25px 6px 11px; font: inherit; font-size: 11px; color: #371495; cursor: pointer;
  transition: border-color .18s var(--ease), background .18s var(--ease);
}
.cbuild__trigger:hover { background: #eee4fc; }
.cbuild__trigger[aria-expanded="true"] { background: #fff; border-color: #371495; }

.cbuild__listbox {
  position: absolute; z-index: 5; top: calc(100% + 6px); left: 0;
  min-width: 182px; width: max-content; max-width: min(266px, 90vw);
  background: #fff;
  border: 1px solid #eaeaf9;
  border-radius: 4px;
  box-shadow: var(--shadow);
  padding: 6px;
  display: grid; gap: 3px;
  /* Empeche tout contenu (ex. la description de "Blocking", la plus longue
     des cinq) de deborder du popover arrondi : sans overflow:hidden ici et
     min-width:0 sur .cbuild__opt-text plus bas, un flex item garde sa
     largeur intrinseque et "saigne" hors du cadre plutot que de passer a la
     ligne. */
  overflow: hidden;
}
.cbuild__listbox[hidden] { display: none; }
/* .ccomp__list (vitrine de composants, plus bas) partage ces regles de ligne :
   meme structure de <li role="option">, juste hors d'un popover absolu. */
.cbuild__listbox li,
.ccomp__list li {
  display: flex; align-items: center; gap: 6px;
  padding: 6px; border-radius: 4px;
  cursor: pointer;
}
/* Survol violet clair. Ne depend plus de aria-selected : dans le menu du
   type de contrainte, cliquer une option autre que "Limit" ferme le menu
   sans rien changer (setupConstraintBuilder, app.js) — si aria-selected
   gardait ce style, la ligne "Limit" resterait visuellement "en survol" en
   permanence meme sans la souris dessus, ce qui n'est pas voulu. */
.cbuild__listbox li:hover, .ccomp__list li:hover,
.cbuild__listbox li:focus, .ccomp__list li:focus { outline: none; background: #f1ecfc; }
.cbuild__listbox li:hover .cbuild__opt-predicate, .ccomp__list li:hover .cbuild__opt-predicate,
.cbuild__listbox li:focus .cbuild__opt-predicate, .ccomp__list li:focus .cbuild__opt-predicate { color: #371495; }
/* Physician dropdown (Physicians/Groups list) : #f1ecfc ci-dessus est trop
   proche du blanc du corps du menu pour marquer l'etat de survol de facon
   fiable — teinte plus soutenue ici, scopee au panneau physicien seul (le
   reste des listbox du widget garde #f1ecfc). */
.ccomp__panel[data-role="physician-panel"] .ccomp__list li:hover,
.ccomp__panel[data-role="physician-panel"] .ccomp__list li:focus { background: #ddd0fa; }
.cbuild__opt-icon {
  width: 25px; height: 25px; flex: none;
  display: grid; place-items: center;
  background: #eaeaf9; border-radius: 3px;
  transition: background-color .18s var(--ease);
}
/* Glyphe masque (voir constraintOptionRows(), app.js) : mask-image decoupe
   la forme de l'icone source (--opt-icon-src) et background-color en pilote
   la couleur — c'est ce qui permet au survol ci-dessous d'inverser bg/glyphe
   sans dupliquer les SVG en variante claire/foncee. */
.cbuild__opt-icon-glyph {
  width: 14px; height: 14px;
  background-color: #594CCC;
  -webkit-mask-image: var(--opt-icon-src); mask-image: var(--opt-icon-src);
  -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
  -webkit-mask-position: center; mask-position: center;
  -webkit-mask-size: contain; mask-size: contain;
  transition: background-color .18s var(--ease);
}
.cbuild__listbox li:hover .cbuild__opt-icon, .ccomp__list li:hover .cbuild__opt-icon,
.cbuild__listbox li:focus .cbuild__opt-icon, .ccomp__list li:focus .cbuild__opt-icon {
  background-color: #594CCC;
}
.cbuild__listbox li:hover .cbuild__opt-icon-glyph, .ccomp__list li:hover .cbuild__opt-icon-glyph,
.cbuild__listbox li:focus .cbuild__opt-icon-glyph, .ccomp__list li:focus .cbuild__opt-icon-glyph {
  background-color: #EAEAF9;
}
/* Case a cocher decorative (taches, multi-selection) : l'etat reel est
   porte par aria-selected sur le <li> (voir taskCheckboxRows, app.js) ; ici
   on ne fait que dessiner un carre qui se remplit en fonction de cet
   attribut, cochee ou non. */
.cbuild__opt-checkbox {
  width: 14px; height: 14px; flex: none;
  display: flex; align-items: center; justify-content: center;
  border: 1.5px solid #9479f3; border-radius: 4px;
  background: #fff;
}
li[aria-selected="true"] .cbuild__opt-checkbox { background: #371495; border-color: #371495; }
.cbuild__opt-check { width: 8px; height: 7px; display: block; opacity: 0; }
li[aria-selected="true"] .cbuild__opt-check { opacity: 1; }
.cbuild__opt-text { display: flex; flex-direction: column; flex: 1 1 auto; min-width: 0; }
.cbuild__opt-predicate { font-size: 10px; line-height: 14px; color: #353a45; overflow-wrap: break-word; }
.cbuild__opt-desc { font-size: 8px; line-height: 13px; color: #353a45; overflow-wrap: break-word; }

/* ---- Panneau "Limit constraint" (jours) --------------------------------
   Une seule boite (chevron + contenu), separee de la phrase : le champ
   "from [...]" au-dessus (dans .cbuild__sentence) n'est qu'un declencheur —
   c'est ce panneau qui porte le vrai contenu ("Limit constraint" en kicker,
   "Days" en titre, puis le picker), comme dans la maquette Figma (node
   18:169) plutot que la fusion "resume + tiroir" des versions precedentes. */
.cbuild__constraint-info {
  margin-top: 17px; padding: 17px;
  background: rgba(238,232,253,.5); border-radius: 3px;
}
/* Le panneau entier (tete + corps) est masque par defaut ([hidden] sur
   .cbuild__constraint-info, voir constraintBuilderMarkup()/setupConstraintBuilder()
   dans app.js) et ne s'affiche que quand le champ "from [...]" est
   selectionne — comme les listbox physicien/tache/contrainte, pas un
   sous-etat visible en permanence. */
.cbuild__constraint-info[hidden] { display: none; }
.cbuild__info-head { display: flex; align-items: center; }
.cbuild__info-body { min-width: 0; margin-top: 17px; }
.cbuild__info-body[hidden] { display: none; }
.cbuild__drawer-kicker {
  display: flex; align-items: center; gap: 3px;
  font-size: 10px; color: #371495; margin: 0;
}
/* .cbuild__info-body prefixe : sans lui, la specificite d'un simple .cbuild__drawer-title
   (0,1,0) perd face a .cs-sec p (0,1,1) — le <p> generique des sections de cas —
   et margin-bottom repasse a var(--s4) malgre cette regle. */
.cbuild__info-body .cbuild__drawer-title { font-size: 14px; font-weight: 500; color: #303030; margin: 0; }
.cbuild__drawer-hint { font-size: 11px; color: #303030; margin: 0 0 17px; }
.cbuild__select-all {
  display: block; font: inherit; font-size: 11px; line-height: 11px;
  color: #371495; background: none; border: none; padding: 0; margin: 0 0 8px;
  cursor: pointer;
}
.cbuild__select-all:hover { text-decoration: underline; }
.cbuild__days { display: flex; flex-wrap: wrap; gap: 6px; }
.cbuild__day {
  font: inherit; font-size: 11px; text-transform: uppercase;
  padding: 6px; min-width: 29px; border-radius: 3px;
  border: 1.5px solid #9479f3; background: transparent; color: #371495;
  cursor: pointer; transition: background .18s var(--ease), color .18s var(--ease);
}
.cbuild__day:hover { background: rgba(148,121,243,.15); }
.cbuild__day.is-on { background: #371495; border-color: #371495; color: #fff; }
.cbuild__day.is-on:hover { background: #2c1077; }

/* ---- Colonne de droite : illustration + recap. L'illustration reproduit
   l'etat "Limit" de la maquette Figma (node 18:383, le seul illustre dans le
   fichier source — d'ou la restriction du widget a ce seul type, voir
   content.js) : un seul losange (une tache selectionnee = un losange, voir
   constraintBuilderMarkup) plus la ligne pointillee/crochet, positionnes en
   % des coordonnees Figma d'origine (cadre 244x262.989) pour rester
   proportionnels a n'importe quelle taille d'affichage. Le fond violet clair
   n'habille QUE ce bloc — pas .cbuild__right au complet — pour ne pas
   deborder sous le titre/la phrase de synthese en dessous, comme dans la
   maquette (le noeud "Illu" original porte lui-meme ce fond, pas un cadre
   plus grand qui l'englobe). */
.cbuild__right {
  display: flex; flex-direction: column; align-items: center; text-align: center;
}
.cbuild__illu {
  width: 100%; margin-bottom: 11px;
  display: flex; align-items: center; justify-content: center;
  background: #e8e2fb; border-radius: 7px; padding: 17px 11px;
}
.cbuild__illu-graphic {
  position: relative; width: 100%; max-width: 147px;
  aspect-ratio: 244 / 262.989;
  margin-inline: auto;
  /* --illu-count (mis a jour par updatePreview(), app.js) pilote --illu-base
     ci-dessous : voir le commentaire sur .cbuild__illu-layer--slot0..3 plus
     bas pour ce que ce calcul centre. */
  --illu-count: 1;
  --illu-base: calc(35% - (var(--illu-count) - 1) * 3.5%);
}
/* rotate(45deg) : le path SVG trace un losange dont les diagonales sont deja
   a 45°/135° dans son propre viewBox (voir commentaire dans app.js) — sans
   cette rotation, la forme rendue est penchee plutot que "debout" (pointes
   en haut/bas/gauche/droite) comme dans la maquette. transform-origin:
   center (valeur par defaut) pour pivoter sur place, sans decaler le
   positionnement left/top ci-dessous.

   Largeur reduite (43.48%, contre 60.86% dans la maquette source) : a la
   taille d'origine, le losange pivote deborde des deux lignes de reference
   (pointille a gauche, crochet "Limit" a droite — la rotation elargit son
   emprise visuelle a width*sqrt(2)). Cette valeur est calculee pour que
   l'emprise pivotee tienne exactement entre les deux. Un etage (slot) par
   tache cochee (jusqu'a cfg.maxTasks) : meme left/width pour tous, seul top
   varie (cbuild__illu-layer--slot0..3), du plus bas (une seule tache) au
   plus haut (quatre). */
.cbuild__illu-layer {
  position: absolute; left: 24.06%; width: 43.48%; height: auto;
  display: block; transform: rotate(45deg);
  /* Meme langage que le losange collant de heroMedia (constraint-limit.json) :
     un losange qui apparait part du meme pivot (top:35%, voir --illu-base
     ci-dessus quand --illu-count:1) que les autres avant de glisser vers son
     etage — setupConstraintBuilder() pose top/opacity inline pour l'etat de
     depart, cette transition anime le retour vers l'etage cible (slotN) une
     fois l'inline retire. Couvre aussi le recentrage : quand --illu-count
     change, TOUS les losanges deja visibles glissent en douceur vers leur
     nouveau --illu-base au lieu de sauter. */
  transition: top .45s cubic-bezier(.22,1,.36,1), opacity .3s ease;
}
/* display:block ci-dessus bat la regle UA [hidden]{display:none} (une regle
   auteur l'emporte toujours sur l'UA, meme a specificite egale) : sans cette
   ligne, les 4 losanges restent visibles en permanence quel que soit le
   nombre de taches cochees, hidden ou non (voir illuDiamonds dans app.js). */
.cbuild__illu-layer[hidden] { display: none; }
/* Avant : top fixe par slot (34.87%/27.87%/20.87%/13.87%), toujours empile
   depuis slot0 (le bas) vers le haut. Comme les losanges visibles sont
   toujours slot0..slot(N-1) (jamais l'inverse), le losange du BAS restait
   fixe et seul le HAUT du tas montait avec N — la pile entiere derivait donc
   vers le haut par rapport au crochet "Limit" a mesure que N grandissait.
   --illu-base (calc ci-dessus, .cbuild__illu-graphic) recentre le groupe
   visible sur le meme pivot (35%, cf --illu-count) quel que soit N, au lieu
   de toujours ancrer slot0 en bas. */
.cbuild__illu-layer--slot0 { top: var(--illu-base); }
.cbuild__illu-layer--slot1 { top: calc(var(--illu-base) + 7%); }
.cbuild__illu-layer--slot2 { top: calc(var(--illu-base) + 14%); }
.cbuild__illu-layer--slot3 { top: calc(var(--illu-base) + 21%); }
/* slot0 est toujours le losange le plus HAUT du tas visible (top le plus
   petit, cf. formule ci-dessus) et slot3 le plus bas — mais sans z-index
   explicite, l'ordre de peinture suit l'ordre du DOM (slot0..slot3), donc le
   losange du BAS (slot le plus eleve, ajoute en dernier dans le HTML) se
   retrouvait peint PAR-DESSUS ceux du dessus. On veut l'inverse : le losange
   du haut du tas reste visuellement au premier plan. */
.cbuild__illu-layer--slot0 { z-index: 4; }
.cbuild__illu-layer--slot1 { z-index: 3; }
.cbuild__illu-layer--slot2 { z-index: 2; }
.cbuild__illu-layer--slot3 { z-index: 1; }
/* Ligne de reference pointillee (gauche), toujours pleine hauteur : ancien
   "Line 13" du SVG (constraint-illu-rule.svg, retire — voir le commentaire
   dans app.js), reproduit en CSS pur. */
.cbuild__illu-guide {
  position: absolute; left: 13%; top: 20%; height: 75.29%;
  border-left: 1.5px dashed #371495; display: block;
}
/* Crochet "Limit" (droite) : sa hauteur suit --illu-count (calc ci-dessous)
   au lieu d'etre figee comme l'ancien SVG — plus de taches coches = crochet
   plus grand, du plus petit (une tache) au plus grand (cfg.maxTasks, 4),
   toujours centre sur le VRAI centre de la pile de losanges quel que soit
   --illu-count (pas seulement a 1 tache) :

   .cbuild__illu-layer fait width:43.48%, donc 43.48% * (244/262.989) =
   40.34% de la HAUTEUR du cadre (top/height sont relatifs a cette hauteur)
   — un carre dont le centre (que rotate(45deg) ne deplace pas) est a la
   moitie de cote sous son `top`, soit --illu-base + 20.17% pour le losange
   du HAUT de la pile (slot0, a --illu-base). Le losange du BAS de la pile
   (slot le plus eleve visible, a --illu-base + (--illu-count-1)*7%) a donc
   son centre a --illu-base + 20.17% + (--illu-count-1)*7%. Le centre de la
   pile entiere est la moyenne des deux : --illu-base + 20.17% +
   (--illu-count-1)*3.5% — cette derniere partie s'annule exactement avec
   --illu-base (calc(35% - (--illu-count-1)*3.5%) plus haut), donc ce centre
   est fixe (55.17%) quel que soit --illu-count : c'est le pivot sur lequel
   `top` ci-dessous centre le crochet (pivot - height/2), pas sur --illu-base
   seul (qui n'est que le HAUT de la pile, pas son centre). Meme pivot pour
   .cbuild__illu-bracket-label (juste en dessous), qui reste donc ancre au
   milieu du crochet a 1 tache comme a 4. border-right sert de hampe
   verticale (equivalent de l'ancien "Line 10" du SVG retire), ::before/
   ::after des courtes barres horizontales en haut/bas ("Line 11"/
   "Line 12") — memes proportions que l'ancien SVG (crochet cote droit,
   x∈[78.04%, 81.76%]). */
.cbuild__illu-bracket {
  position: absolute; left: 78.07%; width: 3.69%;
  top: calc(var(--illu-base) + 20.17% + (var(--illu-count) - 1) * 3.5% - (10% + (var(--illu-count) - 1) * 7% + 20px) / 2);
  height: calc(10% + (var(--illu-count) - 1) * 7% + 20px);
  border-right: 1.5px solid rgba(55,20,149,.7);
  transition: top .45s cubic-bezier(.22,1,.36,1), height .45s cubic-bezier(.22,1,.36,1);
}
.cbuild__illu-bracket::before,
.cbuild__illu-bracket::after {
  content: ''; position: absolute; left: 0; right: 0;
  border-top: 1.5px solid rgba(55,20,149,.7);
}
.cbuild__illu-bracket::before { top: 0; }
.cbuild__illu-bracket::after { bottom: 0; }
.cbuild__illu-bracket-label {
  position: absolute; left: 84.84%;
  top: calc(var(--illu-base) + 20.17% + (var(--illu-count) - 1) * 3.5%);
  transform: translateY(-50%);
  font-size: 11px; color: rgba(55,20,149,.7); white-space: nowrap;
  transition: top .45s cubic-bezier(.22,1,.36,1);
}
/* Time a gauche, Tasks a droite (au-dessus des losanges) — fidele a la
   maquette Figma (node 18:383) : le libelle des taches n'est plus empile
   sous celui des jours, il se cale a droite via right:%, quelle que soit la
   longueur du texte ("Diag - Floor 2" vs "4 tasks"). text-align reste a
   gauche (dt "Tasks" aligne sur dd, comme le libelle "Time") : la boite se
   deplace horizontalement pour rester a droite (elle epouse la largeur de
   son contenu), mais le texte a l'interieur ne se retourne pas.

   right:25% (et non 0) pour ne jamais toucher le crochet "Limit" vertical de
   .cbuild__illu-bracket : sa bande occupe x∈[78.07%, 81.76%] de la largeur
   de l'illustration. Le bord droit du libelle doit donc rester sous ~78% ;
   right:25% le place a 75%, avec une marge de securite. */
.cbuild__illu-label { position: absolute; text-align: left; }
.cbuild__illu-label--time { top: 0; left: 0; }
.cbuild__illu-label--tasks { top: 80%; right: 25%; }
.cbuild__illu-label dt { font-size: 8px; color: rgba(55,20,149,.8); }
.cbuild__illu-label dd { font-size: 11px; font-weight: 500; color: #371495; margin: 0; white-space: nowrap; }
/* .cbuild__right prefixe pour la meme raison que .cbuild__info-body plus
   haut : .cs-sec p (0,1,1) bat sinon un simple .cbuild__preview-title
   (0,1,0) et margin-bottom repasse a var(--s4). */
.cbuild__right .cbuild__preview-title { align-self: flex-start; text-align: left; font-size: 14px; font-weight: 500; color: #303030; margin: 0 0 8px; }
.cbuild__recap {
  /* +4px (0.7 * 4 ≈ 3px) par rapport a la simple ligne 1.6 : espace un peu
     plus les lignes de la phrase quand elle passe a la ligne (pastilles +
     texte se touchaient visuellement d'une ligne a l'autre). */
  font-size: 11px; line-height: calc(1.6em + 3px); color: #303030; text-align: left; margin: 0;
}
.cbuild__pill {
  display: inline-flex; align-items: center;
  /* marge verticale : sans elle, deux pastilles sur deux lignes consecutives
     de la phrase (quand elle passe a la ligne) se touchent quasiment — voir
     leur hauteur (padding + ligne) face a line-height de .cbuild__recap. */
  margin-block: 3px;
  background: #e3defb;
  color: #371495; font-weight: 400;
  padding: 3px 8px; border-radius: 6px;
}

/* ---- Vitrine de composants (componentsShowcaseMarkup()/setupComponentsShowcase(),
   app.js), sous le rule-builder ci-dessus : quatre champs autonomes, pas une
   phrase composee — chaque .ccomp__panel s'ouvre/se ferme independamment.
   Reprend la palette/police du rule-builder (violet #371495, Roboto) pour
   rester coherent avec le widget juste au-dessus, et reutilise ses classes
   d'options (cbuild__opt-*, cbuild__day, cbuild__select-all) — seuls les
   elements propres a ce widget (onglets, recherche, stepper, radio) ont leur
   propre prefixe ccomp__. */
.ccomp { margin-block: var(--s6); font-family: 'Roboto', 'Segoe UI', sans-serif; }
/* Cadre degrade, meme traitement que .cbuild__frame (rule-builder ci-dessus)
   mais avec la texture propre a la maquette Figma "Components" (node
   52:1781, fichier "Claude-portfolio-image-generation") — pas le degrade du
   rule-builder, les deux widgets viennent de deux maquettes distinctes.
   Contrairement a .cbuild__card (une seule carte blanche a 60%, centree),
   les quatre panneaux flottent ici directement sur le fond pleine largeur,
   comme dans la maquette source. */
/* Hauteur fixe, identique au rendu de .cbuild__frame (le rule-builder
   au-dessus) : les deux cadres degrades doivent occuper le meme gabarit
   vertical dans la section, meme si leur contenu differe. Les panneaux sont
   positionnes en absolu (voir .ccomp__grid/.ccomp__panel plus bas) : un
   panneau qui s'ouvre flotte donc par-dessus le cadre au lieu de pousser sa
   hauteur — d'ou overflow: visible plutot qu'un scroll interne. */
.ccomp__frame {
  background-image: url('../assets/img/components-showcase-bg.jpg');
  background-size: cover;
  background-position: center;
  border-radius: 20px;
  padding: clamp(20px, 4vw, 40px);
  height: 430px;
  overflow: visible;
}
/* Positions absolues, une seule rangee (top:5%). Physicians et Tasks
   affichent des pastilles sur une seule ligne des que les deux onglets
   (physiciens+groupes / taches+gardes) ont une selection (voir
   physicianTriggerHTML/taskTriggerHTML, app.js) : Physicians peut alors
   grandir jusqu'a ~345px (4 physiciens + 3 groupes, le maximum des donnees
   de demo) et Tasks jusqu'a ~225px. Les positions ci-dessous ont ete
   ecartees (24/50/75 -> 37/55/79) pour garder un espace mesure a partir de
   ces largeurs maximales, verifie sans chevauchement au lieu du quinconce
   plus serre de la maquette Figma source (52:1781) — priorite donnee a la
   lisibilite du contenu dynamique sur la fidelite exacte au pixel. */
.ccomp__grid { position: relative; width: 100%; height: 100%; }
.ccomp__panel[data-role="physician-panel"] { left: 1%; top: 5%; }
.ccomp__panel[data-role="task-panel"] { left: 55%; top: 5%; }
.ccomp__panel[data-role="constraint-panel"] { left: 37%; top: 5%; }
.ccomp__panel[data-role="period-panel"] { left: 79%; top: 5%; }
.ccomp__panel { position: absolute; min-width: 0; }
/* Largeur au contenu (pas 100% de la colonne) : fidele aux declencheurs de
   la maquette Figma (node 52:1781), toujours plus etroits que le panneau
   blanc qu'ils ouvrent en dessous — jamais etires a la largeur de leur
   colonne. Blanc plutot que le lavande pale de .cbuild__trigger (#f7f3fe,
   pense pour flotter DANS une carte blanche) : sur la texture de
   .ccomp__frame, un fond lavande se fondrait dans l'image — fidele au
   declencheur blanc a bordure violette de la maquette (node 52:2141 etc.). */
.ccomp__trigger {
  width: max-content;
  /* Plafonne aussi contre la largeur du viewport (pas juste 100% du panneau,
     qui est lui-meme en width:auto quand le declencheur est positionne en
     absolu — voir .ccomp__panel) : une rangee de pastilles (voir
     .ccomp__pillrow ci-dessous) est plus large que le texte simple qu'elle
     remplace et pourrait sinon deborder du cadre. */
  max-width: min(360px, calc(100vw - 64px));
  gap: 8px; background: #fff;
  /* Ecrase le padding 6px/25px/6px/11px herite de .cbuild__select/
     .cbuild__trigger (pense pour un chevron en position absolue, voir
     .cbuild__field-chevron) : ici le chevron est un vrai enfant flex (voir
     fieldChevron, app.js), donc un padding symetrique de 6px le colle au
     bord droit au lieu de laisser un vide de 25px apres lui. */
  padding: 6px;
}
.ccomp__trigger:hover { background: #f7f3fe; }
.ccomp__trigger[aria-expanded="true"] { background: #fff; }
.ccomp__trigger-chevron { flex: none; width: 16px; height: 16px; pointer-events: none; }
.ccomp__trigger-value { display: inline-flex; min-width: 0; }
/* Pastilles croisees des declencheurs Physicians/Tasks (physicianTriggerHTML/
   taskTriggerHTML, app.js) : un texte simple (physicianTriggerLabel/
   taskTriggerLabel) suffit tant qu'une seule des deux categories a une
   selection ; des que les deux en ont une, le texte est remplace par ces
   pastilles pour montrer les deux a la fois.
   Task (node 58:2870) : deux pastilles independantes, cote a cote.
   Physicians (node 58:2890) : structure imbriquee, pas une rangee de
   pastilles a plat — .ccomp__pillgroup est le cadre clair (meme fond que le
   texte "Total" qu'il contient, donc aucune bordure visible entre les deux)
   qui ENGLOBE le texte Total, les deux pastilles pleines Physicians/Groups
   et la croix globale. */
.ccomp__pillrow { display: inline-flex; align-items: center; gap: 10px; flex-wrap: nowrap; }
/* nowrap plutot que wrap : le declencheur Physicians doit rester lisible sur
   une seule ligne (width: max-content sur .ccomp__trigger le laisse grandir
   pour l'accueillir) plutot que d'empiler Total/Physicians/Groups/croix sur
   plusieurs lignes. */
.ccomp__pillgroup {
  display: inline-flex; align-items: center; gap: 4px; flex-wrap: nowrap;
  background: #f0f0fa; border-radius: 6px; padding: 4px; color: #371495;
}
/* La croix globale (data-pill-remove="all") est le seul enfant direct de
   .ccomp__pillgroup qui ne soit pas deja etire par un align-items:stretch
   interne (contrairement aux .ccomp__pill imbriquees) : sous
   align-items:center, elle ne portait que sa propre hauteur (14px mesures)
   contre 21.6px pour ses voisines, ce qui tronquait le fond de survol.
   align-self:stretch l'aligne sur la meme hauteur que "N Total" et les
   pastilles Physicians/Groups. */
.ccomp__pillgroup > .ccomp__pill-remove { align-self: stretch; }
.ccomp__pill {
  display: inline-flex; align-items: stretch;
  border-radius: 6px; overflow: hidden;
  background: #f0f0fa; color: #371495;
  font-size: 11px; font-weight: 400;
}
/* Physicians/Groups (accent) : violet plein + texte/croix blancs — contraste
   avec le texte Total et la croix globale, qui restent au fond clair de
   .ccomp__pillgroup. */
.ccomp__pill--accent { background: #6f69d5; color: #fff; }
.ccomp__pill-label {
  display: inline-flex; align-items: center; padding: 2px 8px; white-space: nowrap;
  font-size: 11px; font-weight: 400; color: inherit;
}
.ccomp__pill-remove {
  display: inline-flex; align-items: center; justify-content: center;
  padding: 2px 6px; cursor: pointer;
  border-radius: 4px;
  transition: background .15s var(--ease);
}
.ccomp__pill-remove:hover { background: rgba(55,20,149,.12); }
/* Accent (violet plein, texte/croix blancs) : la teinte sombre ci-dessus
   deviendrait invisible sur ce fond — teinte intermediaire entre le fond
   #6f69d5 et le violet fonce #371495 pour rester visible sans trop
   assombrir. */
.ccomp__pill--accent .ccomp__pill-remove:hover { background: #533EB5; }
.ccomp__pill--accent .ccomp__pill-remove-icon { filter: brightness(0) invert(1); }
/* Sorti du flux (position: absolute) plutot que d'etre un enfant normal du
   panneau : sinon, un corps ouvert de 300px devient l'enfant-en-flux le
   plus large du panneau (voir .ccomp__panel, width: auto) des qu'il depasse
   la largeur du declencheur ferme — ce qui elargit le panneau lui-meme et le
   fait chevaucher son voisin de droite, meme quand le declencheur seul
   tiendrait sans probleme. Verifie avec Tasks (declencheur ~225px, corps
   300px) : sans ce retrait du flux, ouvrir Tasks chevauchait Period malgre
   l'ecart calcule pour les seuls declencheurs. */
.ccomp__body {
  position: absolute;
  top: calc(100% + 10px);
  left: 0;
  background: #fff; border: 1px solid #eaeaf9; border-radius: 4px;
  box-shadow: var(--shadow);
  padding: 16px;
  /* Plafonne la largeur du corps pour qu'il ne deborde jamais du cadre a
     droite, sans quoi il provoquerait un scroll horizontal. */
  width: min(300px, calc(100vw - 64px));
}
.ccomp__body[hidden] { display: none; }
/* Period (left: 79%) est trop pres du bord droit du cadre pour qu'un corps
   de 300px s'ouvre vers la droite sans deborder (verifie : deborde des la
   largeur de cadre proche du seuil mobile de 860px). Ancre a droite du
   declencheur (right: 0) plutot qu'a gauche comme les trois autres
   panneaux, il s'ouvre donc vers la gauche a la place. */
.ccomp__panel[data-role="period-panel"] .ccomp__body { left: auto; right: 0; }
.ccomp__field-label { font-size: 11px; color: #353a45; margin: 0 0 8px; }

/* Bascule Physicians/Groups, Tasks/Shifts, Fixed period/Series : contrairement
   a .cbuild__pill (simple etiquette dans la phrase de synthese), ces pastilles
   sont de vrais boutons cliquables — l'onglet actif reste rempli, l'inactif
   reste clair. */
.ccomp__tabs { display: flex; gap: 8px; margin-bottom: 12px; }
.ccomp__tab {
  display: inline-flex; align-items: center; gap: 6px;
  font: inherit; font-size: 11px; color: #371495;
  background: #eaeaf9; border: none; border-radius: 6px;
  padding: 4px 11px; cursor: pointer;
  transition: background .18s var(--ease), color .18s var(--ease);
}
.ccomp__tab:hover { background: #e0defa; }
.ccomp__tab.is-active { background: #371495; color: #fff; }
.ccomp__tab-count {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 14px; height: 14px; padding: 0 2px;
  border-radius: 2px; background: #fff; color: #371495;
  font-size: 9px; font-weight: 600;
}

/* Champ de recherche (physicien, tache) : icone + saisie + suggestion
   fantome (le texte grise apres le curseur, voir setupSearchField() dans
   app.js) + bouton "x" pour vider, affiche seulement quand le champ n'est
   pas vide. */
.ccomp__search {
  display: flex; align-items: center; gap: 6px;
  background: #f0f0fa; border: 2px solid transparent; border-radius: 6px;
  padding: 6px 8px; margin-bottom: 10px;
}
.ccomp__search:focus-within { border-color: #594ccc; }
.ccomp__search-icon { width: 13px; height: 13px; flex: none; }
.ccomp__search-field { position: relative; flex: 1 1 auto; min-width: 0; }
.ccomp__search-input {
  position: relative; z-index: 1;
  display: block; width: 100%;
  font: inherit; font-size: 11px; color: #353a45;
  background: transparent; border: none; padding: 0;
}
.ccomp__search-input:focus { outline: none; }
/* Superposee derriere la saisie (z-index 0, fond transparent sur .ccomp__search-input
   au-dessus) : seule la partie qui depasse le texte deja tape ("ccomp__ghost-rest")
   reste visible, alignee au caractere pres car meme police/taille/padding. */
.ccomp__search-ghost {
  position: absolute; inset: 0; z-index: 0;
  display: flex; align-items: center;
  font: inherit; font-size: 11px; white-space: pre;
  pointer-events: none;
}
.ccomp__ghost-typed { visibility: hidden; }
.ccomp__ghost-rest { color: #9479f3; }
.ccomp__search-clear {
  flex: none; width: 16px; height: 16px;
  display: flex; align-items: center; justify-content: center;
  background: none; border: none; padding: 0; cursor: pointer;
}
.ccomp__search-clear[hidden] { display: none; }
.ccomp__search-clear img { width: 13px; height: 13px; }

/* Liste d'options en flux normal (pas un popover absolu comme .cbuild__listbox :
   quatre panneaux independants n'ont pas besoin de flotter au-dessus du
   reste de la page). Les lignes elles-memes (<li role="option">) reutilisent
   cbuild__opt-checkbox/opt-text/opt-predicate — voir taskCheckboxRows(),
   app.js — et le survol partage avec .cbuild__listbox li plus haut. */
.ccomp__list { display: grid; gap: 3px; max-height: 190px; overflow-y: auto; margin: 0 0 8px; padding: 0 2px 0 0; }
.ccomp__list[hidden] { display: none; }
/* Ligne masquee par le filtre de recherche (voir filterList(), app.js). */
.ccomp__list li[hidden] { display: none; }

/* Compteur +/- (assignations max, jours consecutifs) : une seule icone
   chevron haut+bas (constraint-stepper.svg) coupee en deux moities cliquables
   via background-position, plutot que deux icones separees. */
.ccomp__stepper {
  display: inline-flex; align-items: center; gap: 4px;
  background: #f0f0fa; border-radius: 6px; padding: 6px 8px;
  vertical-align: middle;
}
.ccomp__stepper-input {
  width: 24px; font: inherit; font-size: 11px; color: #353a45;
  background: transparent; border: none; padding: 0; text-align: center;
}
.ccomp__stepper-input:focus { outline: none; }
.ccomp__stepper-arrows { display: flex; flex-direction: column; }
.ccomp__stepper-btn {
  width: 14px; height: 7px; padding: 0; border: none; background-color: transparent;
  background-image: url('../assets/icons/constraint-stepper.svg');
  background-size: 14px 14px; background-repeat: no-repeat;
  cursor: pointer; opacity: .8;
}
.ccomp__stepper-btn:hover { opacity: 1; }
.ccomp__stepper-btn--up { background-position: top; }
.ccomp__stepper-btn--down { background-position: bottom; }

/* Radios (periode) : meme logique que .cbuild__opt-checkbox (case decorative
   pilotee par un attribut d'etat, ici aria-checked plutot que aria-selected),
   mais ronds et sans <input> natif — bouton + role="radio", coherent avec
   .cbuild__day (bouton + aria-pressed) plutot qu'un <input type="radio">
   qu'il aurait fallu restyler entierement. */
.ccomp__radio-row {
  display: flex; align-items: center; gap: 8px; width: 100%;
  font: inherit; font-size: 11px; color: #303030; text-align: left;
  background: none; border: none; border-radius: 4px;
  padding: 6px; margin: 0; cursor: pointer;
}
.ccomp__radio-row:hover { background: #f1ecfc; }
.ccomp__radio {
  position: relative; flex: none; width: 15px; height: 15px;
  border: 1.5px solid #9479f3; border-radius: 50%; background: #fff;
}
.ccomp__radio-row[aria-checked="true"] .ccomp__radio { border-color: #371495; }
.ccomp__radio-row[aria-checked="true"] .ccomp__radio::after {
  content: ''; position: absolute; inset: 3px; border-radius: 50%; background: #371495;
}
/* Sous-contenu revele par un choix a radio (jours, periode nommee, stepper de
   serie) : indente au niveau du libelle du radio parent (6px de padding +
   15px de rond + 8px de gap, voir .ccomp__radio-row ci-dessus). */
.ccomp__nested { margin: 2px 0 8px 29px; }
.ccomp__nested[hidden] { display: none; }

/* Liens/indications decoratifs ("Add a time slot", "contact support") : ni
   l'un ni l'autre ne menent nulle part dans cette demo (voir node 21:499,
   "add time slot is not an interactive option") — couleur plus pale et
   cursor:default les distinguent volontairement des vrais liens cliquables
   (cbuild__select-all, violet fonce #371495, soulignes au survol). */
.ccomp__static-link, .ccomp__static-hint {
  display: block; font-size: 11px; color: #9479f3; padding: 6px; margin: 0;
  cursor: default;
}
.ccomp__static-inline { text-decoration: underline; text-decoration-style: dotted; }

/* ---- Figure repliee dans un tiroir "En voir plus" ----------------------
   Pour une figure dense et facultative a la lecture (ex. le mapping des 24
   contraintes) : <details> natif, donc accessible au clavier et aux
   lecteurs d'ecran sans JavaScript supplementaire (voir figureDrawer dans
   content.js et figureFor() dans app.js). */
.figure-drawer { margin-block: var(--s6); }
.figure-drawer summary {
  cursor: pointer;
  list-style: none;
  display: inline-flex; align-items: center; gap: var(--s2);
  font-size: var(--fs-small); font-weight: var(--w-semibold);
  color: var(--accent);
}
.figure-drawer summary::-webkit-details-marker { display: none; }
/* Chevron Lucide (icone MIT, inseree en SVG dans figureFor() plutot que
   chargee depuis un CDN : un seul <path>, pas la peine d'ajouter une
   dependance en script pour ca). Plus grand qu'une icone de texte courante
   pour rester lisible a cote du libelle, et pivote a 180° a l'ouverture —
   l'"animation au clic" demandee, sans JS : le pivot suit l'attribut
   [open] que <details> bascule tout seul. */
.figure-drawer__chevron {
  width: 22px; height: 22px;
  transition: transform .25s var(--ease);
}
.figure-drawer[open] .figure-drawer__chevron { transform: rotate(180deg); }
.figure-drawer .figure { margin-top: var(--s3); margin-bottom: 0; }

/* ---- Tiroir "See more of the process" (.cs-more, content.js: moreDrawer) -
   Meme <details> que .figure-drawer ci-dessus (styles de summary/chevron
   partages), mais son contenu est fait de plusieurs etapes completes
   (titre + paragraphes + figure) plutot que d'une seule figure. */
/* Le declencheur du tiroir "See more..." reprend la couleur d'une entree de
   nav plutot que l'accent : grise (ink-faint) au repos, foncee (ink) au
   survol — .figure-drawer summary seul (utilise ailleurs pour une figure
   isolee) garde l'accent, cette regle-ci ne cible que la variante cs-more. */
.figure-drawer.cs-more summary { color: var(--ink-faint); transition: color .2s var(--ease); }
.figure-drawer.cs-more summary:hover,
.figure-drawer.cs-more summary:focus-visible { color: var(--ink); }
/* Le chevron s'eloigne aussi un peu du libelle au survol (meme principe que
   .cs-nav__back plus haut) — translateX seul pendant la lecture, combine au
   pivot d'ouverture quand le tiroir est deja ouvert ET survole, puisque
   `transform` ne peut porter qu'une seule valeur a la fois. */
.figure-drawer.cs-more summary:hover .figure-drawer__chevron,
.figure-drawer.cs-more summary:focus-visible .figure-drawer__chevron { transform: translateX(3px); }
.figure-drawer.cs-more[open] summary:hover .figure-drawer__chevron,
.figure-drawer.cs-more[open] summary:focus-visible .figure-drawer__chevron { transform: rotate(180deg) translateX(3px); }
/* Tiroir "Experience" de la page About (expDrawerMarkup() dans app.js) :
   son declencheur reprend le style du h3 .cs-more__title qu'il remplace —
   ce tiroir n'a plus de titre par section a l'interieur, voir ce fichier.
   Scope a .editorial__block (About uniquement) pour ne pas toucher le
   tiroir "See more of the process" des etudes de cas, qui partage les
   memes classes .figure-drawer.cs-more. */
.editorial__block .figure-drawer.cs-more summary { font-size: var(--fs-h3); font-weight: var(--w-medium); color: var(--ink); }
.cs-more__body { margin-top: var(--s5); }
.cs-more__item + .cs-more__item { margin-top: var(--s8); }
.cs-more__title { font-size: var(--fs-h3); font-weight: var(--w-medium); margin-bottom: var(--s3); }
/* Images du tiroir (mapping des 24 contraintes, benchmark) sans le cadre
   standard de .figure__frame (fond, liser, padding) : ce sont deja des
   captures nettes, pas des maquettes claires ayant besoin d'un fond neutre. */
.cs-more .figure__frame { padding: 0; border: none; background: none; }
/* Canevas Figma en direct (voir embedFor() dans app.js) : aspect-ratio plutot
   qu'une hauteur fixe, pour rester proportionne quelle que soit la largeur
   de la colonne (tiroir en pleine largeur ou repli mobile). */
.cs-more__embed {
  aspect-ratio: 16 / 10;
  border-radius: var(--radius);
  overflow: hidden;
  background: var(--frame-bg);
}
.cs-more__embed iframe { width: 100%; height: 100%; border: none; display: block; }

/* ---- Tiroir dont le declencheur est un titre existant, style inchange ---
   Utilise a deux endroits (voir app.js) : pageEditorial(), b.collapsed
   (un h2 entier de bloc devient tiroir — page gap, "What I did instead" /
   "What it changed about how I work") et pageCase(), s.collapsed (une
   section entiere, son s.headline comme declencheur — ex. Licence
   management/Before) ou un sous-titre ##! au fil de s.body (Licence
   management/Context, "Different types of users and possibilities").
   Demande utilisateur explicite : la taille/graisse/couleur du titre ne
   doit PAS changer en devenant declencheur — a la difference de
   .figure-drawer summary (section precedente), qui impose sa propre
   typographie, ce tiroir-ci ne touche a rien du titre qu'il enveloppe. Le
   seul ajout est le chevron a cote, et le triangle natif de <summary> est
   retire comme partout ailleurs sur le site. */
.text-drawer summary {
  cursor: pointer;
  list-style: none;
  display: flex;
  align-items: center;
  gap: var(--s2);
}
.text-drawer summary::-webkit-details-marker { display: none; }
/* Le titre garde sa propre marge quand il est seul (etat replie/ouvert
   normal hors tiroir) ; ici elle doublonnerait avec .text-drawer__body,
   donc on la retire une fois a l'interieur du declencheur. */
.text-drawer summary > :first-child { margin-bottom: 0; }
.text-drawer__chevron {
  width: 18px; height: 18px; flex: 0 0 auto;
  color: var(--ink-faint);
  transition: transform .25s var(--ease);
}
.text-drawer[open] .text-drawer__chevron { transform: rotate(180deg); }
.text-drawer__body { margin-top: var(--s4); }
/* Mobile (demande utilisateur) : le chevron rejoint le bord droit du
   declencheur au lieu de coller au titre — space-between fonctionne ici
   puisque summary n'a que deux enfants flex (titre + chevron). */
@media (max-width: 700px) {
  .text-drawer summary { justify-content: space-between; }
}

/* La vignette video des cartes de projet : par defaut, meme traitement que
   les images/SVG juste au-dessus (100%, object-fit: cover) — le fichier est
   deja cadre au ratio de .card__media (voir heroMedia dans content.js), donc
   cover ne fait qu'absorber un ecart d'arrondi, pas un vrai recadrage. */
.card__media video {
  width: 100%; height: 100%;
  object-fit: cover;
  transition: transform .5s var(--ease);
}
.card:hover .card__media video { transform: scale(1.03); }
/* "Scheduling constraints" est le cas particulier : sa video est cadree en
   portrait (voir .cs__hero-media plus bas), donc cover la recadrerait au
   lieu de la montrer en entier — on repasse ici en object-fit: contain.
   Legerement plus petite que la boite (92%, centree par le flex de
   .card__media) pour laisser respirer le fond #e9e1f9 autour d'elle plutot
   que de toucher les bords de la carte. clip-path rogne le bas : le fichier
   source a un fin liseré sombre au bord inferieur de son cadre (visible a
   l'oeil dans la video elle-meme, pas un artefact de lecture) — comme la
   video occupe toute la hauteur de son element (object-fit: contain, cote
   contraignant), ce pourcentage retire exactement ce liseré plutot que de
   l'agrandir. */
.card[data-slug="constraints"] .card__media video {
  width: 92%; height: 92%;
  object-fit: contain;
  clip-path: inset(0 0 4% 0);
}
/* Licence management : meme reduction que constraints ci-dessus, mais sans
   clip-path — licence-hero.mp4 n'a pas de liseré au bord inferieur.
   MORTE depuis la fusion (branche petal-merged-projects) : voir la note
   jumelle plus haut sur .card[data-slug="licence-management"] .card__media. */
.card[data-slug="licence-management"] .card__media video {
  width: 92%; height: 92%;
  object-fit: contain;
}

/* Meme vignette, en Lottie (voir cardMedia() dans app.js) : dotlottie-wc ne
   supporte pas object-fit, donc on lui donne directement son propre ratio
   natif (524/600, comme .cs__hero-media plus bas) plutot que d'etirer la
   boite flex — .card__media centre deja son contenu (display:flex). */
.card__media dotlottie-wc {
  width: auto;
  transition: transform .5s var(--ease);
}
.card:hover .card__media dotlottie-wc { transform: scale(1.03); }
.card[data-slug="constraints"] .card__media dotlottie-wc {
  aspect-ratio: 524 / 600;
  height: 92%;
  clip-path: inset(0 0 4% 0);
}
/* Bible app : meme raison qu'en couverture (voir .cs__hero-media[data-slug=
   "bible-app"] plus haut) — ratio propre de l'ecran (375/812) et reduite a
   80% pour rester entouree du gris clair de la vignette ci-dessous. */
.card[data-slug="bible-app"] .card__media dotlottie-wc {
  aspect-ratio: 375 / 812;
  height: 80%;
}

/* Bouton "Retour en haut" (pageCase() dans app.js), apres la derniere
   section d'un processus : un vrai lien vers #overview, pas un bouton JS —
   meme principe que le reste de la navigation de la fiche. Centre et
   discret : ce n'est pas une action a mettre en avant, juste un raccourci
   pour qui vient de finir la lecture. */
.cs-back-to-top {
  /* Couleur claire au repos, comme une entree de nav — color-mix pour rester
     coherent avec le meme calcul que .site-nav a (opacite .82 de l'ink
     courant). width:fit-content + margin-inline:auto plutot que
     justify-content:center sur une boite pleine largeur : le fond au survol
     colle ainsi au libelle (icone puis texte, alignes a gauche l'un de
     l'autre dans cette boite retrecie) au lieu de courir sur toute la
     largeur du conteneur. */
  display: flex; align-items: center; gap: 4px;
  width: fit-content; margin: var(--s8) auto 0;
  padding: var(--s2) var(--s3); border-radius: var(--radius-sm);
  color: color-mix(in srgb, var(--ink) 82%, transparent); text-decoration: none;
  font-size: var(--fs-small); font-weight: var(--w-medium);
  transition: color .18s var(--ease), background .18s var(--ease);
}
.cs-back-to-top:hover, .cs-back-to-top:focus-visible { color: var(--ink); background: var(--veil); }
/* Demande utilisateur : texte plus sombre au repos pour la version flottante
   des etudes de cas UNIQUEMENT — plein var(--ink) du theme (deja la couleur
   du survol ci-dessus) plutot que le mix a 82% transparent hors survol.
   :not(#gap-back-to-top) exclut le bouton de la page gap, ajuste separement
   (couleur inchangee). */
.cs-back-to-top:not(#gap-back-to-top) { color: var(--ink); }
/* DESKTOP SEULEMENT (essai, retour possible) : fixe comme la croix
   (#overlay-close, coin superieur droit) mais en bas a droite — visible en
   permanence plutot que seulement apres la derniere section. Meme verre que
   la croix et la pilule mobile (--glass, section 1) : les trois flottent
   au-dessus de la meme fiche, ils ne peuvent pas etre en deux matieres.
   Scope au bureau : sous 1000px la barre flottante (.cs-nav) occupe deja ce
   coin, ajouter un second objet fixe par-dessus l'encombrerait. */
@media (min-width: 1001px) {
  .cs-back-to-top {
    position: fixed; z-index: 150;
    bottom: 26px; right: clamp(16px, 2.4vw, 30px);
    margin: 0;
    background: var(--glass);
    -webkit-backdrop-filter: var(--glass-blur); backdrop-filter: var(--glass-blur);
    box-shadow: var(--glass-shadow);
    /* Essai, retour possible : squircle de .btn (var(--radius), 14px, voir
       section 1) plutot que la pilule — demande utilisateur, meme forme que
       le bouton Submit du portail du prenom. */
    border-radius: var(--radius);
  }
  /* MEME PIEGE QUE .cs-nav (section 12) : .cs-back-to-top vit DANS #page,
     pas a cote comme la croix. Pendant sheetIn/sheetOut, #page porte un
     transform qui devient son bloc conteneur — "26px au-dessus du bas de la
     fenetre" se lirait alors "26px au-dessus du bas de la fiche", donc hors
     ecran. On la masque simplement le temps de l'animation. */
  body.is-overlay.is-opening .cs-back-to-top,
  body.is-overlay.is-closing .cs-back-to-top { visibility: hidden; }
}
/* Le bouton "Back to top" de la page gap (index.html, #gap-back-to-top,
   voir sa note et setupGapBackToTop() dans app.js) n'a pas la barre
   flottante mobile des etudes de cas (pas de .cs-nav sous 1000px) : rien
   n'occupe le coin bas-droit sur cette page a aucune largeur, donc il peut
   rester fixe partout — contrairement a .cs-back-to-top ci-dessus, qui ne
   l'est qu'a partir de 1001px pour laisser la place a cette barre. Un id,
   pas de media query : il l'emporte sur la regle de base a toute largeur,
   y compris sous 1001px ou seule celle-ci s'applique. */
#gap-back-to-top {
  position: fixed;
  /* z-index : 135, pas 150 comme #loader/.cs-back-to-top — il doit rester
     au-dessus de l'en-tete/la barre de progression (120/130) mais SOUS
     .gate (140, portail du prenom). setupGapBackToTop() ne cache ce bouton
     que par route (hidden si route.name !== 'gap'), pas par etat du
     portail : sur /gap, il redevenait visible AVANT que le portail soit
     passe, et a 150 il peignait par-dessus lui (voile #gap-back-to-top vu
     flotter dans le portail, demande utilisateur). */
  z-index: 135;
  bottom: 26px; right: clamp(16px, 2.4vw, 30px);
  margin: 0;
  /* Demande utilisateur : meme fond (defaut + survol) que .back-link
     (var(--veil)/var(--veil-strong)) plutot que --glass, en gardant le flou
     du verre ci-dessous mais sans son ombre (demande utilisateur). */
  background: var(--veil);
  -webkit-backdrop-filter: var(--glass-blur); backdrop-filter: var(--glass-blur);
  box-shadow: none;
  border-radius: var(--radius);
  /* Demande utilisateur : meme poids que .back-link (var(--w-semibold)) —
     .cs-back-to-top (partagee avec la barre flottante des etudes de cas,
     non touchee ici) reste en var(--w-medium). */
  font-weight: var(--w-semibold);
}
#gap-back-to-top:hover, #gap-back-to-top:focus-visible { background: var(--veil-strong); }
/* Essai, retour possible : effet magnetique (setupMagneticGapBackToTop()
   dans app.js), meme rebond que .foot-magnetic/.petal-experiment — voir la
   note complete pres de .editorial__block a plus haut. Redeclare color et
   background (herites de .cs-back-to-top, section 8d) pour ne pas les
   perdre : cette regle, plus specifique (id), les ecraserait sinon. */
#gap-back-to-top {
  transition: color .18s var(--ease), background .18s var(--ease), transform .5s cubic-bezier(.34, 1.15, .64, 1);
}
/* Demande utilisateur, bureau seulement : fond retire au repos (le verre
   reste via le blur), garde au survol. :not(:hover):not(:focus-visible)
   plutot qu'un simple override de fond : specificite (id + 2 pseudo) qui ne
   s'applique QUE hors survol, donc n'entre jamais en conflit avec la regle
   de survol juste au-dessus — pas besoin d'y repeter le fond. */
@media (min-width: 1001px) {
  #gap-back-to-top:not(:hover):not(:focus-visible) { background: none; }
}
/* BUG CORRIGE : .cs-back-to-top { display: flex } (regle auteur) bat la
   regle [hidden] { display: none } de la feuille UA, MEME specificite
   (0,1,0 chacune) mais l'origine UA perd toujours face a l'auteur — le
   bouton restait donc visible sur CHAQUE page, pas seulement /gap, tant que
   `hidden` seul le pilotait (meme piege que .cbuild__illu-layer[hidden],
   voir CLAUDE.md). setupGapBackToTop() (app.js) pose toujours l'attribut
   `hidden`, il ne faisait juste plus rien visuellement sans cette regle. */
#gap-back-to-top[hidden] { display: none; }
/* display:block sur le <svg>, meme raison que .cs-nav__back-icon plus haut :
   sans ca l'espace de descendance implicite d'un svg inline l'eloigne du
   texte et le decale sous le centrage de align-items. */
.cs-back-to-top__icon { display: block; width: 1em; height: 1em; }
/* Correction optique demande utilisateur, cette page seulement : les deux
   "p" de "Back to top" ont un jambage qui tire le centre visuel de l'encre
   du texte ~1px SOUS le centre geometrique de sa boite (mesure : centre de
   l'encre a 594.2px, centre de l'icone a 593.2px), alors que "Back" (sans
   jambage) n'a quasi aucun ecart (-0.5px, imperceptible) — d'ou la fleche
   seule ici. Scope a #gap-back-to-top : la barre flottante des etudes de cas
   (.cs-back-to-top) n'est pas concernee. */
#gap-back-to-top .cs-back-to-top__icon { transform: translateY(1px); }
/* Meme principe que .cs-nav__back : la fleche seule s'eloigne du libelle au
   survol. Elle pointe vers le haut, donc "s'eloigne" = translate vers le
   haut. */
.cs-back-to-top > span:first-child { transition: transform .2s var(--ease); }
.cs-back-to-top:hover > span:first-child { transform: translateY(-1.5px); }

/* ---- 8d. Le pied d'etude de cas : les autres projets ----
   Trois cartes plutot qu'un seul lien : arrive au bout d'une lecture longue,
   le visiteur doit avoir un choix, pas un couloir. */
.cs-next { margin-top: var(--s8); padding-top: var(--s7); }
.cs-next__kicker { margin-bottom: var(--s6); }
/* Alignee sur .cs__content (essai, retour possible) plutot que sur les deux
   colonnes ensemble (nav laterale + contenu) : meme mecanique que
   .cs__cases-intro/.cs__case-switch plus haut (margin-left: --cs-nav-inset,
   width en consequence pour garder le bord DROIT sur celui de .wrap--wide).
   Ne joue que si l'ancetre porte .wrap--wide, ce qu'app.js n'ajoute QUE
   lorsque hasProcess (voir pageCase()) : sans nav laterale, --cs-nav-inset
   decalerait le bloc dans le vide. Sous 1000px --cs-nav-inset retombe a 0
   (section 12) : marge et largeur reprennent seules leurs valeurs pleines. */
.wrap--wide .cs-next {
  margin-left: var(--cs-nav-inset);
  width: calc(100% - var(--cs-nav-inset));
}

/* Trois colonnes serrees, au lieu des deux larges de la page d'accueil.
   Ce sont des suggestions, pas la vitrine principale. */
.cards--next {
  grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr));
  gap: var(--s6) var(--s5);
}
.cards--next .card__title { font-size: var(--fs-h3); }
.cards--next .card__client { font-size: var(--fs-small); }
/* Etiquettes retirees des suggestions (essai, retour possible) : trois
   cartes serrees, chacune deja avec client + titre, n'ont pas besoin des
   tags pour donner envie de cliquer — ils faisaient surtout du bruit ici. */
.cards--next .card__tags { display: none; }

/* Side quests : 4 par ligne (Yabara deplace vers Work, il n'en reste que 4).
   min(250px, 100%) plutot que 320 : a la largeur max de .wrap (1120px de
   contenu) et un column-gap de --s6 (32px), 4 colonnes de 320px ne
   tiendraient pas (1352px requis) mais 4 de 250px tiennent (1096px) — donc
   auto-fit les tient sur une seule ligne tant que l'ecran ne redescend pas
   sous --wrap-max, et repasse a 3/2/1 en dessous, comme avant. */
.cards--side {
  grid-template-columns: repeat(auto-fit, minmax(min(250px, 100%), 1fr));
  gap: var(--s7) var(--s6);
}


/* ==========================================================================
   9. PAGES EDITORIALES (A propos, article sur les deux ans)
   ========================================================================== */

/* ---- Trame de fond, page About (index.html, .about-grid) ----
   Reference explicite de l'utilisateur : .background-grid/._2/._3 sur
   l'ancien marvinsrd.com/fr/accueil — trois colonnes plein ecran a
   l'origine, resserrees a SIX (demande utilisateur : "plus de lignes de
   trame"), memes proportions relatives, separees par un filet pointille
   presque invisible. display:none par defaut, montree uniquement par
   body.route-about (deja pose par paint() pour le lien retour de cette
   page, voir app.js) : purement decorative, jamais besoin ailleurs qu'ici.
   z-index:0, meme plan que .underlay (section 11 bis) — les deux ne sont
   jamais visibles en meme temps (About n'ouvre pas de fiche par-dessus
   elle). */
.about-grid { display: none; }
body.route-about .about-grid { display: block; }
.about-grid__col {
  position: fixed; z-index: 0; top: 0;
  width: 16.67vw; height: 100%;
  border-right: 3px dashed rgba(255, 255, 255, .05);
}
.about-grid__col:nth-child(1) { left: 0; }
.about-grid__col:nth-child(2) { left: 16.67vw; }
.about-grid__col:nth-child(3) { left: 33.33vw; }
.about-grid__col:nth-child(4) { left: 50vw; }
.about-grid__col:nth-child(5) { left: 66.67vw; }
.about-grid__col:nth-child(6) { left: 83.33vw; }

/* Meme degagement que les etudes de cas : le bouton "Retour" est fixe. */
.editorial { padding-block: calc(var(--back-h) + var(--s6)) var(--s9); }
/* La page gap n'a pas besoin de ce padding-top : son bouton retour ne
   flotte plus PAR-DESSUS l'en-tete, il vit dans une vraie colonne de nav a
   cote du titre (.editorial__gapgrid plus bas) — meme raisonnement que
   .cs:has(.cs-nav) .cs__head pour une etude de cas avec nav laterale. */
.editorial--gap { padding-top: var(--s6); padding-bottom: 0; }

/* ---- Page gap : nav laterale collante, meme mecanique que .cs__body/.cs-nav
   (section 8b) pour une etude de cas — MEME grille (190px + colonne
   flexible), MEME position:sticky. Deux colonnes independantes plutot qu'un
   bouton position:fixed cale a l'oeil sur la colonne du titre : les deux
   boites etant centrees separement (une pour le bouton, une pour le texte),
   elles ne coincidaient qu'a certaines largeurs d'ecran et le bouton fini
   par recouvrir la lede des que le titre passait dessous. align-items:start
   les aligne ici sur la meme ligne PAR CONSTRUCTION, jamais par coincidence.
   position:sticky (et non fixed) : le bouton reste joignable tout au long du
   defilement (demande explicite) exactement comme .cs-nav, sans jamais
   pouvoir chevaucher le contenu puisqu'il occupe sa propre colonne. */
.editorial__gapgrid {
  display: grid;
  grid-template-columns: 190px minmax(0, 1fr);
  gap: clamp(var(--s6), 5vw, var(--s8));
  align-items: start;
}
/* Colonne de contenu resserree (demande utilisateur, resserree une seconde
   fois) : sans max-width elle remplissait tout l'espace restant de la
   grille jusqu'a --wrap-max, sur une largeur d'ecran genereuse. Partie de
   .wrap--narrow (860px, deja etablie ailleurs sur le site), reduite encore
   d'un cran. */
.editorial__gapcol { max-width: 760px; }
/* z-index : sans lui, .editorial__gapnav et la colonne de contenu (`w`,
   z-index:auto toutes les deux) se departagent par ordre du DOM — `w` vient
   APRES nav dans .editorial__gapgrid (voir pageEditorial()), elle peint donc
   par-dessus. Invisible sur bureau (les deux colonnes ne se recouvrent pas,
   grid-template-columns les separe), mais sous 1000px la grille repasse a
   une seule piste (plus bas dans ce fichier) et nav se retrouve empilee au
   MEME endroit que le haut du texte qui suit : le bouton Back passait donc
   derriere lui des qu'ils se chevauchaient au defilement. Une valeur faible
   suffit, il n'a besoin de battre qu'un voisin en z-index:auto — pas les
   elements fixes du site (gate, en-tete...), beaucoup plus haut. */
/* isolation:isolate : force .editorial__gapnav a creer son PROPRE contexte
   d'empilement de maniere deterministe, plutot que de dependre de la regle
   generale (z-index:auto vs positif, etape 6 vs 7 de l'algorithme de
   peinture CSS2.1) — certains moteurs mobiles (WebKit/iOS notamment) sont
   connus pour mal gerer position:sticky combine a une grille CSS sans ce
   coup de pouce explicite. */
.editorial__gapnav { position: sticky; top: calc(var(--head-h) + var(--s6)); isolation: isolate; z-index: 5; }
/* Demande utilisateur, bureau seulement : rapprocher le bouton Back de
   l'en-tete. Un alignement EXACT sur le centre de "Marvin S." a ete essaye
   et abandonne : la pilule du site couvre toute la largeur a cette hauteur
   et passe PAR-DESSUS (z-index 120 > 118 ici), le bouton se retrouvait donc
   entierement cache dessous. top reprend calc(head-h + s3) — meme formule
   et meme ecart (12px) que .back-link partage (section 1) juste sous
   l'en-tete — plutot que le s6 (32px) d'origine, pour un "juste en dessous"
   coherent avec le reste du site. margin-top remonte la position de repos
   d'autant (align-items:start la place sinon ~30px plus bas que ce seuil ;
   mesure en direct) pour que collage et repos tombent au meme endroit, sans
   a-coup au defilement. */
@media (min-width: 1001px) {
  .editorial__gapnav { margin-top: -30px; top: calc(var(--head-h) + var(--s3)); }
}
/* .back-link est normalement position:fixed (bouton flottant partage par le
   site) — ici il vit dans une nav position:sticky (ci-dessus), donc en flux
   normal : static rend inertes son top/left/z-index. Selecteur compose
   (.editorial__gapnav .back-link, 0,2,0) et pas .editorial__back seul : DEUX
   regles de section 12 ciblent .back-link (0,1,0, mais plus bas dans le
   fichier donc gagnantes a egalite de specificite) pour le bouton flottant
   PARTAGE, hors de tout .wrap :
     - sous 1000px, un margin qui doublerait le retrait deja fourni par le
       .wrap qui entoure notre grille ;
     - sous 560px, left:50%+transform:translateX(-50%) pour le centrer en bas
       d'un ECRAN FIXE — sur un element position:static ceci ne centre rien
       (left est ignore) mais le transform, lui, s'applique quand meme et le
       decale hors champ d'une demi-largeur (vu en pratique : le bouton
       sortait a gauche du wrap).
   Le compose l'emporte sur les deux, quelle que soit sa position dans le
   fichier. */
.editorial__gapnav .back-link {
  position: static; margin: 0; left: auto; bottom: auto; transform: none;
  /* Demande utilisateur : meme forme que #gap-back-to-top (squircle, pas la
     pilule bordee partagee par le bouton flottant), sans filet. */
  border: none; border-radius: var(--radius);
  /* Demande utilisateur : meme flou que #gap-back-to-top (var(--glass-blur),
     14px) plutot que le blur(20px) du bouton flottant partage ci-dessus —
     meme materiau verre/transparent que le bouton "Back to top" de cette
     page. Le fond (var(--veil)) est deja identique via la regle de base. */
  -webkit-backdrop-filter: var(--glass-blur); backdrop-filter: var(--glass-blur);
}
/* Restaure le SEUL hover normal (translateX(-3px), section 1) par-dessus
   celui du 560px : meme specificite que lui (0,2,0 vs 0,2,0, mais plus bas
   dans le fichier), ce compose (0,3,0) l'emporte dans tous les cas — sans
   quoi le bouton se serait aussi decale au survol. */
.editorial__gapnav .back-link:hover { transform: translateX(-3px); }
/* Demande utilisateur, bureau seulement : meme traitement que
   #gap-back-to-top ci-dessus — fond (var(--veil), herite de .back-link)
   retire au repos, garde au survol (.back-link:hover pose deja son propre
   fond, section 1 : cette regle ne le touche pas puisqu'elle ne s'applique
   jamais pendant :hover). */
@media (min-width: 1001px) {
  .editorial__gapnav .back-link:not(:hover):not(:focus-visible) { background: none; }
}
/* Demande utilisateur, mobile seulement : le meme verre transparent
   (fond var(--veil) + flou var(--glass-blur)) que ci-dessus, repose ici de
   maniere explicite plutot que suppose herite — .editorial__gapnav
   .back-link (0,2,0) bat deja la regle partagee de la section 12
   (.back-link, 0,1,0, backdrop-filter:none) par specificite, mais une
   assertion directe evite tout doute sur la cascade a cette largeur. */
@media (max-width: 1000px) {
  .editorial__gapnav .back-link {
    background: var(--veil);
    -webkit-backdrop-filter: var(--glass-blur); backdrop-filter: var(--glass-blur);
  }
}
/* Essai, retour possible : effet magnetique (setupMagneticGapLinks() dans
   app.js, qui pose `transform` en ligne au pointeur) sur le bouton Back et
   les liens du corps de l'article — meme rebond ("damped spring") que
   .foot-magnetic/.petal-experiment, voir attachMagneticTilt(). Redeclare le
   raccourci `transition` en entier (pas juste transform) : .back-link pose
   deja background dedans (section 1), et .editorial__gapnav .back-link
   (0,2,0, plus specifique) ecraserait sinon toute la liste. */
.editorial__gapnav .back-link {
  transition: transform .5s cubic-bezier(.34, 1.15, .64, 1), background .2s var(--ease);
}
/* Meme effet sur les liens (Salsa, Yabara, cognitive debt) rendus par
   pageEditorial() — .u-underline et .u-arrow-link partagent tous deux ce
   selecteur, aucun des deux n'a de transition sur l'ancre elle-meme (celle
   de .u-underline porte sur text-decoration-color, redeclaree ici pour ne
   pas la perdre — voir la note juste au-dessus sur le raccourci complet). */
.editorial__block a {
  transition: text-decoration-color .2s ease-out, transform .5s cubic-bezier(.34, 1.15, .64, 1);
}
/* Le lien "cognitive debt" (.u-arrow-link, inline-flex) bougeait deja au
   survol magnetique, mais pas "treating it the same way..."/l'ATS Yabara
   (.u-underline, display:inline par defaut) : `transform` n'a AUCUN effet
   sur une boite inline non remplacee (CSS Transforms), seulement sur un
   inline-block/inline-flex/etc. inline-block reste un mot au fil de la
   phrase (peut passer a la ligne comme un tout) mais devient transformable,
   et son contenu continue de s'enrouler normalement si le lien est long. Ne
   touche pas .u-arrow-link (deja inline-flex) : cible .u-underline seule. */
@media (min-width: 1001px) {
  /* inline-block reste ici un choix bureau uniquement : un survol/magnetique
     n'a de sens qu'au pointeur. Sous 1000px (colonne etroite), le lien redevient
     inline pour que les mots wrappent normalement avec le reste de la phrase —
     sans quoi "treating it the same way..."/l'ATS Yabara, traites comme un bloc
     atomique par l'algorithme de coupure de ligne, sautaient une ligne entiere
     avant ET apres au lieu de se fondre dans le paragraphe (constate en
     pratique). */
  .editorial__block a.u-underline { display: inline-block; }
}

/* Sous 1000px, pas de place pour une colonne separee sur un ecran etroit :
   la grille repasse a une seule piste. Le bouton Back de cette colonne
   (.editorial__gapnav) est CACHE ici plutot que laisse empile au-dessus du
   titre : il restait position:sticky (herite de .editorial__gapnav) DANS
   #main, qui porte .is-entering (donc un `transform`) en permanence tant que
   la page est affichee — exactement pourquoi #gap-back-to-top vit hors de
   #main (voir sa note dans index.html). Sans grille pour separer nav et
   texte spatialement, ce `transform` ancetre cassait assez le collant/
   l'empilement du bouton pour que le texte de l'article semble passer
   PAR-DESSUS lui au defilement (constate en pratique). setupBackLink()
   (app.js) bascule vers #back-link — le bouton partage, deja hors de #main,
   deja fiable pour About et les etudes de cas — des que la largeur passe
   sous ce seuil. */
@media (max-width: 1000px) {
  .editorial__gapgrid { grid-template-columns: minmax(0, 1fr); }
  .editorial__gapnav { display: none; }
}
/* Demande utilisateur : #back-link reprend la forme squircle sans filet de
   .editorial__gapnav .back-link (voir plus haut) quand il fait office de
   bouton Back de la page gap sous 1000px — meme traitement que
   body.route-about ci-dessus. Sans effet sur bureau : #back-link reste
   hidden pour /gap tant que setupBackLink() ne bascule pas (voir sa note). */
body.route-gap .back-link { border: none; border-radius: var(--radius); }
/* La regle generique sous 1000px (section 12, .back-link { position: static;
   ... }) a ete ecrite pour les etudes de cas : sur ces fiches, une barre de
   sections vient se coller sous l'en-tete, et un bouton fixe la recouvrirait
   — d'ou le choix de le laisser defiler avec la page. La page gap n'a pas
   cette barre, et #gap-back-to-top y reste fixe sans probleme : le bouton
   Back doit garder les memes caracteristiques (demande utilisateur), pas
   heriter d'une contrainte qui ne s'applique pas ici. On retablit donc
   position:fixed et le flou pour /gap specifiquement, sans toucher a la
   regle partagee (About et etudes de cas la gardent telle quelle). */
@media (max-width: 1000px) {
  body.route-gap .back-link {
    position: fixed;
    margin: 0;
    -webkit-backdrop-filter: var(--glass-blur); backdrop-filter: var(--glass-blur);
  }
}

/* .editorial__lede et .editorial__block p sont cappees a --measure (68ch)
   partout ailleurs (meme convention que .cs-sec p) : une colonne de lecture
   confortable. La colonne de la page gap est elle-meme deja bornee par
   .wrap (1280px) moins la nav — demande explicite : que le texte remplisse
   cette colonne plutot que de laisser un vide a droite. Compound selector
   (0,2,0 / 0,2,1) : bat les regles de base (0,1,0 / 0,1,1) quel que soit
   l'ordre dans le fichier. */
.editorial--gap .editorial__lede,
.editorial--gap .editorial__block p { max-width: none; }

.editorial__title { font-size: var(--fs-h1); font-weight: var(--w-medium); margin-bottom: var(--s6); }

/* ---- Photo scotchee, page About (aboutPhotoMarkup() dans app.js) ----
   Reference explicite de l'utilisateur : .column.w-col-6 + .scotch sur
   l'ancien marvinsrd.com/fr/accueil — un carre legerement incline, deux
   bandes de scotch rectangulaires a des angles INDEPENDANTS du carre (pas
   alignees dessus), qui donnent l'illusion de deux morceaux colles a la
   main plutot qu'un decor symetrique. Pas de coins tres arrondis ni de
   marge blanche large comme .draggable-card (autre reference,
   lelezhang.design, section 9 bis) : une photo scotchee a plat, pas une
   carte polaroid. */
/* .about-photo-row : porte desormais les marges verticales autrefois sur
   .about-photo directement — l'"french accent" margin-note (voir
   marginNoteHTML() dans app.js) s'empile juste au-dessus de la photo, en
   FLUX normal plutot qu'en overlay absolu a la Figma, pour rester visible
   quelle que soit la largeur d'ecran (demande utilisateur explicite). */
.about-photo-row {
  display: flex; flex-direction: column; align-items: flex-start;
  gap: var(--s2);
  margin: var(--s7) 0 var(--s8);
}
.about-photo {
  position: relative;
  width: min(220px, 55vw);
  aspect-ratio: 1 / 1;
  transform: rotate(-6deg);
}
.about-photo__img {
  width: 100%; height: 100%;
  object-fit: cover; display: block;
  border-radius: 4px;
}
/* Gris quasi-opaque (proche du rgb(196,196,196) de la reference), jamais un
   jeton de theme : un morceau de scotch ne change pas de teinte avec la
   page, meme raison que le papier de .draggable-card. */
.about-photo__tape {
  position: absolute;
  width: 42%; height: 15%;
  background: rgba(214, 214, 214, .82);
  box-shadow: 0 1px 3px rgba(6, 22, 43, .2);
}
.about-photo__tape--1 { left: -12%; top: 10%; transform: rotate(-10deg); }
.about-photo__tape--2 { right: -12%; bottom: 12%; transform: rotate(16deg); }

/* .editorial__lede-row : meme raisonnement que .about-photo-row juste au-
   dessus — la "callout" margin-note s'empile avant la lede, en flux normal,
   pour rester visible sur mobile. La marge basse de la lede (autrefois
   directement sur .editorial__lede) vit ici pour englober la note. */
.editorial__lede-row {
  display: flex; flex-direction: column; align-items: flex-start;
  gap: var(--s2);
  margin-bottom: var(--s7);
}
.editorial__lede {
  font-size: var(--fs-lead); color: var(--ink-soft); font-weight: var(--w-light);
  max-width: var(--measure);
  white-space: pre-line;   /* respecte le \n present dans content.js */
}
/* Essai (demande utilisateur) : la lede de About en encart, meme recette que
   .editorial__block .editorial__callout plus bas (fond teinte, filet
   d'accent) mais SEULEMENT les proprietes qui different d'un
   .editorial__lede simple — font-size/poids/mesure/marge restent celles du
   lede, aucune regle .editorial__block p a battre ici (la lede vit HORS de
   .editorial__block, voir pageEditorial()) donc pas besoin du meme
   selecteur compose. Coins gauches carres (haut ET bas) : coherent avec le
   filet d'accent qui longe ce bord, meme convention que .todo plus bas. */
.editorial__lede.editorial__callout {
  border-left: 2px solid white;
  padding: 0px var(--s5);
  border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
  color: var(--ink);
}

/* ---- "Margin notes", page About (marginNoteHTML() dans app.js) ----
   Commentaires laisses par le designer sur la maquette Figma source
   (fichier "Claude portfolio image generation") integres tels quels comme
   des apartes manuscrits — DEUX jeux de coordonnees (mobile ci-dessous,
   desktop plus bas dans le fichier), Figma composant chaque largeur
   separement plutot qu'une seule mise en page redimensionnee :
     - mobile (< 1001px, ce bloc) : frames 450:12065 "Hero", 450:12069
       "Exp", 450:12073 "Education" — chaque note loge a DROITE de sa
       cible (la colonne occupe toute la largeur, aucune marge laterale
       disponible a gauche comme sur desktop), fleche courbant vers la
       gauche.
     - desktop (>=1001px) : frames 446:12036 "Hero", 446:12050
       "Experience" — voir plus bas dans ce fichier.
   Absolu aux DEUX largeurs (jamais en flux) : un aparte hors-cadre
   n'ajoute donc aucune hauteur a la mise en page et ne peut pas pousser le
   contenu reel, quelle que soit la largeur d'ecran.
   Police Caveat (manuscrite, chargee via Google Fonts — voir index.html),
   --ink-faint pour rester du bruit de marge jamais confondu avec le
   contenu reel (et lisible dans les deux themes, contrairement au blanc a
   40% d'opacite de la maquette source, pensee pour un fond bleu fixe). */
.margin-note {
  position: absolute;
  z-index: 1;
  max-width: none;
  font-family: 'Caveat', cursive;
  font-size: 1.3rem; line-height: 1.15;
  color: var(--ink-faint);
}
.margin-note__text { margin: 0; }
/* Chaque note pointant vers Experience/Education porte deux fleches dans
   le DOM (mobile puis desktop, voir marginNoteHTML() dans app.js) — Figma
   dessine une courbe DIFFERENTE a chaque largeur pour ces deux-la (pas
   juste une rotation/un miroir du meme vecteur, a la difference de
   "photo" et "lede" qui reutilisent le meme vecteur aux deux largeurs).
   Seule la fleche pertinente s'affiche ; l'media query desktop plus bas
   inverse ces deux regles. */
.margin-note__arrow--desktop { display: none; }

/* ---- Photo ("Strong french accent", node 450:12074/451:12091) : texte
   en haut-droite (a la hauteur du titre "About", pas de la photo — la
   fleche fait tout le trajet), fleche EN DESSOUS courbant vers le
   bas-GAUCHE jusqu'a l'angle haut-droit de la photo. */
.about-photo-row { position: relative; }
/* left:0 + largeur = CELLE DE LA PHOTO (meme formule min(220px,55vw) que
   .about-photo), texte aligne a droite dedans : ancre le mot-cle sur la
   photo elle-meme plutot que sur .about-photo-row, qui est un conteneur
   flex pleine largeur (block-level malgre display:flex) et peut donc etre
   bien plus large que la photo des que la colonne editoriale depasse ~400px
   (entre les breakpoints mobile et desktop) — sinon right:0 ancrait la note
   au bord droit de la ROW, loin de la photo, plutot qu'au bord droit de la
   photo (demande utilisateur : rester proche de la photo a toute largeur). */
.margin-note--photo {
  top: -84px; left: 120px; width: min(220px, 55vw); text-align: right;
}
@media (max-width: 370px) {
  .margin-note--photo { left: 80px; }
}
.margin-note--photo .margin-note__text { max-width: 130px; margin-left: auto; }
/* Largeur/hauteur/decalage en min(Xpx, Yvw) — MEME ratio que .about-photo
   (55vw plafonne a 220px) applique a la taille d'origine de la fleche
   (58x64, decalage 60px a 220px de photo) : sous ~400px de viewport reel, la
   photo elle-meme retrecit (vw), donc la fleche doit retrecir avec elle
   (meme ratio, 58/220=14.5vw etc.) sinon elle reste a taille fixe et finit
   par recouvrir une bien plus grande partie d'une photo devenue petite
   (demande utilisateur : jamais "par-dessus" la photo, teste a 295px). */
.margin-note--photo .margin-note__arrow {
  position: absolute; top: 90%; right: min(2.88em, 15vw);
  width: min(58px, 14.5vw); height: min(64px, 16vw);
  transform: scaleY(-1) rotate(210.77deg);
}

/* ---- Lede/callout ("Are you really going...", node 450:12087/451:12089) :
   meme relation qu'en desktop (fleche a GAUCHE du texte, pas dessous) —
   repris tel quel de la mise en page flex de base, juste repositionne. */
.editorial__lede-row { position: relative; }
.margin-note--lede {
  display: none; align-items: center; gap: var(--s2);
  bottom: 100%; right: 0; margin-bottom: var(--s3); width: max-content;
}
.margin-note--lede .margin-note__text { max-width: 165px; position: relative; top: -0.96em; }
.margin-note--lede .margin-note__arrow { width: 44px; height: 31.8px; }

/* ---- Company ("No big tech company", node 450:12079/451:12093) : texte
   a droite du declencheur "Experience", fleche en dessous courbant vers
   le bas-gauche jusqu'a "Career break" juste en dessous. */
.editorial__block .cs-more__item { position: relative; }
/* width en min(125px, 100% - 145px) : le "100%" d'un enfant position:absolute
   se resout contre SA propre containing block, ici .cs-more__item (position:
   relative ci-dessus) — donc contre la largeur reelle de la colonne, pas une
   valeur fixe. 145px = largeur du declencheur "Experience" (~124px, fixe,
   c'est un mot) + une marge de secu ~20px : sous les tres petits viewports
   (teste a 279px reel), 125px fixes deborderaient par-dessus ce mot (demande
   utilisateur, "jamais par-dessus le texte") — cette formule retrecit la
   note (et la fait passer sur 3 lignes) juste assez pour rester a cote. */
.margin-note--company {
  display: none;
  top: -76px; right: 0; width: min(125px, 100% - 145px);
}
.margin-note--company .margin-note__arrow {
  position: absolute; top: 20%; right: 6.49em;
  width: 46px; height: 66px;
}

/* ---- University ("No ivy-league university", node 450:12082/451:12095) :
   texte a droite, fleche AU-DESSUS (deja retournee dans le vecteur importe,
   voir figmaArrowUniversityMobileIcon()) courbant vers le haut-gauche. */
.margin-note--university {
  display: none;
  top: -2.02em; right: 0; width: 140px;
}
.margin-note--university .margin-note__arrow {
  position: absolute; bottom: -10%; right: 6.97em;
  width: 34px; height: 20px;
}
/* Sous ~380px de large, "Experience"/"Education" (les cibles) prennent une
   part bien plus grande d'une colonne devenue tres etroite. 318px = seuil
   ou .margin-note--company elle-meme commence a retrecir (min(125px, 100% -
   145px) cesse de valoir 125px en dessous d'environ 270px de colonne, soit
   ~318px de viewport reel avec les gouttieres) : les deux regles s'activent
   ensemble, sans zone intermediaire ou l'une bouge sans l'autre. Meme sous
   ce seuil, .margin-note--company retreci deborde encore par la FLECHE
   (decalage fixe 6.49em, pas relatif a la largeur de la note qui vient de
   retrecir) sur le mot "Experience" ; la note "university" (top:-2.02em)
   chevauche carrement le titre "Education", qui occupe TOUTE la largeur de
   la colonne (un h3, contrairement au <summary> etroit d'Experience) —
   demande utilisateur explicite, teste a 279px reel, "jamais par-dessus le
   texte". Compromis : la fleche "company" pointe vers le bas sans plus
   atteindre tout a fait "Career break" (elle reste proche, juste plus
   courte horizontalement) ; "university" remonte davantage et sa fleche
   perd son debord bas (-10% -> 0) pour degager "Education" verticalement. */
@media (max-width: 318px) {
  .margin-note--company .margin-note__arrow { right: 1em; }
  .margin-note--university { top: -2.3em; }
  .margin-note--university .margin-note__arrow { bottom: 0; }
}

/* ---- Margin notes, mise en position Figma (>=1001px) ---------------------
   Reproduction au pixel pres de la position/relation texte<->fleche des 4
   commentaires sur la maquette source (fichier "Claude portfolio image
   generation", frames 446:12036 "Hero" et 446:12050 "Experience", mesures
   via get_metadata puis converties en coordonnees relatives a chaque cible
   — demande utilisateur explicite, "BE STRICT"). Absolu plutot qu'en flux
   (regle de base ci-dessus, qui reste le repli < 1001px) : c'est le SEUIL
   ou .editorial__gapgrid (section 9) repasse a deux colonnes et ouvre la
   marge laterale (190px de nav + gap) necessaire aux notes "photo" et
   "company/university", qui debordent a GAUCHE de leur cible comme dans la
   maquette. La note "lede" n'a pas besoin de cette marge (positionnee
   AU-DESSUS de l'encart, dans sa propre largeur) : elle reste absolue a
   toute largeur, voir plus bas.
   Chaque `.margin-note--x` devient son propre contexte de positionnement
   (position:absolute) pour sa fleche enfant (position:absolute egalement,
   .margin-note__arrow) — l'ordre du DOM (icone puis texte, marginNoteHTML()
   dans app.js) n'a plus d'importance, chacun est place independamment. */
@media (min-width: 1001px) {
  .about-photo-row { position: relative; }

  /* Bascule inverse de la regle de base (.margin-note__arrow--desktop
     plus haut) : a partir d'ici, c'est la fleche DESKTOP qu'on affiche. */
  .margin-note__arrow--mobile { display: none; }
  .margin-note__arrow--desktop { display: block; }

  /* Chaque `.margin-note--x` : position:absolute (bloc simple, plus de
     flex) pour le texte, avec sa fleche en enfant position:absolute —
     l'ordre du DOM (icone puis texte, marginNoteHTML() dans app.js) n'a
     plus d'importance, chacun est place independamment. Fleche dimen-
     sionnee au ratio natif du vecteur Figma importe (111.196 x 60.988,
     voir figmaArrowDownRightIcon() dans app.js) mais agrandie par rapport
     a sa taille d'origine dans la maquette : a l'echelle 1:1 de la
     maquette (cadre 1195px vs notre colonne 760px) elle serait trop
     petite pour vraiment relier le texte a sa cible dans notre mise en
     page. */
  .margin-note--photo, .margin-note--company, .margin-note--university {
    width: 140px;
  }
  .margin-note--photo .margin-note__arrow,
  .margin-note--company .margin-note__arrow,
  .margin-note--university .margin-note__arrow {
    position: absolute; top: auto; right: -13px; margin: 0;
    width: 70px; height: 38.4px; transform: none;
  }

  /* Texte au-dessus, fleche EN DESSOUS courbant vers le bas-droite jusqu'a
     toucher le bord gauche de la photo a mi-hauteur (mesure Figma : fleche
     x:84->193 / y:233->286 relatifs au cadre, photo a gauche ~x:194 —
     l'extremite de la fleche affleure donc ce bord). */
  .margin-note--photo {
    position: absolute;
    top: -4px; left: -200px;
  }
  .margin-note--photo .margin-note__arrow { top: 52px; }

  /* Texte en haut-droite de l'encart, fleche a SA GAUCHE (pas dessous) —
     demande utilisateur explicite : la fleche cote-a-cote avec son texte
     rend plus evident qu'elle designe CE texte (et par sa courbe, l'encart
     juste en dessous) plutot que de sembler flotter separement. Repris de
     la mise en page flex de base (.margin-note, icone puis texte) : plus
     besoin d'override display:block ici, juste le positionnement absolu du
     groupe entier. */
  .editorial__lede-row { position: relative; }
  .margin-note--lede {
    position: absolute; align-items: center;
    max-width: none; width: max-content;
    bottom: 100%; right: 0; margin-bottom: var(--s3); transform: none;
  }
  /* position:relative + top negatif sur le TEXTE (pas la fleche) : remonte
     le texte pour que la pointe de la fleche (fixe) arrive au milieu des 2
     lignes — demande utilisateur explicite, en em (relatif a la taille de
     police du texte) plutot qu'en px pour tenir a n'importe quelle largeur
     d'ecran. */
  .margin-note--lede .margin-note__text { max-width: 190px; position: relative; top: -1.68em; }
  .margin-note--lede .margin-note__arrow { width: 52px; height: 37.6px; transform: none; }

  /* Meme relation que .margin-note--photo (texte au-dessus, fleche dessous
     vers le bas-droite) — chaque note reste ancree a SA PROPRE section
     (.cs-more__item, position:relative ci-dessous) pour suivre Experience
     et Education independamment de leur position de defilement. Proche du
     tout premier item (~ "Career break") plutot que plus bas dans la liste
     (demande utilisateur : plus haut, pour designer sans ambiguite toute
     la section Experience des qu'elle apparait, pas une seule entree). */
  .editorial__block .cs-more__item { position: relative; }
  .margin-note--company {
    position: absolute;
    top: 20px; right: calc(100% + var(--s5));
  }
  .margin-note--company .margin-note__arrow { top: 52px; }
  /* Meme ancrage que --company, meme vecteur (figmaArrowDownRightIcon()),
     mais retourne verticalement (scaleY) : exactement ce que fait le node
     Figma d'origine, qui reutilise CE MEME "Vector 2" avec un -scale-y-100
     plutot que d'en dessiner un troisieme pour University. Descendue a
     hauteur des entrees CFI/IUT (demande utilisateur : plus bas, pour
     designer la liste Education elle-meme plutot que seulement son titre
     juste au-dessus). */
  .margin-note--university {
    position: absolute;
    top: 220px; right: calc(100% + var(--s5));
  }
  .margin-note--university .margin-note__arrow { bottom: 52px; transform: scaleY(-1); }
}

.editorial__block { margin-bottom: var(--s8); }
/* Le "PS" final (content.js: about.blocks, handwritten:true) reprend le
   meme traitement manuscrit que les margin-notes ci-dessus (demande
   utilisateur), applique a un vrai bloc de contenu cette fois (titre ET
   paragraphe, pas une simple aparte) : meme police, meme leger angle, mais
   sa propre couleur (--ink, pas --ink-faint) puisqu'il reste du contenu a
   lire, pas du bruit de marge. */
.editorial__block--handwritten {
  transform: rotate(-1.5deg);
  font-family: 'Caveat', cursive;
}
/* Selecteur compose (0,2,1) : bat .editorial__block h2 / .editorial__block p
   (0,1,1 chacune) quel que soit l'ordre dans le fichier, meme convention que
   .editorial__lede.editorial__callout plus haut — voir CLAUDE.md. */
.editorial__block.editorial__block--handwritten h2 { font-size: 2.5rem; font-weight: 600; }
.editorial__block.editorial__block--handwritten p { font-size: 1.6rem; line-height: 1.3; color: var(--ink); }
.editorial__block h2 { font-size: var(--fs-h2); margin-bottom: var(--s4); font-weight: var(--w-medium); }
.editorial__block p { max-width: var(--measure); margin-bottom: var(--s4); color: var(--ink-soft); }
.editorial__block p:last-child { margin-bottom: 0; }
/* par.emojiList (app.js) : liste a puces dont l'emoji remplace la puce
   native (demande utilisateur) — list-style:none sur le <ul>, l'emoji est un
   enfant flex normal plutot qu'un ::marker, pour rester aligne avec le texte
   sur plusieurs lignes. Texte en semibold, demande utilisateur egalement. */
.editorial__block .editorial__emoji-list {
  list-style: none; max-width: var(--measure); margin: 0 0 var(--s4);
}
.editorial__block .editorial__emoji-list li {
  display: flex; align-items: flex-start; gap: var(--s2);
  margin-bottom: var(--s2);
}
.editorial__block .editorial__emoji-list li:last-child { margin-bottom: 0; }
.editorial__emoji-list-icon { flex: none; }
.editorial__emoji-list-text { font-weight: var(--w-regular); color: var(--ink-soft); }
/* Intitule au-dessus d'un paragraphe nomme (ex. "Salsa") — meme habillage
   que .cs-sec__title, pour un aparte sans promouvoir un h2 a part entiere.
   Selecteur compose : .editorial__block p (0,1,1) battrait une classe seule
   (0,1,0), meme piege que .cs-sec p documente dans CLAUDE.md. */
.editorial__block .editorial__ptitle {
  font-size: var(--fs-tiny); text-transform: uppercase; letter-spacing: .1em;
  color: var(--ink-faint); font-weight: var(--w-semibold);
  margin-bottom: var(--s2);
}

/* ---- Experience / Education (b.items, expItemMarkup() dans app.js) ----
   Reference explicite de l'utilisateur : #experience sur antonioso.ng —
   deux lignes par entree, une colonne label etroite a gauche (organisation,
   puis categorie/lieu) et une colonne valeur a droite (role + dates, puis
   description). Colonne gauche en largeur fixe (flex-basis), pas grid : la
   valeur doit pouvoir passer a la ligne independamment sans que la grille
   entiere ne se resserre. */
.exp-list { display: flex; flex-direction: column; gap: var(--s6); margin-top: var(--s4); }
.exp__item { display: flex; flex-direction: column; gap: var(--s1); }
.exp__row { display: flex; gap: var(--s4); }
.exp__org, .exp__tag {
  flex: 0 0 120px;
  font-size: var(--fs-small);
}
/* Fleche seule suffit a signaler un lien ici (demande utilisateur) : pas de
   soulignement sur l'organisation, contrairement a .u-arrow-link ailleurs
   sur le site (etudes de cas comprises) — meme recette que .foot/.site-nav__end
   plus haut, restreinte a cette page via la classe exp__org. */
.exp__org.u-arrow-link > span:first-child { text-decoration: none; }
.exp__org { font-weight: var(--w-semibold); }
.exp__tag {
  font-size: var(--fs-tiny); text-transform: capitalize;
  color: var(--ink-faint);
}
.exp__value {
  display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between;
  column-gap: var(--s3); row-gap: var(--s1);
  flex: 1 1 0%; min-width: 0;
}
.exp__role { font-weight: var(--w-semibold); }
.exp__meta { display: inline-flex; align-items: center; gap: var(--s2); color: var(--ink-faint); font-size: var(--fs-small); }
/* Pastille de DUREE seulement (pas une simple date) — meme recette que
   .draft-badge (fond accent, texte --accent-ink) pour rester sur la palette
   deja etablie plutot qu'une nouvelle couleur. Cachee (demande utilisateur) :
   markup et regles gardees intactes pour pouvoir la reactiver d'un coup en
   retirant juste ce display:none. */
.exp__badge {
  display: none;
  align-items: center;
  background: var(--accent); color: var(--accent-ink);
  font-size: var(--fs-tiny); font-weight: var(--w-semibold);
  border-radius: var(--radius-tag); padding: 1px var(--s2);
}
/* Selecteur compose : .editorial__block p (0,1,1) battrait .exp__text seule
   (0,1,0), meme piege que .editorial__ptitle juste au-dessus. Sur la page
   About (qui partage editorial--gap avec /gap, voir pageEditorial()),
   .editorial--gap .editorial__block p (0,2,1) l'emporterait aussi sur ce
   compose a 2 classes — d'ou un second compose a 3 classes juste apres,
   pour garder la colonne de lecture ici alors que /gap la laisse deborder
   ailleurs. */
.editorial__block .exp__text { flex: 1 1 100px; min-width: 0; max-width: var(--measure); margin: 0; color: var(--ink-soft); font-size: var(--fs-small); }
.editorial--gap .editorial__block .exp__text { max-width: var(--measure); }
@media (max-width: 600px) {
  .exp-list { gap: var(--s4); }
  .exp__row { flex-direction: column; gap: var(--s1); }
  .exp__org, .exp__tag { flex-basis: auto; }
}
/* Mobile, dans le drawer Experience/Education uniquement (figure-drawer
   cs-more — pas /gap, qui garde la mise en page ci-dessus) : .exp__org et
   .exp__tag viennent de deux <div class="exp__row"> differents dans le DOM
   (expItemMarkup()), donc pas moyen de les mettre sur la meme ligne en
   flex/colonne seul — display:contents "aplatit" chaque .exp__row pour que
   ses enfants deviennent des items directs de la grille .exp__item, qu'on
   replace librement par grid-template-areas (demande utilisateur : tag a
   cote de org, dates sous le role — sans toucher au markup).
   2e colonne en 1fr (pas max-content) : .exp__value/.exp__text enjambent
   les 2 colonnes, et avec 2 colonnes max-content la largeur de piste se
   base sur leur contribution "max-content" (texte SANS retour a la ligne,
   potentiellement toute la phrase) plutot que sur leur minimum — colonnes
   demesurees, org/tag ecartes l'un de l'autre. 1fr absorbe l'espace restant
   sans etre contraint par ce calcul, .exp__tag reste colle a org via
   justify-self:start (sinon etire sur toute la piste 1fr). */
@media (max-width: 600px) {
  /* .exp-list{gap:s4} (regle generale juste au-dessus) suffisait avec
     l'ancienne mise en page (2 lignes/entree) ; la grille ci-dessous en
     tient jusqu'a 3 (org+meta / role / text — tag masque sur mobile, voir
     plus bas), donc chaque entree est plus haute — le meme espacement
     separe moins bien visuellement (demande utilisateur, a deux reprises :
     encore plus d'air entre les entrees). */
  .figure-drawer.cs-more .exp-list { gap: var(--s6); }
  /* Carte (demande utilisateur) : chaque entree se detache du fond bleu de
     marque (cette page n'est jamais en theme clair, voir isCase dans
     paint()) — memes jetons que .figure__frame/.editorial__callout pour
     rester dans la meme famille visuelle que le reste du site plutot que
     d'inventer une nouvelle valeur. --radius le rayon standard d'un panneau
     (figure__frame, cartes projet), --s4 son padding usuel.
     Fond : --veil-strong (rgba(0,0,0,.12) — le "bloc mis en avant" du theme
     de marque, voir .todo/.editorial__callout) essaye d'abord, juge trop
     sombre (demande utilisateur) — --veil (rgba(0,0,0,.05), le second et
     dernier jeton de voile noir du theme de marque, deja utilise pour la
     pilule de nav/les survols) le remplace ici. */
  .figure-drawer.cs-more .exp__item {
    display: grid;
    grid-template-columns: max-content 1fr;
    column-gap: var(--s2); row-gap: var(--s1);
    grid-template-areas: "org meta" "role role" "text text";
    background: var(--veil);
    border-radius: var(--radius);
    padding: var(--s4);
  }
  .figure-drawer.cs-more .exp__row,
  .figure-drawer.cs-more .exp__row--sub { display: contents; }
  /* .exp__value n'a plus besoin de porter role+meta ensemble ici (c'etait sa
     seule raison d'etre sur mobile — role au-dessus de meta, tous deux
     detaches de la ligne org) : demande utilisateur, meta rejoint desormais
     la ligne d'org, et role descend seul sous tag. display:contents
     l'aplatit donc a son tour, meme mecanisme que .exp__row/--sub juste
     au-dessus, pour que role et meta redeviennent des enfants directs de la
     grille .exp__item et soient placables independamment. */
  .figure-drawer.cs-more .exp__value { display: contents; }
  /* org/meta sur la meme ligne, alignes sur leur ligne de base commune (les
     deux partagent --fs-small) plutot que sur le bas de leur boite — align-
     self:end suffisait quand meta etait absent de cette ligne, mais un badge
     de duree (s'il est un jour reactive, voir .exp__badge plus haut) grandit
     la boite de meta sans deplacer son texte, et end desalignerait alors le
     texte des deux. baseline reste correct dans les deux cas. */
  .figure-drawer.cs-more .exp__org { grid-area: org; align-self: baseline; }
  .figure-drawer.cs-more .exp__meta { grid-area: meta; justify-self: end; align-self: baseline; }
  /* Categorie retiree ici (demande utilisateur) : "org" seul (souvent "/"
     pour l'entree gap, sans lien ni categorie) suffit sur mobile — pas de
     grid-area "tag" pour ne pas reserver une ligne/un gap vide. */
  .figure-drawer.cs-more .exp__tag { display: none; }
  .figure-drawer.cs-more .exp__role { grid-area: role; }
  .figure-drawer.cs-more .exp__text { grid-area: text; }
}

/* ---- Carte "polaroid" glissable (draggableCardMarkup()/setupDraggableCards()
   dans app.js) ----
   Reference explicite de l'utilisateur : les cartes eparpillees de
   lelezhang.design (classe .draggable-card telle quelle, cadre carre en
   papier blanc legerement incline, qu'on attrape a la souris/au doigt).
   Papier TOUJOURS blanc et texte TOUJOURS sombre, quel que soit le theme de
   la page qui l'accueille (ici le theme de marque/bleu de la page gap) :
   une photo scotchee sur la page ne change pas de teinte avec elle — d'ou
   des couleurs en dur plutot que des jetons de theme ici, seule exception du
   fichier.
   Le deplacement est un transform (--card-x/--card-y, poses par
   setupDraggableCards()) et non un repositionnement dans le flux : la carte
   reste un bloc normal entre les paragraphes, seule son APPARENCE suit le
   pointeur, donc elle ne peut jamais rompre la mise en page autour d'elle. */
.draggable-card {
  --card-x: 0px; --card-y: 0px;
  display: block; width: fit-content;
  margin: var(--s7) auto;
  transform: translate(var(--card-x), var(--card-y)) rotate(var(--card-angle, -4deg));
  cursor: grab; touch-action: none; user-select: none;
  transition: transform .2s var(--ease);
}
.draggable-card.is-dragging { cursor: grabbing; transition: none; }
/* Plusieurs cartes (par.cards, voir app.js) : en grappe cote a cote plutot
   qu'empilees chacune sur sa propre ligne — meme esprit que la pile de
   cartes qui se chevauchent sur le site de reference. flex-wrap : sous une
   largeur qui ne peut pas accueillir les deux cote a cote, elles retombent
   proprement l'une sous l'autre plutot que de deborder. */
.draggable-card-group {
  display: flex; flex-wrap: wrap; justify-content: center; align-items: flex-end;
  margin: var(--s7) auto;
}
.draggable-card-group .draggable-card { margin: 0; }
.draggable-card-group .draggable-card:not(:first-child) { margin-left: -48px; }
/* Le "papier" : meme recette que .card-container sur le site de reference
   (fond blanc, coins tres arrondis, liser quasi invisible, ombre douce en
   deux passes — une large et diffuse, une fine et serree, pour un lift qui
   ne bascule jamais dans le pose-sur-une-table trop appuye). width en min()
   plutot qu'une valeur fixe : deux cartes de 280px cote a cote debordent un
   ecran etroit (voir la balayage de largeurs dans CLAUDE.md) — 78vw laisse
   quand meme deviner la seconde carte derriere sur la plus petite largeur
   testee (320px). */
.draggable-card__inner {
  width: min(280px, 78vw); padding: 8px; border-radius: 24px;
  background: #fff; border: .8px solid rgba(16,35,63,.08);
  box-shadow: 0 4px 12px rgba(16,35,63,.18), 0 1px 2px rgba(16,35,63,.1);
}
.draggable-card .figure { margin: 0; }
/* Cadre CARRE, quel que soit le ratio du media source (video 16:9 ou photo
   3:4 ici) : les cartes de reference sont toutes carrees elles aussi,
   object-fit:cover recadre le media au lieu de l'ecraser ou de laisser des
   bandes vides. */
.draggable-card .figure--remote .figure__frame,
.draggable-card .vplayer { aspect-ratio: 1 / 1; border-radius: 16px 16px 2px 2px; }
.draggable-card .figure--remote video,
.draggable-card .figure--remote img {
  height: 100%; object-fit: cover; border-radius: 16px 16px 2px 2px;
}
.draggable-card figcaption {
  padding-inline: 2px; text-align: center; color: #10233F;
}
/* Demande utilisateur : le bouton son du lecteur video seul (celui de la
   video "Having fun") passe au coin haut-droit, pas au coin bas-gauche avec
   lecture/pause — scope a .draggable-card, jamais a .media-controls/
   .media-btn de base (partages par TOUT le site, voir la note au-dessus de
   .media-controls dans le fichier). .media-controls s'etend au cadre entier
   (inset:0) pour que ses DEUX boutons se positionnent chacun independamment
   en absolute, plutot que de rester alignes cote a cote par le flex de base. */
.draggable-card .media-controls { inset: 0; }
.draggable-card .media-controls [data-act="play"] {
  position: absolute; left: var(--s3); bottom: var(--s3);
}
.draggable-card .media-controls [data-act="sound"] {
  position: absolute; top: var(--s3); right: var(--s3);
}
/* Essai verre blanc tente puis annule (demande utilisateur) : retour au
   verre sombre partage par le reste du site (.media-btn de base), juste un
   peu plus transparent ici (.4/.18 au lieu de .55/.22) — scope a
   .draggable-card uniquement, .media-btn de base inchangee. */
.draggable-card .media-btn {
  background: rgba(12,18,28,.4);
  border-color: rgba(255,255,255,.18);
}
.draggable-card .media-btn:hover { background: rgba(12,18,28,.6); }

/* --- 9 bis. UN PROJET RENDU COMME UN ARTICLE (pageArticle, app.js 5e ter) --
   "Documenting salsa dance" est une side quest, donc une fiche : elle garde
   .cs (fond blanc, rythme vertical, croix de fermeture) et son en-tete est un
   .cs__head ordinaire. Ce bloc ne decrit donc QUE le corps — ce qu'une etude
   de cas met dans .cs-sec et qu'un article met dans une suite de blocs.

   Les regles reprennent celles de .editorial__block plus haut plutot que
   celles de .cs-sec : la colonne est etroite (.wrap--narrow) et la mesure de
   lecture est la meme que sur l'article des deux ans. Elles ne sont pas
   partagees avec .editorial__block par une liste de selecteurs parce que les
   deux vivent dans des themes differents — l'article-projet sur la fiche
   blanche, l'editorial sur le bleu du site — et qu'ils divergeront (medias,
   citations) du cote projet seulement. */
.cs--article { padding-bottom: var(--s9); }

/* Le media d'ouverture. Volontairement PAS .cs__hero-media : celui-la impose
   un cadre 16:9 et un fond #e9e1f9 releves sur l'animation de Contraintes,
   ce qui n'a aucun sens pour une photo. Ici le media garde son ratio natif. */
.article__hero { margin-top: var(--s6); margin-bottom: var(--s2); }
.article__hero .figure { margin-block: 0; }

.article__block { margin-bottom: var(--s7); }
.article__block:last-child { margin-bottom: 0; }
.article__block h2 { font-size: var(--fs-h2); margin-bottom: var(--s4); font-weight: var(--w-medium); }
.article__block p { max-width: var(--measure); margin-bottom: var(--s4); color: var(--ink-soft); }
.article__block p:last-child { margin-bottom: 0; }
/* .article__p : meme mesure/couleur/marge que .article__block p, pour un
   paragraphe qui embarque une liste a puces (voir pageArticle() dans app.js
   et termBody plus haut) — un div, pas un p, car <ul> n'y est pas valide. */
.article__block .article__p { max-width: var(--measure); margin-bottom: var(--s4); color: var(--ink-soft); }
.article__block .article__p:last-child { margin-bottom: 0; }
.article__block .article__p .cs-sec__list { color: inherit; max-width: none; margin: var(--s2) 0; }

/* Le glossaire d'un bloc (articleTerms(), app.js). Un <dl> vertical au filet
   gauche, et NON les cartes teintees horizontales de .cs-sec__terms : voir la
   note au-dessus de la fonction — cinq definitions longues ne tiennent pas en
   colonnes, et cinq aplats #E9F2FE d'affilee ecrasent le texte autour.
   Le terme reprend le poids et la couleur pleine de --ink pour se detacher du
   corps en --ink-soft : c'est le seul contraste dont la liste a besoin.

   UN SEUL filet continu court le long de toute la liste, plutot qu'un filet
   par terme. C'est ce qui evite d'avoir a annuler l'ecart entre un <dt> et son
   <dd> (en grille ils sont deux enfants distincts, et un `gap` couperait le
   trait au milieu de chaque definition) : ici dt et dd sont dans le flux, ils
   portent le meme bord gauche, et l'espace entre deux termes est simplement le
   padding bas du <dd>. Rien a resynchroniser. */
.article__terms { margin: var(--s5) 0; }
.article__terms > dt,
.article__terms > dd { padding-left: var(--s5); border-left: 2px solid var(--line); }
.article__terms > dt {
  font-weight: var(--w-semibold); color: var(--ink);
  margin: 0; padding-top: var(--s1);
}
.article__terms > dd {
  margin: 0; padding-top: var(--s2); padding-bottom: var(--s5);
  color: var(--ink-soft); max-width: var(--measure);
}
/* Le filet s'arrete a la derniere ligne lue, pas plus bas. */
.article__terms > dd:last-child { padding-bottom: 0; }

/* Le schema d'un concept, a l'interieur de sa definition. Marges plus serrees
   que dans le corps de l'article (--s4 au lieu de --s6) : il appartient au
   terme au-dessus de lui, et un ecart trop large le detacherait de ce qu'il
   illustre pour le faire flotter entre deux concepts.
   NOTE : la figure herite du `max-width: var(--measure)` pose sur le <dd>,
   puisqu'elle est dedans. C'est sans effet ici — 68ch depasse deja la colonne
   de l'article — mais si un jour la colonne s'elargit, c'est ce qui bridera
   les schemas, et il faudra le lever ici plutot que retirer la mesure du
   texte. */
.article__terms .media-single,
.article__terms .media-grid { margin-block: var(--s4); }
.article__terms dd > .media-single:last-child,
.article__terms dd > .media-grid:last-child { margin-bottom: 0; }
/* Un carrousel (voir "Lines and lanes") porte son propre `margin-block:
   var(--s6)` (32px, .cs-carousel plus haut) — plus large que le
   `margin-block: var(--s4)` du wrapper ci-dessus, c'est donc lui qui gagne le
   collapse et qui fixait l'ecart avec le paragraphe. Reduit ici cote haut
   seulement : le bas garde ses 32px avant le terme suivant. */
.article__terms .media-single .cs-carousel,
.article__terms .media-grid .cs-carousel { margin-top: var(--s3); }

/* Le niveau imbrique (les facettes de "Body possibilities"). Le RETRAIT est ce
   qui dit "ceci est dedans" : sans lui, "Turn height" s'aligne exactement sur
   "Timing" et se lit comme un concept de plus. Le corps plus petit seul ne
   suffisait pas — teste, la difference passe inapercue. Pas de second filet en
   revanche : a ce retrait il longerait le premier de trop pres. */
.article__subterms { margin: var(--s4) 0 0; padding-left: var(--s5); }
.article__subterms dt { font-weight: var(--w-semibold); color: var(--ink); font-size: var(--fs-small); }
.article__subterms dd { margin: var(--s1) 0 var(--s4); font-size: var(--fs-small); }
.article__subterms dd:last-child { margin-bottom: 0; }
/* Sur telephone la colonne de texte descend a ~257px (mesure a 320px, la
   largeur de reflow WCAG 1.4.10) et le retrait lui en prend encore 40. On le
   reduit de moitie : assez pour que la subordination se lise toujours, pas
   assez pour hacher les definitions en cinq mots par ligne. Media query
   locale plutot que la section 12, comme .cs-sec__terms juste au-dessus : la
   regle n'a de sens qu'a cote de celle qu'elle corrige. */
@media (max-width: 700px) {
  .article__subterms { padding-left: var(--s3); }
}

/* Les medias gardent le `margin-block: var(--s6)` de .media-single/.media-grid
   (section 8), qui les detache deja du texte. Rien a redire ici — sauf la
   marge basse du dernier enfant du bloc : .article__block porte la sienne, et
   les deux s'ajouteraient. Le texte, lui, reste sur --measure ; les visuels
   prennent toute la colonne, qui est deja etroite (.wrap--narrow). */
.article__block > :last-child { margin-bottom: 0; }

/* --- 9 ter. LA PLANCHE SYNCHRONISEE AVEC LA VIDEO ------------------------
   Voir beatSyncMarkup()/setupBeatSync() dans app.js. Video en haut, planche
   en dessous, le temps courant encadre sur la planche.

   EMPILE, JAMAIS EN DEUX COLONNES. Les deux doivent etre lus ENSEMBLE : cote
   a cote dans une colonne de 700px, la planche tomberait a ~340px, soit des
   cases de 45px avec des annotations de 5px — illisibles. La planche a besoin
   de toute la largeur ; la video, non (elle est bridee a 400px, voir plus
   bas), et l'empilement garde les deux dans un meme ecran. */
.beatsync { margin-block: var(--s6); }
/* Pas d'`aspect-ratio` : un <video> local porte ses propres dimensions (ici
   640x480), donc la boite se dimensionne seule et reste juste si le fichier
   change de format un jour. C'etait l'iframe YouTube qui en avait besoin,
   n'ayant aucun ratio naturel. */
.beatsync__video {
  /* `relative` : c'est le repere des commandes et du compteur, tous deux
     poses SUR l'image. */
  position: relative;
  /* LA VIDEO EST BRIDEE A 360px, PAS A LA LARGEUR DE LA COLONNE. A 100% elle
     faisait 700x525 (elle est en 4:3), et 525 + la planche (373) + la legende
     donnaient un bloc de ~1020px : il fallait defiler entre la danse et la
     planche, alors que tout l'interet est de les lire ensemble. A 360x270 le
     bloc tombe a ~770px et les deux tiennent dans un meme ecran.
     Si ce chiffre bouge un jour, refaire le calcul en entier : hauteur du bloc
     = video (largeur x 0,75) + 373 (la planche a 698px) + ~100 (marges, ligne
     de lecture, legende). `min()` pour que le telephone garde 100%. */
  max-width: min(360px, 100%);
  /* CALEE A GAUCHE, comme toutes les figures de l'article (voir boundStyle()
     dans app.js) : bridee a 360px et centree, elle flottait au milieu d'une
     colonne de 700px, decalee du bord du texte et du bord de la planche juste
     en dessous. Alignee a gauche, la video, la planche et le paragraphe
     partagent la meme verticale. Pas de `margin-inline` du tout ici : la boite
     est un bloc, elle se cale donc a gauche d'elle-meme. */
  border-radius: var(--radius); overflow: hidden;
  background: #000;                     /* le noir du lecteur avant chargement */
  border: 1px solid var(--frame-line);
}
.beatsync__player { display: block; width: 100%; height: auto; }

/* Les commandes, posees sur la video en bas a gauche. Pas les controles
   natifs : ceux-ci exposeraient la barre de progression du fichier ENTIER
   (13s) alors que seul l'enchainement compte, et un clic dessus sortirait de
   la zone que la planche decrit.

   .media-controls / .media-btn / .media-ico NE SONT PAS PROPRES A LA PLANCHE
   (d'ou l'absence de prefixe .beatsync__) : le lecteur video autonome de la
   section "So what is a move?" porte exactement la meme rangee de
   boutons (voir mediaControlsMarkup() dans app.js et .vplayer plus bas). Le
   seul pre-requis pour les reutiliser est un parent en `position: relative`.
   Regles a garder generiques : rien ici ne doit supposer une planche. */
.media-controls {
  position: absolute; left: var(--s3); bottom: var(--s3);
  display: flex; gap: var(--s2);
}
.media-btn {
  display: grid; place-items: center;
  width: 44px; height: 44px;            /* la cible tactile recommandee, pile */
  padding: 0; cursor: pointer;
  color: #fff; border-radius: 999px;
  /* Verre sombre plutot que le --glass clair du reste du site : il flotte sur
     une image video, dont on ne sait rien — le clair disparaitrait sur un plan
     clair. Le noir translucide + le flou tiennent sur n'importe quel fond. */
  background: rgba(12,18,28,.55);
  -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
  border: 1px solid rgba(255,255,255,.22);
}
.media-btn:hover { background: rgba(12,18,28,.75); }
.media-btn:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
.media-ico { width: 20px; height: 20px; display: block; }
/* Le bouton de vitesse (voir speedButtonMarkup() dans app.js) porte un texte
   ("1×"/"0.5×"), pas une icone : `width: 44px` le tronquerait. `min-width`
   garde la cible tactile pile sur le rond de lecture tout en laissant le
   texte respirer. */
.media-btn--speed {
  width: auto; min-width: 44px; padding-inline: var(--s3);
  font-size: var(--fs-small); font-weight: var(--w-semibold);
  font-variant-numeric: tabular-nums;
}
/* Une seule des deux icones est visible, choisie par l'etat du bouton. Les
   deux restent dans le DOM : basculer n'est alors qu'un changement
   d'attribut, sans reconstruction ni rechargement de SVG.

   LES DEUX BOUTONS NE SUIVENT PAS LA MEME CONVENTION, et c'est deliberE :
   - LECTURE : l'icone montre L'ACTION. En pause (aria-pressed="false") on
     affiche le triangle de lecture. C'est ce que fait tout lecteur video.
   - SON : l'icone montre L'ETAT. Muet (aria-pressed="false") on affiche le
     haut-parleur barre. C'est aussi ce que fait tout lecteur video — et
     l'inverse (le haut-parleur intact affiche pendant que la video est muette,
     pour annoncer l'action) se lit comme un bug : on croit le son actif.
   Les aria-label, eux, restent des ACTIONS pour les deux ("Play", "Sound on") :
   c'est la convention d'un bouton, et aria-pressed porte deja l'etat. */
.media-btn[aria-pressed="false"] .media-ico--pause,
.media-btn[aria-pressed="true"]  .media-ico--play,
.media-btn[aria-pressed="false"] .media-ico--sound-on,
.media-btn[aria-pressed="true"]  .media-ico--sound-off { display: none; }

/* Le lecteur video autonome (voir videoPlayerMarkup()/setupVideoPlayers()).
   UNE SEULE LIGNE, et c'est voulu : l'habillage des boutons est deja ci-dessus,
   le cadre et la legende viennent de .figure--remote, et la video elle-meme est
   deja mise en forme par `.figure--remote video` (display:block, width:100%,
   coins arrondis). Redeclarer tout ca ici ne ferait que dupliquer des valeurs
   qui perdraient de toute facon la cascade face a ce selecteur.
   `position: relative` est le seul ajout indispensable : c'est lui qui ancre
   .media-controls sur l'image plutot que sur la page. */
.vplayer { position: relative; }

/* Le compte en cours, en haut a droite de la video. Chiffre seul et gros :
   c'est ce qu'on lit du coin de l'oeil en regardant danser, pas une etiquette
   a dechiffrer. MEME TAILLE que .media-btn (44px, la cible tactile
   recommandee) : les trois ronds de la video (compte, lecture, vitesse)
   doivent former une seule famille visuelle, pas deux tailles differentes.
   Fond plus opaque et bordure plus marquee que les commandes (.media-btn
   reste a .55/.22) : ce rond porte l'information la plus utile de la demo —
   LE temps courant — et doit rester lisible sur un plan clair du sujet, pas
   seulement sur le fond noir du lecteur. */
.beatsync__count {
  position: absolute; top: var(--s3); right: var(--s3); margin: 0;
  min-width: 44px; height: 44px;
  display: grid; place-items: center;
  font-size: var(--fs-h3); font-weight: var(--w-semibold);
  font-variant-numeric: tabular-nums;   /* le rond ne tressaute pas de 1 a 8 */
  color: #fff; border-radius: 999px;
  background: rgba(12,18,28,.78);
  -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
  border: 1px solid rgba(255,255,255,.4);
  box-shadow: 0 2px 10px rgba(0,0,0,.35);
}
/* Vide tant que rien n'est pose (avant le premier show()) : sans ca, une
   pastille vide flotterait sur l'image. */
.beatsync__count:empty { display: none; }

/* La planche. `position: relative` porte le reperage en pourcentage des cases
   ET du cadre : tous deux sont positionnes par rapport a CETTE boite, donc
   elle doit epouser l'image au pixel pres — d'ou le `display: block` sur
   l'image (sans lui, la ligne de base laisse ~4px sous elle et tout le
   reperage glisse d'autant). */
/* Le rail : c'est LUI qui rogne et qui defile, jamais la planche. Le cadre et
   les cases se reperent en pourcentage de .beatsync__sheet, donc celle-ci
   doit garder sa taille pleine — la rogner deplacerait tous les reperes. */
.beatsync__rail {
  margin-top: var(--s5);
  overflow-x: auto; overflow-y: hidden;
  border-radius: var(--radius); border: 1px solid var(--frame-line);
  /* Le cadre du temps courant est colle au bord haut/bas de sa case : sans
     cette ligne, un rail qui defile le rognerait de 2px. */
  scrollbar-width: thin;
}
.beatsync__sheet {
  position: relative;
  /* La planche fait 744px de large dans Figma (elle en faisait 831 : elle a
     ete resserree exprES pour tenir ici). La colonne de l'article fait ~700px,
     soit 94% du naturel — assez proche pour que les annotations restent
     lisibles, et SANS defilement horizontal sur ordinateur, ce qui etait le
     defaut de la version 831px.
     Le plancher a 640px n'existe donc que pour le telephone : en dessous, on
     prefere faire defiler le rail plutot que reduire encore. A 640px les cases
     font 85px, toujours bien au-dessus de la cible tactile de 44px. */
  min-width: 640px; width: 100%;
}
.beatsync__sheet img { display: block; width: 100%; }

/* PAS DE DEBORDEMENT HORS COLONNE. Une version precedente elargissait le bloc
   de 90px de chaque cote au-dessus de 1200px pour que la planche tienne sans
   defiler ; c'est annule — la video et la planche restent dans le
   .wrap--narrow comme tout le reste de l'article, sur demande. Ce n'est plus
   un compromis depuis que la planche est passee a 744px : elle tient dans la
   colonne. Le rail ne defile que sous 640px, et le cadre du temps courant y
   est suivi automatiquement par revealBeat(), donc le defilement ne demande
   rien au lecteur. */

/* Les zones cliquables, une par temps. Transparentes : la planche se suffit,
   elles n'ajoutent qu'une cible. Elles restent atteignables au clavier, et le
   focus les revele — sinon un utilisateur au clavier tabule dans le vide. */
.beatsync__spot {
  position: absolute; margin: 0; padding: 0;
  background: none; border: 0; border-radius: var(--radius-sm);
  cursor: pointer;
}
.beatsync__spot:hover { background: rgba(32,120,240,.10); }
.beatsync__spot:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* Le cadre du temps courant. `pointer-events: none` est indispensable : pose
   par-dessus les cases, il intercepterait sinon le clic destine a la case
   qu'il recouvre — c'est-a-dire precisement celle sur laquelle on est. */
.beatsync__marker {
  position: absolute; pointer-events: none;
  border: 2px solid var(--accent); border-radius: var(--radius-sm);
  background: rgba(32,120,240,.08);
  /* Le cadre DEBORDE sa case d'environ 8%. Colle a la case, il mordait sur le
     numero du temps et sur les annotations qui depassent de la grille (celles
     de "L creates tension...", par exemple), et le contenu encadre devenait
     moins lisible que ses voisins — l'inverse du but. La mise a l'echelle part
     du centre, donc la case reste centree dans son cadre sans recalculer
     aucune coordonnee.
     `scale` et non width/height : ceux-ci sont copies depuis la case active
     (marker.style.cssText dans setupBeatSync), et les gonfler ici obligerait a
     recalculer aussi left/top pour rester centre. */
  transform: scale(1.08);
  transition: left .18s ease, top .18s ease, width .18s ease, height .18s ease;
}
@media (prefers-reduced-motion: reduce) { .beatsync__marker { transition: none; } }

.beatsync__read {
  margin: var(--s3) 0 0; font-size: var(--fs-small);
  color: var(--ink); font-weight: var(--w-semibold);
  /* Reserve la ligne : sans hauteur minimale, le premier temps affiche fait
     sauter la planche et la legende de 20px. */
  min-height: 1.4em;
}
.beatsync__caption {
  margin: var(--s2) 0 0; font-size: var(--fs-small); color: var(--ink-faint);
}

/* Les paragraphes entre crochets sont des consignes de redaction. Le lime les
   rend impossibles a publier par distraction. */
.todo {
  background: var(--veil-strong);
  border-left: 3px solid var(--accent);
  padding: var(--s4) var(--s5);
  border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
  color: var(--ink) !important;
  font-size: var(--fs-small); font-style: italic;
}

/* Paragraphe mis en avant (par.callout, gap uniquement — voir plus haut) :
   plus de fond ni de rayon (demande utilisateur, retires) — le filet gauche
   et l'italique suffisent a le distinguer du corps de texte. Marges 24px =
   --s5, comme le padding inline, pour rester sur l'echelle du theme plutot
   qu'un px en dur. Selectionneur compose pour battre `.editorial__block p`
   sans !important. */
.editorial__block .editorial__callout {
  border-left: 2px solid white;
  padding: 0 var(--s5);
  margin: var(--s5) 0;
  color: var(--ink);
  font-size: var(--fs-small);
  font-style: italic;
}
/* `par.links` (pageEditorial() dans app.js) : deux phrases (Resume/Email),
   chacune son propre <p> a l'interieur — pas de mise en page propre ici,
   .editorial__block p (espacement/couleur) suffit deja via le selecteur
   descendant pour margin-bottom/max-width. `l.noUnderline` : la fleche
   suffit a signaler le lien (demande utilisateur), meme raisonnement que
   .exp__org plus haut — compose a 3 classes pour battre
   .u-arrow-link > span:first-child (0,2,1) sans !important. */
/* Meme classe, deux contextes : la rangee de liens ci-dessus, ET "See
   projects" dans .exp__text (item.linkAfter, expItemMarkup()) — reutilise
   telle quelle, sans ajouter de taille de police (celle-ci vient de son
   contexte, .exp__text ou .editorial__links p, jamais d'ici). */
.editorial__links .about-cta-link.u-arrow-link > span:first-child,
.exp__text .about-cta-link.u-arrow-link > span:first-child { text-decoration: none; }
/* "See projects" (Petal) en semibold (demande utilisateur) — la taille reste
   celle de .exp__text (aucune regle ici ne la touche, voir plus haut).
   Couleur blanc plein constante, repos et survol (demande utilisateur) :
   pas de survol qui change de teinte, juste --ink (blanc sur ce theme). */
.exp__text .about-cta-link { font-weight: var(--w-semibold); color: var(--ink); }
/* "The full story" (career break, .hero__gap reutilise tel quel — voir
   gapCtaLinkHTML()) : demande utilisateur, meme taille que la phrase
   .exp__text qui le porte (inherit plutot que --fs-small en dur, au cas ou
   .exp__text change de taille un jour) et semibold plutot que le medium de
   .hero__gap sur l'accueil. Couleur blanc plein constante (ecrase le
   color-mix estompe au repos de .hero__gap — la regle :hover d'origine reste
   inoffensive, elle pose deja --ink). vertical-align: middle corrige le
   desalignement vertical propre a ce lien : contrairement a extArrowLinkHTML
   (texte PUIS icone), ici l'icone est le PREMIER enfant du conteneur
   inline-flex (voir gapCtaLinkHTML()) — son baseline de premiere ligne suit
   alors celui du <svg> (sans ligne de base de texte) plutot que celui du
   texte, ce que .u-arrow-link seul (voir sa note plus haut) ne couvre pas. */
.exp__text .hero__gap { font-size: inherit; font-weight: var(--w-semibold); color: var(--ink); vertical-align: middle; }
/* Meme habillage que le declencheur "Experience" (.figure-drawer.cs-more
   summary plus haut) et le h2 "PS" (.editorial__block h2 plus haut) : pleine
   couleur ink et graisse medium plutot que le corps de texte estompe
   (.editorial__block p, ink-soft) — demande utilisateur. Compose a 3
   classes pour battre .editorial__block p (0,1,1) sans dependre de l'ordre
   des regles dans la feuille. */
.editorial__block .editorial__links p { color: var(--ink); font-size: var(--fs-h3); font-weight: var(--w-medium); }
/* "PS" force un peu de defilement avant d'apparaitre (demande utilisateur,
   agrandi une seconde fois) : min() plutot qu'un vh pur, pour ne pas
   exiger un defilement demesure sur un grand ecran de bureau. */
.editorial__block--spaced { margin-top: min(140vh, 1100px); }


/* ==========================================================================
   10. PIED DE PAGE
   ========================================================================== */

/* Le voile translucide (var(--veil) seul) laissait la grille pointillee de
   fond transparaitre a travers lui (demande utilisateur : la retirer du pied
   de page). #page est pourtant au-dessus de cette grille (z-index 1 contre
   -1, section 6) — le probleme n'est pas l'empilement mais l'opacite du
   fond lui-meme. Rendu opaque par un calque var(--veil) peint par-dessus un
   calque plein var(--foot-bg), au lieu du voile seul sur le fond transparent
   de #page. --foot-bg (et non --bg) : demande utilisateur d'assombrir la
   teinte de marque a celle du pied de page de marvinsrd.com, sans toucher au
   fond blanc des etudes de cas (theme clair, ou --foot-bg vaut --bg). */
.site-foot {
  position: relative;
  overflow: hidden;
  padding-block: var(--s8);
  background: linear-gradient(var(--veil), var(--veil)), var(--foot-bg);
}
/* Trame geometrique du pied de page de marvinsrd.com (demande utilisateur :
   reprendre les memes classes) — traits et formes pointilles disperses,
   copies un pour un depuis sa feuille de style (memes positions/tailles en
   %, memes rotations, meme opacite .1). position:relative+overflow:hidden
   ci-dessus reproduisent son .footer : les formes se calent sur .site-foot
   entier (pas sur .wrap.foot) et sont rognees a ses bords, exactement comme
   sur le site de reference. */
.ligne-footer {
  position: absolute; left: 0; bottom: 0;
  width: 37%; height: 10px;
  border-style: none none dashed;
  border-width: 2px;
  border-color: transparent transparent rgba(255, 255, 255, .1);
  transform: rotate(-45deg); transform-origin: 0% 100%;
  /* Purement decoratif, mais plusieurs variantes (._3/._4/._6, height:50-100%)
     couvrent la colonne "Get in touch" a droite : sans ce pointer-events:none,
     ces div interceptaient les clics/survols des liens en dessous (bug
     remonte par l'utilisateur — elementFromPoint renvoyait .ligne-footer._4
     au lieu du lien mailto). */
  pointer-events: none;
  /* Derive continue, jamais deux fois le meme trajet (demande utilisateur :
     "bouger de facon aleatoire sans s'arreter") — voir la note groupee sous
     .grand-cercle plus bas pour le principe d'ensemble. */
  animation: footDrift1 27s ease-in-out infinite;
}
.ligne-footer._2 {
  inset: 0 auto auto 0;
  width: 40%; height: 10px;
  border-style: dashed none none;
  border-color: rgba(255, 255, 255, .1) transparent transparent;
  transform: rotate(45deg); transform-origin: 0% 0%;
  animation-name: footDrift2;
  animation-duration: 33s;
}
.ligne-footer._3 {
  /* Le bord bas dessine par .ligne-footer de base n'est pas retire ici (le
     site de reference ne le fait pas non plus) : ._3 AJOUTE un bord haut,
     ce qui dessine les deux traits horizontaux de la forme en "Z" repliee. */
  left: auto; right: 0; bottom: 0;
  width: 40%; height: 50%;
  border-style: dashed none dashed none;
  border-color: rgba(255, 255, 255, .1) transparent rgba(255, 255, 255, .1) transparent;
  transform: none;
  animation-name: footDrift3;
  animation-duration: 24s;
}
.ligne-footer._4 {
  left: auto; right: 0; bottom: 0;
  width: 40%; height: 100%;
  border-style: none none none dashed;
  border-color: transparent transparent transparent rgba(255, 255, 255, .1);
  transform: none;
  animation-name: footDrift4;
  animation-duration: 30s;
}
.ligne-footer._6 {
  /* Meme remarque que ._3 : le bord bas de base reste actif, ._6 AJOUTE un
     bord gauche — d'ou le coin (bas + gauche), contrairement a ._4 qui
     eteint explicitement le bas pour ne garder que le gauche. */
  left: auto; right: 0; bottom: 0;
  width: 20%; height: 100%;
  border-style: none none dashed dashed;
  border-color: transparent transparent rgba(255, 255, 255, .1) rgba(255, 255, 255, .1);
  transform: none;
  animation-name: footDrift5;
  animation-duration: 21s;
}
.carre {
  position: absolute; top: 36%; right: 37%;
  width: 60px; height: 60px;
  border: 2px dashed rgba(255, 255, 255, .1);
  pointer-events: none;
  animation: footDrift6 19s ease-in-out infinite;
}
.grand-cercle {
  position: absolute; left: 0; bottom: 0;
  width: 170px; height: 170px;
  border: 2px dashed rgba(255, 255, 255, .1);
  border-radius: 150px;
  pointer-events: none;
  animation: footDrift7 36s ease-in-out infinite;
}

/* Sept trajets distincts plutot qu'un seul reutilise : des points de passage
   irreguliers (pourcentages non equidistants, amplitudes qui varient d'une
   etape a l'autre) evitent le va-et-vient trop lisible d'un aller-retour
   simple, et SEPT DUREES SANS DIVISEUR COMMUN EVIDENT (19 a 36s) desynchronisent
   les sept formes les unes des autres — leurs cycles ne se realignent plus
   jamais de facon perceptible, ce qui lit comme un mouvement continu et
   sans repetition plutot que comme une boucle. ease-in-out (pas linear) :
   chaque trajet ralentit a l'approche de chaque point, evitant les angles
   mecaniques d'une derive a vitesse constante. Les formes deja tournees
   (.ligne-footer, ._2) gardent leur rotate() de base dans CHAQUE keyframe —
   remplacer `transform` l'ecraserait sinon et les redresserait a l'horizontale
   pendant l'animation. footDrift6/7 n'ajoutent qu'un translate (une rotation
   sur .grand-cercle, symetrique, ne se verrait pas ; sur .carre, dont le
   carre reste toujours un carre, un tout petit rotate suffit a se voir sans
   distraire). La regle globale de la section 13 (animation-duration: .01ms
   sous prefers-reduced-motion, sur *,*::before,*::after) couvre deja ces
   sept animations : aucun garde-fou supplementaire n'est necessaire ici. */
@keyframes footDrift1 {
  0%   { transform: rotate(-45deg) translate(0, 0); }
  18%  { transform: rotate(-45deg) translate(16px, -9px); }
  46%  { transform: rotate(-45deg) translate(-10px, 14px); }
  72%  { transform: rotate(-45deg) translate(9px, 7px); }
  100% { transform: rotate(-45deg) translate(0, 0); }
}
@keyframes footDrift2 {
  0%   { transform: rotate(45deg) translate(0, 0); }
  22%  { transform: rotate(45deg) translate(-13px, 11px); }
  55%  { transform: rotate(45deg) translate(15px, -8px); }
  81%  { transform: rotate(45deg) translate(-6px, -12px); }
  100% { transform: rotate(45deg) translate(0, 0); }
}
@keyframes footDrift3 {
  0%   { transform: translate(0, 0); }
  28%  { transform: translate(-12px, -10px); }
  53%  { transform: translate(8px, 13px); }
  79%  { transform: translate(11px, -7px); }
  100% { transform: translate(0, 0); }
}
@keyframes footDrift4 {
  0%   { transform: translate(0, 0); }
  20%  { transform: translate(10px, 9px); }
  49%  { transform: translate(-9px, -13px); }
  77%  { transform: translate(-13px, 8px); }
  100% { transform: translate(0, 0); }
}
@keyframes footDrift5 {
  0%   { transform: translate(0, 0); }
  24%  { transform: translate(9px, -11px); }
  58%  { transform: translate(-11px, 6px); }
  83%  { transform: translate(6px, 12px); }
  100% { transform: translate(0, 0); }
}
@keyframes footDrift6 {
  0%   { transform: translate(0, 0) rotate(0deg); }
  26%  { transform: translate(-11px, 9px) rotate(6deg); }
  54%  { transform: translate(13px, 12px) rotate(-5deg); }
  80%  { transform: translate(8px, -10px) rotate(3deg); }
  100% { transform: translate(0, 0) rotate(0deg); }
}
@keyframes footDrift7 {
  0%   { transform: translate(0, 0); }
  30%  { transform: translate(14px, -12px); }
  60%  { transform: translate(-9px, -16px); }
  85%  { transform: translate(-14px, 9px); }
  100% { transform: translate(0, 0); }
}
/* Plus de margin-bottom ici (demande utilisateur) : .foot__bottom vivait
   juste en dessous, pleine largeur — maintenant qu'il est retourne dans la
   premiere colonne (son propre margin-top s'en charge), la grille est le
   dernier element de .foot et n'a plus besoin d'air sous elle, deja fourni
   par le padding-bottom de .site-foot. */
.foot__grid {
  display: grid; gap: var(--s6);
  grid-template-columns: 1.5fr 1fr 1fr;
}
/* La premiere colonne passe en flex column pour que .foot__bottom (son
   margin-top: auto plus bas) se colle au tout dernier pixel du bas de la
   colonne — 0px d'ecart — sans que la colonne elle-meme grandisse : sa
   hauteur reste celle que la grille lui donne deja (align-items: stretch sur
   .foot__grid, calquee sur la plus haute des trois colonnes), le flex ne
   fait que redistribuer l'espace qu'elle a deja.
   align-items: flex-start ICI (axe transversal, donc horizontal en colonne) :
   sans ca, le stretch par defaut etire le lien "Resume" (un <a> en
   inline-flex — .u-arrow-link, donc pas naturellement pleine largeur) sur
   toute la largeur de la colonne au lieu de le garder cale a gauche. */
.foot__grid > div:first-child { display: flex; flex-direction: column; align-items: flex-start; }
.foot__name { font-size: var(--fs-h2); margin-bottom: var(--s3); font-weight: var(--w-medium); }
/* Vit dans .foot__bottom, sous la ligne de copyright, lui-meme revenu dans
   la premiere colonne de .foot__grid (demande utilisateur) — plus en pied de
   page pleine largeur. white-space: nowrap plutot que pre-line : le \n de
   footerNote (content.js) doit se lire comme un simple espace, la note tient
   sur une seule ligne. Meme taille que le copyright (--fs-tiny) : les deux
   lignes de .foot__bottom lisent au meme poids. */
.foot__note { color: var(--ink-soft); font-size: var(--fs-tiny); white-space: nowrap; }
.foot h3 {
  font-size: var(--fs-tiny); text-transform: uppercase; letter-spacing: .1em;
  color: var(--ink-faint); margin-bottom: var(--s4); font-weight: var(--w-semibold);
}
.foot ul { display: grid; gap: var(--s3); }
/* Le soulignement venait de .u-underline / .u-arrow-link (portes par chaque
   lien en JS) ; il est retire plus bas pour le pied de page, qui garde donc
   ici sa propre transition couleur : var(--ink) plutot que blanc en dur,
   pour rester juste si le pied de page passe un jour sur le theme clair. */
.foot a { color: var(--ink-soft); font-size: var(--fs-small); transition: color .2s ease-out; }
.foot a:hover, .foot a:focus-visible { color: var(--ink); }
/* Le pied de page n'a pas besoin du soulignement des liens partages
   (.u-underline / .u-arrow-link) : la colonne dediee et l'espacement suffisent
   deja a signaler que ce sont des liens. (0,2,0)/(0,2,1) : juste assez de
   specificite pour l'emporter sur les regles de base ci-dessus. */
/* display:inline-block indispensable ici : les liens Sitemap n'ont que
   .u-underline (une simple ancre, display:inline par defaut), et `transform`
   — donc tout l'effet magnetique de setupMagneticFooterLinks() plus bas —
   n'a AUCUN effet sur un inline non remplace (specification CSS Transforms).
   Les liens Get in touch, eux, marchaient deja : .u-arrow-link pose
   display:inline-flex de son cote. */
.foot .u-underline { text-decoration: none; display: inline-block; }
.foot .u-arrow-link > span:first-child { text-decoration: none; }
/* Surlignage au survol (.petal-experiment et voisins plus haut) essaye sur
   ces liens puis retire deux fois de suite (REVERT, demande utilisateur) :
   les 8 liens Sitemap/Get in touch n'ont plus de boite du tout desormais,
   seulement l'effet magnetique (setupMagneticFooterLinks() dans app.js,
   meme selecteur .foot-magnetic) — meme traitement que .hero__gap plus haut
   (pas de boite, pas de soulignement, juste le mouvement). Le soulignement
   est deja retire plus haut (.foot .u-underline / .foot .u-arrow-link >
   span:first-child) : cette regle-ci ne pose donc que la transition
   `transform`, meme rebond reduit que .petal-experiment et voisins. */
.foot .foot-magnetic { transition: color .2s ease-out, transform .5s cubic-bezier(.34, 1.15, .64, 1); }
.foot .foot-magnetic:focus-visible { transform: translateY(-2px); }
/* Un peu plus d'air entre l'adresse mail et l'icone "send" (demande
   utilisateur) : le 3px de .u-arrow-link__icon (pense pour une fleche fine)
   serrait un peu plus visiblement contre le point final d'une adresse mail. */
.foot-mail .u-arrow-link__icon { margin-left: 6px; }
/* Interaction "cliquer pour copier" du lien mail (demande utilisateur, cf.
   .link-muted.cursor-pointer sur krystianzun.com) — voir setupFootMailCopy()
   dans app.js pour la logique JS et buildFooter() pour le marquage HTML.

   .foot-mail__sizer / .foot-mail__visible : le sizer (invisible, en flux
   normal) rejoue l'etat par defaut (adresse + icone) et fixe la largeur de
   TOUTE L'ANCRE ; .foot-mail__visible, superpose dessus en position
   absolute, porte le contenu reel (seul le texte a l'interieur est modifie
   par JS). Sans ce sizer, "Click to copy"/"Copied" (plus courts que
   l'adresse) retreciraient la zone de survol a chaque changement d'etat —
   la souris se retrouverait hors zone, ce qui referme l'etat, ce qui
   elargit la zone, ce qui la fait rentrer dedans... un clignotement en
   boucle a la frontiere, exactement le glitch reproductible sur le site de
   reference. Largeur figee sur l'etat par defaut (le plus long des trois) :
   quel que soit l'etat affiche, la zone de survol/clic reste toujours celle
   de l'adresse mail.
   .foot-mail__visible reste en flex (herite de .u-arrow-link, pas de
   justify-content : les enfants se collent a gauche par defaut) : l'icone
   colle donc toujours au texte actuellement affiche avec le meme ecart
   qu'au repos (demande utilisateur) — seul l'espace inutilise quand le
   texte est plus court que l'adresse atterrit APRES l'icone, jamais entre
   elle et le texte. */
.foot-mail { position: relative; }
.foot-mail__sizer { visibility: hidden; display: inline-flex; align-items: center; white-space: nowrap; }
.foot-mail__visible { position: absolute; inset: 0; display: inline-flex; align-items: center; white-space: nowrap; }
/* sendIcon et copyIcon (dans .foot-mail__visible uniquement — le sizer ne
   porte que sendIcon, les deux ayant la meme taille peu importe l'icone
   choisie pour reserver la largeur) sont tous les deux dans le DOM en
   permanence, empiles dans la meme cellule de grille : le CSS choisit
   lequel est visible, jamais le JS — un simple fondu opacite, memes
   proprietes que le survol/focus habituel de .u-arrow-link__icon (opacite
   estompee -> pleine, meme transition). */
.foot-mail__visible .u-arrow-link__icon { display: grid; }
.foot-mail__icon-arrow, .foot-mail__icon-copy { grid-area: 1 / 1; transition: opacity .2s ease-out; }
.foot-mail__icon-copy { opacity: 0; }
@media (hover: hover) and (pointer: fine) {
  .foot-mail:hover .foot-mail__icon-arrow,
  .foot-mail:focus-visible .foot-mail__icon-arrow { opacity: 0; }
  .foot-mail:hover .foot-mail__icon-copy,
  .foot-mail:focus-visible .foot-mail__icon-copy { opacity: 1; }
}
/* Pas de survol sur mobile/tactile (demande utilisateur) : la fleche "send"
   est carrement retiree, l'icone copie reste affichee en permanence — meme
   opacite au repos que la fleche partout ailleurs (heritee du parent
   .u-arrow-link__icon, inchangee ici).
   Specificite (0,3,0) plutot que .foot-mail__icon-arrow seul (0,1,0) :
   .u-arrow-link__icon svg { display: block } (regle generique, plus haut
   dans ce fichier) est (0,1,1) et gagnerait sinon malgre l'ordre — la fleche
   restait affichee, empilee sur l'icone copie (bug constate : "les deux
   icones s'affichent l'une sur l'autre" sur mobile). */
@media (hover: none), (pointer: coarse) {
  .foot-mail__visible .u-arrow-link__icon .foot-mail__icon-arrow { display: none; }
  .foot-mail__icon-copy { opacity: 1; }
}
/* Confirmation "Copied" (demande utilisateur) : sur desktop, .is-copied peut
   rester affichee apres que la souris a quitte le lien (le delai de
   setupFootMailCopy() dans app.js continue de tourner). Sans ces regles,
   l'icone y retombait sur la fleche ET sur son opacite estompee au repos
   (.35, .u-arrow-link__icon) des la sortie du survol — copyIcon reste
   desormais pose en pleine opacite tant que "Copied" est affiche, souris
   dessus ou non. Memes specificites ((0,3,0) pour les deux premieres,
   (0,3,0) aussi pour la troisieme malgre l'ecart de longueur — deux classes
   composees puis un descendant dans les trois cas) que les regles
   .foot-mail:hover equivalentes, definies plus bas dans ce fichier : la
   cascade les fait gagner sans avoir besoin de plus de poids. Rien a faire
   cote mobile, deja permanent (regle au-dessus). */
.foot-mail.is-copied .foot-mail__icon-arrow { opacity: 0; }
.foot-mail.is-copied .foot-mail__icon-copy { opacity: 1; }
.foot-mail.is-copied .u-arrow-link__icon { opacity: 1; }
/* Revenu dans la premiere colonne de .foot__grid, sous le lien "Resume"
   (demande utilisateur). margin-top: auto (et non une valeur fixe) :
   dans le flex column de cette colonne (voir .foot__grid > div:first-child
   ci-dessus), ca pousse .foot__bottom tout au fond de la colonne — 0px
   d'ecart avec son bord bas — quelle que soit la hauteur que la grille lui
   donne, plutot que de fixer arbitrairement l'air au-dessus de lui. */
.foot__bottom {
  display: flex; flex-direction: column;
  margin-top: auto;
  font-size: var(--fs-tiny); color: var(--ink-faint);
}
/* Plus de <br> entre "© 2026 Marvin S." et "All rights reserved." (demande
   utilisateur) : les deux tiennent sur la meme ligne, separes par ce petit
   espace plutot que par un saut de ligne. */
.foot__rights { margin-left: var(--s2); }

/* ==========================================================================
   11. TRANSITIONS DE PAGE
   --------------------------------------------------------------------------
   Deux mecanismes, du plus moderne au plus compatible :
     - si le navigateur connait l'API View Transitions, app.js l'utilise et le
       navigateur anime le fondu tout seul ;
     - sinon, on retombe sur l'animation CSS ci-dessous.
   ========================================================================== */

@keyframes pageIn {
  from { opacity: 0; transform: translateY(10px); }
  to   { opacity: 1; transform: translateY(0); }
}
main.is-entering { animation: pageIn .38s var(--ease) both; }

/* ::view-transition-* sont des pseudo-elements generes par le navigateur
   pendant la transition ; on ne fait que regler leur duree. */
::view-transition-old(root),
::view-transition-new(root) { animation-duration: .3s; }


/* ==========================================================================
   11 bis. L'OUVERTURE EN FICHE DES ETUDES DE CAS
   --------------------------------------------------------------------------
   COMMENT CA MARCHE, EN UNE PHRASE : le site reste une SPA a une seule page,
   mais quand on ouvre une etude de cas, on garde une PHOTO de la page qu'on
   quitte, on la floute derriere, et on fait monter le nouveau contenu
   par-dessus. Rien n'est reellement "ouvert au-dessus" de quoi que ce soit.

   LES TROIS PIECES
     .underlay   calque fixe, plein ecran, DERRIERE tout (z-index 0). Il
                 contient le clone fige de la page precedente. Vide et
                 display:none le reste du temps.
     #page       le contenu vivant (main + pied de page). En mode fiche il
                 prend un fond blanc, des coins arrondis en haut et une
                 ombre : c'est lui, la "fiche".
     .overlay-close  la croix, en haut a droite, a l'interieur de la fiche.

   POURQUOI LE DEFILEMENT RESTE CELUI DU DOCUMENT
   Il aurait ete tentant de faire de la fiche un conteneur scrollable a part
   (position:fixed + overflow:auto). Ca casse tout le reste du site : la
   barre de progression, le scroll-spy de la nav laterale et son pourcentage
   lisent window.scrollY. La fiche reste donc un bloc normal dans le flux ;
   seule son APPARENCE change. Le calque du dessous, lui, est fixe : il ne
   bouge pas pendant qu'on lit, exactement comme une vraie fenetre modale.
   ========================================================================== */

/* --head-h vaut 82px sur <html> (voir syncHeadHeight dans app.js). En mode
   fiche l'en-tete du site est masque : on redefinit la variable ICI, sur
   <body>, ou elle l'emporte pour tout ce qui est en dessous. La nav laterale
   d'etude de cas vient donc se coller a 14px du haut — la meme valeur que le
   retrait de la fiche, pour que le bord blanc arrondi reste visible. */
body.is-overlay {
  --head-h: 14px;

  /* Deux elements fixes vivent HORS de la fiche et ne peuvent donc pas
     heriter de son theme clair : la barre de progression en haut de l'ecran
     et le lien d'evitement. Sur le bleu du site ils sont lime ; poses sur le
     blanc de la fiche, ce meme lime tombe a 1,34:1 — illisible (voir le
     commentaire de --accent tout en haut du fichier). On leur redonne ici
     l'accent du theme clair, la meme valeur que celle du bloc .theme-light. */
  --accent:     #2078F0;
  --accent-ink: #FFFFFF;
}

/* L'EN-TETE NE DISPARAIT PAS : IL EST RECOUVERT.
   Il appartient a la page qu'on laisse derriere ; la fiche passe par-dessus
   lui comme par-dessus le reste (#page monte a z-index 125 contre ses 120).

   Une version anterieure le masquait carrement (visibility:hidden) et en
   glissait une copie dans le calque photographie. Ca se voyait : il
   s'effaçait net a l'ouverture et revenait net a la fermeture — deux ruptures
   au milieu d'un mouvement cense etre continu. Il reste donc EN PLACE, au
   meme endroit, simplement cache derriere la fiche.

   position:fixed, et c'est le seul deplacement qu'on lui impose. Il est
   sticky et colle en haut de la fenetre des qu'on a defile : le fixer a
   top:0 ne change donc rien a ce qu'on voit, mais le sort du flux. Sans ça
   il reserverait sa hauteur au-dessus de la fiche, qui ne demarrerait plus a
   14px du bord mais 100px plus bas — la fiche ne couvrirait plus l'ecran.

   Il n'est ni assombri ni floute par lui-meme : le voile (.veil) passe
   devant lui et s'en charge, comme il le fait pour la photo. C'est tout
   l'interet de mettre le flou dans une couche a part — l'en-tete n'a rien de
   particulier a faire pour en faire partie, il lui suffit d'etre dessous.

   app.js le rend inerte pendant ce temps (attribut inert) : ce qui est
   DERRIERE un calque ne se clique pas, ne prend pas le focus et n'est pas
   annonce. Sans ça, la navigation mobile s'ouvrirait sous la fiche —
   .site-head porte un z-index qui l'y enferme. pointer-events:none double la
   mise pour les navigateurs sans inert. */
body.is-overlay .site-head {
  position: fixed; top: 0; left: 0; right: 0;
  pointer-events: none;
}

/* Le lien retour flottant fait doublon avec la croix : on le masque partout
   en mode fiche (app.js le neutralise aussi, ceinture et bretelles). */
body.is-overlay .back-link { display: none; }

/* ---- Le calque du dessous ---- */
.underlay { display: none; }

body.is-overlay .underlay {
  display: block;
  position: fixed; inset: 0; z-index: 0;
  overflow: hidden;
  pointer-events: none;
  /* Filet de securite : la photo remplit la fenetre, mais si elle manquait
     (calque vide une fraction d'image), c'est ce bleu nuit qu'on verrait, et
     non le bleu de marque du site — un fond de scene, pas une page. */
  background: #06162B;
}

/* La photo elle-meme : plein cadre, NETTE et non assombrie. Ni le flou ni
   l'assombrissement ne sont poses ici — ils appartiennent au voile, qui
   passe devant elle ET devant l'en-tete du site (voir .veil plus bas).

   Elle occupe TOUTE la fenetre, sans reduction ni coins arrondis. Une
   version anterieure la reduisait facon "carte posee en arriere" : ça
   decouvrait le fond sombre en bandes sur les cotes, ce qui ressemblait a
   un bug plutot qu'a de la profondeur.

   PAS DE MISE A L'ECHELLE NON PLUS, meme un leger agrandissement. Un scale
   se fait depuis le centre : tout ce qui n'y est pas glisse, vers le haut
   au-dessus, vers le bas en dessous. Quelques pixels seulement, mais la
   page derriere est censee etre RESTEE OU ELLE ETAIT — c'est ce qui vend
   l'idee qu'on pose quelque chose par-dessus elle. Le moindre glissement se
   voit et trahit le truc. Il ne reste donc que le flou et l'assombrissement,
   qui eux ne deplacent rien.

   REVENIR EN ARRIERE : remettre `transform: scale(1.03)` ici et une ligne
   transform dans veilIn / veilOut plus bas. */
.underlay__inner {
  position: absolute; inset: 0;
  overflow: hidden;
  background: var(--bg);
}

/* .underlay__shot est decale vers le haut de la position de defilement
   qu'avait la page au moment de la photo, pour montrer exactement ce que la
   personne regardait. */
.underlay__shot {
  position: absolute; top: 0; left: 0;
}

/* Les videos et les Lottie du clone ne rejouent pas (ni autoplay, ni JS) :
   elles s'afficheraient noires. app.js les remplace par une image figee de
   leur derniere frame (un <canvas>) ou, a defaut, par un aplat neutre.
   Les deux reprennent la taille exacte mesuree sur l'original. */
.underlay__frame { display: block; }
/* 100% par defaut : c'est ce qu'il faut a la page d'accueil RECONSTRUITE
   (seedUnderlay, quand on arrive directement sur l'URL d'une etude de cas),
   ou rien n'a encore ete mis en page et ou il n'y a donc aucune taille a
   mesurer. Quand on photographie une vraie page, freezeSnapMedia ecrase ces
   deux valeurs par les dimensions relevees sur l'element d'origine. */
.underlay__blank {
  display: block; width: 100%; height: 100%;
  background: rgba(255,255,255,.10); border-radius: var(--radius-sm);
}

/* ---- Le voile ----
   La vitre depolie. Elle ne contient rien : elle floute et assombrit tout ce
   qui est peint SOUS elle, c'est-a-dire la photo de l'accueil (z-index 0) et
   l'en-tete du site (120), qui se retrouvent traites ensemble, comme la
   seule et meme page qu'ils formaient avant l'ouverture.

   z-index 124, soit juste sous la fiche (125). Tout ce qui monte plus haut
   lui echappe et reste net, ce qui est voulu dans les trois cas : la barre
   de progression (130), le portail du prenom (140) et la croix (150).

   backdrop-filter et non filter. filter aurait floute le voile lui-meme —
   qui est vide, donc rien. backdrop-filter agit sur l'ARRIERE-PLAN, et c'est
   la seule facon d'atteindre d'un coup deux elements qui ne se touchent pas
   dans le DOM. Autrement il faudrait flouter chacun de son cote, avec deux
   animations a garder synchronisees et un raccord visible entre les deux.

   rgba(6,22,43,.18) reproduit exactement l'assombrissement precedent, quand
   la photo etait posee a 82 % d'opacite sur un fond #06162B : 18 % de ce
   meme bleu nuit par-dessus, c'est le meme calcul, en une couche au lieu de
   deux. */
.veil { display: none; }

body.is-overlay .veil {
  display: block;
  position: fixed; inset: 0; z-index: 124;
  pointer-events: none;
  -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px);
  background: rgba(6, 22, 43, .18);
}

/* ---- La fiche ---- */
body.is-overlay #page {
  /* z-index 125 : au-dessus de .site-head (120), qui reste affiche EN
     DESSOUS, a sa place. La fiche le recouvre au lieu de le remplacer. La
     croix (150), le portail du prenom (140) et la barre de progression (130)
     restent eux au-dessus de la fiche. */
  position: relative; z-index: 125;
  margin-top: 14px;                       /* le liser de photo floutee visible en haut */
  background: var(--bg);                  /* blanc : #page porte .theme-light */
  border-radius: 22px 22px 0 0;
  /* clip et NON hidden. Sans decoupage, un enfant qui va d'un bord a l'autre
     et porte un fond peint par-dessus les coins arrondis et les efface : sous
     1000px, .cs-nav devient exactement ça — une barre blanche pleine largeur
     posee au sommet de la fiche. C'etait la raison pour laquelle l'arrondi
     disparaissait sur tablette et mobile.
     hidden ferait de la fiche un conteneur de defilement : .cs-nav, qui est
     en position:sticky, se collerait alors a ELLE (qui ne defile pas) et ne
     suivrait plus la fenetre. clip decoupe sans creer ce conteneur. */
  overflow: clip;
  box-shadow: 0 -2px 60px -12px rgba(0,0,0,.55);
  /* Une etude de cas est toujours plus longue qu'un ecran, mais on garantit
     que la fiche remplit au moins la fenetre : sinon le fond sombre du
     calque apparaitrait sous le pied de page. */
  min-height: calc(100vh - 14px);
  /* SUR UNE FICHE COURTE, min-height ETIRE #page AU-DELA DE #main + #site-foot
     — sans ceci, le supplement restait un vide BLANC (le fond de #page,
     ci-dessus) sous le pied de page plutot que la couleur du pied de page lui
     -meme (essai, retour possible : demande utilisateur, "le gris du pied de
     page doit arriver en bas de l'ecran comme le bleu sur l'accueil"). En
     colonne flex, #main garde sa taille naturelle (aucun flex declare = 0 1
     auto) et #site-foot (flex: 1 0 auto plus bas) absorbe seul tout le
     supplement : le pied de page grandit, pas le vide au-dessus de lui. */
  display: flex; flex-direction: column;
}
body.is-overlay #site-foot { flex: 1 0 auto; }

/* ---- La croix ----
   position:fixed : elle reste accessible apres 4000px de defilement. */
.overlay-close { display: none; }

body.is-overlay .overlay-close {
  display: flex; align-items: center; justify-content: center;
  /* z-index 150 contre 125 pour #page : la croix est soeur de la fiche, donc
     elle passe au-dessus de TOUT son contenu d'un coup, .cs-nav comprise
     (qui monte a 100 quand elle devient une barre collee en haut sous
     1000px, mais reste enfermee dans le contexte d'empilement de #page). */
  position: fixed; z-index: 150;
  top: 26px; right: clamp(16px, 2.4vw, 30px);
  width: 42px; height: 42px;
  border-radius: 50%;
  color: var(--ink);
  /* Le liser a saute : l'ombre et le flou suffisent a detacher la croix du
     fond, et un trait fin la faisait lire comme un bouton de formulaire.
     Voir --glass dans la section 1 : la barre du mobile a le meme verre. */
  background: var(--glass);
  -webkit-backdrop-filter: var(--glass-blur); backdrop-filter: var(--glass-blur);
  box-shadow: var(--glass-shadow);
  /* Le fondu d'apparition est une ANIMATION, pas une transition — voir le
     bloc .is-opening / .is-closing plus bas, qui explique pourquoi.
     La transition ne garde que le survol, qui ne joue que sur un element
     deja visible et stable. */
  animation: crossIn .3s var(--ease) both;
  transition: background .2s var(--ease), transform .2s var(--ease);
}
body.is-overlay .overlay-close[hidden] { display: none; }

/* Le portail du prenom est un vrai dialogue modal (aria-modal="true"), et il
   s'ouvre par-dessus la fiche quand on arrive directement sur l'URL d'une
   etude de cas. Rien ne doit flotter au-dessus de lui : la croix, a
   z-index 150 contre 140 pour le portail, y passait — un bouton de sortie
   par-dessus une question a laquelle on n'a pas encore repondu.
   is-locked est posee sur <body> pendant toute la duree du portail. */
body.is-locked .overlay-close { display: none; }

/* La croix ne participe pas au mouvement : elle apparait une fois la fiche
   posee, et s'efface des que la fermeture commence. Elle est fixee a la
   fenetre, pas a la fiche — la laisser visible pendant la montee la ferait
   flotter, immobile, au-dessus d'un ecran encore vide.

   POURQUOI UNE ANIMATION ET NON UNE TRANSITION.
   Entre deux etudes de cas, la croix repasse par display:none (attribut
   hidden, pose par setupOverlayClose). Or une transition n'a rien a
   interpoler quand un element sort de display:none : il n'existe pas d'etat
   "avant" a partir duquel partir, donc rien ne s'anime et la croix
   apparaitrait d'un coup. Une animation nommee, elle, demarre normalement
   des qu'un element est rendu ; et passer de `animation: none` (etat masque)
   a `animation: crossIn` en relance une neuve a chaque ouverture. */
@keyframes crossIn { from { opacity: 0; } to { opacity: 1; } }

body.is-overlay.is-opening .overlay-close,
body.is-overlay.is-closing .overlay-close { animation: none; opacity: 0; }
body.is-overlay .overlay-close svg { width: 20px; height: 20px; }
body.is-overlay .overlay-close:hover { background: #FFFFFF; transform: scale(1.06); }

/* Un peu d'air sous la croix : le titre de l'etude de cas commence plus bas
   pour ne pas passer dessous sur les ecrans etroits. */
body.is-overlay .cs__head { padding-top: var(--s7); }
body.is-overlay .cs:has(.cs-nav) .cs__head { padding-top: var(--s7); }

/* ---- Les animations ----
   Deux mouvements simultanes, c'est tout l'effet : la fiche monte depuis le
   bas de l'ecran, la photo recule et se floute. translateY(100vh) et non
   100% : #page mesure plusieurs milliers de pixels, un pourcentage le
   projetterait tres loin hors de l'ecran et l'arrivee paraitrait instantanee.

   La courbe (.16, 1, .3, 1) demarre franchement et se pose tres doucement.
   C'est ce freinage long qui donne la sensation de poids. */
@keyframes sheetIn  { from { transform: translateY(100vh); } to { transform: translateY(0); } }

/* LA SORTIE N'EST PAS LA MONTEE A L'ENVERS, et ce n'est pas un choix de
   style — c'est une contrainte de geometrie.

   A l'ouverture, on part toujours du haut de la fiche : la descendre d'une
   hauteur d'ecran suffit a la sortir du champ. A la fermeture, non : on peut
   etre a 2000px dans une etude de cas. Le haut de la fiche est alors bien
   au-dessus de la fenetre, et il faudrait la faire descendre de 2700px pour
   liberer l'ecran. translateY(100vh) n'en deplaçait que 695 : l'animation se
   terminait avec l'etude de cas encore en place, puis le contenu etait
   remplace d'un coup. C'etait ça, le saut.

   Une distance calculee en JS reglerait le probleme mais ferait filer la
   fiche a plusieurs milliers de pixels par seconde. On combine donc un
   glissement court — assez pour lire "la fiche s'en va" — et un fondu qui,
   lui, aboutit quel que soit le defilement. La page floutee en dessous se
   denoue en meme temps : c'est elle qui reste. */
@keyframes sheetOut {
  from { transform: translateY(0);    opacity: 1; }
  to   { transform: translateY(22vh); opacity: 0; }
}

/* Le voile se depolit. Une seule animation la ou il y en avait deux (le flou
   sur la photo, l'assombrissement sur le cadre, plus une troisieme sur
   l'en-tete) : elles portaient sur des elements differents et devaient etre
   tenues ensemble a la main.

   Le rayon est anime, pas l'opacite du voile. Un voile qu'on fait apparaitre
   en fondu montre un flou deja complet a travers un calque translucide : le
   decor est net puis brouille, avec un fondu enchaine entre les deux. En
   animant le rayon, il se brouille VRAIMENT, progressivement. C'est le
   mouvement d'une mise au point qu'on perd, et c'est ce qu'on veut ici.

   Les deux ecritures de backdrop-filter sont reprises dans les keyframes :
   sans la prefixee, Safari lit bien la valeur au repos mais n'a rien a
   interpoler pendant l'animation. */
@keyframes veilIn {
  from { -webkit-backdrop-filter: blur(0px);  backdrop-filter: blur(0px);
         background-color: rgba(6, 22, 43, 0); }
  to   { -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px);
         background-color: rgba(6, 22, 43, .18); }
}
@keyframes veilOut {
  from { -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px);
         background-color: rgba(6, 22, 43, .18); }
  to   { -webkit-backdrop-filter: blur(0px);  backdrop-filter: blur(0px);
         background-color: rgba(6, 22, 43, 0); }
}

/* L'entree de la barre flottante du mobile, une fois la fiche posee. Voir la
   note dans la section 12, qui explique pourquoi elle ne peut pas monter avec
   elle. Meme mouvement que la fiche, en plus court : elle vient du bas.

   ELLE SERT AUX DEUX BARRES, et c'est volontaire. La pilule du site (.site-nav
   sous 860px) est retiree pendant toute la duree d'une fiche et doit revenir
   quand celle-ci se referme : meme hauteur, meme retrait de 20px, meme place a
   l'ecran, donc exactement le meme translateY pour les sortir de la fenetre.
   Ecrire une seconde animation identique aurait garanti qu'un jour l'une des
   deux serait retouchee sans l'autre — et le raccord entre les deux barres,
   qui se relaient au meme endroit, tient precisement a ce qu'elles bougent
   pareil. Voir body.is-returning .site-nav, section 12. */
@keyframes csBarIn {
  from { transform: translateY(calc(100% + 20px)); opacity: 0; }
  to   { transform: none;                          opacity: 1; }
}

/* .is-opening (ouverture) et .is-closing (fermeture) sont posees puis
   retirees par app.js.

   PAS de will-change ici, contrairement au reflexe habituel. Une etude de
   cas mesure plus de 6000px de haut : demander au navigateur de promouvoir
   #page sur sa propre couche graphique revient a lui faire allouer une
   texture de cette taille, pour 600ms d'animation. Un transform anime
   declenche deja la promotion, et le navigateur, lui, sait la limiter a ce
   qui est visible. */
body.is-overlay.is-opening #page { animation: sheetIn .62s cubic-bezier(.16, 1, .3, 1) both; }
body.is-overlay.is-closing #page { animation: sheetOut .38s cubic-bezier(.3, 0, .3, 1) both; }

body.is-overlay.is-opening .veil { animation: veilIn  .62s cubic-bezier(.16, 1, .3, 1) both; }
body.is-overlay.is-closing .veil { animation: veilOut .38s cubic-bezier(.3, 0, .3, 1) both; }

/* Le fondu de page habituel (pageIn) est neutralise pour l'ouverture et la
   fermeture — mais cote JS, en ne posant pas la classe .is-entering (voir
   swap() dans app.js). L'eteindre ici avec `animation: none` paraissait plus
   simple et etait un piege : la regle cesse de s'appliquer avec la classe
   .is-opening, et l'animation repart alors de son premier keyframe. */


/* ==========================================================================
   12. RESPONSIVE
   --------------------------------------------------------------------------
   La plupart des reglages s'adaptent seuls grace a clamp() et auto-fit. Il ne
   reste que les points ou la STRUCTURE doit changer.
   ========================================================================== */

/* --- Sous 1000px : la nav laterale de case study passe au-dessus ------- */
@media (max-width: 1000px) {
  /* minmax(0, 1fr), pas 1fr nu : une piste 1fr a un minimum automatique base
     sur le contenu (comme min-width:auto en flexbox). Un enfant qui refuse de
     retrecir sous sa taille "naturelle" (ex. .cs-brands, width: fit-content)
     forcait alors TOUTE la colonne — donc la page entiere — a deborder du
     viewport mobile au lieu de se contenter de wrap/retrecir localement.
     minmax(0, 1fr) autorise la piste a descendre a 0, ce qui laisse le debordement
     se resoudre au bon endroit (wrap ou retrecissement) plutot que de se
     propager jusqu'a la grille elle-meme. */
  .cs__body { grid-template-columns: minmax(0, 1fr); }

  /* --cs-nav-inset a 0 (voir .cs plus haut) : la nav laterale quitte la
     grille ci-dessus, la colonne de contenu commence donc au bord gauche —
     .cs__cases-intro/.cs__case-switch n'ont plus besoin de decaler leur
     boite pour s'aligner sur elle. */
  .cs { --cs-nav-inset: 0px; }

  /* Essai, retour possible : le grand padding-bottom du bureau (var(--s9),
     section 8) laissait un vide entre la fin de la fiche (.cs-next) et
     #site-foot qui la suit toujours dans #page (index.html) — sur bureau il
     compensait la barre laterale collante, mais ici la pilule/croix flotte
     DEJA au-dessus du contenu sans lui reserver de place. On retombe donc sur
     var(--s8) : le meme ecart que celui de l'accueil entre la derniere carte
     et le pied de page (.section, padding-block: --s8, section 7), auquel
     s'ajoute le padding-top propre de #site-foot (--s8 egalement, section 11)
     — meme empilement de deux paddings des deux cotes de la frontiere, meme
     rythme qu'a l'accueil. */
  .cs { padding-bottom: var(--s8); }

  /* Le bouton "Retour" repasse dans le flux normal. En reste fixe, il
     recouvrirait la barre de sections qui vient se coller sous l'en-tete.
     Il defile donc avec la page sur petit ecran — compromis assume : mieux
     vaut un bouton qui s'eloigne qu'un bouton qui masque la navigation. */
  .back-link {
    position: static;
    margin: var(--s5) var(--gutter) 0;
    -webkit-backdrop-filter: none; backdrop-filter: none;
  }
  .cs__head, .editorial { padding-top: var(--s6); }
  /* .editorial--gap (section 9) reduit deja ce padding-top a --s6 en
     permanence, en supposant que le bouton Back vit dans sa propre colonne de
     nav (.editorial__gapgrid) qui ne recouvre jamais le titre. Sous 1000px
     cette colonne disparait (section 9, .editorial__gapnav { display: none })
     et le bouton redevient flottant par-dessus le contenu (meme
     body.route-gap .back-link, position: fixed, section 9) — il lui faut donc
     le meme degagement que .editorial de base (calc(--back-h + --s6)),
     sans quoi il atterrit sur le titre. */
  body.route-gap .editorial--gap { padding-top: calc(var(--back-h) + var(--s6)); }

  /* --- LA BARRE FLOTTANTE DU BAS (maquette Figma, noeud 284:2290) --------
     Sous 1000px, le sommaire quittait le flux pour se coller EN HAUT de la
     fiche, sous l'en-tete. Deux raisons de le descendre :

       - l'en-tete du site est inerte tant qu'une fiche est ouverte (voir
         section 11 bis) ; toute cette bande haute etait donc du decor mort,
         alors que le bas de l'ecran est la seule zone qu'un pouce atteint ;
       - c'etait une barre pleine largeur posee au sommet de la fiche, donc
         exactement l'element qui repeignait par-dessus ses coins arrondis —
         la raison d'etre du overflow:clip de #page.

     Elle devient donc une pilule flottante, avec le bouton retour sorti a sa
     gauche en pastille ronde. La liste, elle, ne change pas de nature : elle
     defile toujours horizontalement (la maquette le demande explicitement),
     et revealInStrip (app.js) la fait deja glisser jusqu'a l'etape lue.

     position:fixed et non sticky : la barre doit rester au bas de la FENETRE
     pendant 4000px de defilement. Elle vit dans #page, dont le contexte
     d'empilement (z-index 125) la maintient sous la croix (150) et sous le
     portail du prenom (140) — c'est voulu dans les deux cas. Pendant les
     animations d'ouverture et de fermeture, #page porte un transform et
     devient donc son bloc conteneur : la barre monte avec la fiche, puis se
     recale sur la fenetre. C'est le mouvement qu'on veut de toute facon. */
  .cs-nav {
    /* UNE SEULE HAUTEUR POUR LES DEUX OBJETS. La pastille de retour et la
       pilule doivent faire exactement la meme taille, et la pilule est le
       cote fragile : sa hauteur suivrait sinon la police, l'interligne et la
       presence ou non de la barre de progression sous l'entree active — trois
       choses qui bougent. On la fixe donc ici, et le contenu se centre
       dedans. Changer la taille du texte ne desaligne plus rien. */
    --cs-bar-h: 56px;
    /* La taille de l'anneau de progression. Une variable et non deux valeurs
       en dur : elle dimensionne a la fois la boite et le svg qu'elle contient
       (.cs-nav__ring plus bas). */
    --cs-ring: 22px;

    position: fixed; z-index: 100;
    left: var(--gutter); right: var(--gutter);
    /* env(safe-area-inset-bottom) : la barre d'accueil des iPhone recents
       mange le bas de l'ecran. Le repli 0px sert partout ailleurs. */
    bottom: calc(20px + env(safe-area-inset-bottom, 0px));
    display: flex; align-items: center; gap: var(--s3);
    /* Annule la colonne collante du bureau. */
    top: auto; max-height: none; overflow: visible;
    padding: 0;
  }
  /* Un projet fusionne (.cs__case-switch) pose `.cs:has(.cs__case-switch)
     .cs-nav { top: var(--s4) }` plus haut (section 8b), pour aligner "Back"
     sur les onglets une fois les deux figes en haut sur bureau. Meme
     specificite (0,3,0) que cette regle-ci : sans ce reset, elle l'emportait
     encore sous 1000px et etirait la boite fixed de la barre flottante entre
     --s4 (haut) et bottom (bas) au lieu de la laisser compacte a --cs-bar-h
     — une boite quasi plein ecran, transparente mais toujours cliquable, qui
     bloquait toute la page. */
  .cs:has(.cs__case-switch) .cs-nav { top: auto; }

  /* LES DEUX FONDUS AUX EXTREMITES DE LA LISTE : voir .cs-nav ol plus bas.
     Ce sont deux masques poses sur la liste elle-meme, plus deux voiles poses
     par-dessus. Historique, parce que la difference n'est pas evidente et que
     la premiere version etait fausse : deux pseudo-elements de .cs-nav
     peignaient un degrade de --glass au-dessus de la pilule pour effacer ce
     qui defilait dessous. Or --glass est translucide : par-dessus le verre il
     en fait un SECOND, et le fondu se voyait comme un rectangle plus clair,
     avec un bord net, au milieu de la pilule — sur un fond sombre surtout. Il
     fallait en plus les caler par calcul sur tout ce qui precede la liste (la
     pastille, l'ecart, l'anneau et sa marge), un calcul a refaire a chaque
     changement de mise en page. Un masque, lui, efface le contenu au lieu de
     le recouvrir : rien a repeindre, rien a caler, et il suit la liste. */

  /* Le lien retour devient la pastille ronde de la maquette. font-size:0
     efface le libelle "Back" a l'oeil SANS le retirer de l'arbre
     d'accessibilite : il reste le nom du lien pour un lecteur d'ecran. */
  /* .cs-nav a.cs-nav__back et non .cs-nav__back seul : le bouton retour EST un
     <a> dans .cs-nav, donc la regle generique .cs-nav a (0,1,1) l'emporterait
     sur une classe nue (0,1,0) — et lui rendrait sa taille de texte, donc son
     libelle visible. */
  .cs-nav a.cs-nav__back {
    flex: 0 0 auto;
    /* display:flex et NON la grille de .cs-nav a : ce bouton-ci ne porte pas de
       barre de progression, juste une icone et un libelle efface. En grille a
       une colonne, les deux s'empilent sur deux rangees et l'icone remonte
       au-dessus du centre du cercle. En rangee flex, elle est seule a occuper
       de la place, donc centree. */
    display: flex;
    width: var(--cs-bar-h); height: var(--cs-bar-h); margin-bottom: 0;
    align-items: center; justify-content: center;
    /* Le libelle reduit a font-size:0 reste un item flex de largeur nulle : en
       gardant le gap du bureau, l'icone serait decalee d'un demi-gap. */
    gap: 0;
    border-radius: 50%;
    font-size: 0;
    color: var(--ink);
    background: var(--glass);
    -webkit-backdrop-filter: var(--glass-blur); backdrop-filter: var(--glass-blur);
    box-shadow: var(--glass-shadow);
  }
  /* UNE CROIX, PAS UNE FLECHE. Sous 1000px cette pastille est la seule sortie
     de la fiche — la vraie croix (.overlay-close) est masquee juste en dessous
     — et ce qu'elle fait, c'est fermer l'etude de cas, pas reculer d'un cran
     dans l'historique. Elle en porte donc le dessin, repris trait pour trait
     de la croix de index.html (meme trace, meme epaisseur, memes bouts ronds)
     via cs-close.svg : les deux ne sont jamais visibles en meme temps, mais
     elles doivent etre la meme icone.
     Pose en masque plutot qu'en <img> : le masque ne garde que la forme et la
     peint avec currentColor, donc l'icone suit la couleur du lien (survol
     compris) sans second fichier.
     Le chemin est relatif a CE fichier, dans css/ — d'ou le ../ */
  .cs-nav__back span:first-child {
    flex: 0 0 auto;
    width: 18px; height: 18px;
    background: currentColor;
    -webkit-mask: url('../assets/icons/cs-close.svg') center / contain no-repeat;
            mask: url('../assets/icons/cs-close.svg') center / contain no-repeat;
  }
  .cs-nav__back:hover { transform: none; }
  /* La pastille mobile n'est plus une fleche mais une croix centree dans un
     cercle (voir masque plus haut) : l'animation "la fleche s'eloigne du
     libelle" du bureau n'a pas de sens ici, donc on l'annule. */
  .cs-nav__back:hover > span:first-child { transform: none; }

  /* LA CROIX S'EFFACE DEVANT LA PASTILLE DE RETOUR — SUR MOBILE SEULEMENT.
     Ici, les deux font le meme travail : la pastille ci-dessus est le lien
     "Back" de .cs-nav, qui ramene a la section d'ou l'on vient. Garder les
     deux, c'etait une sortie en haut a droite et une autre en bas a gauche,
     alors que toute la navigation de la fiche est justement passee en bas.
     Sur bureau la croix reste : la colonne y range son lien "Back" tout en
     haut, qui defile et sort de l'ecran, alors que la croix est fixe.

     MAIS PAS SUR UNE FICHE SANS SOMMAIRE. Une etude sans sections de
     processus (masters-essay) n'a pas de .cs-nav, donc pas de lien "Back" ;
     le bouton flottant #back-link est masque sur toutes les fiches
     (setupBackLink) et l'en-tete du site est inerte derriere. Sans la croix,
     il ne resterait pour sortir que le bouton "precedent" du navigateur. Le
     :has() la laisse donc exactement la ou elle est le seul chemin.

     :has() sur <body> et non un selecteur de voisinage : la croix est placee
     AVANT #page dans index.html, donc aucun combinateur ne permet de remonter
     jusqu'a elle depuis .cs-nav.

     .is-overlay est repris dans le selecteur pour la specificite, pas pour le
     sens : `body:has(...)` seul pese (0,2,1) — :has() prend celle de son
     argument — soit exactement autant que `body.is-overlay .overlay-close`
     (section 11 bis), qui rallume la croix. A egalite c'est elle qui gagnait,
     en arrivant apres. (0,3,1) tranche. */
  body.is-overlay:has(.cs-nav) .overlay-close { display: none; }

  /* LA BARRE NE PEUT PAS ETRE A L'ECRAN PENDANT L'OUVERTURE, et il faut en
     tenir compte plutot que l'ignorer. #page porte un transform tant que dure
     sheetIn ; un ancetre transforme devient le referentiel de ses descendants
     en position:fixed, donc "20px au-dessus du bas de la fenetre" devient
     "20px au-dessus du bas de la fiche", soit plusieurs milliers de pixels
     sous l'ecran. La croix y echappe parce qu'elle est SOEUR de #page ; la
     barre, elle, vit dedans, avec les liens qu'elle designe.

     Laissee telle quelle, elle etait donc absente pendant les 620ms de
     l'ouverture puis apparaissait d'un coup. On assume le decalage et on en
     fait une entree : masquee tant que la fiche monte, puis posee une fois
     qu'elle est arrivee. Le :not() est ce qui declenche l'animation — une
     animation demarre quand son selecteur COMMENCE a s'appliquer, donc au
     retrait de .is-opening par app.js. Sur une arrivee directe (aucune
     animation de fiche), le selecteur s'applique d'emblee et la barre entre
     tout de suite. */
  body.is-overlay.is-opening .cs-nav,
  body.is-overlay.is-closing .cs-nav { visibility: hidden; }
  /* backwards et non both. `both` laisserait la derniere frame de csBarIn
     s'appliquer indefiniment, or une animation l'emporte sur une declaration
     ordinaire : le transform de .is-gone juste en dessous n'aurait aucun
     effet et la barre ne se retirerait jamais. `backwards` ne pose que
     l'etat de depart, avant le demarrage — l'etat d'arrivee etant deja
     l'etat naturel (aucun transform, opacite pleine), rien n'est perdu. */
  body.is-overlay:not(.is-opening) .cs-nav { animation: csBarIn .34s var(--ease) backwards; }

  /* LA PILULE SE RETIRE SUR "PROJET SUIVANT" — LA CROIX RESTE (essai, retour
     possible). Passe la derniere section, on ne parcourt plus l'etude de cas :
     le sommaire n'a plus rien a designer et un anneau/liste plaque par-dessus
     les cartes du projet suivant ne dirait plus rien. Mais SEULE la pilule
     (.cs-nav__pill : anneau + liste) se retire ; .cs-nav__back reste en place
     jusqu'au bas de la page, glace comprise — sous ce gabarit c'est la SEULE
     sortie de la fiche (.overlay-close y est masque, voir plus haut), la
     perdre a ce point laisserait l'utilisateur sans moyen de fermer.
     Posee par setupCaseBehaviours (app.js), qui surveille .cs-next au meme
     titre que les sections, et retiree des qu'on remonte. */
  .cs-nav.is-gone .cs-nav__pill {
    opacity: 0; pointer-events: none; transform: translateX(-8px);
  }
  .cs-nav__pill { transition: opacity .3s var(--ease), transform .3s var(--ease); }

  /* La pilule. Blanche sur une fiche blanche : c'est l'ombre SEULE qui lui
     donne un bord, sans liser — un trait ferme la forme et la fait lire comme
     un champ, la ou on veut un objet pose au-dessus de la page. (La maquette
     prevoit a terme un fond de fiche legerement teinte, qui la detacherait
     encore mieux — pas encore fait.)

     Le retrait horizontal est plus large que le vertical : la liste defile,
     et une entree coupee net au ras du bord arrondi se lirait comme la fin de
     la liste. L'air sur les cotes montre au contraire qu'il y a une suite.

     LE VERRE EST PORTE PAR .cs-nav__pill, PAS PAR LA LISTE. La liste est le
     conteneur de defilement ; tout ce qu'on y range defile avec les entrees.
     L'anneau de progression devant rester plante a gauche, il est son VOISIN
     dans ce conteneur — et non un enfant plante en position:sticky, qui aurait
     laisse les entrees passer derriere lui : deux couches de --glass l'une sur
     l'autre font une tache plus claire au milieu de la pilule. Ici les entrees
     defilent A COTE de l'anneau, jamais dessous. */
  .cs-nav__pill {
    flex: 1 1 auto; min-width: 0;
    height: var(--cs-bar-h);
    display: flex; align-items: center;
    border-radius: 999px;
    /* Le meme verre que la croix (--glass, section 1) : les deux flottent
       au-dessus de la meme fiche, ils ne peuvent pas etre en deux matieres. */
    background: var(--glass);
    -webkit-backdrop-filter: var(--glass-blur); backdrop-filter: var(--glass-blur);
    box-shadow: var(--glass-shadow);
  }

  /* L'ANNEAU DE PROGRESSION (maquette Figma, noeud 295:2356).
     Il ne remplace rien : sous 1000px la barre flottante ne montrait plus du
     tout l'avancement dans l'etude (le rail du bureau y est masque, et le
     soulignement par section est en pause — voir plus bas), il ne restait que
     le filet de 2px en haut de la fenetre, qu'on ne voit pas en lisant.

     Son retrait a gauche vaut l'ancien retrait de la liste : c'est lui qui
     tient desormais l'air contre le bord arrondi de la pilule.

     Sa marge de DROITE, elle, est le seul espace ou une entree ne peut pas
     entrer : elle repousse la boite du <ol>, donc le bord de la zone de
     defilement. Le retrait interne de la liste, lui, est A L'INTERIEUR de
     cette zone — un libelle qui defile passe dessous, simplement estompe par
     le fondu. Sans cette marge, il finissait donc contre l'anneau. */
  .cs-nav__ring {
    flex: 0 0 auto;
    display: grid; place-items: center;
    margin-inline: var(--s5) var(--s4);
    width: var(--cs-ring); height: var(--cs-ring);
  }
  /* display:block explicite : il n'existe aucune regle de base pour svg dans
     cette feuille, et la valeur inline de l'agent utilisateur ajouterait
     l'espace sous la ligne de base, qui decentrerait l'anneau dans la pilule.
     La rotation fait partir le trace de midi plutot que de 3 heures. */
  .cs-nav__ring svg {
    display: block; width: 100%; height: 100%;
    overflow: visible;                  /* le linecap arrondi deborde du viewBox */
    transform: rotate(-90deg); transform-origin: center;
  }
  /* Le trace prend le meme jeton que le rail du bureau (.cs-nav__bar) : les
     deux disent la meme chose, ils ne peuvent pas etre de deux couleurs.
     --accent vaut #2078F0 dans le theme clair — le bleu de la maquette.
     Le fond, lui, monte d'un cran par rapport au rail (--line et non
     --line-soft) : un filet de 2px sur 200px de haut se lit meme a 9 %
     d'opacite, un cercle de 22px n'offre pas la meme surface, et sans lui
     l'anneau vide n'est plus qu'un point flottant au depart de la lecture. */
  .cs-nav__ring-track,
  .cs-nav__ring-bar { fill: none; stroke-width: 2; stroke-linecap: round; }
  .cs-nav__ring-track { stroke: var(--line); }
  .cs-nav__ring-bar   { stroke: var(--accent); }

  .cs-nav ol {
    flex: 1 1 auto; min-width: 0;
    height: 100%;
    display: flex; gap: var(--s2); align-items: center;
    /* A gauche, un cran de moins : c'est la marge droite de l'anneau qui tient
       maintenant l'ecart, et elle le tient MIEUX — rien ne peut y defiler.
       Les deux additionnees redonnent le meme air qu'a droite. */
    padding-inline: var(--s4) var(--s5);
    /* Pas de scroll-padding : il servait a empecher un calage sur `start` de
       coller la premiere entree au bord. Le calage est maintenant `center`,
       plus rien ne bute contre un bord — et un retrait ASYMETRIQUE decalerait
       le centre de la zone de calage de la moitie de sa difference, soit 4px
       de desaccord permanent avec revealInStrip, qui vise le centre reel. */
    /* La liste va jusqu'au bord droit de la pilule : sans cet arrondi, une
       entree qui defile depasserait dans le coin arrondi du verre. */
    border-radius: 999px;
    overflow-x: auto;
    scrollbar-width: none;              /* masque la barre (Firefox) */
    scroll-snap-type: x proximity;      /* chaque element s'aligne proprement */
    /* LES DEUX FONDUS. Une entree qui sort de la liste est coupee net par
       overflow ; le masque la fait disparaitre en fondu a la place.
       Les deux paliers valent EXACTEMENT les deux retraits internes : le
       masque est donc opaque partout ou un libelle se pose a l'arret, et ne
       degrade que la bande ou il ne fait que passer. Les repeter en dur les
       ferait diverger du padding a la premiere retouche.
       Le masque se cale sur la boite de la liste et ne defile pas avec son
       contenu — c'est ce qui rendait un pseudo-element de la liste inutilisable
       ici, et ce qui obligeait a le poser sur .cs-nav. */
    -webkit-mask-image: linear-gradient(to right, transparent 0, #000 var(--s4),
                        #000 calc(100% - var(--s5)), transparent 100%);
    mask-image: linear-gradient(to right, transparent 0, #000 var(--s4),
                #000 calc(100% - var(--s5)), transparent 100%);
  }
  .cs-nav ol::-webkit-scrollbar { display: none; }
  /* center et non start : revealInStrip (app.js) CENTRE l'etape en cours dans
     la pilule. Avec un calage sur `start`, le calage de proximite reprenait la
     main juste apres et ramenait l'entree contre le bord gauche — les deux se
     battaient, et c'est le calage qui gagnait, puisqu'il agit en dernier.
     Ils disent maintenant la meme chose : le doigt qui s'arrete pres du centre
     y est termine par le navigateur, exactement ou le scroll-spy l'aurait
     pose. */
  .cs-nav li { scroll-snap-align: center; }

  /* Une grille plutot qu'une rangee flex : la barre de progression peut ainsi
     occuper une SECONDE ligne sous le libelle, sans avoir a l'emballer dans un
     element de plus. */
  .cs-nav a {
    display: grid; grid-template-columns: auto;
    align-items: center; justify-content: center;
    gap: 3px;
    /* Le libelle passe a la taille du texte courant : a 15px il etait plus
       petit que le corps de l'etude de cas qu'il sert a parcourir, alors qu'il
       se lit du coin de l'oeil, en mouvement, au pouce.
       L'interligne descend a 1.2 : celui de lecture (1.6) est fait pour des
       paragraphes, et gonflerait chaque entree d'une dizaine de pixels pour
       une ligne unique. La hauteur de la pilule etant fixe, ce padding ne
       regle plus que la pastille de survol. */
    padding: 6px var(--s3);
    font-size: var(--fs-body); line-height: 1.2;
    white-space: nowrap;
    border-radius: var(--radius-pill);
  }
  .cs-nav a:hover { background: var(--veil-strong); }

  /* LA PILULE BLEUE PASSE DU TOUCHER A L'ETAT ACTIF, ET SUR CETTE MISE EN PAGE
     SEULEMENT. La section 8b l'interdit sur le bureau, et pour une raison qui
     tient toujours la-bas : le fond gris y appartient au survol, et le porter
     aussi sur l'entree active mettait deux pilules a l'ecran des qu'on survolait
     une AUTRE entree — plus rien ne disait laquelle voulait dire "vous etes
     ici". Ne pas retirer cette regle de la section 8b.

     Ici il n'y a pas de survol : un doigt ne stationne pas, il touche et part.
     La seule pilule affichee est donc celle de l'entree active, et elle est
     necessaire — la barre defile, l'entree en cours n'est plus reperable par sa
     position dans une colonne comme sur le bureau, et la couleur seule est
     faible sur un libelle isole qui passe au milieu de trois autres.
     Meme jeton que le survol : c'est la teinte que la maquette montre, et deux
     bleus a 7 % voisins se liraient comme une erreur. */
  .cs-nav a.is-active { background: var(--veil-strong); }

  /* Le soulignement de l'entree active EST sa barre de progression : il se
     remplit pendant la lecture de cette section (voir updateSectionProgress
     dans app.js). Il occupe toute la largeur de l'entree, donc les deux
     colonnes de la grille.

     ESSAI EN COURS — soulignement retire pour voir ce que donne la barre sans
     lui. Pour le remettre : `display: none` -> `display: block` sur la ligne
     ci-dessous, et rien d'autre. Le balisage et le calcul JS sont laisses en
     place exprès, ils ne coutent rien et rendent le retour immediat. */
  .cs-nav a.is-active .cs-nav__sprog {
    display: none; grid-column: 1 / -1;
    height: 2px; border-radius: 1px;
    background: var(--line-soft);
    overflow: hidden;
  }
  .cs-nav__sbar {
    display: block; height: 100%; width: 0%;
    border-radius: 1px; background: var(--accent);
    transition: width .2s linear;
  }

  /* Le RAIL de la progression d'ensemble disparait — pas la progression
     elle-meme, qui est reprise par l'anneau au debut de la pilule : c'est un
     changement de forme, la mesure est la meme (le meme p, dans le meme
     updateProgress). Un rail vertical n'a rien a longer sur une barre
     horizontale de 56px de haut. Le balisage reste en place : app.js y ecrit
     sans le tester.
     Consequence a connaitre : ce bloc porte role="progressbar" et son
     aria-valuenow. display:none le retire de l'arbre d'accessibilite, donc ici
     c'est .cs-nav__ring qui porte le role — et l'inverse sur le bureau, ou
     c'est l'anneau qui est masque. Il n'y en a jamais deux annonces a la
     fois. */
  .cs-nav__prog { display: none; }

  /* La croix flottante (.cs-nav__back, jamais retiree — voir section 8d)
     recouvre le bas de la fiche : sans cette reserve, elle masquerait en
     permanence la fin du pied de page, qu'aucun defilement ne pourrait alors
     degager.
     ESSAI, RETOUR POSSIBLE : la reserve allait sur #page (padding-bottom),
     ce qui laissait le vide BLANC releve par l'utilisateur — le fond de
     #page, pas celui du pied de page, juste avant que #site-foot arrive.
     Deplacee sur .site-foot (son propre padding-bottom, meme mecanique que
     .site-foot sur l'accueil juste plus bas dans cette section) : la reserve
     est maintenant DANS le pied de page, donc peinte avec son fond a lui.
     :has(.cs-nav) la limite aux etudes de cas qui ont un sommaire — partout
     ailleurs il n'y a pas de croix flottante, et 90px de reserve en trop
     seraient un defaut. */
  body.is-overlay:has(.cs-nav) .site-foot {
    padding-bottom: calc(var(--s8) + 90px + env(safe-area-inset-bottom, 0px));
  }

  /* Herite de l'epoque ou .cs-nav se collait en haut : elle visait --head-h
     (14px, le retrait de la fiche) et laissait donc une bande vide de 14px
     au-dessus d'elle une fois defilee. La barre est passee en bas, mais la
     ligne reste — --head-h sert aussi de repere a scrollToSection et au
     scroll-spy, et l'en-tete du site est bel et bien invisible derriere la
     fiche : lui reserver une hauteur ferait descendre chaque ancre de 14px
     pour rien. */
  body.is-overlay { --head-h: 0px; }
}

/* --- Sous 860px : la pilule descend en barre d'onglets ----------------- */
@media (max-width: 860px) {
  /* PLUS D'EN-TETE SUR MOBILE. Il ne portait plus que le nom : la pilule est
     descendue au bas de la fenetre et le bouton menu a disparu avec le tiroir
     (section 4). Une bande collante en haut d'un ecran de telephone pour un
     seul mot, c'est un douzieme de la hauteur utile pris a la lecture — et ce
     mot n'etait qu'un lien vers l'accueil, ou les trois entrees de la barre
     ramenent deja.

     ON LE VIDE, ON NE LE RETIRE PAS. #site-nav est son ENFANT dans
     index.html, donc un display:none emporterait la barre avec lui. Reduit a
     une boite de hauteur nulle, il ne reste que ce qu'on lui demande encore :
     le contexte d'empilement (position:sticky, z-index 120, section 4) qui
     pose la barre au-dessus du contenu et sous la fiche.
     Une seule ligne suffit depuis que la pilule porte tout : l'en-tete n'a
     plus ni fond, ni filet, ni grille a annuler ici — il n'en a plus du tout
     (section 4). Reste son retrait, qui ferait sinon descendre la boite. */
  .site-head { padding: 0; }
  /* Le nom est dans la pilule maintenant, mais il n'a rien a faire dans une
     barre d'onglets : Home y tient deja ce role, et il y prendrait la place
     d'une entree. */
  .brand { display: none; }

  /* --head-h tombe donc a zero. Ce n'est qu'une approximation : syncHeadHeight
     (app.js) mesure l'en-tete et ecrit la vraie valeur sur <html>, qui vaudra
     0px elle aussi. Les ancres se posent maintenant a --s5 du bord haut, ce
     qui est exactement ce qu'on veut quand rien ne les surplombe.

     --head-was EST CE QUE L'EN-TETE PRENAIT DANS LE FLUX, et qu'il faut rendre
     au heros. En vidant la bande, tout le haut de l'accueil est remonte de sa
     hauteur et le "Hey, nice to meet you!" est venu se coller au bord de
     l'ecran. On ne remet pas la bande pour autant : c'est le heros seul qui
     reprend la mesure a son compte (.hero plus bas, et dans le media 560px),
     la ou elle se voit.
     Calculee et non ecrite en dur : c'est la formule meme de l'en-tete d'avant
     — une ligne de texte courant a l'interligne de la nav, plus ses deux
     retraits. Retoucher --fs-body ou --s4 la suit au lieu de la contredire. */
  :root {
    --head-h: 0px;
    --head-was: calc(var(--fs-body) * var(--lh-nav) + var(--s4) * 2);  /* 55.2px */
  }

  /* Le retrait propre du heros reste --s7, celui de la feuille de base : on
     n'ajoute que la hauteur rendue.

     ET LA HAUTEUR MINIMALE SUIT, sans quoi la moitie du retrait est mangee.
     Le heros centre son contenu dans une boite d'au moins 400px : gonfler le
     retrait haut ne fait qu'y reduire l'espace libre, que le centrage
     redistribue de part et d'autre — le texte ne descend alors que de la
     moitie de ce qu'on a ajoute. En donnant a la boite les 55px de plus
     qu'elle occupait quand l'en-tete etait au-dessus d'elle, on retrouve
     l'espace libre d'avant, donc le meme centrage, et le bas du heros retombe
     exactement ou il etait — les sections en dessous ne bougent pas non plus.
     (Sous 560px min-height passe a 0 et la question ne se pose plus : le
     retrait seul suffit, c'est ce que fait le media suivant.) */
  .hero {
    padding-top: calc(var(--s7) + var(--head-was));
    min-height: calc(var(--hero-min) + var(--head-was));
  }

  /* ------------------------------------------------------------------------
     LA BARRE D'ONGLETS DU BAS

     C'est la MEME pilule que sur le bureau — meme element, memes liens, meme
     soulignement lime pour la section en cours. Elle change seulement de
     place : le haut de l'ecran sur un telephone est la zone la plus loin du
     pouce, et le site a deja pris le parti du bas pour la navigation d'une
     etude de cas (section 12 plus haut). Deux barres de navigation, une en
     haut et une en bas selon la page, se seraient contredites.

     Elle remplace le tiroir plein ecran qui descendait de l'en-tete au
     hamburger. Trois entrees ne valent pas un ecran entier ni un clic
     prealable : elles tiennent sur une rangee, et etre visibles en
     permanence est precisement ce qu'on attend d'un menu de trois liens.

     position:fixed sur un enfant de .site-head : sticky ne cree pas de bloc
     conteneur pour un descendant fixe (seuls transform, filter et consorts le
     font), donc la barre se cale bien sur la FENETRE. Elle reste en revanche
     dans le contexte d'empilement de l'en-tete (z-index 120), ce qui la pose
     au-dessus du contenu ordinaire et sous la fiche (125) et la croix (150) —
     l'ordre voulu.

     Les mesures sont celles de .cs-nav sous 1000px : meme hauteur de 56px,
     memes 20px au-dessus du bord (safe-area comprise), memes marges
     laterales. Les deux barres ne sont jamais visibles ensemble, mais quand
     l'une remplace l'autre a l'ouverture d'une fiche, elle doit se poser au
     meme endroit — sinon la navigation saute de quelques pixels. */
  .site-nav {
    position: fixed;
    left: var(--gutter); right: var(--gutter);
    bottom: calc(20px + env(safe-area-inset-bottom, 0px));
    /* EN RANGEE, PAS EN GRILLE. Sur le bureau la pilule est une grille a trois
       colonnes (nom | entrees | Resume et Contact) ; ici il ne reste que les
       entrees, et elles doivent se repartir sur toute la largeur. Sans cette
       ligne elles heriteraient de la grille et se rangeraient dans ses trois
       pistes. */
    display: flex;
    height: 56px;
    /* space-evenly et non le gap fixe du bureau : sur une barre pleine
       largeur, un ecart constant tasserait les entrees au centre. */
    justify-content: space-evenly; gap: var(--s3);
    padding-inline: var(--s4);
    /* LE VERRE, PLUS EPAIS QU'EN HAUT DE PAGE. Meme ombre que la barre d'une
       etude de cas — les deux sont le meme objet flottant, elles ne peuvent
       pas etre en deux matieres — mais le gris et le flou montent tous les
       deux : la pilule de l'en-tete se detache sur un aplat de bleu uni,
       alors que celle-ci passe sur les visuels des cartes, ou --veil (noir a
       5 %) et 14px de flou ne la separaient plus de rien. A 14 % de noir et
       60px, le fond redevient une matiere et le libelle reste lisible sur
       n'importe quel visuel.
       Valeur en dur et non un jeton : c'est le reglage de CETTE barre, pas
       une matiere partagee — la couleur du verre des fiches, elle, reste
       --glass (section 1).
       L'ombre remplace le liser : un trait fermerait la forme et la ferait
       lire comme un champ, la ou on veut un objet pose sur la page. */
    background: #00000025;
    -webkit-backdrop-filter: blur(60px); backdrop-filter: blur(60px);
    box-shadow: var(--glass-shadow);
  }

  /* LE GROUPE DES ENTREES REDEVIENT SES ENTREES. display:contents retire la
     boite sans retirer ses enfants : les quatre liens redeviennent des
     elements flex de la barre, et le space-evenly ci-dessus les repartit un
     par un au lieu de pousser un seul bloc au milieu. Le div n'existe que
     pour donner une colonne au centre de la grille du bureau. */
  .site-nav__links { display: contents; }

  /* LE GROUPE DE DROITE SORT DE LA BARRE. Resume ouvre un PDF, Contact ouvre
     une application de courrier : les deux quittent le site, ce qui n'est pas
     de la navigation et n'a donc rien a faire dans une barre d'onglets — les
     autres entrees menent a un endroit de ce site-ci. Le pied de page les
     porte tous les deux.
     display:none et non une suppression du HTML : au-dessus de 860px la
     pilule les garde. */
  .site-nav__end { display: none; }

  /* HOME ENTRE DANS LA BARRE, lui. Le nom qui tenait ce role est masque avec
     l'en-tete (plus haut) : sans cette entree, rien ne ramenerait plus
     explicitement en haut de l'accueil depuis le bas d'une longue page, ni
     depuis About. display:block et non revert : les liens de la pilule sont
     des elements flex, donc deja blocifies — c'est la valeur qu'ils ont
     partout ailleurs. */
  #nav-home { display: block; }

  /* La barre appartient a la page, pas a la fiche posee dessus : une etude de
     cas a sa propre barre au meme endroit (section 12), et les deux
     s'empileraient. On la retire donc pendant toute la duree de la fiche —
     y compris sur une etude sans sommaire, ou c'est la croix qui sert de
     sortie et ou une barre de site par-dessus le texte serait un contresens. */
  body.is-overlay .site-nav { display: none; }
  /* Meme motif pour la page gap : elle a sa propre navigation retour
     (.editorial__gapnav, section 9) — la pilule du site par-dessus son texte
     serait le meme contresens que sur une etude de cas. */
  body.route-gap .site-nav { display: none; }

  /* ...ET ELLE REVIENT COMME LA BARRE DE LA FICHE ETAIT ARRIVEE.
     Le display:none ci-dessus tombe d'un seul coup, au moment ou paint()
     retire is-overlay : la pilule se retrouvait plaquee au bas de l'ecran,
     sans transition, juste apres que la fiche eut mis 380ms a descendre. Elle
     etait le seul element non anime de toute la sequence de fermeture, et
     c'est celui que l'oeil regarde — le pouce y retourne.

     On rejoue csBarIn (section 11 bis), LA MEME animation que celle de la
     barre de sections, pas une copie : les deux barres se relaient au meme
     endroit de l'ecran, l'une doit donc partir d'ou l'autre partait.

     POURQUOI UNE CLASSE ET NON LE SIMPLE RETOUR DU DISPLAY. Une animation
     redemarre bien toute seule quand un element quitte display:none — c'est
     ce qui suffit a .cs-nav. Mais ici elle redemarrerait AUSSI au tout
     premier rendu du site, ou la barre n'a rien a rejouer : rien ne l'a
     chassee, elle est simplement la. is-returning (app.js, paint()) restreint
     le mouvement au seul cas ou il veut dire quelque chose : une fiche vient
     de se refermer, la page qui etait dessous nous est rendue.

     backwards et non both, pour la meme raison qu'a la ligne de .cs-nav :
     l'etat d'arrivee est deja l'etat naturel de la barre, et un `both` la
     laisserait sous une animation terminee — donc au-dessus de toute
     declaration ordinaire qu'on poserait ensuite. */
  body.is-returning .site-nav { animation: csBarIn .34s var(--ease) backwards; }

  /* La reserve du bas, jumelle de celle de #page:has(.cs-nav) : sans elle la
     barre recouvrirait la fin du pied de page, qu'aucun defilement ne
     pourrait alors degager. :not(.is-overlay) parce que la regle ci-dessus
     vient justement de retirer la barre dans ce cas — et que la fiche a deja
     la sienne.
     Posee sur .site-foot (son propre padding-bottom) et non sur #page : le
     pied de page est le dernier bloc visible avant cette reserve, c'est donc
     a lui qu'elle appartient plutot qu'au conteneur qui l'enveloppe. */
  body:not(.is-overlay) .site-foot { padding-bottom: calc(var(--s6) + 90px + env(safe-area-inset-bottom, 0px)); }

  .pair { grid-template-columns: 1fr; }
  .foot__grid { grid-template-columns: 1fr 1fr; }
  .cs-next__link { flex-direction: column; align-items: flex-start; }
  .cbuild__layout { grid-template-columns: 1fr; }
  .cbuild__card { width: 100%; }
  /* Sous 860px le cadre passe en pile normale (pas de positionnement absolu
     façon Figma, qui suppose une largeur de cadre confortable) : chaque
     panneau reprend le flux du document, l'un sous l'autre, et le cadre
     retrouve son scroll interne puisque sa hauteur ne suit plus le contenu. */
  .ccomp__frame { overflow-y: auto; }
  .ccomp__grid { display: flex; flex-direction: column; gap: var(--s5); height: auto; }
  .ccomp__panel { position: static; }
  /* Chaque corps reprend le flux normal (l'ancrage left:0/right:0 ci-dessus
     n'a de sens que quand le panneau est positionne en absolu). */
  .ccomp__body { position: static; margin-top: 10px; }
  .lottie-carousel { grid-template-columns: 1fr; }
  .lottie-carousel__tabs { flex-direction: row; flex-wrap: wrap; gap: var(--s4) var(--s5); }
}

/* --- Sous 560px : les petits ecrans ----------------------------------- */
@media (max-width: 560px) {
  /* Le raccourci padding-block ecraserait le padding-top pose dans le media
     860px : on lui reapplique --head-was, avec le retrait plus serre qui est
     celui de ce palier. */
  .hero { min-height: 0; padding-block: calc(var(--s6) + var(--head-was)) var(--s7); }
  .foot__grid { grid-template-columns: 1fr; gap: 0; }
  /* Demande utilisateur : Marvin S. et le lien Resume seuls tout en haut de
     l'empilement mobile — pas le copyright ni la mention "vibe-coded"
     (.foot__bottom), qui restaient colles a Resume puisque les trois vivent
     dans le meme <div> (premiere colonne de .foot__grid, voir buildFooter()
     dans app.js). display:contents efface la boite de ce <div> ICI SEULEMENT
     (uniquement sous 560px : le bureau garde sa colonne flex intacte) — ses
     trois enfants deviennent alors des items de grille a part entiere, donc
     individuellement reordonnables. gap ci-dessus passe a 0 (le var(--s6) du
     bureau s'appliquerait sinon entre Marvin S. et Resume aussi, qui doivent
     rester colles — seul foot__name { margin-bottom } les espace) ; chaque
     bloc reprend son propre ecart via margin-top ci-dessous. */
  .foot__grid > div:first-child { display: contents; }
  .foot__grid > div:nth-child(2) { order: 3; margin-top: var(--s6); }
  .foot__grid > div:nth-child(3) { order: 2; margin-top: var(--s6); }
  /* .foot__bottom (copyright + note) passe en dernier (order: 4), apres
     Sitemap et Get in touch. Son margin-top: auto (section 11) comptait sur
     la colonne ETIREE par la grille (align-items: stretch, calquee sur la
     plus haute des trois) pour avoir de la hauteur en trop a pousser dedans —
     ici chaque item n'occupe que sa hauteur propre (et n'est meme plus dans
     la meme colonne), donc l'auto retomberait a 0 : une valeur fixe reprend
     le relais. */
  .foot__bottom { order: 4; margin-top: var(--s6); }
  .gist { grid-template-columns: 1fr 1fr; }
  .back-link { left: 50%; transform: translateX(-50%); bottom: var(--s4); }
  .back-link:hover { transform: translateX(-50%) translateY(-2px); }
  /* Meme raison que la section 9 (max-width: 1000px, body.route-gap
     .back-link) : cette regle centre le bouton en pastille au bas de l'ecran,
     pensee pour les fiches qui le laissent defiler avec la page. Sur /gap le
     bouton reste fixe en haut a gauche (section 9) — sans cette exception, le
     centrage horizontal s'appliquait quand meme ici (height fixe sur
     .back-link, donc bottom est ignore face a top, mais left/transform ne le
     sont pas), le bouton restait en haut mais glissait au centre. */
  body.route-gap .back-link { left: var(--gutter); transform: none; }
  body.route-gap .back-link:hover { transform: translateX(-3px); }
  .figure__frame { padding: var(--s2); }
  .cards { gap: var(--s7); }
}

/* --- Sous 360px : la barre d'onglets se resserre ----------------------- */
/* Quatre libelles a 16px demandent 216px, plus 36px d'ecarts et 32px de
   retraits : 284px, quand la pilule n'en mesure que 257 sur un ecran de
   320px (320 moins deux gouttieres). "About" debordait du verre par la
   droite. Trois entrees tenaient, la quatrieme ne tient plus.

   On resserre la barre — et elle seule — plutot que de retirer une entree,
   de laisser la liste defiler ou de la faire passer sur deux rangees :
   quatre onglets qu'on voit tous d'un coup valent mieux que trois plus un
   cache, et 15px reste une taille de libelle lisible (c'est --fs-small, la
   meme que les legendes du site).

   320px n'est pas un ecran anecdotique : c'est la largeur de reference du
   critere de reflow des WCAG (1.4.10). Le seuil est a 360px pour prendre la
   marge d'un ou deux caracteres si un libelle s'allonge. */
@media (max-width: 360px) {
  .site-nav { padding-inline: var(--s2); gap: var(--s2); }
  .site-nav a { font-size: var(--fs-small); }
}


/* ==========================================================================
   13. PREFERENCES UTILISATEUR
   ========================================================================== */

/* Certaines personnes ressentent nausees ou vertiges avec les animations
   (troubles vestibulaires). Le systeme permet de demander "moins de
   mouvement" ; on respecte ce reglage. Ce n'est pas une option esthetique,
   c'est une exigence d'accessibilite (WCAG 2.3.3). */
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation-duration: .01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .01ms !important;
  }
  /* On garde les barres de progression, qui informent plutot qu'elles ne
     decorent. */
  .progress__fill, .cs-nav__bar, .cs-nav__sbar, .loader__bar { transition-duration: .2s !important; }
}

/* Impression : un recruteur imprime parfois un portfolio. On repasse en noir
   sur blanc, sans quoi la page consommerait une cartouche entiere. */
@media print {
  .site-head, .site-nav, .back-link, .progress, .cs-nav,
  .loader, .gate, .cs__cta, .cs-next, .skip-link,
  .underlay, .veil, .overlay-close { display: none !important; }
  html, body { background: #fff !important; color: #000 !important; font-size: 11pt; }
  /* La fiche redevient une page ordinaire sur le papier. */
  #page { margin: 0 !important; border-radius: 0 !important; box-shadow: none !important; }
  .cs-sec p, .editorial__block p, .article__block p, .stat__l, .cs__tagline { color: #000 !important; }
  .stat__n { color: #000 !important; }
  .tag { border: 1px solid #000; color: #000 !important; background: none !important; }
  .figure__frame { border: 1px solid #ccc; }
  a { color: #000; text-decoration: underline; }
  /* On imprime la destination des liens, sinon ils sont inutiles sur papier. */
  .cs-sec a[href^="http"]::after { content: " (" attr(href) ")"; font-size: 9pt; color: #555; }
}

/* Le message affiche si JavaScript est desactive. */
.noscript { padding: var(--s8) var(--gutter); max-width: 620px; margin-inline: auto; }
.noscript h1 { font-size: var(--fs-h1); margin-bottom: var(--s4); }
.noscript a { color: var(--accent); }
