/* ============================================================
   COQUILLE v3 — relevé image par image de 02-docs/lg2.mov
   ------------------------------------------------------------
   Ce que la vidéo a corrigé, en deux fois :
   1. lg2 n'est PAS une grille de tuiles (première lecture, fausse) ;
   2. ce n'est pas non plus une pile de bandes verticales (deuxième
      lecture, fausse aussi) — c'est **une piste HORIZONTALE** :
      [texte][image][texte][image]… sur une seule rangée qui file
      vers la gauche pendant que la page descend.
   Titre géant qui DÉBORDE sur l'image voisine, rond ROUGE sur
   chaque image.

   Couleurs relevées au pixel dans la vidéo :
     rouge  #fc1f00   ·   fond #fdfdfd   ·   rail #000

   Ce qu'on ajoute à la référence :
     • la déformation cylindre au scroll (fx.js)
     • le rond rouge devient AJOUTER AU PANIER
   ============================================================ */

:root {
  --ink:    #0a0a0a;
  --ink-2:  #4d4d4d;
  --ink-3:  #a8a8a8;
  --ghost:  #cfcfcf;          /* les noms en attente dans la liste du bas */
  --line:   #e3e3e3;
  --bg:     #fdfdfd;
  --box:    #ededed;
  --red:    #fc1f00;
  --rail:   54px;
  --sans:   -apple-system, BlinkMacSystemFont, "Helvetica Neue", Helvetica, Arial, sans-serif;
  --mono:   ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  --pad:    clamp(20px, 4.6vw, 70px);

  --ab: 0px;                  /* aberration, piloté par fx.js */
}

* { box-sizing: border-box; }
body {
  margin: 0; background: var(--bg); color: var(--ink);
  font-family: var(--sans); font-size: 16px; line-height: 1.5;
  -webkit-font-smoothing: antialiased;
  padding-right: var(--rail);
  overflow-x: hidden;
}
a { color: inherit; text-decoration: none; }
h1, h2 { margin: 0; font-weight: 500; letter-spacing: -.03em; line-height: 1.02; }
p { margin: 0; }
ul { margin: 0; padding: 0; list-style: none; }
.wrap { padding: 0 var(--pad); }
.lbl { font-family: var(--mono); font-size: 11px; letter-spacing: .14em;
  text-transform: uppercase; color: var(--ink-3); }

/* ---------- rail noir fixe à droite (Contact / English chez lg2) ---------- */
.rail {
  position: fixed; top: 0; right: 0; bottom: 0; width: var(--rail); z-index: 80;
  background: #000; color: #fff;
  display: flex; flex-direction: column; align-items: center; justify-content: space-between;
  padding: 26px 0;
}
.rail button, .rail a {
  background: none; border: 0; color: #fff; cursor: pointer; padding: 0;
  font-family: var(--mono); font-size: 11px; letter-spacing: .12em; text-transform: uppercase;
  writing-mode: vertical-rl;
}
.rail .n { color: var(--red); }

/* ---------- header ---------- */
.hd { padding: 26px var(--pad); display: flex; align-items: center; gap: 24px; }
.hd-b { font-weight: 700; font-size: 19px; letter-spacing: -.03em; }
.hd-n { margin-left: auto; display: flex; gap: 30px; }
.hd-n a { font-size: 14px; color: var(--ink-2); }
.hd-n a:hover { color: var(--ink); }
@media (max-width: 800px) { .hd-n { display: none; } }

/* ---------- hero : titre géant puis image pleine largeur ---------- */
.hero { padding: clamp(50px, 8vw, 120px) var(--pad) clamp(40px, 5vw, 80px); }
.hero h1 { font-size: clamp(38px, 7.4vw, 104px); max-width: 14ch; }
.hero-img { height: clamp(300px, 46vw, 660px); background: var(--box); }

/* ---------- intro deux colonnes ---------- */
.intro {
  padding: clamp(50px, 7vw, 110px) var(--pad);
  display: grid; grid-template-columns: 1fr 1.35fr; gap: clamp(24px, 4vw, 70px);
}
.intro h2 { font-size: clamp(20px, 2.1vw, 30px); font-weight: 400; letter-spacing: -.02em; line-height: 1.22; }
.intro .cols p { font-size: 15px; color: var(--ink-2); }
.intro .cols p + p { margin-top: 20px; }
@media (max-width: 900px) { .intro { grid-template-columns: 1fr; } }

/* ============================================================
   LA PISTE HORIZONTALE — le cœur de la référence
   ------------------------------------------------------------
   `wrap` est haut (fx.js lui donne 100vh + la course), `stick` reste
   collé à l'écran, `track` est la rangée qu'on translate en X. Le
   scroll reste donc vertical et natif : rien n'est détourné, on ne
   se bat avec aucun geste du navigateur.
   ============================================================ */
.track-wrap { position: relative; }
.track-stick {
  position: sticky; top: 0; height: 100vh; overflow: hidden;
  display: flex; align-items: center;
}
.track {
  display: flex; align-items: center; gap: clamp(20px, 3vw, 60px);
  padding: 0 var(--pad); will-change: transform;
}

/* Un bloc = UNE facette. Elle contient sa colonne de texte ET son
   image, parce que le titre doit passer AU-DESSUS de l'image et se
   COMPOSER avec elle : les deux exigent le même contexte d'empilement.
   Le `background` est obligatoire — `.pan` porte un `transform`, donc
   crée un contexte, et sans fond le titre en `mix-blend-mode` n'aurait
   rien contre quoi se composer (il resterait blanc sur blanc). */
.pan {
  flex: 0 0 auto; position: relative;
  display: flex; align-items: center; gap: clamp(16px, 2.2vw, 44px);
  background: var(--bg);
  transform-origin: 50% 50%; will-change: transform;
}

/* colonne de texte — surtout PAS de z-index ni de transform ici :
   l'un ou l'autre isolerait le groupe et le titre ne verrait plus
   l'image posée à côté */
.pan-txt { width: clamp(300px, 26vw, 420px); flex: 0 0 auto; }
.pan-p { font-size: 15px; line-height: 1.62; color: var(--ink-2); margin-top: 26px;
  position: relative; z-index: 3; }

/* Le titre déborde de sa colonne et passe sur l'image de son propre
   bloc. `mix-blend-mode: difference` sur du blanc donne exactement le
   comportement relevé dans la vidéo : NOIR sur le fond clair, BLANC
   dès qu'il croise une image sombre — une seule règle, pas de calque. */
.pan-h {
  font-size: clamp(40px, 7vw, 116px);
  white-space: nowrap; width: max-content;
  color: #fff; mix-blend-mode: difference;
  position: relative; z-index: 3;
  pointer-events: none;
}

/* image — z-index sous le titre, pour qu'il la recouvre */
.pan-img {
  position: relative; z-index: 1; flex: 0 0 auto;
  width: clamp(420px, 46vw, 860px); aspect-ratio: 16 / 10;
  background: var(--box);
}
.pan--full .pan-img { background: #0a0a0a; }

/* le rond rouge de la référence — ici, c'est le panier */
.plus {
  position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
  width: clamp(60px, 5vw, 86px); aspect-ratio: 1; border-radius: 50%;
  border: 0; cursor: pointer; z-index: 4;
  background: var(--red); color: #fff;
  display: flex; align-items: center; justify-content: center;
  font-size: 30px; font-weight: 300; line-height: 1;
  transition: transform .18s ease, background .18s;
}
.plus:hover { transform: translate(-50%, -50%) scale(1.09); }
.plus.on { background: #0a0a0a; }
.plus .t {
  position: absolute; top: calc(100% + 12px); left: 50%; transform: translateX(-50%);
  font-family: var(--mono); font-size: 10px; letter-spacing: .12em; text-transform: uppercase;
  color: var(--ink-2); white-space: nowrap;
}

/* indice de progression, en bas de la section collée */
.track-cue {
  position: absolute; left: var(--pad); bottom: 34px; z-index: 5;
  display: flex; align-items: center; gap: 12px;
}
.track-bar { width: clamp(120px, 16vw, 260px); height: 1px; background: var(--line); }
.track-bar i { display: block; height: 1px; width: 0; background: var(--red); }

/* ---------- sous 860px : la piste redevient une pile verticale ----------
   Au doigt, un scroll horizontal piloté par la position verticale se
   bat avec le geste natif. On rend la main au navigateur. */
@media (max-width: 860px) {
  .track-stick { position: static; height: auto; overflow: visible; display: block; }
  .track { flex-direction: column; align-items: stretch; transform: none !important;
    gap: 40px; padding: 40px var(--pad); }
  .pan { transform: none !important; }
  .pan { flex-direction: column; align-items: stretch; }
  .pan-txt, .pan-img { width: 100%; }
  .pan-h { font-size: clamp(32px, 9vw, 56px); white-space: normal; }
  .track-cue { display: none; }
}

/* ============================================================
   LISTE DU BAS — noms empilés en gris, aperçu qui suit le curseur
   ============================================================ */
.list { padding: clamp(50px, 7vw, 110px) var(--pad) clamp(60px, 8vw, 130px); position: relative; }
.list-i {
  display: block; font-size: clamp(34px, 6vw, 92px); font-weight: 500;
  letter-spacing: -.03em; line-height: 1.16; color: var(--ghost);
  transition: color .16s; cursor: pointer;
}
.list-i:hover { color: var(--ink); }
.peek {
  position: fixed; width: clamp(240px, 26vw, 380px); aspect-ratio: 16 / 10;
  background: var(--box); border: 1px solid var(--line);
  pointer-events: none; opacity: 0; transition: opacity .18s;
  z-index: 30; transform: translate(-50%, -50%);
}
.peek.on { opacity: 1; }
.peek i {
  position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
  width: 52px; aspect-ratio: 1; border-radius: 50%; border: 1.5px solid var(--red);
  display: flex; align-items: center; justify-content: center;
  color: var(--red); font-size: 22px; font-style: normal; font-weight: 300;
}
@media (max-width: 860px) { .peek { display: none; } }

/* ============================================================
   PIED — marquee + coordonnées, sur fond noir comme la référence
   ============================================================ */
.ft { background: #000; color: #fff; padding: clamp(40px, 5vw, 80px) 0 46px; overflow: hidden; }
.mq { display: flex; gap: 60px; width: max-content; align-items: center;
  animation: mq 26s linear infinite; margin-bottom: clamp(40px, 5vw, 80px); }
.mq span { font-size: clamp(34px, 5.6vw, 82px); font-weight: 500; letter-spacing: -.03em; white-space: nowrap; }
.mq i { width: 58px; aspect-ratio: 1; border-radius: 50%; background: var(--red); flex: 0 0 auto;
  display: flex; align-items: center; justify-content: center; font-style: normal; font-size: 26px; font-weight: 300; }
@keyframes mq { to { transform: translateX(-50%); } }
.ft-g { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 26px; padding: 0 var(--pad); }
.ft-g p { font-size: 14px; color: #b9b9b9; }
.ft-g .lbl { color: #6f6f6f; margin-bottom: 8px; }

/* ---------- panier ---------- */
.ovl { position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 90; display: none; }
.ovl.on { display: block; }
.drw {
  position: fixed; top: 0; right: var(--rail); bottom: 0; width: min(430px, calc(100% - var(--rail)));
  z-index: 95; background: var(--bg); border-left: 1px solid var(--line);
  transform: translateX(calc(100% + var(--rail))); transition: transform .22s ease;
  display: flex; flex-direction: column;
}
.drw.on { transform: none; }
.drw-h { padding: 22px 26px; border-bottom: 1px solid var(--line);
  display: flex; align-items: center; justify-content: space-between; }
.drw-h h2 { font-size: 18px; }
.drw-b { padding: 20px 26px; overflow: auto; flex: 1; }
.drw-f { padding: 16px 26px; border-top: 1px solid var(--line); display: flex; gap: 10px; }
.ci { display: flex; gap: 12px; align-items: baseline; padding: 13px 0; border-bottom: 1px solid var(--line); }
.ci b { font-size: 15px; font-weight: 500; }
.ci button { margin-left: auto; border: 0; background: none; cursor: pointer;
  font-family: var(--mono); font-size: 10px; letter-spacing: .1em; text-transform: uppercase; color: var(--ink-3); }
.ci button:hover { color: var(--red); }
.btn { font-family: var(--mono); font-size: 11px; letter-spacing: .1em; text-transform: uppercase;
  padding: 12px 16px; border: 1px solid var(--ink); background: var(--ink); color: #fff; cursor: pointer; }
.btn--g { background: transparent; color: var(--ink); }
.btn--off { border-color: var(--line); background: var(--box); color: var(--ink-3); cursor: not-allowed; }

/* ---------- aberration : sur les titres seulement ---------- */
.hero h1, .list-i, .mq span, .pan-h {
  text-shadow: calc(var(--ab) * -1) 0 0 rgba(255,0,60,.5), var(--ab) 0 0 rgba(0,190,255,.5);
}
/* PAS sur .pan-p : à 15 px le dédoublement ne teinte pas, il brouille —
   vérifié en capture, le paragraphe devenait illisible. L'aberration ne
   porte que sur du display. */

@media (prefers-reduced-motion: reduce) {
  .track-stick { position: static; height: auto; overflow: visible; }
  .track { flex-direction: column; transform: none !important; }
  .pan { transform: none !important; }
  .mq { animation: none; }
  .drw { transition: none; }
  .hero h1, .list-i, .mq span, .pan-h { text-shadow: none; }
}

/* ---------- hook de QA ?tx= : n'afficher que la piste ----------
   Chrome headless ne repeint pas après un scroll : le seul moyen de
   capturer la piste en cours de course est de la décaler sur place.
   Sans masquer le reste, le hero passe derrière et brouille tout. */
body.qa-track > .hd,
body.qa-track > main > section,
body.qa-track > main > .hero-img,
body.qa-track > .ft,
body.qa-track > .peek { display: none; }

/* ============================================================
   PAGE MÉTHODE — COPIE CONFORME de zhenyary.com/workflow
   ------------------------------------------------------------
   Palette, structure ET MODÈLE DE DIMENSIONNEMENT relevés sur le
   CSS réel de la page (inliné dans le HTML servi par leur Nuxt).

   ⚠️ LEUR MODÈLE, et pourquoi mon responsive était moins bon :
   ils dimensionnent TOUT en `vw` — 143 occurrences, et ZÉRO
   `clamp()`. La composition scale donc linéairement avec la
   largeur, à toutes les tailles. Mes `clamp(min, Xvw, max)`
   figeaient la mise en page dès qu'on sortait des bornes, et le
   rapport entre les blocs se cassait.

   Trois règles tenues ici, toutes reprises d'eux :
     • horizontal en `vw`, vertical en `vh` ;
     • typo en `vw` au desktop, en `px` FIXES pour le petit corps
       au mobile (13-15px) — le vw y devient illisible ;
     • deux points de rupture seulement : 1050 (ajustements) et
       600 (restructuration complète, titres tous à 12.5vw).

   Valeurs relevées telles quelles : gouttière 8vw · titre hero
   7.8vw · flèche 12×3.6vw · note 1.2vw · Learn/Think/Create 5.2vw
   · A./B./C. 1.5vw · rond 8vw à left:15vw · mot de chapitre 10.6vw
   · lettre+numéro 2.2vw · têtes serif 3.4vw · Problem 4.1vw ·
   corps 1.5vw · .problem padding-top 18vw.

   Palette relevée au pixel sur les captures :
     fond #dad5ca · rouge #e64a26 · rouge sombre #7e2f18
     encre #191919 · fond outro #eae9e6 · halo #e8e6e2
   ============================================================ */

@font-face { font-family:'Fraunces'; font-style:normal; font-weight:400 700; font-display:swap;
  src:url(assets/fonts/fraunces-latin-3.woff2) format('woff2');
  unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; }
@font-face { font-family:'Fraunces'; font-style:italic; font-weight:400 700; font-display:swap;
  src:url(assets/fonts/fraunces-latin-1.woff2) format('woff2');
  unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; }
@font-face { font-family:'Space Grotesk'; font-style:normal; font-weight:400 600; font-display:swap;
  src:url(assets/fonts/space-grotesk-latin.woff2) format('woff2');
  unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; }
/* ⚠️ JOST — LA GÉOMÉTRIQUE DE LA RÉFÉRENCE, et la QUATRIÈME famille du
   dépôt. Manish, capture de zhenya à l'appui : « je crois que je préfère
   largement la fonte de zhenya », puis, mis devant le coût : Jost partout
   en display sur la page approche.

   C'est la Futura open source : « a » d'un seul étage, « e » à barre
   horizontale, cercles vrais — les trois traits qui séparaient Space
   Grotesk de la capture. Space Grotesk RESTE déclarée : elle est la
   police de `body.zh`, donc de methode.html, qui ne s'uniformise pas
   (invariant 3). Les deux cohabitent, chacune sur sa page.

   ⚠️ AUTO-HÉBERGÉE comme les trois autres, latin + latin-ext (44 ko).
   Aucun appel à fonts.googleapis.com : la règle « zéro dépendance
   externe au runtime » vaut aussi pour une police, et une page qui va
   chercher une fonte chez Google fuite l'IP de chaque visiteur. */
@font-face { font-family:'Jost'; font-style:normal; font-weight:400 600; font-display:swap;
  src:url(assets/fonts/jost-latin.woff2) format('woff2');
  unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; }
@font-face { font-family:'Jost'; font-style:normal; font-weight:400 600; font-display:swap;
  src:url(assets/fonts/jost-latin-ext.woff2) format('woff2');
  unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; }

body.zh {
  --red: #e64a26; --warm: #7e2f18; --ink: #191919; --mute: #8b8779;
  --serif: 'Fraunces', Georgia, serif;
  --geo: 'Space Grotesk', -apple-system, sans-serif;
  --gut: 8vw;                       /* leur gouttière, .d-f { padding: 0 8vw } */
  background: #dad5ca; color: #191919; padding-right: 0;
}
body.zh .rail, body.zh .hd { display: none; }

.borrowed { position: fixed; left: .6rem; bottom: .6rem; z-index: 90;
  background: var(--red); color: #fff; padding: .3rem .55rem; border-radius: .12rem;
  font-family: var(--geo); font-size: .56rem; letter-spacing: .1em; text-transform: uppercase; opacity: .8; }

/* ---------- nav : padding 5vh 0 5vh 4vw, liens 2.2vw ---------- */
.zh-nav { display: grid; grid-template-columns: repeat(5, 1fr);
  align-items: start; padding: 5vh 4vw 0; font-family: var(--geo); }
.zh-nav a { font-size: 2.2vw; letter-spacing: -.02em; }
.zh-nav .sub { display: block; font-size: .82vw; letter-spacing: .06em;
  text-transform: uppercase; margin-top: .8em; }
.zh-nav .mid { text-align: center; font-size: .82vw; letter-spacing: .06em;
  text-transform: uppercase; padding-top: .9em; }
.zh-nav .on, .zh-nav .on .sub { color: var(--red); }
.zh-nav .r { text-align: right; }
.zh-nav .home { display: none; }        /* visible seulement ≤1050, cf. plus bas */

/* ---------- fold 01 ---------- */
.zh-hero { padding: 8vh var(--gut) 6vh; position: relative;
  min-height: 78vh; display: flex; flex-direction: column; }
.zh-hero .zh-cue { margin-top: auto; }
.zh-smile { position: absolute; left: 15vw; top: -1vw; width: 8vw; height: 8vw;
  border-radius: 50%; border: .12vw solid #fff;
  display: flex; align-items: center; justify-content: center;
  color: #fff; font-size: 2.4vw; line-height: 0; padding-top: 1.6vw; }
/* ⚠️ 7.8vw est LEUR valeur, avec LEUR didone. Fraunces est plus large :
   à 7.8vw « It's all about my → design » cassait en deux lignes alors
   qu'elle tient chez eux. Calibré sur la largeur mesurée. */
.zh-h1 { font-family: var(--serif); font-weight: 400; font-size: 6.9vw;
  line-height: 1.18; letter-spacing: -.02em; }
.zh-h1 i { font-style: italic; }
.zh-arrow { display: inline-block; vertical-align: .2em; width: 12vw; height: .22vw;
  background: var(--ink); position: relative; margin: 0 2vw; }
.zh-arrow::after { content:''; position: absolute; right: 0; top: 50%;
  width: 1.1vw; height: 1.1vw; border-top: .22vw solid var(--ink); border-right: .22vw solid var(--ink);
  transform: translateY(-50%) rotate(45deg); }
.zh-l2 { display: flex; align-items: baseline; gap: 3vw; }
.zh-note { font-family: var(--geo); font-size: 1.2vw; width: 6.2vw; line-height: 1.5;
  text-transform: uppercase; padding-bottom: .5vw; }
.zh-chaps { display: flex; align-items: baseline; }
.zh-chaps a + a { padding-left: 2vw; }
.zh-chaps span { font-family: var(--serif); font-size: 1.5vw; color: var(--red); padding-right: .5vw; }
.zh-chaps b { font-family: var(--serif); font-weight: 400; font-size: 5.2vw; letter-spacing: -.01em; }
.zh-chaps .hot b { color: var(--red); }
.zh-chaps .warm b { color: var(--warm); }
.zh-chaps .ink b { color: var(--ink); }
.zh-cue { margin-top: 14vh; text-align: center; font-family: var(--geo);
  font-size: .82vw; letter-spacing: .06em; text-transform: uppercase; }

/* ---------- chapitres ---------- */
/* leur `article { padding: 0 8vw 5vw }` — padding-top NUL. Mon rythme
   vertical était deux fois trop lâche : chez eux Create et
   Implementation tiennent dans un seul écran. */
.zh-ch { padding: 0 var(--gut) 5vw; display: grid; gap: 2vw; }
.zh-letter { font-family: var(--geo); font-size: 2.2vw; }
.zh-word { font-family: var(--geo); font-weight: 400; font-size: 10.6vw;
  line-height: .88; letter-spacing: -.045em; }
.zh-num { font-family: var(--geo); font-size: 2.2vw; }
.zh-head { font-family: var(--serif); font-weight: 400; font-size: 3.4vw;
  line-height: 1.14; letter-spacing: -.01em; }
.zh-head .a { color: var(--red); display: block; }
.zh-body { font-family: var(--geo); max-width: 34vw; }
.zh-body p { font-size: 1.5vw; line-height: 1.7; }
.zh-body p + p { margin-top: 1.5em; }

/* fold 02 */
.zh-ch--learn { grid-template-columns: 42% 24% 1fr; align-items: start; }
.zh-list { margin-top: 3vw; font-family: var(--geo); }
.zh-list li { font-size: 1.5vw; padding: .3em 0; }
.zh-down { margin-top: 3vw; }
.zh-down i { display: block; width: 4.6vw; height: 2.1vw; border-bottom: .12vw solid var(--ink);
  position: relative; }
.zh-down i::after { content:''; position: absolute; right: 0; bottom: -.5vw;
  width: .9vw; height: .9vw; border-top: .12vw solid var(--ink); border-right: .12vw solid var(--ink);
  transform: rotate(45deg); }
/* leur .problem { padding-top:18vw } est calé sur LEUR hauteur de
   colonne ; ici la liste est plus courte, 8vw recale le bloc au même
   niveau relatif que sur la capture */
.zh-accent { position: relative; padding-top: 5vw; font-family: var(--geo); }
.zh-accent::before { content:''; position: absolute; left: 38%; top: 50%;
  transform: translate(-50%, -50%); width: 34vw; height: 34vw; border-radius: 50%; z-index: -1;
  background: radial-gradient(closest-side, #e8e6e2, rgba(232,230,226,0)); }
.zh-accent h3 { font-family: var(--geo); font-weight: 400; font-size: 4.1vw;
  letter-spacing: -.04em; line-height: .98; }
.zh-accent li { font-size: 1.7vw; padding: .2em 0; }
.zh-accent--serif h3 { font-family: var(--serif); letter-spacing: -.01em; line-height: 1.08; }

/* fold 03 */
.zh-ch--think { grid-template-columns: 30% 32% 1fr; align-items: center; }
.zh-ring-wrap { position: relative; }
.zh-ring { position: relative; width: 30vw; height: 30vw; margin: 0 auto; }
.zh-ring i { position: absolute; left: 50%; top: 50%; font-style: italic;
  font-family: var(--geo); font-weight: 500; font-size: 1.5vw; transform-origin: 0 0; }
.zh-ring-mid { position: absolute; left: 50%; top: 50%; transform: translate(-50%,-50%);
  font-size: 5vw; line-height: 1; }
.zh-ring-tail { text-align: center; font-size: 2.4vw; color: var(--mute); margin-top: 2vw; }
.zh-think-txt { display: grid; grid-template-columns: auto 1fr; gap: 1.2vw; align-items: start; }
.zh-kick { font-family: var(--geo); font-size: 1.2vw; line-height: 1.3;
  text-transform: uppercase; color: var(--red); }
.zh-star { color: var(--red); font-size: 2vw; margin-left: .3em; }

/* fold 04 */
.zh-ch--create { grid-template-columns: 42% 1fr; align-items: start; }
.zh-step { font-family: var(--serif); color: var(--red); }
.zh-it { font-style: italic; color: var(--red); display: block; }
.zh-rule { height: .13vw; background: var(--ink); margin: 2.4vw 0; }
.zh-pair { display: flex; align-items: baseline; gap: 1vw; }
.zh-pair em { font-style: normal; font-family: var(--geo); font-size: 1.1vw; color: var(--red); }

/* fold 05 */
.zh-ch--impl { grid-template-columns: 1fr; }
.zh-ch--impl .zh-num { margin-bottom: -1vw; }
.zh-ch--impl .zh-word { font-size: 12.6vw; }
.zh-steps { display: flex; justify-content: space-between; margin-top: .4vw; padding: 0 3vw;
  font-family: var(--serif); }
.zh-steps li { font-size: 1.3vw; }
.zh-impl-row { display: grid; grid-template-columns: 1fr 1fr; gap: 4vw; margin-top: 1.5vw;
  align-items: start; }
.zh-swap { font-family: var(--serif); font-size: 2.2vw; line-height: 1.25; width: 80%; }
.zh-swap s { color: var(--mute); }
.zh-arrows { display: flex; gap: 3vw; justify-content: flex-start; padding-top: 1.5vw; }
.zh-arrows i { font-style: normal; font-size: 3.6vw; color: #fff; line-height: 1; }

/* fold 06 */
/* chez eux tout le bloc collaboration est dans la colonne de DROITE,
   la gauche reste vide. J'avais mis le titre à gauche et le corps à
   droite, ce qui cassait complètement la composition. */
.zh-collab { padding: 3vw var(--gut) 10vh; display: grid; grid-template-columns: 1fr 1fr; gap: 2vw; }
.zh-collab > * { grid-column: 2; }
.zh-collab-sub { font-family: var(--serif); font-size: 1.7vw; line-height: 1.3; margin: 1vw 0 1.6vw; }

/* ---------- fold 07 ---------- */
.zh-outro { background: #eae9e6; padding-bottom: 5vh; overflow: hidden; }
.zh-big { font-family: var(--geo); font-weight: 400; white-space: nowrap;
  font-size: 12.4vw; letter-spacing: -.045em; line-height: 1; padding-left: var(--gut);
  will-change: transform; }
.zh-out-grid { display: grid; grid-template-columns: 1fr auto 1fr; align-items: stretch; }
.zh-out-side { display: flex; flex-direction: column; justify-content: space-evenly;
  padding: 12vh 0; font-family: var(--geo); }
.zh-out-side p { display: flex; align-items: center; gap: 2vw; font-size: 1.5vw; padding-left: var(--gut); }
.zh-out-side p::after { content:''; flex: 1; height: .07vw; background: rgba(25,25,25,.55); }
.zh-out-side--r p { flex-direction: row-reverse; padding: 0 var(--gut) 0 0; }
.zh-out-side .red { color: var(--red); }
.zh-img { width: 30vw; background: #6b5340; position: relative;
  display: flex; align-items: center; justify-content: center; }
.zh-img span { font-family: var(--serif); color: #fff; font-size: 2.1vw;
  border-bottom: .07vw solid #fff; padding-bottom: .1em; }
.zh-social { position: absolute; bottom: 3vh; left: 0; right: 0; display: flex;
  justify-content: space-evenly; font-family: var(--serif); font-size: 1.1vw; color: #fff; }

/* ============================================================
   1050px — leurs ajustements, pas une restructuration
   ============================================================ */
@media only screen and (max-width: 1050px) {
  /* leur règle : `nav .home, nav .spacer { display:none }` +
     `nav .container .home-main { display:block }` — le « GO HOME »
     centré disparaît et un « Home » apparaît en tête */
  .zh-nav .sub, .zh-nav .mid { display: none; }
  .zh-nav .home { display: block; order: -1; }
  .zh-nav a { font-size: 2.9vw; }
  /* ⚠️ NE PAS collapser les colonnes ici. Chez eux la structure à
     trois zones tient jusqu'à 600px : à 1050 ils ne font que
     rétrécir les colonnes (55% / 30%). J'avais collapsé, et le bloc
     « Problem » retombait sous la liste au lieu de rester à droite. */
  .zh-ch--learn { grid-template-columns: 38% 30% 1fr; }
  .zh-ch--think { grid-template-columns: 30% 34% 1fr; }
  .zh-body { max-width: 100%; }
  .zh-ring { width: 32vw; height: 32vw; }
  .zh-ring i { font-size: 1.9vw; }
}

/* ============================================================
   600px — restructuration complète. Tous les titres à 12.5vw,
   le petit corps en PX FIXES : en vw il devient illisible.
   ============================================================ */
@media only screen and (max-width: 600px) {
  body.zh { --gut: 13vw; }
  .zh-nav { grid-template-columns: 1fr 1fr; row-gap: 2vh; }
  .zh-nav a { font-size: 6vw; }

  .zh-hero { padding: 6vh var(--gut) 4vh; text-align: center; }
  .zh-smile { position: relative; left: auto; top: auto; width: 20vw; height: 20vw;
    margin: 0 auto 4vh; font-size: 6vw; padding-top: 4vw; }
  .zh-h1 { font-size: 12.5vw; line-height: 1.15; }
  /* la flèche disparaît au mobile : sans espace de remplacement,
     « my » et « design » se collent */
  .zh-arrow { display: inline-block; width: 0; margin: 0 .18em; background: none; }
  .zh-arrow::after { display: none; }
  .zh-l2 { display: block; }
  .zh-note { width: 100%; font-size: 0.8125rem; padding: 3vh 0; }
  .zh-chaps { display: block; }
  .zh-chaps a { display: block; }
  .zh-chaps a + a { padding-left: 0; }
  .zh-chaps b { font-size: 18vw; }
  .zh-chaps span { font-size: 4vw; }
  .zh-cue { margin-top: 6vh; font-size: 0.6875rem; }

  .zh-ch, .zh-collab, .zh-out-grid { display: block !important; text-align: center; }
  .zh-word { font-size: 18vw; }
  .zh-letter, .zh-num { font-size: 5vw; }
  .zh-head { font-size: 12.5vw; }
  .zh-body { max-width: 100%; padding-top: 3vh; }
  .zh-body p { font-size: 0.9375rem; }
  .zh-list { margin-top: 3vh; }
  .zh-list li { font-size: 0.9375rem; }
  .zh-down { display: none; }

  .zh-accent { padding-top: 5vh; text-align: left; padding-left: 50%; position: relative; }
  .zh-accent h3 { font-size: 12.5vw; }
  .zh-accent li { font-size: 0.9375rem; }
  .zh-accent::before { width: 70vw; height: 70vw; left: 50%; }

  .zh-ring { width: 70vw; height: 70vw; }
  .zh-ring i { font-size: 3.6vw; }
  .zh-ring-mid { font-size: 12vw; }
  .zh-think-txt { display: block; }
  .zh-kick { font-size: 0.8125rem; padding: 3vh 0 0; }
  .zh-star { display: block; height: 1.25rem; margin: 3vh auto; }

  .zh-step { font-size: 12.5vw; }
  .zh-pair { display: block; }
  .zh-pair em { font-size: 0.8125rem; text-transform: uppercase; }

  .zh-steps { display: block; padding: 0; }
  .zh-steps li { font-size: 7vw; padding: 1vh 0; }
  .zh-impl-row { display: block; }
  .zh-swap { font-size: 7vw; width: 80%; margin: 5vh auto; }
  .zh-arrows { margin: 3vh 0; }
  .zh-arrows i { font-size: 10vw; }

  .zh-collab-sub { font-size: 7vw; }
  .zh-big { font-size: 18vw; }
  .zh-out-side p { display: block; padding: 2vh var(--gut); font-size: 0.9375rem; }
  .zh-out-side p::after { display: none; }
  .zh-out-side--r p { padding: 2vh var(--gut); }
  .zh-img { width: 90%; margin: 4vh auto; aspect-ratio: 3/4; }
  .zh-img span { font-size: 6vw; }
  .zh-social { font-size: 0.8125rem; padding: 0 12vw; }
  /* ⚠️ 🔴 SCOPÉE `body:not(.ap)` — SANS ÇA, C'EST ELLE QUI EMPÊCHAIT
     L'ANNEAU « IDÉE » DE TOURNER SUR TÉLÉPHONE, et rien d'autre.

     Manish, trois fois de suite : « le cercle avec les labels idée, il
     ne tourne pas lors du scroll en mobile ». `fx.js` écrivait pourtant
     bien `style.transform = rotate(…)` à chaque image — j'ai vérifié la
     chaîne INLINE trois tours durant, et elle était juste. Ce que je
     n'avais pas regardé, c'est la valeur CALCULÉE : elle disait `none`.
     Cette ligne, non scopée et en `!important`, gagnait contre le style
     inline. Le JS écrivait, le CSS effaçait.

     ⚠️ ET ELLE EXPLIQUE AUSSI POURQUOI LE CUBE, LUI, TOURNAIT. Il ne
     porte ni `data-sp` ni `data-spin` : `drawCube` pose des attributs
     SVG sur ses arêtes. Deux objets côte à côte, un qui bouge et un qui
     ne bouge pas — la dissymétrie exacte que Manish décrivait, et la
     seule chose qui pouvait la produire.

     ⚠️ LE SEUIL EST 600, celui de la copie conforme (`body.zh`), pas 860
     celui de la page approche. C'est ce décalage qui a rendu le défaut
     invisible à toutes les vérifications faites entre les deux — à
     768 px l'anneau tourne très bien. Le piège du double seuil, encore.

     La règle RESTE pour `body.zh` : sa mise en page mobile empile tout
     en flux et n'attend aucune transformation. Elle n'a simplement plus
     à décider pour une page qui a, elle, son propre arbitrage
     (`on()` / `objOn()` dans `fx.js`), et qui nettoie déjà ses
     transformations quand elle les coupe. */
  body:not(.ap) [data-sp],
  body:not(.ap) [data-spin],
  body:not(.ap) [data-mx] { transform: none !important; }
}

/* `.ap-final h3` ajouté : le bloc de fin de chapitre a remplacé
   `.zh-accent h3` sur la page approche, et il était le seul titre à ne
   pas prendre l'aberration. */
.zh-h1, .zh-word, .zh-head, .zh-accent h3, .ap-final h3, .zh-big, .zh-chaps b {
  text-shadow: calc(var(--ab) * -1) 0 0 rgba(230,74,38,.45), var(--ab) 0 0 rgba(0,150,200,.45);
}
@media (prefers-reduced-motion: reduce) {
  [data-sp], [data-spin], [data-mx] { transform: none !important; }
  .zh-h1, .zh-word, .zh-head, .zh-accent h3, .ap-final h3, .zh-big, .zh-chaps b { text-shadow: none; }
}

/* ============================================================
   SKIN « accueil » sur la structure méthode — page approche
   ------------------------------------------------------------
   Même markup, mêmes classes, mêmes animations que methode.html.
   Seule la peau change : on repasse sur la palette et la typo de
   la page d'accueil v3. C'est la démonstration que la structure
   relevée sur la référence est réutilisable telle quelle, et que
   le reskin de la fin ne touchera qu'un bloc de variables.

   Un seul sélecteur porte tout : `body.ap` au lieu de `body.zh`.
   `--serif` pointe sur le même sans que `--geo` : plus de Fraunces,
   plus de beige. Aucune règle de mise en page n'est réécrite.
   ============================================================ */
/* ⚠️ Manish : « je ne veux pas de noirs purs ou de blancs purs sur le
   site ». Les extrêmes sont donc bannis : l'encre tire sur le brun
   très sombre, le fond sur le crème très pâle, et tout ce qui était
   #fff ou #000 passe par ces tokens. */
body.ap {
  --red: #fc1f00; --warm: #fc1f00;
  /* ⚠️ ORANGE — second accent, demandé par Manish (« thème du site rouge,
     orange »). Il ne remplace pas le rouge : les deux forment une PAIRE,
     et l'anneau de l'interstice interpole de l'un à l'autre autour du
     cercle. Aucune couleur en dur ailleurs : ces deux bornes sont la
     seule source. */
  --orange: #ff7a00;
  --ink:   #14120f;   /* encre, jamais #000 */
  --ink-2: #55524c;   /* texte secondaire */
  --mute:  #9d9a93;
  --paper: #fbfaf8;   /* fond, jamais #fff */
  /* ⚠️ JOST, ET LE CHANGEMENT EST ICI SEULEMENT — une ligne, sur
     `body.ap`. `body.zh` garde Space Grotesk : methode.html est la copie
     conforme de la référence et ne s'uniformise pas (invariant 3).
     Comme `--serif` pointe sur `--geo` juste en dessous, cette seule
     ligne emmène TOUTE la page — mots géants, titres et corps.
     ⚠️ À SURVEILLER : Jost a une hauteur d'x plus basse que Space
     Grotesk. Les mots géants y gagnent (c'est le registre de la
     référence), les paragraphes longs y perdent un peu en confort.
     Si le corps de texte paraît aéré, c'est là qu'il faut regarder —
     pas dans les tailles. */
  --geo: 'Jost', 'Space Grotesk', -apple-system, sans-serif;
  /* ⚠️ LE SERIF EST REBRANCHÉ, et cette ligne défait une décision.
     Elle disait `--serif: var(--geo)` — « le skin d'accueil est tout en
     sans ». Manish, après le passage à Jost : « j'aimerais un serif
     comme zhenya sur ce site, on fait un pairing de font ».

     Et c'est le bon sens de lecture de la référence : elle n'est PAS
     tout en sans. Relevé sur `zhenya.mov` à 20 s — « Contact Me » dans
     la plaque, « Dribbble / Behance / Twitter » sous elle, et sur la
     capture des chapitres « Long-lasting Collaboration » et sa
     sous-ligne : partout un serif à fort contraste, en second registre.
     La géométrique porte les MASSES (mots géants, corps), le serif
     porte les ADRESSES au lecteur (titres de section, sous-titres,
     l'appel de la plaque). C'est ça, leur pairing.

     ⚠️ Fraunces n'est pas un choix nouveau : c'est DÉJÀ le tenant-lieu
     du serif de la référence sur `body.zh` (methode.html, la copie
     conforme). Le pairing existait donc dans le dépôt ; la page approche
     était la seule à l'avoir débranché. Zéro fichier ajouté, et la
     police est déjà chargée par cette page — elle compose les fragments
     mathématiques du casque. */
  --serif: 'Fraunces', Georgia, serif;
  /* ⚠️ L'AXE `opsz` EST CE QUI FAIT LE CONTRASTE. Fraunces est variable :
     à `opsz` bas elle est trapue et douce (une police de texte), à
     `opsz` haut elle affine ses déliés et se rapproche du didone de la
     référence. Sans ce réglage, le pairing existe mais ne RESSEMBLE pas —
     c'est la différence entre « il y a un serif » et « c'est ce
     serif-là ». Posé sur `body.ap` : tout ce qui hérite en profite,
     y compris ce qui sera ajouté plus tard. */
  font-variation-settings: 'opsz' 144;

  /* ============================================================
     LA GRILLE — 12 colonnes, une seule gouttière
     ------------------------------------------------------------
     Manish : « le site devrait avoir une grille avec des gutters.
     grille de quoi, 8 ? on aura assez ? »

     ⚠️ 8 SUFFISAIT EN NOMBRE MAIS ÉCHOUAIT SUR DEUX POINTS, mesurés
     sur la page à 1440 px (largeur utile 1210) :

       · il ne divise pas par 6, alors que la rangée Implementation
         qu'on veut réintroduire en compte six. Il aurait fallu la
         sortir de la grille le jour même où on la pose ;
       · il DÉPLAÇAIT le validé : 4|1|3 donnait 590|126|436 contre
         502|130|491 aujourd'hui — le mot géant +18 %, le contenu −11 %.

     12 en 5|2|5 donne 487|178|487 : à 15 px près la composition
     actuelle, et il divise par 4 (rangée du hero) comme par 6.

     ⚠️ TOUT DÉRIVE DE `--gut`. C'est le point : avant, TROIS espacements
     horizontaux cohabitaient sans lien — marge calc(8 * var(--vwu)), gouttière 2,calc(5 * var(--vwu)),
     colonne du chapeau calc(9 * var(--vwu)), chacun posé à l'œil au fil des séances.
     Désormais une seule valeur les commande tous, et la grille se
     recalcule seule quand la marge change (voir le plafond plus bas).

     `--gap` est en POURCENTAGE de la largeur utile et non en `vw` :
     sinon, une fois la largeur plafonnée, la gouttière continuerait de
     grandir et mangerait les colonnes. */
  /* le tween de la cascade du hero — un seul curseur, cf. `.zh-chaps` */
  --casc-t: .28s;
  --gut: 8vw;
  --usable: calc(100vw - 2 * var(--gut));
  /* ⚠️ `--vwu` : UN « vw » QUI SE FIGE AVEC LA MISE EN PAGE. Sans lui, le
     plafond de largeur produit un bug net : à 2560 la grille est gelée à
     1360 px mais un `calc(2.5 * var(--vwu))` vaut toujours 64 px — dans la rangée du hero,
     « Prototypage » débordait de son créneau et percutait « Livrable ».
     Tant que --gut vaut 8vw, la largeur utile fait 84vw : `--vwu` vaut
     donc exactement calc(1 * var(--vwu)), et toutes les valeurs héritées se relisent
     telles quelles. Passé le plafond, elles cessent de grandir avec
     l'écran — comme la grille. */
  --vwu:    calc(var(--usable) / 84);
  --gap:    calc(2 * var(--vwu));
  --col:    calc((var(--usable) - 11 * var(--gap)) / 12);
  background: var(--paper); color: var(--ink); padding-right: 0;
}
/* ⚠️ PLAFOND. La page n'avait AUCUNE largeur maximale : tout en `vw`.
   Juste à 1440, illisible à 2560 — 5 colonnes y feraient 866 px, soit
   ~104 signes par ligne. Plutôt que de plafonner un conteneur (ce qui
   couperait les fonds pleine largeur de l'outro), on fait GROSSIR LA
   MARGE au-delà du seuil : la largeur utile se fige à 1360 px, la
   grille se recalcule toute seule puisqu'elle dérive de `--gut`, et les
   fonds restent pleine largeur.
   Sans effet en dessous de 1620 px — donc rien ne bouge sur l'écran de
   travail de Manish. Retirer ce bloc = revenir au pleine largeur. */
@media (min-width: 1620px) {
  body.ap { --gut: calc((100vw - 1360px) / 2); }
}
body.ap .rail, body.ap .hd { display: none; }   /* concepts : ni rail ni panier */
body.ap .zh-nav { display: none; }              /* et pas de menu non plus */

/* les rares endroits où la référence comptait sur le contraste
   serif / sans doivent retrouver un contraste ici : on le prend
   sur la GRAISSE et la casse, pas sur la famille */
body.ap .zh-head { font-weight: 500; letter-spacing: -.035em; }
/* ⚠️ LE H1 RESTE EN GÉOMÉTRIQUE — c'est la seule exception au serif
   rebranché, et elle vient de deux endroits qui disent la même chose.
   Manish, en voyant la première passe : « un seul partenaire en sans
   serif, ce sera plus lisible ». Et la référence fait pareil : chez elle
   le mot géant (« Implementation ») est dans la géométrique, c'est la
   RANGÉE EN DESSOUS qui est en serif. Le pairing n'est pas « le gros en
   serif », c'est « les masses en géométrique, les adresses au lecteur en
   serif ». Un H1 de 7 --vwu en didone, ce sont des déliés d'un pixel sur
   la plus grande surface de la page : joli en capture, illisible sur un
   écran de travail. */
body.ap .zh-h1 { font-family: var(--geo); font-weight: 400; letter-spacing: -.045em; line-height: 1.1; }
body.ap .zh-h1 i { font-style: normal; color: var(--red); }
body.ap .zh-chaps b { font-weight: 500; letter-spacing: -.035em; }
/* .zh-steps / .zh-swap / .zh-it / .zh-accent / .zh-pair / .zh-arrows :
   ces gabarits n'existent plus sur la page approche depuis le passage au
   chapitre unique (cf. « UNE SEULE GRILLE » en fin de fichier). Leurs
   surcharges `body.ap` ont été retirées — les classes restent vivantes
   pour methode.html, qui est la copie conforme de la référence. */
body.ap .zh-smile { border-color: var(--red); color: var(--red); }
/* cinq mots de chapitre au lieu de trois : à calc(5.2 * var(--vwu)) la rangée débordait.
   Recalé sur le nombre réel, et autorisé à passer à la ligne. */
body.ap .zh-chaps { flex-wrap: wrap; row-gap: .4vw; }
body.ap .zh-chaps b { font-size: calc(3.2 * var(--vwu)); }
body.ap .zh-chaps span { font-size: calc(1.1 * var(--vwu)); }
body.ap .zh-chaps a + a { padding-left: calc(1.4 * var(--vwu)); }
/* le titre tient en deux lignes, pas trois */
/* « que la phrase prenne tout l'écran », comme chez la référence :
   leur ligne va d'une gouttière à l'autre. Calibré sur la ligne la
   plus longue — « la manière de travailler » — pour qu'elle remplisse
   la largeur utile sans déborder. */
/* Calibré sur la ligne la PLUS LONGUE — « De la stratégie au code » —
   pour qu'elle tienne sur une seule ligne. À calc(8.6 * var(--vwu)) elle cassait en deux
   et le titre passait à trois lignes. La première ligne, plus courte,
   n'est jamais contrainte : la flèche y absorbe le reste. */
/* la taille est POSÉE PAR JS (fitTitle) : elle s'ajuste à la ligne la
   plus longue, donc à la langue et à la largeur. Le vw ci-dessous n'est
   qu'une valeur de départ avant mesure. */
body.ap .zh-h1 { font-size: calc(7.2 * var(--vwu)); line-height: 1.08; }
body.ap .zh-h1 span { white-space: nowrap; }
body.ap .zh-h1 u { text-decoration: none; }
body.ap .zh-note { font-size: calc(1.05 * var(--vwu)); width: calc(7.5 * var(--vwu)); }
body.ap .zh-outro { background: #f0efec; }
body.ap .zh-img { background: #0a0a0a; }
body.ap .zh-out-side p::after { background: rgba(10,10,10,.4); }
/* ⚠️ 1,72 ET NON 1,5 — RELEVÉ SUR LA VIDÉO, pas remonté à l'oeil.
   Manish : « augmente la typo pour qu'elle ait la même taille [que
   zhenya] ». Mesuré sur `02-docs/zhenya.mov` à 20 s, image ramenée à
   1280 px de large : « let's make » monte à 16 px de la ligne de base au
   sommet des ascendantes, « together » fait 21 px d'ascendante à
   descendante — soit un corps d'environ 22 px, donc 1,72 % de la largeur
   de fenêtre. Le 1,5vw partagé était sous-évalué de 15 %.

   ⚠️ EN `--vwu` ET NON EN `vw` NU (invariant 1) : la règle partagée est
   en `vw`, ce qui est juste pour methode.html mais faux ici — passé le
   plafond de 1620 px la mise en page se fige et un `vw` nu continuerait
   de grandir tout seul.

   ⚠️ La règle partagée reste à 1,5vw : methode.html ne s'uniformise pas
   (invariant 3). Mais la mesure vaut pour elle aussi — c'est le même
   plan de la même vidéo. À reprendre là-bas dans une passe qui ne
   concerne QUE la copie conforme. */
body.ap .zh-out-side p { font-size: calc(1.72 * var(--vwu)); }

/* ---------- hero de la page approche : bloc centré, sans flèche ni rond ----------
   Manish : « c'est censé être l'intro donc centré ». Chez la référence
   le bloc est calé à GAUCHE sur la gouttière mais centré VERTICALEMENT
   dans l'écran — c'est cette lecture-là qui est appliquée. */
body.ap .zh-smile { display: none; }
/* La flèche revient en fin de PREMIÈRE ligne, et sa pointe doit tomber
   sur la fin de « la manière de travailler ». Plutôt que de deviner une
   largeur, on rend la ligne 1 flexible et on laisse la flèche ABSORBER
   l'espace restant : les deux lignes finissent alors exactement au même
   point, quel que soit le texte ou la largeur de l'écran.
   Épaisseur relevée sur la référence : nettement plus grasse que la
   mienne, .2vw → .3vw, et la pointe suit. */
/* La flèche passe sur la SECONDE ligne et la précède. Elle absorbe la
   largeur restante : « Du problème » en haut à gauche, « au code » en
   bas à droite, reliés par le trait. Le bloc devient un diagramme de la
   promesse, pas seulement une phrase. */
/* La flèche revient en fin de PREMIÈRE ligne — « Un seul partenaire ⟶ »
   — et absorbe la largeur restante, si bien que les deux lignes finissent
   au même point quel que soit le texte. */
/* la ligne QUI PORTE la flèche devient flexible, quelle qu'elle soit */
body.ap .zh-h1 span:has(.zh-arrow) { display: flex; align-items: baseline; gap: calc(2.4 * var(--vwu)); }
body.ap .zh-arrow { flex: 1; height: .3vw; background: var(--ink);
  position: relative; margin: 0; }
body.ap .zh-arrow::after { content:''; position: absolute; right: 0; top: 50%;
  width: calc(1.25 * var(--vwu)); height: calc(1.25 * var(--vwu));
  border-top: .3vw solid var(--ink); border-right: .3vw solid var(--ink);
  transform: translateY(-50%) rotate(45deg); }
body.ap .zh-hero {
  min-height: 100vh; padding: 0 var(--gut);
  display: flex; flex-direction: column; justify-content: center;
}
body.ap .zh-hero-in { margin-bottom: auto; margin-top: auto; }
body.ap .zh-cue { margin: 0 0 6vh; }

/* les deux lignes forment UN bloc : même colonne, même alignement.
   Avant, « Tout se joue dans la » et le mot rouge repartaient en deux
   blocs distincts et la composition se lisait cassée. */
body.ap .zh-h1 { display: block; }
body.ap .zh-h1 span { display: block; }
body.ap .zh-h1 .l2 { color: var(--red); }

/* les phases passent SOUS le titre, en rangée */

body.ap .zh-chaps { flex-wrap: nowrap; }
body.ap .zh-chaps b { font-size: calc(2.8 * var(--vwu)); }

/* ---------- la boucle de couleur ----------
   Le tween est ici : sans transition, l'animation commute au lieu de
   fondre, et c'est tout ce qui fait sa douceur.

   ⚠️ UN SEUL CURSEUR POUR LE TWEEN. La durée était écrite quatre fois
   (couleur du mot, couleur du repère, montée du lien, descente du
   micro-texte) : la changer d'un seul côté désynchronisait la couleur
   du mouvement, alors que tout l'intérêt de piloter les deux par UNE
   classe est qu'ils soient forcément simultanés.

   Elle est passée de .42 à .34 s en même temps que le rythme de la
   cascade a été raccourci (Manish : « lisible mais plus rapide »). Le
   tween doit rester une fraction franche du pas : à .42 s pour un pas
   de 1,9 s, la montée occupait encore un quart du temps d'affichage et
   c'est elle qu'on lisait, pas le mot. */
body.ap .zh-chaps b,
body.ap .zh-chaps span { transition: color var(--casc-t) ease; }
body.ap .zh-chaps a b { color: var(--ink); }
body.ap .zh-chaps a.lit b { color: var(--red); }

/* ---- l'état « monté » ----
   ⚠️ CONSIGNE INVERSÉE en cours de route, et c'est la seconde qui vaut :
   d'abord « les A, B, C ne bougent pas », puis « ce n'est pas beau si le
   A, B ou C ne bouge pas ». Le repère monte donc AVEC son mot — le
   transform est reporté sur le lien, qui les porte tous les deux. */
body.ap .zh-chaps a {
  display: inline-flex; align-items: baseline;
  transform: translateY(0);
  transition: transform var(--casc-t) cubic-bezier(.4,0,.2,1);
}
body.ap .zh-chaps b {
  display: inline-block;
  transition: color var(--casc-t) ease;
}
body.ap .zh-chaps a.lit { transform: translateY(-.9vw); }

/* ============================================================
   Rangée de phases — bloc AUTORITAIRE, en fin de fichier
   ------------------------------------------------------------
   Trois règles `.zh-chaps` se contredisaient plus haut : deux de mes
   remplacements avaient échoué en silence, et la dernière déclaration
   gagnait au hasard de l'ordre. Elles sont supprimées ; tout est ici.

   Manish : « peut-être que learn, think etc. prennent toute la largeur
   de la manière de travailler ». La rangée occupe donc la même colonne
   que le titre et se répartit d'un bord à l'autre.
   ============================================================ */
body.ap .zh-l2 { display: block; margin-top: calc(2.4 * var(--vwu)); }
body.ap .zh-chaps {
  display: flex; flex-wrap: nowrap; width: 100%;
  justify-content: space-between; align-items: baseline; gap: 0;
}
body.ap .zh-chaps a { display: inline-flex; align-items: baseline; padding: 0; }
body.ap .zh-chaps a + a { padding-left: 0; }
/* ⚠️ LES REPÈRES A B C D RESTENT EN GÉOMÉTRIQUE — Manish : « les A, B, C
   (juste les lettres : toujours du sans serif) ». Ils sont passés en
   serif avec le reste de `.zh-chaps` en rebranchant `--serif`, et c'est
   un cas où la règle générale a tort : un repère n'est pas une adresse
   au lecteur, c'est une ÉTIQUETTE. À 1 --vwu, un didone ne donne plus
   qu'un empattement d'un demi-pixel — la lettre perd sa forme sans rien
   gagner. Le mot à côté garde le serif : c'est lui qu'on lit. */
body.ap .zh-chaps span {
  font-family: var(--geo);
  font-size: calc(1.05 * var(--vwu)); padding-right: .35vw;
}
body.ap .zh-chaps b { font-size: calc(3.4 * var(--vwu)); font-weight: 500; letter-spacing: -.035em; }

/* ============================================================
   Ajustements du hero — retours de Manish
   ============================================================ */

/* La flèche s'aligne sur la hauteur de CASSE BASSE, pas sur la boîte
   de ligne. `ex` est exactement l'unité de hauteur d'x : on cale sur
   la ligne de base puis on remonte d'un demi-x. Le trait tombe alors
   au milieu des minuscules, comme chez la référence. */

body.ap .zh-arrow { transform: translateY(-.5ex); }

/* Plus d'air entre le titre et la rangée de phases : « ça doit être
   beau et graphique ». */
body.ap .zh-l2 { margin-top: calc(6 * var(--vwu)); }

/* « Défilez pour lire » était bien trop petit. Manish a vu pourquoi :
   chez la référence il n'est PAS responsive — taille fixe, donc il ne
   rétrécit pas avec la fenêtre. En rem plutôt qu'en px pour rester
   relatif au réglage du navigateur. */
body.ap .zh-cue { font-size: .78rem; letter-spacing: .1em; }

/* ---------- switcher de langue, flottant ---------- */
.lang-sw {
  position: fixed; right: calc(1.6 * var(--vwu)); top: calc(1.6 * var(--vwu)); z-index: 95;
  display: flex; align-items: center; gap: .5vw;
  font-family: var(--geo, sans-serif); font-size: .72rem;
  letter-spacing: .1em; text-transform: uppercase;
}
.lang-sw button {
  background: none; border: 0; cursor: pointer; padding: .2rem;
  font: inherit; letter-spacing: inherit; color: #a8a8a8;
  transition: color .25s ease;
}
.lang-sw button.on { color: var(--red, #fc1f00); }
.lang-sw button:hover { color: var(--ink, #0a0a0a); }
.lang-sw i { width: .06rem; height: .8rem; background: #cfcfcf; }

/* ============================================================
   La rangée : ÉTATS visibles, verbe révélé au passage
   ------------------------------------------------------------
   L'état dit ce que le projet devient ; le verbe, ce que Manish y
   fait. Le second n'apparaît que sur l'étape active — sinon la
   rangée redevient un menu de quatre lignes.
   ============================================================ */
body.ap .zh-chaps a { position: relative; flex-direction: row; }
body.ap .zh-chaps b { text-transform: uppercase; letter-spacing: -.02em; }
/* Le verbe s'aligne sur le MOT, pas sur le repère « A. », et sa largeur
   est celle du mot — il revient donc à la ligne au lieu de s'étaler.
   D'où le conteneur `u.w` qui enveloppe mot + verbe : c'est lui qui
   donne l'origine et la largeur. */
body.ap .zh-chaps .w { position: relative; display: inline-block; text-decoration: none; }
/* ⚠️ AUCUN TWEEN D'OPACITÉ SUR DU TEXTE (Manish). Le micro-texte
   était en `opacity: 0` et ne se révélait que sur l'étape active.
   Sans fondu, deux issues seulement : une coupe sèche quatre fois par
   cycle, ou l'affichage permanent. C'est le permanent qui est retenu —
   la place lui était déjà réservée (padding-bottom de .zh-l2), et la
   cascade continue de se lire par la COULEUR et la MONTÉE, qui ne sont
   pas des opacités. */
body.ap .zh-chaps em {
  position: absolute; left: 0; top: 100%; width: 100%;
  margin-top: .7vw; white-space: normal; line-height: 1.35;
  font-style: normal; font-size: 1.15rem; letter-spacing: .01em;
  color: var(--ink-2);
  pointer-events: none;
}

/* le trait s'aligne sur la casse basse de sa ligne */
body.ap .zh-h1 .zh-arrow { transform: translateY(-.5ex); }

/* pendant la mesure du titre, la flèche est retirée du flux : sinon son
   `flex: 1` masque la largeur réelle du texte (voir fitTitle) */
body.ap .zh-h1.measuring .zh-arrow { display: none; }

/* ============================================================
   Alléger la rangée — « ça doit respirer et être élégant »
   ------------------------------------------------------------
   Ce qui fait respirer la référence n'est pas d'abord le corps :
   ses mots sont en ROMAIN BAS DE CASSE dans un serif léger, quand
   les miens étaient en CAPITALES, en demi-gras, dans un sans
   géométrique — soit trois fois plus de matière noire à surface
   égale. On garde notre recette et on joue les quatre leviers qui
   allègent : casse, graisse, corps, et le vide en dessous.
   ============================================================ */
body.ap .zh-chaps b {
  text-transform: none;          /* la capitale était le premier fautif */
  font-weight: 400;              /* 500 → 400 */
  font-size: var(--t-head);      /* le MÊME cran que les titres de chapitre */
  letter-spacing: -.025em;
}
body.ap .zh-chaps span { font-size: .85vw; }
/* le repère se pose sur la ligne de base du mot, plus discret */
body.ap .zh-chaps { align-items: baseline; }

/* du vide sous la rangée : c'est lui qui fait la respiration, autant
   que la typo. Le micro-texte flottant en absolu ne pousse rien, on
   réserve donc la place à la main. */
body.ap .zh-l2 { margin-top: calc(5 * var(--vwu)); padding-bottom: calc(9 * var(--vwu)); }
body.ap .zh-chaps em { font-size: 1rem; margin-top: calc(1 * var(--vwu)); }

/* ============================================================
   Le micro-texte prend la largeur du CRÉNEAU, pas celle du mot
   ------------------------------------------------------------
   La règle « largeur du texte = largeur du mot » tenait avec des
   textes courts ; avec ceux d'aujourd'hui elle produisait quatre
   rubans de six lignes. Le texte garde son ALIGNEMENT sur le mot —
   c'était le point important — mais il respire sur la largeur du
   créneau.

   D'où le passage de flex/space-between à une grille de quatre
   colonnes égales : c'est elle qui définit le créneau. Effet de bord
   assumé et souhaitable — le dernier mot ne vient plus buter contre
   le bord droit, ce qui faisait lire la rangée comme un menu.
   ============================================================ */
body.ap .zh-chaps {
  display: grid; grid-template-columns: repeat(12, minmax(0, 1fr));
  column-gap: var(--gap); align-items: baseline; width: 100%;
}
body.ap .zh-chaps a { grid-column: span 3; }   /* 4 domaines x 3 colonnes */
body.ap .zh-chaps a { justify-self: start; position: static; }
/* L'ORIGINE reste le mot — c'était le point important — mais la
   LARGEUR devient celle du créneau. Un créneau vaut (calc(84 * var(--vwu)) − 3 × calc(2.5 * var(--vwu)))
   / 4 ≈ calc(19 * var(--vwu)) ; on en retire la place du repère « A. » pour que le bloc
   de texte reste dans sa colonne. */
body.ap .zh-chaps .w { position: relative; }
/* le micro-texte prend son CRÉNEAU de 3 colonnes, moins la place du
   repère « A. » — il reste ainsi aligné sur le MOT et pas sur le repère,
   ce qui était le point acquis, mais sa largeur vient de la grille. */
body.ap .zh-chaps em {
  position: absolute; left: 0; right: auto;
  width: calc(3 * var(--col) + 2 * var(--gap) - calc(1.6 * var(--vwu)));
}

/* ⚠️ Conséquence directe du retrait du fondu : le micro-texte étant
   désormais TOUJOURS visible, il monte avec son mot — et les quatre
   blocs de texte se retrouvent sur quatre lignes de base différentes,
   qui bougent en permanence. Tant qu'il apparaissait à l'activation,
   personne ne pouvait le voir.
   Le transform reste sur le lien (le repère « A. » doit monter avec son
   mot, consigne de Manish) et le micro-texte le COMPENSE exactement :
   le mot monte, le texte ne bouge pas. */
body.ap .zh-chaps em { transition: transform var(--casc-t) cubic-bezier(.4,0,.2,1); }
body.ap .zh-chaps a.lit em { transform: translateY(.9vw); }

/* aucun blanc pur non plus dans les éléments hérités du gabarit.
   ⚠️ `.zh-arrows i` était dans ce groupe : les flèches du chapitre
   Livrable se retrouvaient couleur papier SUR le papier, donc
   invisibles. Elles gardent le gris de la règle plus haut. */
body.ap .zh-img span,
body.ap .zh-social { color: var(--paper); }
body.ap .zh-img span { border-bottom-color: var(--paper); }
body.ap .zh-outro { background: #f2efea; }
body.ap .zh-img { background: var(--ink); }

/* ============================================================
   LA PLAQUE DE L'OUTRO — les liens SOUS la photo
   ------------------------------------------------------------
   Manish : « enlève dribbble, mets juste linkedin et email en dessous
   de la photo ». La référence les pose DANS son image, en absolu, en
   blanc ; ici ils en sortent, donc ils retombent sur le fond clair et
   doivent repasser à l'encre — sinon ils sont blancs sur crème, donc
   invisibles (c'est exactement le piège déjà payé avec les flèches du
   chapitre Livrable, juste au-dessus).

   `methode.html` garde sa version : la copie conforme ne s'uniformise
   pas (invariant 3). D'où le wrapper `.zh-plate`, qui n'existe que sur
   la page approche.

   La photo arrive en `background-image` par-dessus l'encre : tant
   qu'elle n'est pas fournie, la plaque reste pleine et le libellé
   lisible dans les deux cas. Rien à changer le jour où elle arrive,
   sinon la chaîne `photo` dans content/approach.js.
   ============================================================ */
body.ap .zh-plate { display: flex; flex-direction: column; align-self: center; }
body.ap .zh-plate .zh-img {
  /* +50 % sur demande de Manish : 30 → 45 unités de largeur utile.
     `--vwu` et non `vw` — au-delà du plafond de 1620 la plaque cesse de
     grandir avec l'écran, comme le reste de la grille.

     ⚠️ La HAUTEUR ne vient plus de la rangée. Elle était étirée par le
     `align-items: stretch` de `.zh-out-grid`, donc élargir la plaque
     l'aurait laissée à sa hauteur d'avant : plus large, pas plus
     grande. L'`aspect-ratio` reprend la proportion d'origine (432×205 à
     1440) et fait suivre les deux dimensions.

     ⚠️ 5/4 EST LE FORMAT NATIF DE LA PHOTO (2000 × 1598). C'est le
     seul rapport qui ne recadre RIEN : ni le casque en haut, ni les
     bottes en bas, ni le panorama sur les côtés. Le 45/21 des essais
     précédents venait de la plaque vide, où aucune image ne dictait
     quoi que ce soit — il aurait tranché la silhouette en une bande de
     torse. Si la photo change, cette ligne se règle sur SA proportion,
     pas sur un goût de mise en page. */
  /* ⚠️ 3/4 PORTRAIT, ET LA LARGEUR BAISSE AVEC. C'est une contrainte de
     hauteur, pas un revirement : l'outro doit tenir dans un écran (le
     grand titre, la plaque, les liens). À 45 unités de large, un 3/4
     ferait 864 px de haut et l'outro déborderait de 200 px. 32 donne
     461 x 615, soit une SURFACE deux fois supérieure à la plaque
     paysage de 648 x 302 qu'elle remplace — plus étroite, mais bien
     plus présente. */
  width: calc(32 * var(--vwu));
  aspect-ratio: 3 / 4;
  background-size: cover; background-position: center;
}
/* ⚠️ LE VOILE, ET POURQUOI IL EXISTE. « Écrivez-moi » est blanc souligné
   de blanc, au centre de la plaque — c'est-à-dire pile sur la
   combinaison, la partie la plus claire et la plus bariolée de l'image.
   Sur l'encre pleine il se lisait tout seul ; sur la photo, non. Le
   voile est un dégradé et non un aplat : il pèse au centre, où se pose
   le libellé, et relâche vers les bords pour laisser le ciel et les
   lumières de la ville intacts. */
body.ap .zh-plate .zh-img::before {
  content: ''; position: absolute; inset: 0;
  /* l'ellipse suit le format : large et basse en portrait, pour poser
     une bande derrière le libellé sans assombrir le ciel ni le sol */
  background: radial-gradient(ellipse 82% 26% at 50% 50%,
              rgba(10,10,10,.64), rgba(10,10,10,.14) 74%, rgba(10,10,10,0));
}
/* le libellé repasse AU-DESSUS du voile : sans ça le pseudo-élément,
   plus tard dans l'ordre de peinture, le recouvrirait */
body.ap .zh-plate .zh-img span { position: relative; z-index: 1; }

/* ============================================================
   LE TITRE TRAVERSE LA PLAQUE ET S'Y INVERSE
   ------------------------------------------------------------
   Manish : « que le texte chevauche, avec les lettres qui inversent les
   couleurs de l'image ».

   C'est la technique déjà relevée au pixel sur `lg2.com/services` et
   consignée le 2026-08-05 : **ce n'est pas deux calques**, c'est du
   texte BLANC en `mix-blend-mode: difference`. Le blend rend
   `255 − fond` : sur le crème de l'outro il tombe quasi noir, sur
   l'encre de la plaque il repasse quasi blanc. Une seule phrase, deux
   lectures, et le raccord est exact au pixel puisqu'il est calculé et
   non dessiné.

   ⚠️ LES DEUX PIÈGES, déjà payés une fois sur l'accueil :

   1. **Le groupe d'isolement doit contenir la plaque ET porter un
      fond.** Sans `isolation: isolate` sur `.zh-outro`, le blend
      remonte jusqu'à la racine et se calcule sur un empilement qu'on ne
      maîtrise pas. Et sans fond explicite dans ce groupe, le texte
      blanc reste blanc — donc invisible sur le papier.
   2. **Le `z-index` va sur le TEXTE, jamais sur un wrapper qui
      contiendrait aussi l'image** : isoler ce wrapper couperait le
      texte de son fond, et il cesserait de « voir » la plaque.

   `.zh-big` porte déjà un `transform` (la traversée), donc déjà un
   contexte d'empilement ; il lui manquait seulement de passer DEVANT
   `.zh-img`, qui est en `position: relative` et vient après lui dans le
   DOM. D'où le `z-index: 2`.

   ⚠️ Conséquence assumée sur la couleur : hors de la plaque, le blend
   donne un noir froid (#0d1015) et non l'encre chaude du reste de la
   page. C'est inhérent au procédé — l'alternative serait deux calques
   et un raccord à la main, qui se dérègle au premier changement de
   taille.

   ⚠️ PAGE APPROCHE SEULEMENT (invariant 3).
   ============================================================ */
/* ============================================================
   L'INTRO — le titre s'écrit, la flèche se trace, la rangée s'ouvre
   ------------------------------------------------------------
   ⚠️ AUCUN FONDU. La consigne « aucun tween d'opacité sur du texte » a
   été assouplie pour le SCROLL et pour lui seul (voir `data-fade` dans
   fx.js). L'intro s'en tient aux trois moyens qui n'en sont pas : la
   lettre qui apparaît, le trait qui se trace, le volet qui s'ouvre.

   ⚠️ `visibility` ET NON `display` NI `opacity` : elle cache sans
   retirer de la place. Le titre occupe donc sa largeur finale dès la
   première lettre, et `fitTitle()` — qui mesure la ligne la plus longue
   pour choisir le corps — reste juste pendant toute la frappe. Avec des
   lettres qui entrent dans le flux, la ligne grandirait à chaque frappe
   et l'ajustement serait faux du début à la fin.
   ============================================================ */
/* ⚠️ PIÈGE PAYÉ : `body.ap .zh-h1 i` pose `color: var(--red)` — c'était
   la deuxième ligne du titre dans le markup d'origine. Les lettres de
   la frappe étant elles aussi des `<i>`, elles héritaient du rouge et
   TOUT le titre virait, les deux lignes. La couleur doit donc être
   rendue à la ligne qui la porte (`.l1` encre, `.l2` rouge). */
body.ap .zh-h1 .tw { color: inherit; font-style: inherit; }
/* ⚠️ ET LE MÊME PIÈGE DE L'AUTRE CÔTÉ : `body.ap .zh-h1 span` pose
   `display: block` (c'est ce qui met les deux lignes du titre l'une
   sous l'autre). L'enveloppe de mot étant un `span`, elle en héritait —
   chaque MOT partait sur sa propre ligne. La règle doit donc vivre ici,
   pas dans le bloc mobile où je l'avais d'abord mise : elle vaut à
   toutes les largeurs. */
body.ap .zh-h1 .tww { display: inline; white-space: nowrap; }
body.ap .zh-h1 .tw { visibility: hidden; }
body.ap .zh-h1 .tw.on { visibility: visible; }
/* LE CURSEUR DE FRAPPE — un pavé plein, à la ms dos.
   Manish : « le curseur devrait être un rectangle plein plutôt que
   juste un sélecteur. »

   ⚠️ C'EST TOUJOURS UN BORD, et c'est ce qui le rend gratuit : un bord
   droit épais annulé par une marge négative de la même valeur ne coûte
   pas un pixel de mise en page. Le titre est mesuré par `fitTitle()` à
   la ligne près — un pavé qui prendrait sa place ferait rétrécir tout
   le titre à chaque frappe. Ne pas remplacer par un `::after` en flux.

   ⚠️ ET IL DÉBORDE SUR LA LETTRE SUIVANTE, exactement comme une console
   où le curseur occupe la CASE à écrire. Ça marche parce que les
   lettres à venir sont en `visibility: hidden` : elles gardent leur
   place, invisibles, sous le pavé. */
body.ap .zh-h1 .tw.cur {
  border-right: .5em solid var(--red); margin-right: -.5em;
}
/* ⚠️ LE CLIGNOTEMENT EST EN `steps`, JAMAIS EN FONDU. Un curseur qui
   s'estompe est un curseur de traitement de texte ; celui d'une console
   est franc. `steps(1, end)` sur deux intervalles donne le tout-ou-rien.
   Il ne joue qu'à l'arrêt — voir `type()` dans app.js. */
body.ap .zh-h1 .tw.cur.bl { animation: zh-dos 1.06s steps(1, end) infinite; }
@keyframes zh-dos { 50% { border-right-color: transparent; } }
/* ⚠️ La flèche compose DEUX transformations. Elle portait déjà un
   `translateY(-.5ex)` (calage sur la ligne de base) ; le tracé s'ajoute
   par une variable plutôt que de réécrire la propriété, sinon l'un des
   deux réglages écrase l'autre. */
body.ap .zh-h1 .zh-arrow {
  transform: translateY(-.5ex) scaleX(var(--arw, 1));
  transform-origin: left center;
  /* ⚠️ .9s ET `T_ARROW = 820` DANS app.js VONT ENSEMBLE. Le JS attend
     avant de lancer la seconde ligne ; si le trait dure plus longtemps
     que cette attente, le titre s'écrit par-dessus une flèche encore en
     train de se tracer. Manish : « la flèche est trop rapide » — les
     deux valeurs ont été relevées du même geste. */
  transition: transform .9s cubic-bezier(.22,.7,.2,1);
}
/* la pointe n'apparaît qu'une fois le trait tracé : une flèche dont la
   tête arrive avant sa hampe se lit comme un défaut de rendu.
   ⚠️ Le retard suit la durée du trait — .34s le calait sur .52s, il
   faut .62s pour .9s, sinon la pointe revient trop tôt. */
body.ap .zh-h1 .zh-arrow::after {
  transition: opacity .18s linear .62s;
  opacity: var(--arw, 1);
}
/* LES QUATRE DOMAINES — un fondu en cascade, et c'est une DÉROGATION.
   Manish, 2026-08-12 : « les 4 A B C D arrivent trop vite aussi. tween
   opacité avec un beau ease out les uns à la suite des autres. »

   ⚠️ L'INTERDIT DE L'OPACITÉ SUR DU TEXTE TIENT PARTOUT AILLEURS. Il a
   été assoupli une première fois pour le scroll (2026-08-10), une
   seconde ici — et cette seconde ne vaut QUE pour ces quatre liens, à
   l'entrée. `.zh-cue` juste en dessous garde son volet, l'écriture du
   titre garde ses lettres et son trait : c'est ce qui garde la
   dérogation lisible au lieu de la rendre contagieuse.

   ⚠️ La transformation de la cascade EST RÉÉCRITE ICI, volontairement :
   `.zh-chaps a` porte déjà `transition: transform var(--casc-t)` plus
   haut, et une seconde déclaration de `transition` l'écraserait au lieu
   de s'y ajouter. Les deux tiennent donc dans une seule liste.

   ⚠️ AUCUNE VALEUR D'OPACITÉ AU REPOS. Le fondu est piloté depuis
   `introduce()` par une propriété inline qu'on RETIRE — la règle ne
   déclare donc pas d'`opacity`, et l'élément posé n'en porte aucune. */
/* ⚠️ RALENTI ET ADOUCI UNE SECONDE FOIS. Manish : « stratégie,
   prototypage etc. arrivent toujours de manière trop rapide en anim in.
   plus doux. ease out. »

   Deux valeurs, et elles ne font pas la même chose — c'est ce que la
   première passe avait raté en ne touchant qu'à la durée :

     · la DURÉE (.78 → 1.15 s) donne au fondu le temps de se voir ;
     · la COURBE fait la douceur. `cubic-bezier(.2,.8,.25,1)` est déjà
       sortant, mais il finit sa course tôt et le mot se POSE. Le nouveau
       (.16,1,.3,1) est un exponentiel sortant : il consomme 80 % du
       chemin dans le premier tiers du temps, puis rampe. C'est ce
       dernier tiers qui se lit comme « doux » — le mot n'arrive jamais
       vraiment, il cesse d'arriver.

   Le décalage entre les quatre est dans app.js (`T_STAG`) : la durée
   ci-dessous les fait se CHEVAUCHER, ce qui est voulu. Quatre fondus qui
   s'attendent proprement font une liste ; quatre qui se recouvrent font
   une vague. */
body.ap .zh-chaps a {
  transition: transform var(--casc-t) cubic-bezier(.4,0,.2,1),
              opacity 1.15s cubic-bezier(.16,1,.3,1);
}
body.ap .zh-cue { transition: clip-path .62s cubic-bezier(.22,.7,.2,1); }
/* ⚠️ Le temps de poser l'état de départ de l'intro, TOUTE transition du
   hero est coupée — sinon la valeur initiale s'anime elle-même, à
   rebours. Voir `introduce()` dans app.js. */
body.ap .zh-hero.no-t,
body.ap .zh-hero.no-t * { transition: none !important; }

/* ⚠️ 🔴 PLUS DE mix-blend-mode, PLUS D'isolation SUR CETTE PAGE. Manish :
   « plutôt que ça blende, le texte qui blendait devient juste blanc — ce
   sera plus joli en plus ». Il a raison sur les deux tableaux :

     · à l'oeil, le blend rendait hors de la plaque un noir FROID
       (255 - fond, soit #0d1015) au lieu de l'encre chaude du reste de la
       page — un écart que j'avais consigné comme « assumé » et qui ne
       l'était pas vraiment. Deux calques donnent l'encre exacte dehors et
       un blanc franc dedans ;
     · à la machine, un blend sort WebKit du chemin accéléré et lui fait
       recomposer le titre à chaque frame — sur une page qu'on vient
       justement d'alléger de trente couches de composition.

   Le principe : le titre en encre, puis une COPIE BLANCHE par-dessus,
   découpée exactement à la plaque, recalée par fx.js à chaque frame (le
   titre se déplace, la plaque non).

   ⚠️ index.html garde SON blend (.pan-h) : c'est le relevé de LG2, sur
   une autre page, et le titre y croise des images pleines dont la
   géométrie n'est pas connue d'avance — la substitution par calques n'y
   serait pas possible. */
body.ap .zh-big-pair { position: relative; }
body.ap .zh-big { position: relative; z-index: 2; }
body.ap .zh-big--w {
  position: absolute; inset: 0;
  z-index: 3; color: var(--paper);
  pointer-events: none;
  /* fermée par défaut : si le calage JS ne tourne pas (mouvement réduit,
     mobile), pas de titre blanc en travers de la page */
  clip-path: inset(0 100% 0 0);
}
/* ⚠️ LE CHEVAUCHEMENT EST CALCULÉ, PAS ÉCRIT ICI — voir `fitOverlap()`
   dans app.js. Une valeur en dur a été essayée (−6 unités de grille) et
   elle ne peut pas marcher : `fitBig()` donne au titre un corps
   DIFFÉRENT selon la langue, si bien qu'une même marge recouvrait
   55 % des lettres en français et 72 % en anglais. Manish : « l'image
   ne doit recouvrir que le dernier tiers du texte » — une fraction,
   donc, et une fraction se calcule sur la hauteur réelle de l'encre.
   Le JS écrit `margin-top` en style inline ; ne pas le redéclarer ici,
   les deux se battraient. */
/* ⚠️ Le reste de l'outro ne se blende PAS : les mentions latérales et
   les liens sont sur le papier, ils n'ont rien à inverser. Le blend est
   posé sur `.zh-big` seul, pas sur un ancêtre commun. */
body.ap .zh-plate .zh-social {
  position: static;                 /* elle était en absolu DANS l'image */
  justify-content: center;
  gap: calc(4 * var(--vwu));
  margin-top: calc(1.5 * var(--vwu));
  color: var(--ink);
}
body.ap .zh-plate .zh-social span { border-bottom: 1px solid var(--ink); padding-bottom: .25em; }

/* ============================================================
   Chevauchement vertical entre sections
   ------------------------------------------------------------
   La parallaxe déplace chaque bloc jusqu'à ±34 vh (fx.js). Avec des
   sections serrées, un bloc rapide d'un chapitre entre dans le
   suivant — c'est ce qu'on voyait avec « TOUT TIENT À » qui remontait
   dans la section Stratégie. Deux réponses complémentaires : de la
   garde verticale ici, une amplitude réduite dans fx.js.
   ============================================================ */
body.ap .zh-word { white-space: nowrap; }

/* ============================================================
   UN PROPOS PAR ÉCRAN
   ------------------------------------------------------------
   ⚠️ LE SNAP A ÉTÉ RETIRÉ. Manish : « l'aberration bug avec le snap sur
   Safari. on retire le snap ». Diagnostic, pour mémoire : pendant son
   animation de snap, WebKit avance `scrollY` par bonds bien plus gros
   qu'une frame de geste normal. La vélocité — d'où sortent l'aberration
   ET le cylindre — sature donc à ±1 pendant toute l'animation, puis se
   fige au dernier événement de scroll reçu, qui n'est pas forcément le
   dernier de l'animation. Effet immobile et à fond : ça ne se lit pas
   comme une déformation, ça se lit comme un bug.

   CE QUI RESTE, et qui n'a rien à voir avec le snap : chaque section
   fait un écran plein et centre son contenu dedans. C'est le rythme de
   la page, pas un support d'accrochage — les captures validées reposent
   dessus.

   Si le snap revient un jour, deux choses à re-résoudre : le
   `body { overflow-x: hidden }` du haut de ce fichier, qui fait du
   <body> un conteneur de défilement et empêche WebKit d'appliquer un
   snap posé sur <html> ; et la saturation de vélocité ci-dessus, qu'il
   faudrait mesurer autrement (sur le temps, pas sur la frame).
   ============================================================ */
body.ap .zh-ch,
body.ap .ap-entry,
body.ap .zh-collab {
  min-height: 100vh;
  padding: 12vh var(--gut);
  align-content: center;      /* la rangée de grille se centre dans l'écran */
}
/* l'outro AUSSI, et pas seulement pour la symétrie : dernière section du
   document, plus courte qu'un écran, elle ne pouvait pas être amenée en
   haut de la fenêtre — le scroll butait sur le bas de page avant. Son
   point d'ancrage était donc injoignable et le snap terminait sur la
   collaboration. */
body.ap .zh-outro {
  min-height: 100vh;
  display: flex; flex-direction: column; justify-content: center;
}

/* ---------- la rangée : sous le titre, même fold ----------
   Elle a été un instant son propre écran snappé, puis un instant
   au-dessus du titre ; les deux ont été annulés (cf. app.js). Elle
   retrouve donc son réglage d'origine — un `margin-top` qui la décolle
   du titre, et un `padding-bottom` qui réserve la place du micro-texte,
   lequel est en position absolue et ne pousse rien. */
body.ap .zh-hero .zh-l2 { margin: calc(5 * var(--vwu)) 0 0; padding-bottom: calc(9 * var(--vwu)); }

/* ============================================================
   LA CONDITION D'ENTRÉE — la charnière hero → chapitres
   ------------------------------------------------------------
   Le mouvement des deux références d'agence, et le seul que la page ne
   faisait pas : ouvrir sur les embarras du LECTEUR avant d'énumérer
   ses propres capacités. Les quatre chapitres se lisent ensuite comme
   une réponse et non comme un menu.

   ⚠️ MÊME GRILLE, GÉOMÉTRIE INVERSE — et c'est délibéré. Un chapitre
   pose son mot géant à gauche (1/5) et son contenu à droite (8/5) ;
   ici, la PHRASE occupe les colonnes de gauche, à la place du mot
   géant. Le bloc se distingue donc au premier coup d'oeil sans sortir
   de la grille — et la liste retombe exactement sur la colonne de
   contenu des chapitres, ce qui garde une seule ligne de lecture pour
   toute la page.

   Elle ne va que jusqu'à la colonne 6 (et non 7) : la 7 reste vide
   pour que la phrase ne vienne jamais toucher la liste, quelle que
   soit la langue — l'anglais est ici le plus court, mais rien ne
   garantit qu'il le restera.
   ============================================================ */
body.ap .ap-entry {
  display: grid;
  grid-template-columns: repeat(12, minmax(0, 1fr));
  column-gap: var(--gap);
  row-gap: calc(2.2 * var(--vwu));
  align-items: start;
}
/* placement EXPLICITE, pas de placement automatique : le chapeau doit
   rester au-dessus de la phrase même si l'un des trois blocs vient à
   manquer (la liste et la chute sont facultatives côté contenu) */
body.ap .ap-entry-kick { grid-row: 1; grid-column: 1 / span 5; }
body.ap .ap-entry-head { grid-row: 2; grid-column: 1 / span 6; }
body.ap .ap-entry-side { grid-row: 2; grid-column: 8 / span 5; }
/* même raison que dans les chapitres : la phrase et la liste n'ont pas
   la même hauteur d'ascendante, un calage par le haut de boîte les
   décalerait d'une trentaine de pixels */
body.ap .ap-entry-head,
body.ap .ap-entry-side { align-self: baseline; }

body.ap .ap-entry-head {
  margin: 0;
  font-size: var(--t-head); font-weight: 500;
  line-height: 1.1; letter-spacing: -.035em;
}
/* les trois mêmes balises que dans un titre de chapitre, et trois
   seulement (cf. content/approach.js) */
body.ap .ap-entry-head b { font-weight: inherit; color: var(--red); }
body.ap .ap-entry-head s { color: var(--mute); text-decoration-thickness: .06em; }

/* la liste réutilise `.ap-list` — même corps, même interligne, mêmes
   respirations que les listes des chapitres. Seule sa marge haute
   tombe : là-bas elle décolle la liste du texte qui la précède, ici
   elle est le premier élément de sa colonne. */
body.ap .ap-entry-side .ap-list { margin-top: 0; }

/* LA CHUTE. Elle dit l'entrée ET la sortie : sans la sortie, « j'entre
   là » se lit comme quelqu'un qui s'installe — exactement la crainte
   du directeur qui staffe. Elle reste au corps du texte (l'échelle n'a
   que trois crans), c'est le filet et le blanc qui la détachent. */
body.ap .ap-entry-final {
  margin: calc(3 * var(--vwu)) 0 0;
  padding-top: calc(1.6 * var(--vwu));
  border-top: 1px solid var(--red);
  font-size: var(--t-body); line-height: 1.5;
}

/* ============================================================
   UNE SEULE GRILLE POUR LES QUATRE CHAPITRES
   ------------------------------------------------------------
   Manish, capture à l'appui : « tout est inégal », puis « je veux que
   tu uniformies le tout, même layout, même hiérarchie de texte ».

   La cause était mesurable : chaque chapitre redéfinissait sa propre
   géométrie — 42/24/1fr calé en haut, 36/30/1fr calé au CENTRE, 42/1fr,
   puis pleine largeur. Le contenu démarrait à 46 %, 79 %, 45 % puis 0 %
   de l'écran, et le mot géant tantôt en haut, tantôt à mi-hauteur.

   Trois colonnes, les mêmes pour les quatre :

     1  le mot géant (repère compris)   42 %
     2  le chapeau rouge                 calc(9 * var(--vwu))   — souvent vide, et c'est
                                                voulu : une colonne
                                                réservée reste une
                                                colonne
     3  titre, corps, liste, bloc final  le reste

   La colonne 2 existe pour « Tout tient à une » / « expérience qui se
   tient » : une phrase coupée en deux colonnes, comme les textes rouges
   de la référence. Elle est réservée partout pour que le TITRE démarre
   au même x dans les quatre chapitres — c'est elle qui garantit
   l'alignement, pas son contenu.
   ============================================================ */
body.ap .zh-ch--ap {
  display: grid;
  /* ⚠️ minmax(0,1fr) et pas 1fr : le mot géant est en `white-space:
     nowrap`, avec `1fr` il gonflerait sa propre piste et emporterait
     la grille avec lui. */
  grid-template-columns: repeat(12, minmax(0, 1fr));
  column-gap: var(--gap);
  align-items: start;
}
body.ap .zh-ch--ap > .zh-left { grid-column: 1 / span 5; }   /* le mot géant */
body.ap .zh-ch--ap > .ap-kick { grid-column: 6 / span 2; }   /* le chapeau   */
body.ap .zh-ch--ap > .ap-main { grid-column: 8 / span 5; }   /* le contenu   */
/* La première ligne se cale sur la LIGNE DE BASE, pas sur le haut de
   boîte. Le mot géant (interligne .88, corps ~100 px) et le titre
   (1,1 / 49 px) n'ont pas la même hauteur d'ascendante : calés par le
   haut, leurs capitales démarraient à 62 px d'écart. `baseline` le
   résout sans valeur magique, et tient quelle que soit la taille que
   fitWords finit par poser. */
body.ap .zh-ch--ap > * { align-self: baseline; }
/* ⚠️ LES NŒUDS DE LA COMPOSITION 2 SONT ÉMIS PARTOUT, MASQUÉS ICI. Le
   gabarit ne se demande jamais dans quelle page il tourne : il rend
   toujours la même chose, et c'est le skin qui montre. `body.c2`, en fin
   de fichier, les rallume — même spécificité, l'ordre tranche. Voir le
   bloc « COMPOSITION 2 » tout en bas. */
body.ap .ap-mark, body.ap .ap-rule { display: none; }

/* ---------- colonne 1 : le mot géant ---------- */
/* le repère DANS le mot : même ligne de base, corps calé sur celui du
   mot pour qu'il suive toute réduction de fitWords. Le rapport .37 est
   celui qu'avait le repère autonome (2,calc(2 * var(--vwu)) contre ~5,calc(9 * var(--vwu))), à ceci près
   qu'il ne se dérègle plus quand le mot rétrécit. */
body.ap .zh-word .zh-letter {
  font-size: .37em; letter-spacing: 0;
  padding-right: .16em;
  /* ROUGE, comme dans la rangée du hero. C'était le seul repère noir de
     la page : « A. » en encre dans les chapitres, « D. » en rouge trois
     écrans plus haut, pour la même fonction. */
  color: var(--red);
}

/* ============================================================
   L'INTERSTICE — l'anneau « idée », son propre écran
   ------------------------------------------------------------
   Ni chapitre ni slot : une respiration entre deux chapitres, sur un
   écran plein, centrée. Manish : « comme son propre bloc, centré au
   milieu ». C'est le seul écran de la page qui ne demande rien à lire.

   La rotation vient de `fx.js` (`data-spin`), pilotée par la POSITION
   de scroll et non par une `@keyframes` : une animation temporelle
   tournerait aussi à l'arrêt, et c'est justement le lien avec la course
   qui fait l'effet.
   ============================================================ */
/* ⚠️ UN PASSAGE, PAS UNE SECTION. Manish : « pas comme une section
   complète, comme juste un petit passage. inspire-toi de zhenya ».

   Relevé sur `02-docs/zhenya.mov` à 10,6 s : chez eux l'anneau n'occupe
   PAS un écran. Il fait ~27 % de la largeur de la fenêtre et environ la
   moitié de sa hauteur, et il partage sa rangée avec le reste du
   chapitre. Ici il garde son bloc à lui — c'est ce que Manish avait
   demandé — mais il perd son `min-height: 100vh` : la section ne fait
   plus que la hauteur de son contenu. Le rythme devient
   écran plein · passage · écran plein.

   (Le `min-height` venait du snap, qui exigeait des sections d'un écran.
   Le snap est parti, la contrainte avec.) */
body.ap .ap-inter {
  padding: 9vh var(--gut);
  display: flex; flex-direction: column;
  align-items: center; justify-content: center;
}
/* le wrap porte la facette du cylindre (`data-sp`) et empile ses deux
   enfants — l'anneau puis la flèche — centrés l'un sur l'autre */
body.ap .ap-ring-wrap {
  display: flex; flex-direction: column; align-items: center;
}
body.ap .ap-ring-stage {
  /* 4 colonnes ≈ 27 % de la largeur utile, la proportion relevée sur la
     référence — mais exprimée en colonnes, donc solidaire de la grille */
  --stage: calc(4 * var(--col) + 3 * var(--gap));
  position: relative;
  width: var(--stage); aspect-ratio: 1;
  display: grid; place-items: center;
}
body.ap .ap-ring {
  --ring-r: calc(var(--stage) * .45);  /* rayon, lu par le style inline des mots */
  position: absolute; inset: 0;
  will-change: transform;
}
body.ap .ap-ring i {
  position: absolute; left: 50%; top: 50%;
  transform-origin: 0 0;             /* le pivot est le CENTRE du cercle */
  /* ⚠️ LA GÉOMÉTRIQUE DE LA PAGE, ET DROITE. Manish : « je crois que je
     préfère largement la fonte de zhenya ». Deux écarts relevés sur la
     capture, et le second comptait plus que le premier :

       · leur mot est DROIT, le nôtre était en italique — et pas même
         une vraie italique : `--sans` n'en a pas de fichier, le moteur
         PENCHAIT les glyphes droits. Une oblique synthétique sur un mot
         déjà tourné de 27° accumule deux inclinaisons qui ne sont pas
         du même ordre ; c'est ce qui donnait ce flottement mou ;
       · leur mot est GÉOMÉTRIQUE, le nôtre était en Inter — une
         néo-grotesque de texte. Space Grotesk (`--geo`) est la
         géométrique du folio, déjà celle des quatre mots géants : le
         passage cesse d'introduire une quatrième voix.

     Reste un écart que je ne peux pas combler avec ce qui est
     auto-hébergé : leur « a » est à UN étage (une Futura), celui de
     Space Grotesk en a deux. Il faudrait un quatrième fichier de police
     dans le dépôt — décision de design system, pas de retouche. */
  font-family: var(--geo); font-style: normal; font-weight: 500;
  /* ⚠️ LE CORPS DÉRIVE DU RAYON, il n'est plus dans l'échelle de texte.
     Mesuré sur la capture : le mot fait 0,34 fois le rayon de bord à
     bord, soit un corps de 0,168 × rayon pour quatre lettres. Écrit
     comme un rapport, il tient à toutes les largeurs — et le bloc
     mobile n'a plus à le redoubler, puisque `--ring-r` y est déjà
     redéfini. Une valeur en `rem` aurait fait grossir le mot pendant
     que le cercle rétrécit.

     ⚠️ .179 ET NON .168 : le coefficient est calé sur la CHASSE de Jost,
     pas sur une taille en soi. Mesuré aux deux polices au même corps de
     29 px, « idea » fait 59 px en Space Grotesk et 55 en Jost — la
     géométrique est plus étroite. Ce qui est relevé sur la référence
     est la longueur du MOT (0,34 × rayon), pas le corps ; changer de
     police sans reprendre le coefficient aurait rendu 6 % de trop
     petit sans qu'on sache pourquoi. À refaire si la police rechange. */
  font-size: calc(var(--ring-r) * .179); letter-spacing: -.01em;
  white-space: nowrap;
  /* `--c` est posée à l'unité par le rendu : rouge → orange autour du
     cercle, par `color-mix` sur les deux tokens */
  color: var(--c, var(--red));
}
/* ---------- la traîne ----------
   Manish : « tu copies tout ce qui est idea dans un calque en dessous ;
   lorsque je scrolle ça tourne moins vite, ça donne l'impression d'un
   trail — et lorsqu'on a fini de scroller, il se remet derrière le label
   idea initial. ça blende, c'est juste un trail qui se remet en place. »
   (Relevé chez zhenya vers 10 s.)

   ⚠️ 25 % ET NON 75. Corrigé après un premier essai : à 75 % la copie
   pesait autant que l'original et se lisait comme un second anneau. Une
   traîne est un RÉSIDU — elle doit être là sans jamais disputer le mot.

   ⚠️ DÉLAVÉE, PAS TRANSPARENTE — et ce n'est pas une pinaillerie de
   règle. Sur ce fond plat le résultat est le MÊME à l'oeil (25 % vers le
   mot depuis le papier = 25 % d'opacité), mais un `opacity` sur un
   calque de seize mots crée une couche de composition permanente sous un
   élément qui tourne à chaque frame : c'est exactement le genre
   d'empilement qui avait fait ramer Safari. Une couleur mixée ne coûte
   rien.

   Le retard angulaire, lui, est dans fx.js (`data-trail`) : il vaut ZÉRO
   à l'arrêt — le calque se replie alors exactement sous le mot et
   disparaît — et ne s'ouvre que sous la vélocité. */
body.ap .ap-ring--trail i {
  color: color-mix(in oklab, var(--c, var(--red)) 25%, var(--paper));
}
/* ⚠️ L'AMPOULE EST HORS DE `.ap-ring`, donc elle NE TOURNE PAS. C'est
   l'écart voulu avec la référence, qui faisait tourner sa flèche : une
   ampoule qui tourne se lit comme un bug. Les mots orbitent, elle reste
   droite. */
body.ap .ap-bulb {
  position: relative; z-index: 1;
  display: block; width: calc(6 * var(--vwu)); color: var(--orange);
}
body.ap .ap-bulb svg { width: 100%; height: auto; display: block; }
/* ============================================================
   LE CASQUE — le passage qui décode, avant Stratégie
   ------------------------------------------------------------
   Relevé sur le gif de The Hangover fourni par Manish : des fragments
   mathématiques dérivent par-dessus le visage, à des tailles et des
   densités différentes, certains à moitié sortis du cadre. Ce n'est pas
   une équation, c'est un BRUIT de calcul.

   ⚠️ LE CASQUE EST UNE IMAGE DE FOND, POINT. Le fichier fourni est un
   PNG en RGB — traits noirs sur fond BLANC, sans transparence : posé tel
   quel il aurait collé un rectangle blanc au milieu d'une page crème. Il
   est donc redessiné en RGBA, encre de la page et fond transparent, et
   servi en `background-image`. Voir `.ap-casque-img` pour ce que la
   version « masque » a coûté.

   Il reste effacé : c'est un DÉCOR, le sujet est le brouillard de
   calcul devant lui.
   ============================================================ */
/* ---------- le titre ----------
   ⚠️ IL EST HORS DE LA SCÈNE, pas dedans. Manish le veut « au-dessus des
   formules » : posé parmi elles, il aurait pris leur registre — un
   fragment de plus — et se serait fait manger par le grouillement. Au
   -dessus, il devient ce qui NOMME ce qu'on regarde, et la scène cesse
   d'être une blague sans adresse.

   Sans, il manquait une raison au passage. Avec, on sait qu'on regarde
   quelqu'un qui sur-décode faute d'avoir compris — et ça enchaîne sur
   « Écouter avant de proposer », le chapitre juste après.

   Il ne s'écrit pas et ne s'efface pas : c'est un titre, pas un
   fragment. Il ne porte donc ni `data-in` ni `.ap-f`. */
body.ap .ap-casque-t {
  margin: 0 0 2vh;
  font-size: calc(4.2 * var(--vwu));
  line-height: 1.02; letter-spacing: -.035em; font-weight: 500;
  color: var(--red);
}
body.ap .ap-casque-t span { display: block; }

/* ---------- la surface ----------
   ⚠️ PLEINE LARGEUR, et non une boîte de la taille du casque. Dans le
   gif, les chiffres vont d'un bord à l'autre de l'image ; enfermés dans
   un carré central, ils se lisaient comme une vignette et l'échelle
   s'effondrait. */
body.ap .ap-casque {
  position: relative;
  /* ⚠️ L'ÉCHELLE SE RÈGLE ICI : les corps sont posés en style INLINE
     (une valeur par élément) et un style inline bat toute règle CSS.
     Les `em` étant relatifs au parent, c'est lui qui porte l'échelle —
     un seul curseur, toutes les proportions préservées.
     .78 → 1.08 : Manish les voulait plus grands. Le rapport entre eux
     est intact — c'est la scène entière qui monte d'un cran, pas
     quelques fragments repris à la main. */
  font-size: 1.08rem;
  width: 100%;
  aspect-ratio: 1278 / 720;      /* le cadrage du gif, pas une valeur choisie */
}
/* ⚠️ LE CASQUE PREND LA PLACE DU VISAGE. Relevé sur le gif : la tête est
   à droite du centre, x 62-79 %, y 17-56 %. C'est ce qui donne son sens
   au reste — les chiffres tournent autour de quelqu'un. */
body.ap .ap-casque-img {
  /* ⚠️ 🔴 PLUS DE MASQUE DU TOUT — et c'est la bonne réponse, pas un
     repli. Manish, capture en navigation privée à l'appui : « le casque
     n'apparaît jamais, et je ne l'ai jamais vu ». Le fichier était bien
     servi (HTTP 200), le raccourci `-webkit-mask` avait déjà été éclaté
     en propriétés longues, et il restait invisible.

     La cause était DANS le fichier : reconstruit en canal alpha, il
     portait un trait en **RGB (0,0,0)** et ne se lisait donc que par son
     alpha. Un moteur qui interprète le masque en LUMINANCE au lieu de
     l'alpha n'y voit que du noir — luminance 0 — donc masque tout. Le
     casque existait, parfaitement, et se masquait lui-même.

     Deux moteurs, deux lectures, aucun message d'erreur : c'est
     exactement le genre de dépendance qu'il ne faut pas prendre pour un
     élément décoratif. La couleur est donc BAKÉE dans le PNG (RGB =
     l'encre de la page) et l'image est posée en `background-image`
     ordinaire. Ça coûte de ne plus pouvoir la recolorer par token —
     regénérer le fichier si la palette bouge — et ça supprime toute la
     classe de bug.

     ⚠️ `height` explicite EN PLUS de `aspect-ratio` : même logique, on
     ne fait pas dépendre la visibilité d'un élément d'une seule
     propriété récente. */
  /* −25 % sur demande : il tenait trop de place face aux chiffres, qui
     sont le sujet. 30 → 22,5 % de large, la hauteur suit. */
  position: absolute; left: 58%; top: 14%; width: 22.5%; height: 66%;
  background-image: url('content/assets/casque.png');
  background-size: contain;
  background-position: center;
  background-repeat: no-repeat;
  opacity: .62;
}


/* ---------- le graphique ----------
   Relevé sur les frames 36 à 48, et complètement absent de mes deux
   premières lectures : un axe en L, cinq graduations, une courbe
   montante en pointillés. */
/* le graphique suit la même densité que les chiffres */
body.ap .ap-graph {
  /* décalé à gauche : la courbe passait sous le compte, et deux tracés
     superposés ne se lisent ni l'un ni l'autre — la référence les tient
     séparés */
  position: absolute; left: 17%; top: 44%; width: 15%; height: auto;
  overflow: visible; fill: none;
  stroke: color-mix(in oklab, var(--orange) calc(.52 * var(--dens, 1) * 100%), transparent);
}
body.ap .ap-graph-ax { stroke-width: 1.6; }
body.ap .ap-graph-tk { stroke-width: 1.1; }
body.ap .ap-graph-cv { stroke-width: 1.4; stroke-dasharray: 1 5; stroke-linecap: round; }

/* ---------- les chiffres ----------
   ⚠️ SERIF, et c'est un relevé : la scène compose ses chiffres dans un
   serif fin et clair. `'Fraunces'` est NOMMÉE et non `var(--serif)` — le
   skin de cette page redéfinit `--serif: var(--geo)`, elle est tout en
   sans, et la variable rendait donc la Space Grotesk.

   ⚠️ AUCUNE OPACITÉ. Manish, deux fois : « le texte = pas d'opacité ».
   Les plans se disent par la COULEUR, jamais par une transparence. */
body.ap .ap-f {
  font-family: 'Fraunces', Georgia, serif;
  font-style: normal; font-weight: 400;
  line-height: 1; white-space: nowrap;
  /* ⚠️ DEUX FACTEURS, ET AUCUN N'EST UNE OPACITÉ. `--o` est la densité
     propre de l'élément — c'est elle qui fait le chaos, et elle est
     posée à l'unité par le rendu. `--dens` est celle de la SCÈNE,
     écrite par fx.js et maximale au centrage : « plus d'opacité quand on
     est au milieu ». Leur produit règle la part d'orange dans la
     couleur, jamais la transparence du texte — la consigne de fond
     (« le texte = pas d'opacité ») tient, et l'effet demandé aussi. */
  color: color-mix(in oklab,
    var(--orange) calc(var(--o, .62) * var(--dens, 1) * 100%), transparent);
}
/* ⚠️ La rotation est DANS l'élément, pas dessus : `clip-path` se calcule
   dans le repère de son propre noeud, une rotation sur le même élément
   ferait balayer le volet en biais. Volet droit dehors, penché dedans. */
body.ap .ap-f > b { display: inline-block; font-weight: inherit; }
body.ap .ap-f--bit { position: absolute; }

/* ---------- le compte ----------
   Le coeur de la scène, à GAUCHE du casque comme dans le gif. En flux,
   pour qu'ajouter ou retirer une ligne ne demande aucun recalcul —
   l'irrégularité vient des `margin-left` posés ligne à ligne, qui sont
   la main qui écrit. */
body.ap .ap-col {
  position: absolute; left: 40%; top: 20%;
  display: flex; flex-direction: column; align-items: flex-start;
  gap: .1em;
}
body.ap .ap-col .ap-f { position: static; }
/* la chute : les deux seules lignes qui parlent.
   ⚠️ Sélecteur remonté dans la colonne — `.ap-col .ap-f` compte trois
   classes contre deux, et la couleur du compte gagnait. */
body.ap .ap-col .ap-f--dit { color: var(--red); font-weight: 500; margin-top: .3em; }

/* ============================================================
   LE CUBE — second interstice, entre Expérience et Prototypage
   ------------------------------------------------------------
   Il partage la boîte de PASSAGE de l'anneau (`.ap-inter`) : c'est elle
   qui tient le rythme écran plein · passage · écran plein, quel que
   soit l'objet dedans. Ce qui change, c'est ce qu'il y a au milieu.

   ⚠️ TROIS COLONNES CONTRE QUATRE POUR L'ANNEAU, délibérément. À
   dimensions égales les deux passages se liraient comme des jumeaux, et
   la page aurait l'air de se répéter au lieu de respirer deux fois.
   L'anneau porte seize mots sur son pourtour, il a besoin de sa place ;
   le cube n'est qu'un objet, il en demande moins.

   Les coordonnées des douze segments sont écrites par `fx.js` — rien
   ici ne décide de la forme, seulement de son encre et de sa taille.
   ============================================================ */
/* ⚠️ La boîte s'élargit avec l'anneau : le texte orbite à 1,92 demi-côté
   du centre, soit près du double de l'encombrement du cube seul. */
body.ap .ap-cube {
  width: calc(5 * var(--col) + 4 * var(--gap));
  height: auto; display: block; overflow: visible;
  /* ⚠️ LE CUBE PASSE À L'ORANGE ET S'EFFACE. Manish : « j'aimerais que
     tout reste dans les tons d'orange, comme sur idée. peut-être que le
     cube est en opacité 60 ». L'encre en faisait le sujet du passage
     alors que le sujet est le MOT ; à l'orange et effacé, il redevient
     ce qu'il est — le volume autour duquel le mot tourne.
     L'opacité de profondeur, elle, reste écrite par fx.js arête par
     arête : celle-ci ne fait que plafonner l'ensemble. */
  stroke: var(--orange); fill: none;
  stroke-linecap: round;
  /* ⚠️ PAS d'`opacity` ici : sur la racine du SVG elle s'appliquait au
     groupe entier, lettres comprises. Le plafond de 60 % vit désormais
     dans l'opacité de profondeur des ARÊTES (fx.js). */
}
/* le mot sous le cube, dans le registre des chapeaux : c'est une
   légende, pas un titre — le passage ne demande rien à lire */
/* l'anneau de texte : la couleur et la graisse sont ici, la GÉOMÉTRIE
   vient de fx.js — aucune coordonnée n'est écrite dans le CSS. */
/* la couleur des lettres est posée à l'unité par le rendu — rouge aux
   pôles, orange à mi-parcours, par `color-mix` sur les deux tokens,
   exactement comme l'anneau « idée ». Rien en dur ici. */
body.ap .cb-ring { stroke: none; }
body.ap .cb-g { font-family: var(--geo); font-weight: 500; letter-spacing: 0; }
body.ap .ap-cube-word {
  margin: 1.6vh 0 0;
  font-size: var(--t-micro); letter-spacing: .06em;
  text-transform: uppercase; color: var(--red);
}
/* ============================================================
   LES FLÈCHES — troisième interstice, entre Prototypage et Livrable
   ------------------------------------------------------------
   Relevées sur la référence, où elles sont FIXES et rangées à côté de
   la phrase au mot barré du chapitre Implementation. Ici elles ont leur
   passage et elles se tracent au défilement (`data-draw`, fx.js).

   ⚠️ Elles reprennent la largeur de l'anneau (4 colonnes) et non celle
   du cube (3) : quatre flèches espacées ont besoin de la place, et ça
   évite au passage que les trois interstices aient trois largeurs
   différentes — deux suffisent à ne pas se répéter.

   `stroke-dasharray` est posé par le JS, qui LIT la longueur réelle du
   chemin ; ici on ne fixe que l'encre et la géométrie. */
body.ap .ap-arrows {
  display: grid; grid-template-columns: repeat(4, 1fr);
  gap: calc(1.6 * var(--vwu));
  width: calc(4 * var(--col) + 3 * var(--gap));
}
body.ap .ap-arrows svg {
  width: 100%; height: auto; display: block; overflow: visible;
  stroke: var(--ink); stroke-width: 3; fill: none;
  stroke-linecap: round; stroke-linejoin: round;
}
/* ---------- les deux flèches d'un passage ----------
   Relevée sur la référence, la flèche dit « ça continue » et c'est elle
   qui fait lire le bloc comme un PASSAGE plutôt que comme un ornement
   échoué. Elles sont maintenant DEUX — une avant, une après — et
   l'objet est pris en sandwich : c'est ce qui en fait une composition
   verticale au lieu d'un dessin avec une queue.

   ⚠️ UN TRACÉ, PLUS UN GLYPHE. Le `↓` d'origine était un caractère : il
   ne se met à l'échelle que par son corps (donc par sa graisse et son
   chasse en même temps), et surtout il ne se DESSINE pas. Le même chemin
   que `.ap-arrows` — un seul dessin de flèche sur toute la page — se
   trace au défilement par `data-draw`.

   L'écart à l'objet est généreux et VOULU : Manish les voulait « plus
   grandes et plus espacées du cercle ». Collées, deux flèches font une
   ponctuation ; écartées, elles font un axe. */
body.ap .ap-cap {
  display: block; width: calc(3.4 * var(--vwu));
}
body.ap .ap-cap--haut { margin-bottom: 5vh; }
body.ap .ap-cap--bas  { margin-top: 5vh; }
body.ap .ap-cap svg {
  width: 100%; height: auto; display: block; overflow: visible;
  stroke: var(--mute); stroke-width: 3.4; fill: none;
  stroke-linecap: round; stroke-linejoin: round;
}

/* ---------- colonne 2 : le chapeau ---------- */
body.ap .ap-kick {
  margin: 0;
  font-size: var(--t-micro); line-height: 1.35;
  letter-spacing: .06em; text-transform: uppercase;
  color: var(--red);
}

/* ---------- colonne 3 : une seule hiérarchie ----------
   Trois crans, et rien d'autre. Avant, sept corps de texte cohabitaient
   (1,05 / 1,1 / 1,3 / 1,5 / 1,7 / 2,2 / 3,calc(4 * var(--vwu))) et le chapitre
   Prototypage composait ses deux affirmations au MÊME corps que son
   titre — deux niveaux de titre dans un seul chapitre.

   ⚠️ L'ÉCHELLE EST CELLE DE LA RANGÉE DU HERO. Manish a montré la
   rangée en disant « uniformise les titres et les textes selon ceci,
   c'est trop gros ». Les chapitres avaient donc leur propre échelle, en
   vw, plus grosse d'un tiers que celle du hero, sur la même page.

     hero  .zh-cue      .78rem   →   --t-micro
     hero  .zh-chaps em  1rem    →   --t-body
     hero  .zh-chaps b   calc(2.5 * var(--vwu))   →   --t-head

   Le mélange rem / vw n'est pas une inadvertance : c'est celui du hero,
   validé. Le corps ne rétrécit pas avec la fenêtre — il reste lisible —
   là où les titres, eux, suivent la largeur.

   Le mot géant garde sa taille : il ne joue pas le même rôle que le mot
   de la rangée, et Manish ne l'a pas visé. */
body.ap {
  --t-micro: .78rem;   /* chapeau, mentions */
  --t-body:  1rem;     /* corps et listes */
  --t-head:  calc(2.5 * var(--vwu));    /* titre de chapitre et bloc final */
}

body.ap .zh-ch--ap .zh-head {
  margin: 0;
  font-size: var(--t-head); font-weight: 500;
  line-height: 1.1; letter-spacing: -.035em;
}
/* les trois seules balises admises dans un titre (cf. content/approach.js) */
body.ap .zh-ch--ap .zh-head b { font-weight: inherit; color: var(--red); }
body.ap .zh-ch--ap .zh-head s { color: var(--mute); text-decoration-thickness: .06em; }

body.ap .zh-ch--ap .zh-body { max-width: 100%; margin-top: 2.4vw; }
body.ap .zh-ch--ap .zh-body p { font-size: var(--t-body); line-height: 1.7; }
body.ap .zh-ch--ap .zh-body p + p { margin-top: 1.5em; }

body.ap .ap-list { list-style: none; margin: calc(2.6 * var(--vwu)) 0 0; padding: 0; }
body.ap .ap-list li { font-size: var(--t-body); line-height: 1.5; padding: .38em 0; }
body.ap .ap-list em {
  display: block; font-style: normal;
  font-size: var(--t-micro); line-height: 1.35; color: var(--red);
  margin-top: .3em;
}

/* le bloc de fin reprend le corps du titre : c'est la CHUTE du
   chapitre, pas un troisième niveau */
body.ap .ap-final { position: relative; margin-top: calc(5 * var(--vwu)); }
body.ap .ap-final h3 {
  margin: 0;
  font-size: var(--t-head); font-weight: 500;
  line-height: 1.1; letter-spacing: -.035em;
}
body.ap .ap-final ul { list-style: none; margin: calc(1.6 * var(--vwu)) 0 0; padding: 0; }
body.ap .ap-final li { font-size: var(--t-body); line-height: 1.5; padding: .38em 0; }
/* le halo doux qui accompagnait « Le vrai problème » — sur fond quasi
   blanc il assombrit au lieu d'éclaircir */
body.ap .ap-final::before {
  content: ''; position: absolute; left: 22%; top: 50%; z-index: -1;
  width: calc(34 * var(--vwu)); height: calc(34 * var(--vwu)); border-radius: 50%;
  transform: translate(-50%, -50%);
  background: radial-gradient(closest-side, rgba(10,10,10,.055), rgba(10,10,10,0));
}

/* ---------- la section collaboration s'aligne sur les chapitres ----------
   elle ouvrait sa propre grille à 50 / 50 : son texte ne tombait sur la
   colonne d'aucun chapitre */
body.ap .zh-collab {
  grid-template-columns: repeat(12, minmax(0, 1fr));
  column-gap: var(--gap);
}
body.ap .zh-collab > * { grid-column: 8 / span 5; }
/* la phrase d'appui est au corps du texte — l'échelle n'a que trois
   crans, elle n'a pas droit au sien. C'est donc le BLANC qui la détache,
   sinon elle se lit comme la première ligne du paragraphe. */
body.ap .zh-collab-sub { font-size: var(--t-body); margin: calc(1.8 * var(--vwu)) 0 calc(3.4 * var(--vwu)); }
body.ap .zh-collab .zh-head { font-size: var(--t-head); line-height: 1.1; }
body.ap .zh-collab .zh-body { max-width: 100%; }
body.ap .zh-collab .zh-body p { font-size: var(--t-body); line-height: 1.7; }

/* ============================================================
   LA FENÊTRE — un formulaire réduit vers un dock invisible
   ------------------------------------------------------------
   Manish : « comme si on rouvrait une fenêtre déjà ouverte mais
   minimisée dans macOS […] nous n'avons pas de dock, alors on imagine
   que le dock est invisible, caché en dehors, plus bas que la page. »

   ⚠️ LE DOCK EST UN POINT, ET IL EST ICI. `--dock-y` place le repli sous
   le bas de l'écran, l'origine de transformation le vise. Rien n'est
   écrit en dur ailleurs : déplacer le dock, c'est une valeur.

   ⚠️ CE QUI EST ÉCRIT ICI EST LE REPLI, PAS L'EFFET. Le tortillon de
   macOS déforme la fenêtre le long d'une courbe : aucune propriété CSS
   ne le fait, il faut un maillage. Il vit donc dans `genie.js`, qui
   rasterise la fenêtre et la redessine en tranches — et qui lit LES
   MÊMES variables que ce bloc. La réduction ci-dessous reste en
   service, et sert quand le genie ne peut pas jouer : sous 860 px, en
   `prefers-reduced-motion`, à la toute première ouverture (aucune
   capture n'existe encore), ou si la capture échoue.

   ⚠️ LE DOCK EST UN POINT, ET C'EST CELUI-CI POUR LES DEUX CHEMINS.
   `--dock-x` à 50 % donne l'entonnoir symétrique décrit par la
   consigne ; l'écarter du centre donne le balayage latéral du vrai
   genie, celui qui vise une icône. Une seule valeur à bouger.

   ⚠️ AUCUNE OPACITÉ SUR LA FENÊTRE. Elle rétrécit et s'en va, elle ne
   s'efface pas — c'est ce qui distingue « réduire » de « fermer », et ça
   respecte au passage la consigne de fond du folio. Seul le voile, qui
   n'est pas du texte, se fond.

   ⚠️ DEUX DURÉES, PAS UNE. Sur macOS l'ouverture est plus lente et finit
   en douceur, la réduction part vite et file. Une transition symétrique
   donne une fenêtre qui « rebondit », ce qui ne ressemble à rien.
   ============================================================ */
body.ap {
  --dock-y: 118vh;      /* le dock invisible, sous la page */
  --dock-x: 50%;        /* son axe — hors du centre = balayage latéral */
  --dock-w: 10px;       /* la largeur du col, tout au fond de l'entonnoir */
  --win-t: .46s;        /* ouverture, repli CSS */
  --win-t-out: .3s;     /* réduction, repli CSS */
  /* ⚠️ L'ÉCART ENTRE LES DEUX EST LE RÉGLAGE, pas une inattention. La
     réouverture était calée sur la durée du repli CSS (.52s) et Manish
     l'a trouvée trop rapide, deux fois : le tortillon remonte sur
     presque toute la hauteur de l'écran, il lui faut le temps de se
     déplier — alors que la réduction, elle, doit filer.
     ⚠️ ET LA DURÉE NE SUFFISAIT PAS. La courbe comptait autant :
     .52 → .76s n'a presque rien changé parce que la courbe héritée du
     repli met 71 % du mouvement dans les 20 % premiers. Elle est
     corrigée dans `genie.js` — les deux vont ensemble, changer cette
     valeur seule redonnera la même impression. */
  --genie-t: 1.05s;     /* on rouvre — le genie remonte du dock */
  --genie-t-out: .44s;  /* on réduit — bien plus court, comme sur macOS */
}
body.ap .ap-veil {
  position: fixed; inset: 0; z-index: 80;
  background: rgba(20,18,15,.34);
  opacity: 0; transition: opacity var(--win-t-out) ease;
}
body.ap .ap-veil.on { opacity: 1; transition-duration: var(--win-t); }

body.ap .ap-win {
  position: fixed; z-index: 81; left: 50%; top: 50%;
  width: min(clamp(320px, 42vw, 560px), calc(100vw - 2 * var(--gut)));
  background: var(--paper);
  border: 1px solid color-mix(in oklab, var(--ink) 16%, transparent);
  border-radius: 10px;
  box-shadow: 0 24px 70px rgba(20,18,15,.22);
  /* l'origine est EN BAS AU CENTRE : la fenêtre se replie VERS le dock,
     elle ne rétrécit pas sur place */
  transform-origin: 50% 100%;
  transform: translate(-50%, var(--dock-y)) scale(.06, .015);
  transition: transform var(--win-t-out) cubic-bezier(.55,0,.85,.35);
  will-change: transform;
}
body.ap .ap-win.on {
  transform: translate(-50%, -50%) scale(1);
  transition: transform var(--win-t) cubic-bezier(.16,.9,.3,1);
}
/* ⚠️ `overflow: hidden` SUR LE CORPS NE SUFFIT PAS AU TACTILE, et c'est
   devenu visible le 2026-08-14 : le formulaire n'était atteignable avec
   son tortillon qu'au-dessus de 860 px, donc à la souris. Sur téléphone,
   un doigt posé sur le voile ou sur la fenêtre continue de faire défiler
   le DOCUMENT derrière — la fenêtre est `fixed`, elle ne bouge pas, mais
   toute la page glisse dessous. Ça se lit comme un défaut de la modale.

   Deux propriétés, aucune ne touche au positionnement du corps (la
   parade classique — `body { position: fixed; top: -scrollY }` — coûte
   une restauration de défilement à la fermeture, donc un état à ne pas
   perdre en cas d'erreur, pour un gain identique ici) :
     · `touch-action: none` sur le VOILE : il couvre l'écran entier, un
       geste dessus ne veut rien dire, on le rend inerte ;
     · `overscroll-behavior: contain` sur la FENÊTRE : le message est un
       `textarea` qui défile, et arrivé au bout il enchaînait sur le
       document. Le geste s'arrête maintenant à la fenêtre. */
body.ap-modal { overflow: hidden; }   /* la page ne défile plus derrière */
body.ap .ap-veil { touch-action: none; }
body.ap .ap-win  { overscroll-behavior: contain; }

body.ap .ap-win-bar {
  display: flex; align-items: center; gap: .8em; padding: .85em 1em;
  border-bottom: 1px solid color-mix(in oklab, var(--ink) 12%, transparent);
}
body.ap .ap-win-t {
  font-family: var(--geo); font-size: var(--t-body); font-weight: 500;
  letter-spacing: -.02em;
}
/* la pastille de réduction, à gauche comme sur macOS */
body.ap .ap-win-x {
  order: -1; width: 12px; height: 12px; padding: 0;
  border: 0; border-radius: 50%; background: var(--red); cursor: pointer;
}
body.ap .ap-win-x:hover { filter: brightness(.86); }

body.ap .ap-win-f { display: flex; flex-direction: column; gap: 1em; padding: 1.2em; }
body.ap .ap-win-f label { display: flex; flex-direction: column; gap: .4em; }
body.ap .ap-win-f span {
  font-family: var(--geo); font-size: var(--t-micro);
  letter-spacing: .06em; text-transform: uppercase; color: var(--ink-2);
}
body.ap .ap-win-f input,
body.ap .ap-win-f textarea {
  font: inherit; font-size: var(--t-body); color: var(--ink);
  background: transparent; border: 0;
  border-bottom: 1px solid color-mix(in oklab, var(--ink) 22%, transparent);
  padding: .45em 0; border-radius: 0; resize: vertical;
}
body.ap .ap-win-f input:focus,
body.ap .ap-win-f textarea:focus { outline: 0; border-bottom-color: var(--red); }
body.ap .ap-win-f button[type=submit] {
  align-self: flex-start; margin-top: .4em;
  font-family: var(--geo); font-size: var(--t-body); font-weight: 500;
  color: var(--paper); background: var(--ink);
  border: 0; border-radius: 4px; padding: .6em 1.4em; cursor: pointer;
}
body.ap .ap-win-f button[type=submit]:hover { background: var(--red); }
body.ap .ap-win-f button[type=submit][disabled] { opacity: .55; cursor: default; }

/* ------------------------------------------------------------
   LES ERREURS, ET LE « NON » DE LA FENÊTRE
   ------------------------------------------------------------
   Manish : « qu'il ait les erreurs […] lorsqu'il y a une erreur, la
   popup bouge de gauche à droite à gauche comme pour dire non, c'est ce
   que fait apple. »

   ⚠️ L'ERREUR APPARAÎT EN `hidden`, JAMAIS EN FONDU — c'est du texte, et
   la dérogation du 2026-08-12 ne couvre que les quatre domaines de
   l'entrée. Rien à animer ici : elle est là ou elle n'est pas. */
body.ap .ap-err {
  font-family: var(--geo); font-style: normal;
  font-size: var(--t-micro); letter-spacing: .02em; color: var(--red);
}
body.ap .ap-win-f input.ko,
body.ap .ap-win-f textarea.ko { border-bottom-color: var(--red); }
body.ap .ap-win-go { display: flex; align-items: center; gap: 1em; flex-wrap: wrap; }
body.ap .ap-win-st {
  font-family: var(--geo); font-size: var(--t-micro); color: var(--ink-2); margin: 0;
}
body.ap .ap-win-st.ko { color: var(--red); }
/* le piège à robots — hors champ, et surtout PAS en `display:none` :
   une partie des robots ignore ce qui est masqué ainsi, et le piège ne
   prendrait plus rien. La fenêtre est `fixed`, donc bloc conteneur. */
body.ap .ap-hp {
  position: absolute; left: -9999px; top: 0; width: 1px; height: 1px;
  border: 0; padding: 0;
}

/* ⚠️ LES IMAGES-CLÉS RÉÉCRIVENT LE `translate(-50%,-50%)` DU CENTRAGE.
   La fenêtre est centrée PAR sa transformation : une animation qui ne
   poserait que le décalage horizontal la ferait sauter d'un quart
   d'écran avant de trembler. Chaque étape porte donc le centrage
   complet. Et l'animation revient exactement à la valeur de repos, donc
   rien ne bouge quand elle se termine.

   ⚠️ AMPLITUDE DÉCROISSANTE, sur trois allers-retours. Un tremblement
   d'amplitude constante lit « ça vibre » ; un refus lit « non » parce
   qu'il s'éteint. */
@keyframes zh-non {
    0% { transform: translate(-50%, -50%); }
   14% { transform: translate(calc(-50% - 13px), -50%); }
   28% { transform: translate(calc(-50% + 11px), -50%); }
   42% { transform: translate(calc(-50% - 8px),  -50%); }
   57% { transform: translate(calc(-50% + 6px),  -50%); }
   71% { transform: translate(calc(-50% - 3px),  -50%); }
   85% { transform: translate(calc(-50% + 2px),  -50%); }
  100% { transform: translate(-50%, -50%); }
}
body.ap .ap-win.non { animation: zh-non .46s cubic-bezier(.36,.07,.19,.97); }
/* le refus reste LISIBLE sans le geste : le texte rouge et le champ
   souligné disent la même chose, seul le mouvement disparaît */
@media (prefers-reduced-motion: reduce) {
  body.ap .ap-win.non { animation: none; }
  body.ap .zh-h1 .tw.cur.bl { animation: none; }
}

/* ------------------------------------------------------------
   LE GENIE — la surface où la fenêtre est redessinée
   ------------------------------------------------------------
   Pendant le tortillon, la vraie fenêtre est INVISIBLE mais toujours
   en place, et c'est sa capture qui joue sur ce canevas.

   ⚠️ `visibility` ET PAS `display`. La fenêtre garde sa boîte : c'est
   elle qu'on mesure pour savoir d'où part le tortillon, et un
   `display:none` la ferait mesurer à zéro.

   ⚠️ `transition: none` PENDANT LE GENIE. Sinon la transformation du
   repli CSS joue EN MÊME TEMPS que le canevas — deux animations pour
   une fenêtre, dont une invisible qui décale la mesure.

   ⚠️ AUCUNE OPACITÉ SUR LE CANEVAS. Il apparaît et disparaît par
   `clearRect`, jamais par un fondu : la fenêtre est aspirée, elle ne
   s'efface pas. (Même raison que la fenêtre elle-même.) */
body.ap .ap-genie {
  position: fixed; inset: 0; z-index: 82;
  pointer-events: none;
}
/* ⚠️ TROIS CLASSES ET PAS DEUX. `body.ap .ap-win.on` porte déjà sa
   transition à (0,3,1) : écrit `body.ap .ap-win--genie`, ce bloc pesait
   un cran de moins et la transition du repli JOUAIT QUAND MÊME,
   invisible, sous le tortillon. */
body.ap .ap-win.ap-win--genie { visibility: hidden; transition: none; }

/* ⚠️ LE TEMPS DE LA POSE, LES COULEURS SONT À PLAT. html2canvas parse
   la couleur lui-même : un `color-mix(in oklab, …)` lui revient de
   `getComputedStyle` en `oklab(…)`, qu'il ne sait pas lire — il jette,
   et toute la capture est perdue. Ce sont les mêmes teintes, écrites
   dans une syntaxe qu'il connaît : rien ne bouge à l'écran. */
/* ⚠️ LA POSE HORS CHAMP — c'est elle qui donne le genie AU PREMIER
   CLIC. Sans capture préalable, la première ouverture retombait sur le
   repli : « la toute première fois, y a pas l'effet genie ». On capture
   donc la fenêtre avant qu'elle ait jamais paru, ce qui demande deux
   choses en même temps : qu'elle soit rendue (le `hidden` est retiré en
   JS) et qu'elle soit à sa taille OUVERTE. D'où `transform: none` —
   fermée elle porte `scale(.06,.015)`, que html2canvas applique, et la
   capture aurait été un copeau de 34 px.
   `left: -200vw` est la recette standard pour html2canvas : l'élément
   est rendu, hors de l'écran, sans rien déranger (il est `fixed`, donc
   il n'allonge aucun défilement). */
body.ap .ap-win.ap-win--prep {
  left: -200vw; top: 0; transform: none; transition: none; visibility: visible;
}

body.ap .ap-win--shot { border-color: rgba(20,18,15,.16); }
body.ap .ap-win--shot .ap-win-bar { border-bottom-color: rgba(20,18,15,.12); }
body.ap .ap-win--shot .ap-win-f input,
body.ap .ap-win--shot .ap-win-f textarea { border-bottom-color: rgba(20,18,15,.22); }

/* ⚠️ LES LIENS DE L'OUTRO DEVIENNENT DE VRAIS LIENS ET DE VRAIS BOUTONS
   — et il faut leur rendre leur soulignement. Il venait de
   `.zh-plate .zh-social span` : en passant de `<span>` à `<a>` et
   `<button>`, le sélecteur ne mordait plus et les deux libellés se
   retrouvaient nus. Un changement de balise emporte silencieusement
   toutes les règles qui la nommaient. */
body.ap .zh-social a,
body.ap .zh-social button,
body.ap .zh-plate-b {
  font: inherit; color: inherit; background: none; border: 0;
  padding: 0; cursor: pointer; text-decoration: none;
}
body.ap .zh-plate .zh-social a,
body.ap .zh-plate .zh-social button {
  border-bottom: 1px solid var(--ink); padding-bottom: .25em;
}
body.ap .zh-plate .zh-social a:hover,
body.ap .zh-plate .zh-social button:hover { color: var(--red); border-bottom-color: var(--red); }
body.ap .zh-plate-b {
  font-family: var(--serif); color: var(--paper);
  font-size: calc(2.1 * var(--vwu));
  border-bottom: 1px solid var(--paper); padding-bottom: .1em;
}

/* ============================================================
   MOBILE — le vrai bloc `body.ap`, qui n'existait pas
   ------------------------------------------------------------
   ⚠️ LE DÉFAUT ÉTAIT DE SPÉCIFICITÉ, PAS DE VALEURS. Le bloc 600 px
   plus haut a été écrit pour `body.zh` (la copie conforme) : ses
   règles sont en `.zh-*` nu, donc à un seul cran de spécificité. Toutes
   les surcharges de la page approche sont en `body.ap .zh-*`, donc plus
   spécifiques — elles GAGNAIENT au mobile, et la page y rendait ses
   valeurs de bureau.

   Or ces valeurs sont en `--vwu`, c'est-à-dire en fraction de la
   largeur utile : à 390 px, `--vwu` vaut 4 px. Les mots de la rangée,
   posés à `calc(3.4 * var(--vwu))`, tombaient à **13 px** — plus petits
   que leur propre micro-texte. La hiérarchie était exactement inversée,
   et ce n'était pas un réglage à corriger mais un bloc entier à écrire.

   ⚠️ RÈGLE : toute valeur de bureau en `--vwu` doit avoir son pendant
   ici. `--vwu` est fait pour PLAFONNER en haut d'échelle, pas pour
   descendre en bas — au mobile on repasse en `vw` (les titres suivent
   l'écran) et en `rem` (le corps reste lisible), exactement le modèle
   relevé sur la référence.

   ⚠️ LE SEUIL EST 860 ET NON 600, et il est choisi, pas hérité. La
   copie conforme bascule à 600 parce que la référence le fait ; la page
   approche n'y survit pas. Mesuré à 768 : le mot de la rangée tombait à
   19 px contre 16 px pour son propre micro-texte — la même hiérarchie
   inversée qu'à 390, simplement moins visible.

   860 est déjà `MIN_W` dans fx.js, la largeur sous laquelle parallaxe,
   cylindre et opacités sont coupés. Un seul seuil commande donc les
   deux : « pas d'effets » et « mise en page mobile » arrivent ensemble,
   au lieu de se croiser dans une bande de 260 px où la page avait les
   effets d'un bureau et la typographie d'un mobile.

   ⚠️ CONSÉQUENCE : ce bloc ne peut plus s'appuyer sur les règles du
   bloc 600 px (`display: block` sur les grilles, etc.) puisqu'il
   s'ouvre avant lui. Tout ce dont il a besoin est déclaré ici.
   ============================================================ */
@media only screen and (max-width: 860px) {
  /* les grilles retombent en flux — repris du bloc 600 px, qui ne
     s'ouvre plus assez tôt pour nous */
  body.ap .zh-out-grid { display: block; text-align: center; }
  body.ap .zh-collab { display: block; }
  body.ap .zh-nav { display: none; }
  /* l'échelle typographique se réécrit d'un coup : le reste du fichier
     ne connaît que ces trois jetons */
  body.ap {
    --gut: 7vw;
    --t-micro: 0.6875rem;
    --t-body:  0.9375rem;
    --t-head:  8.6vw;
  }

  /* ---------- le hero ---------- */
  /* ⚠️ PAS de `text-align: center` : le bloc `body.zh` centre tout au
     mobile parce que la référence le fait. La page approche est
     composée à gauche du haut en bas, et une intro centrée suivie de
     chapitres alignés à gauche se lit comme deux pages différentes. */
  body.ap .zh-hero { text-align: left; padding: 10vh var(--gut) 5vh; }
  body.ap .zh-h1 { font-size: 11.4vw; line-height: 1.05; }
  /* ⚠️ le titre DOIT pouvoir passer à la ligne ici — c'est aussi ce qui
     rend nécessaire le découpage par mot de l'intro (app.js), sans quoi
     la coupure tomberait au milieu d'un mot */
  body.ap .zh-h1 span { white-space: normal; }
  /* la flèche cesse d'absorber la place restante d'une ligne flex — sur
     deux lignes de titre elle n'aurait plus rien à absorber — et devient
     un filet à elle, entre les deux lignes. Le tracé de l'intro
     continue de fonctionner : c'est le même `scaleX`. */
  body.ap .zh-h1 span:has(.zh-arrow) { display: block; }
  body.ap .zh-h1 .zh-arrow {
    display: block; flex: none; width: 38%; height: 2px;
    margin: .34em 0 .30em; transform-origin: left center;
  }
  body.ap .zh-cue { font-size: 0.6875rem; margin: 4vh 0 0; }
  body.ap .zh-hero .zh-l2 { margin: 5vh 0 0; padding-bottom: 0; }

  /* ---------- la rangée : une colonne, le MOT d'abord ---------- */
  /* deux par ligne avaient été essayés : à 390 px un créneau fait
     ~160 px, où « Prototypage » ne tient pas sans tomber sous 20 px.
     Une seule colonne rend au mot la taille qui en fait un repère. */
  body.ap .zh-chaps { display: block; }
  body.ap .zh-chaps a { display: block; padding: 0; margin-bottom: 3.4vh; }
  body.ap .zh-chaps a + a { padding-left: 0; }
  body.ap .zh-chaps .w { display: block; }
  body.ap .zh-chaps span { font-size: 0.6875rem; padding-right: .45em; }
  body.ap .zh-chaps b { font-size: 9.4vw; text-transform: none; letter-spacing: -.035em; }
  body.ap .zh-chaps em {
    position: static; display: block; width: 100%;
    font-size: 0.875rem; line-height: 1.45; margin-top: .8vh;
  }
  /* la cascade garde sa COULEUR, qui est ce qui se lit ; la montée passe
     de .9vw (3,5 px ici, invisible) à une valeur qui existe */
  body.ap .zh-chaps a.lit { transform: translateY(-0.6vh); }
  body.ap .zh-chaps a.lit em { transform: translateY(0.6vh); }

  /* ---------- les chapitres ---------- */
  body.ap .zh-ch--ap { display: block; text-align: left; }
  body.ap .zh-ch, body.ap .ap-entry, body.ap .zh-collab {
    min-height: 0; padding: 9vh var(--gut);
  }
  /* ⚠️ NOWRAP MAINTENU, et c'est voulu : `fitWords()` ramène les quatre
     mots géants à une taille commune quand le plus long déborde. En
     `normal`, « Prototypage » passerait sur deux lignes — un mot coupé
     n'est plus un repère, c'est un accident. */
  body.ap .zh-word { white-space: nowrap; }
  /* ⚠️ LA PLACE DU JAMBAGE, et c'est un défaut PROPRE AU MOBILE. Le mot
     géant est en `line-height: .88` : sa boîte de ligne est plus courte
     que sa fonte, donc le « p » de « Expérience » et le « g » de
     « Stratégie » descendent SOUS elle. Au bureau ça ne se voit pas — le
     mot est une colonne de la grille, il n'a personne en dessous. En
     colonne unique, l'élément suivant se colle à la boîte de ligne et le
     jambage le barre : mesuré, « TOUT TIENT À UNE » était traversé par
     la hampe du « p ».
     Le réglage va sur le MOT, pas sur ce qui suit : c'est lui qui déborde
     de sa boîte, et un `.ap-kick` vide (il y en a) prendrait sinon une
     marge pour rien. */
  body.ap .zh-ch--ap > .zh-left { padding-bottom: 2.6vw; }
  body.ap .ap-kick { margin-bottom: 2vh; }
  body.ap .zh-ch--ap .zh-head,
  body.ap .ap-final h3,
  body.ap .ap-entry-head,
  body.ap .zh-collab .zh-head { font-size: var(--t-head); }
  body.ap .zh-ch--ap .zh-body p,
  body.ap .ap-list li,
  body.ap .ap-entry-final,
  body.ap .zh-collab .zh-body p,
  body.ap .ap-final li { font-size: var(--t-body); }
  body.ap .ap-kick, body.ap .ap-list em { font-size: var(--t-micro); }
  body.ap .zh-ch--ap .zh-body { margin-top: 3vh; }
  body.ap .ap-list { margin-top: 3vh; }
  body.ap .ap-final { margin-top: 5vh; }
  body.ap .ap-final::before { display: none; }   /* le halo n'a plus de place */

  /* ---------- la condition d'entrée ---------- */
  body.ap .ap-entry { display: block; }
  body.ap .ap-entry-side { margin-top: 4vh; }
  body.ap .zh-collab-sub { font-size: var(--t-body); margin: 2vh 0 3vh; }

  /* ---------- l'interstice ---------- */
  body.ap .ap-inter { padding: 6vh var(--gut); min-height: 0; }
  body.ap .ap-ring-stage { width: 66vw; }
  body.ap .ap-ring { --ring-r: 28vw; }
  /* ⚠️ PLUS DE CORPS ICI, ET C'EST VOULU. Il dérive maintenant de
     `--ring-r`, redéfini deux lignes plus haut : le rapport mesuré sur
     la référence se transporte tel quel au mobile. Un `rem` remis ici
     rouvrirait exactement le défaut que le bloc mobile existe pour
     éviter — un mot qui garde sa taille pendant que le cercle rétrécit. */
  body.ap .ap-bulb { width: 14vw; }
  /* ⚠️ 78vw ET NON 92 : deux fragments sont posés à −6 % et 86 % de la
     boîte, volontairement à moitié sortis — c'est le cadrage de la
     référence. À 92vw la boîte touche presque les bords de l'écran et
     ces deux-là se faisaient couper par la FENÊTRE : à cette échelle,
     un fragment tranché au ras du bord se lit comme un bug et non comme
     une composition. Resserrer la boîte leur rend leur débord. */
  /* ---------- les formules : un PLI VERTICAL, pas une bande ----------
     ⚠️ Demandé par Manish depuis son téléphone : « la partie avec les
     formules mathématiques, revois la formule pour que ça devienne un
     fold vertical de tél complet ».

     Le cadrage `1278 / 720` n'est PAS un choix de mise en page, c'est le
     format du gif relevé — et un 16/9 posé sur un écran de téléphone
     donne une bande de 190 px au milieu de rien. La scène vit de sa
     DENSITÉ ; réduite à un bandeau, elle n'a plus la place d'être dense.

     Ce qui change n'est donc que le CONTENANT. Les positions des
     fragments sont en pourcentage de la boîte (`left: x%`, `top: y%`),
     leurs corps en `em` de la boîte : le relevé se transporte tel quel,
     étiré verticalement au lieu d'être réduit. Aucune donnée de
     `content/approach.js` ne bouge, et le bureau ne bouge pas non plus.

     ⚠️ `100svh` APRÈS `100vh`, et les deux sont nécessaires. Sur iOS,
     `100vh` est la hauteur SANS la barre d'URL : la section dépasse donc
     l'écran visible au chargement, et le « pli complet » demandé se
     retrouve coupé par le bas. `svh` est la petite hauteur, celle qui
     est vraiment visible ; `vh` reste en repli pour les moteurs qui ne
     la connaissent pas. */
  body.ap .ap-inter--casque {
    min-height: 100vh;
    min-height: 100svh;
    /* ⚠️ PLUS DE GOUTTIÈRE LATÉRALE ICI, et c'est le relevé qui le
       demande autant que la géométrie : « Les chiffres occupent TOUTE LA
       LARGEUR de l'image, du bord gauche au bord droit »
       (content/approach.js, lecture frame par frame). Le titre garde la
       sienne — c'est du texte, il se lit dans la grille de la page. */
    padding: 5vh 0 4vh;
    justify-content: flex-start;
    /* ⚠️ LA COUPE EST ICI, ET SEULEMENT ICI. Deux fragments sont posés à
       −6 % et 86 % de la boîte, volontairement à moitié dehors. Tant que
       la scène était insérée dans les gouttières, leur débord tombait
       DANS la marge ; pleine largeur, il tombe hors de l'écran et rend
       21 px de défilement horizontal au document.

       La note d'origine disait « un fragment tranché au ras du bord se
       lit comme un bug » — elle visait une coupe à une ligne INTÉRIEURE
       arbitraire. Ici la coupe est le bord de l'écran d'un champ qui va
       de bord à bord : elle dit « ça continue dehors », ce qui est
       exactement le relevé. Et c'est une coupe LOCALE — pas un
       `overflow-x` sur le corps, qui ferait de la racine un conteneur de
       défilement (sticky et ancrage cassés sur iOS). */
    overflow: hidden;
  }
  body.ap .ap-inter--casque .ap-casque-t { padding: 0 var(--gut); }
  /* la hauteur ne vient plus du cadrage du gif, elle vient de l'écran */
  body.ap .ap-inter--casque .ap-casque {
    aspect-ratio: auto;
    flex: 1 1 auto;
    min-height: 0;
    /* ⚠️ EN `vw` ET NON EN `rem`, et ce n'est pas un détail de goût. Le
       fragment le plus à droite est posé à `left: 86%` et pèse jusqu'à
       4,2 em : pour qu'il tienne dans l'écran il faut
       `0,14 × largeur ≥ 4,2 × corps`, donc un corps qui SUIT la largeur.
       En `rem` il reste fixe pendant que la boîte rétrécit, et à 320 px
       il ressortait par la droite — c'est ce qui a rouvert les 2 px de
       défilement horizontal déjà corrigés une fois.
       3,2vw = 12,5 px à 390, 10,2 px à 320 : le rapport tient partout. */
    font-size: 3.2vw;
  }
  /* ⚠️ LE CASQUE SE REDIMENSIONNE, sinon il se perd. Ses 22,5 % de large
     sont un rapport relevé sur une image en 16/9 ; dans une boîte deux
     fois et demie plus haute que large, ces 22,5 % font 75 px au milieu
     de 700 px de vide — les chiffres tournent autour de personne. Il
     reprend donc sa place relative À L'ÉCRAN, pas dans le gif. */
  body.ap .ap-inter--casque .ap-casque-img {
    left: 50%; top: 38%; width: 40%; height: 24%;
    transform: translateX(-8%);
  }
  /* le compte redescend au tiers : posé à 20 % d'une boîte devenue très
     haute, il se décollait du casque et la colonne flottait seule en
     haut de l'écran */
  body.ap .ap-inter--casque .ap-col { top: 12%; left: 34%; }
  body.ap .ap-inter--casque .ap-graph { top: 62%; left: 12%; width: 26%; }
  body.ap .ap-win { width: calc(100vw - 2 * var(--gut)); }
  body.ap .zh-plate-b { font-size: 6.4vw; }
  body.ap .ap-casque-t { font-size: 9vw; margin-bottom: 1.4vh; }
  body.ap .ap-cube { width: 88vw; }
  /* ⚠️ IL Y AVAIT ICI un `display: none` sur `.cb-g` qui retirait le mot
     en orbite au mobile, pour le coût. Retiré le jour même : orbite
     coupée, Manish a répondu « le cube lag toujours » — donc ce n'était
     pas elle, et le raisonnement de coût (mesuré en Chrome) ne
     transportait pas sur WebKit. Voir le bloc dans `drawCube`. */
  body.ap .ap-arrows { width: 62vw; gap: 4vw; }
  body.ap .ap-cube-word { margin-top: 1.2vh; }
  /* les deux flèches suivent l'échelle mobile : en `vw`, comme tout ce
     qui est titre ici — `--vwu` est fait pour plafonner en haut
     d'échelle, pas pour descendre en bas (voir l'invariant du seuil 860) */
  body.ap .ap-cap { width: 9vw; }
  body.ap .ap-cap--haut { margin-bottom: 3.4vh; }
  body.ap .ap-cap--bas  { margin-top: 3.4vh; }

  /* ---------- l'outro ---------- */
  /* ⚠️ LE BLEND TOMBE AU MOBILE, et pas par prudence : à 600 px et en
     dessous, `[data-mx] { transform: none }` coupe la traversée et
     `.zh-out-grid` repasse en flux — le titre ne recouvre donc plus
     rien. Un `mix-blend-mode: difference` qui n'a que le papier sous lui
     ne fait qu'une chose : remplacer l'encre chaude de la page par un
     noir froid, pour zéro effet. */
  body.ap .zh-big {
    font-size: 13vw; white-space: normal; line-height: 1.02;
    mix-blend-mode: normal; color: var(--ink);
    padding: 0 var(--gut);
  }
  body.ap .zh-outro { min-height: 0; padding: 8vh 0 6vh; }
  body.ap .zh-big--w { display: none; }   /* pas de recouvrement au mobile */
  body.ap .zh-plate { align-self: auto; }
  body.ap .zh-plate .zh-img { width: 100%; aspect-ratio: 3 / 4; }
  body.ap .zh-plate .zh-img span { font-size: 6.4vw; }
  body.ap .zh-plate .zh-social {
    gap: 9vw; margin-top: 2.4vh; font-size: 0.8125rem; padding: 0;
  }
  /* ⚠️ Les colonnes latérales gardaient leur `padding: 12vh 0` et leur
     `justify-content: space-evenly` de bureau, où elles doivent remplir
     la hauteur de la plaque. Repassées en flux, elles n'ont plus rien à
     remplir : les 24vh de marge et la répartition « au plus large »
     creusaient trois trous d'un demi-écran chacun entre le titre, la
     photo et les liens. */
  body.ap .zh-out-side { display: block; padding: 0; }
  body.ap .zh-out-side p { font-size: var(--t-body); padding: 1.1vh var(--gut); }
  body.ap .zh-out-grid > * + * { margin-top: 3vh; }
}


/* ⚠️ IL Y AVAIT ICI une règle `@media (max-width: 360px)` qui rétrécissait
   `.ap-casque` à 82 % pour rattraper 2 px de défilement horizontal. Elle
   est TOMBÉE avec le pli vertical du 2026-08-14 : la scène est désormais
   pleine largeur et son corps est en `vw`, donc le fragment de droite
   tient à toutes les largeurs par construction — voir le bloc mobile.
   Ne pas la remettre : superposée au nouveau cadrage, elle recréerait la
   bande étroite que le pli existe pour supprimer. */

/* mode QA : le hero rend sa hauteur au contenu, sinon ses 100vh
   remplissent toute capture pleine page et masquent les chapitres */
body.qa-flat .zh-hero { min-height: 0; padding-top: 6vh; padding-bottom: 6vh; }


/* ============================================================
   COMPOSITION 2 — `approche2.html`, une variante à comparer
   ------------------------------------------------------------
   Manish : « zhenya a une vraie composition elle. moi c'est une grille
   point. » Le diagnostic est juste, et la cause est l'invariant 4 que
   j'avais écrit : *un gabarit unique, ce qui varie est ce qui est
   PRÉSENT, jamais la géométrie*. Cette règle a fait exactement ce qu'on
   lui demandait — plus aucune largeur négociée à l'oeil — et elle
   garantit mécaniquement la monotonie. Quatre chapitres qui ne peuvent
   différer que par la présence d'un bloc produisent un TABLEAU.

   Relevé sur `zhenya.mov` à 12,5 s, un chapitre contre le nôtre :

     ancrages du mot géant   eux 2, à deux hauteurs   nous 1, même ligne
     corps distincts         eux 7 (11 → 90 px)       nous 3, dont 2 voisins
     filet                   oui                      non
     haut des colonnes       décalés (y 15 / y 110)   alignés

   ⚠️ CE BLOC NE CORRIGE PAS L'INVARIANT 4, IL LE REFORMULE : la
   géométrie a le droit de varier, mais dans un endroit unique et nommé,
   jamais par un décalage arbitré au fil des séances. Tout ce qui suit
   est sous `body.c2` — la v1 ne peut pas bouger d'un pixel, et les deux
   pages se comparent en changeant d'onglet.
   ============================================================ */

/* ---------- 1. décrocher les repères ----------
   Le geste le plus caractéristique de la référence, et le moins cher :
   chez elle « C. » est AU-DESSUS du mot et « 03 » EN DESSOUS. Le mot
   n'est plus aligné sur quelque chose, il est SUSPENDU entre deux
   marques. La colonne de gauche passe d'une hauteur à trois.

   ⚠️ Le repère inline (`.zh-letter`, dans le `<h2>`) est masqué et non
   supprimé du rendu : il porte `font-size: .37em`, donc il suit
   `fitWords()` quand le mot rétrécit. La marque décrochée ne peut pas
   hériter de ça — elle est hors du `<h2>` — d'où un corps propre. Les
   deux existent, une seule s'affiche. */
body.c2 .zh-word .zh-letter { display: none; }
body.c2 .ap-mark {
  display: block;
  font-family: var(--geo);
  font-size: calc(1.5 * var(--vwu)); line-height: 1;
  letter-spacing: .02em;
}
body.c2 .ap-mark--t { color: var(--red); margin-bottom: calc(1.1 * var(--vwu)); }
/* le numéro est en encre douce : deux marques rouges encadrant le mot
   feraient une paire, et une paire se lit comme un cadre — pas comme
   une suspension. Une seule accentuée, l'autre en retrait. */
body.c2 .ap-mark--b { color: var(--mute); margin-top: calc(1.4 * var(--vwu)); }

/* ---------- 2. décaler les hauts ----------
   ⚠️ `align-self: baseline` est ce qui produisait la rangée. Il avait
   une bonne raison (le mot géant et le titre n'ont pas la même hauteur
   d'ascendante, calés par le haut leurs capitales démarraient à 62 px
   d'écart) — mais il impose que TOUT parte du même trait.

   Ici les trois colonnes repartent du haut et c'est un DÉCALAGE ÉCRIT
   qui les sépare : le contenu attaque le plus haut, le chapeau un cran
   plus bas, le mot géant en dernier. L'oeil descend en diagonale au
   lieu de balayer une ligne. Les valeurs sont en `--vwu`, donc solidaires
   de la grille. */
body.c2 .zh-ch--ap > * { align-self: start; }
body.c2 .zh-ch--ap > .zh-left { padding-top: calc(5.5 * var(--vwu)); }
body.c2 .zh-ch--ap > .ap-kick { padding-top: calc(2.2 * var(--vwu)); }

/* ---------- 3. le filet ----------
   Chez la référence il sépare le bloc du haut du titre de section. Un
   TRAIT, pas du vide : c'est ce qui fait qu'une zone vide devient une
   zone, et non une absence. */
body.c2 .ap-rule {
  display: block; height: 1px; width: 100%;
  background: var(--ink); opacity: .55;
  margin: 0 0 calc(2.2 * var(--vwu));
}

/* ---------- 4. ouvrir l'échelle ----------
   Le titre de section et les items de liste étaient à un rapport 2:1 —
   deux corps voisins se lisent comme un seul. La référence tient un
   rapport de 8:1 dans le même écran.

   ⚠️ ON MONTE LE TITRE PLUTÔT QUE DE DESCENDRE LA LISTE : la liste est
   du texte qu'on lit vraiment, et `--t-body` est déjà son plancher
   confortable. C'est le haut de l'échelle qui a de la place. */
body.c2 .zh-ch--ap .zh-head { font-size: calc(var(--t-head) * 1.22); }
body.c2 .ap-list li { font-size: calc(var(--t-body) * .94); }
body.c2 .ap-kick { font-size: calc(var(--t-micro) * .9); }

/* ---------- mobile ----------
   ⚠️ Les décalages de colonnes n'ont plus de sens quand il n'y a plus de
   colonnes : sous le seuil, `.zh-ch--ap` repasse en flux (`display:
   block`) et un `padding-top` de 5 --vwu ne ferait qu'un trou. Les
   marques, elles, RESTENT — c'est leur empilement qui fait la
   composition, et il survit très bien à une colonne unique. */
@media (max-width: 860px) {
  body.c2 .zh-ch--ap > .zh-left,
  body.c2 .zh-ch--ap > .ap-kick { padding-top: 0; }
  body.c2 .ap-mark { font-size: 3.4vw; }
  body.c2 .ap-mark--t { margin-bottom: 1.2vh; }
  body.c2 .ap-mark--b { margin-top: 1.4vh; }
  body.c2 .ap-rule { margin-bottom: 2.4vh; }
}

/* ---------- 5. l'objet entre dans le chapitre ----------
   La correction de fond, et elle vient d'une relecture de la référence
   plutôt que d'une idée : chez elle l'anneau n'est PAS un interstice,
   c'est un CHAMP du chapitre « Think » (`content/method.js`), rendu
   comme sa colonne du milieu. Vérifié sur zhenya.mov à 10,6 s.

   Leur page a quatre chapitres et zéro passage : chaque dispositif
   visuel est un slot d'un chapitre. Un objet posé ENTRE A et B ne peut
   parler que du trajet de A à B ; posé DANS B, il parle de B. C'est
   l'écart entre décorer et démontrer, et c'est ce que Manish voyait
   sans pouvoir le nommer : « le placement de idée et prototypage me
   bug… chez zhenya idea est intégrée ».

   ⚠️ LA GRILLE PASSE DE 5|2|5 À 4|4|4 — mesuré sur leur frame, colonnes
   comprises : leur anneau occupe exactement les pistes 5 à 8. Et notre
   anneau fait DÉJÀ 4 colonnes (`--stage`, relevé sur la référence il y a
   des séances) : l'objet avait la bonne taille pour une place qu'il
   n'occupait pas. Rien à redimensionner, seulement à reparenter.

   ⚠️ DEUX RANGÉES, pas une. Le chapeau et le contenu partagent la
   colonne de droite, l'un au-dessus de l'autre — comme leur « IT'S ALL
   ABOUT » posé contre son titre. Le mot géant et l'objet enjambent les
   deux rangées. Sans ça, le chapeau garderait sa colonne à lui et il ne
   resterait pas 4 pistes pour l'objet. */
body.c2 .zh-ch--ap.has-obj {
  grid-template-rows: auto auto;
  align-items: start;
}
body.c2 .zh-ch--ap.has-obj > .zh-left { grid-column: 1 / span 4;  grid-row: 1 / span 2; }
body.c2 .zh-ch--ap.has-obj > .ap-obj  { grid-column: 5 / span 4;  grid-row: 1 / span 2; }
body.c2 .zh-ch--ap.has-obj > .ap-kick { grid-column: 9 / span 4;  grid-row: 1; }
body.c2 .zh-ch--ap.has-obj > .ap-main { grid-column: 9 / span 4;  grid-row: 2; }
/* le chapeau colle à son titre : c'est une amorce, pas une ligne isolée */
body.c2 .zh-ch--ap.has-obj > .ap-kick { margin-bottom: calc(1.2 * var(--vwu)); }

/* ⚠️ L'OBJET PERD SES MARGES DE PASSAGE. `.ap-ring-wrap` était centré
   dans une section de 9vh de padding ; dans une colonne il doit se
   caler en haut, sinon il flotte au milieu d'une piste dont la hauteur
   est fixée par le texte d'à côté. */
body.c2 .ap-obj { align-self: start; }
body.c2 .ap-obj .ap-ring-wrap { width: 100%; }
/* ⚠️ Le cube est déclaré à 5 colonnes (sa taille de passage) — dans une
   piste de 4 il déborderait sur le texte. En pourcentage, il prend la
   piste, quelle qu'elle soit. */
body.c2 .ap-obj .ap-cube,
body.c2 .ap-obj .ap-arrows { width: 100%; }
/* ⚠️ 🔴 `--stage` NE PASSE PAS EN POURCENTAGE, et j'ai payé l'essai :
   l'anneau s'est vidé de ses mots, seule l'ampoule restait. Deux raisons
   cumulées, et la première est le piège déjà consigné plus haut dans ce
   fichier —
     · les mots se posent par `translate(0, calc(var(--ring-r) * -1))`, et
       un pourcentage dans un `translate` se rapporte à la taille du MOT,
       pas à celle du cercle : les treize se sont empilés au centre ;
     · leur corps vaut `calc(var(--ring-r) * .179)`, et un `font-size`
       en pourcentage de pourcentage n'est pas calculable — la
       déclaration tombe, en silence.
   `--stage` reste donc la valeur de la grille de PAGE (4 colonnes), qui
   vaut au pixel près la piste que l'objet occupe ici. Ce n'est pas une
   coïncidence heureuse : la piste FAIT 4 colonnes, et `--stage` avait
   été relevé à 4 colonnes sur la référence. Les deux dérivent de `--col`,
   ils ne peuvent pas diverger. */

/* Les flèches d'encadrement gardent leur place mais resserrent leur
   écart : 5vh était calibré pour un passage plein écran, où l'objet est
   seul. Dans une colonne, cet écart le pousse sous la ligne de départ du
   texte d'à côté et les deux cessent de se répondre. */
body.c2 .ap-obj .ap-cap--haut { margin-bottom: 2.2vh; }
body.c2 .ap-obj .ap-cap--bas  { margin-top: 2.2vh; }

/* ---------- mobile : l'objet redescend en flux ----------
   Sous le seuil il n'y a plus de colonnes ; l'objet se remet simplement
   entre le mot et le texte, ce qui est son ordre de lecture naturel —
   c'est d'ailleurs l'ordre du DOM, il n'y a rien à réordonner. */
@media (max-width: 860px) {
  body.c2 .ap-obj { margin: 4vh 0; }
  /* 66vw est une LONGUEUR, pas un pourcentage : voir plus haut, `--stage`
     doit rester calculable en `translate` et en `font-size`. */
  body.c2 .ap-obj .ap-ring-stage { --stage: 66vw; width: 66vw; margin: 0 auto; }
}

/* ⚠️ CE QUI REND LA TRANSFORMATION CSS ÉQUIVALENTE À L'ATTRIBUT SVG.
   Les lettres de l'anneau du cube étaient posées par
   `setAttribute('transform', …)` — un attribut de présentation, donc de
   la géométrie : le moteur remettait en page les 54 lettres à chaque
   frame, ce qui pesait 94 % du temps de mise en page de la page entière
   (0,219 s sur 0,232, mesuré sur un Chrome piloté).
   `style.transform` s'applique après la mise en page, mais son repère
   par défaut n'est pas celui de l'attribut. Ces deux lignes le lui
   rendent : origine au point (0,0) du `viewBox`, comme en SVG.
   Les retirer, c'est déplacer toutes les lettres hors du cube. */
body .ap-cube .cb-g { transform-box: view-box; transform-origin: 0 0; }
