/* Zone Studio — styles principaux du child theme. */

/* --- Polices --------------------------------------------------------- */
/* Les @font-face sont dans assets/fonts/stylesheet.css, chargée avant
   celle-ci. Ici on ne fait que nommer les deux familles, pour n'avoir
   qu'un endroit à changer si une police évolue. */
:root {
	--font-display: "Albertus MT Std", Optima, Palatino, Georgia, serif;
	--font-body: "Altform", system-ui, -apple-system, sans-serif;

	/* Flou de l'arrière-plan quand le texte d'un projet est ouvert. Déclaré
	   ici et non sur .project : le pied de page le partage, et il est hors de
	   l'article. Une seule valeur pour que les deux ne se désaccordent pas. */
	--flou-texte: 18px;
	--flou-duree: 0.4s;
	/* ZOOM qui accompagne le flou : la pile des médias grandit d'autant, et
	   l'article la rogne. Un `filter: blur()` fond les bords de l'élément
	   vers le transparent — donc vers le blanc de la page — sur deux à trois
	   fois son rayon : sans ce zoom, un liseré clair cerne les médias. Le
	   grossissement repousse ce bord fondu hors du cadre.
	   1 = pas de zoom (et le liseré revient). Le centre du zoom est calé sur
	   l'écran par app.js, pas sur la page, sinon en milieu d'archive tout
	   filerait de plusieurs centaines de pixels. */
	--flou-zoom: 1.08;
	/* Durée du glissement de l'icône info quand le bouton son arrive ou
	   s'en va. Nulle sur ordinateur, où elle ne bouge pas ; le mobile la
	   règle dans son bloc (voir .project-toggle). Le bouton son attend
	   cette durée en plus avant de réapparaître : l'icône info lui fait
	   place D'ABORD. */
	--boutons-glisse: 0s;
}

/* Sur un petit écran, le même rayon de flou couvre une plus grande part de
   la largeur : le bord fondu est proportionnellement plus large, et il faut
   grossir davantage pour le sortir du cadre. */
@media (max-width: 768px) {
	:root {
		--flou-zoom: 1.12;
	}
}

/* Réserve la place de la barre de défilement, toujours, qu'elle soit affichée
   ou non.

   Sans ça, passer d'une page courte à une page longue fait apparaître la barre
   et rétrécit la zone de contenu d'un coup : tout le site se décale de quelques
   pixels vers la gauche. Avec Swup, ce saut arrive en plein milieu d'une
   transition, ce qui le rend d'autant plus visible.

   `scrollbar-gutter: stable` plutôt que `overflow-y: scroll` : le second force
   une barre TOUJOURS VISIBLE, y compris une barre vide sur les pages qui ne
   défilent pas. Le premier ne réserve que la place, et ne dessine la barre que
   s'il y a de quoi défiler.

   Sans effet sur macOS en barres flottantes, qui ne prennent aucune place —
   mais il n'y a alors pas de saut non plus. C'est exactement là où le problème
   existe que la propriété agit.

   Support : Chrome 94+, Firefox 97+, Safari 16+. Ailleurs elle est ignorée et
   on retrouve le comportement actuel. */
html {
	scrollbar-gutter: stable;
}

body {
	font-family: var(--font-body);

	/* Filet de sécurité contre tout débordement horizontal — une ancienne
	   transition étirait la page au-delà de l'écran, celle d'aujourd'hui (le
	   bandeau) ne bouge qu'en vertical, mais un débordement ferait toujours
	   apparaître une barre de défilement horizontale, atteignable au
	   trackpad.

	   `clip` et non `hidden` : `hidden` ferait de <body> un conteneur de
	   défilement, ce qui neutraliserait tout `position: sticky` posé dedans et
	   changerait l'élément de référence du défilement. `clip` coupe sans rien
	   de tout ça. Support : Chrome 90+, Safari 16+, Firefox 81+ — ailleurs la
	   propriété est ignorée et on retrouve simplement la barre pendant la
	   transition. */
	overflow-x: clip;
}




/* --- Couches de composition forcées ---------------------------------
   HYPOTHÈSE DE TRAVAIL, à valider sur Safari.

   Safari paraît réinitialiser le curseur quand une couche de composition est
   CRÉÉE sous le pointeur. Or ces couches se créent paresseusement, au moment
   où on en a besoin :

     · au PREMIER défilement d'une page  → promotion du contenu qui défile ;
     · au clic sur un FILTRE             → les animations promeuvent les
                                            vignettes ;
     · à une NAVIGATION                  → l'animation de sortie promeut #swup.

   Ce qui expliquerait les trois symptômes d'un seul mécanisme, et surtout la
   précision « seulement en scrollant depuis le haut » : c'est le premier
   défilement, donc le seul qui crée la couche.

   Le remède est de les créer À L'AVANCE, une fois pour toutes. C'est
   exactement ce que fait www.zone-studio.fr, qui n'a pas le problème :
   `backface-visibility: hidden` sur son conteneur de défilement et sur son
   en-tête fixe, `translate3d(0, 0, 0)` sur ses calques de transition.

   `backface-visibility` et NON `will-change: transform` ni un `transform` :
   ces deux-là feraient de l'enveloppant animé un bloc conteneur pour ses
   descendants en `position: fixed` — le bouton "i" d'un projet cesserait
   d'être ancré au viewport. `backface-visibility` promeut sans rien changer
   au positionnement.

   Si l'hypothèse est fausse, ce bloc ne coûte que quelques couches GPU de
   plus et peut être supprimé d'un bloc. */
.transition-fade,
.site-bar,
.transition-bandeau {
	backface-visibility: hidden;
}

/* .project-card a été RETIRÉ de la liste ci-dessus : une couche par vignette
   signifie que le compositeur rééchantillonne chaque carte SÉPARÉMENT dès
   qu'une transformation touche la grille — et le désalignement sous-pixel
   entre couches voisines fabrique des filets clairs entre deux images, que le
   chevauchement des cellules (voir plus bas) ne peut pas rattraper puisqu'il
   opère à la peinture, avant la composition. Toutes les cartes dans la même
   couche, les coutures inter-cartes deviennent impossibles à ce niveau.
   L'hypothèse qui motivait cette promotion (curseur natif Safari) n'a de
   toute façon plus d'objet depuis le curseur dessiné en JS. */

/* --- Transition Swup : le BANDEAU --------------------------------------
   UNE SEULE transition, pour tous les liens. Un panneau plein écran monte
   depuis le bas et couvre la page, le contenu est échangé derrière lui, puis
   il poursuit sa course vers le haut et découvre la suivante. Un seul geste
   vertical, en deux temps — celui de www.zone-studio.fr.

   La page l'accompagne, en plus lent : elle dérive vers le haut pendant
   qu'il la couvre, et la nouvelle se cale en montant depuis un peu plus bas
   pendant qu'il la découvre. C'est cette dérive, plus que le panneau, qui
   donne l'impression qu'une page part et que l'autre arrive.

   Déroulé, et l'ordre des classes de Swup compte :
   1. clic → Swup ajoute `is-changing`, `is-animating`, `is-leaving` ;
   2. l'animation de SORTIE se joue (le bandeau monte), Swup en attend la fin ;
   3. `is-leaving` est retiré et le contenu de #swup est remplacé — le bandeau
      couvre tout, tenu par la première image de son animation d'arrivée ;
   4. `is-animating` est retiré → l'ARRIVÉE se joue (le bandeau s'en va).

   Swup attend la plus longue des animations de sortie, puis d'arrivée, sur
   tout ce qui porte une classe en "transition-" (animationSelector dans
   app.js) : .transition-bandeau et .transition-fade sont donc à l'affût
   ensemble, avec la même durée.

   CE QUE ÇA COÛTE : `transform` sur .transition-fade en fait, le temps de la
   transition, le bloc conteneur de ses descendants en `position: fixed` — le
   bouton "i" d'un projet dérive donc avec la page. C'est l'effet voulu : tout
   part ensemble, seul le menu (hors de .transition-fade) reste en place. */
:root {
	--t-bandeau: 750ms;
	/* Même courbe que le site actuel : départ et arrivée très amortis, le
	   milieu rapide. */
	--bandeau-courbe: cubic-bezier(0.77, 0, 0.175, 1);
	/* Couleur du panneau : celle du fond de page, pour que la page paraisse
	   effacée plutôt que recouverte. */
	--bandeau-fond: #fff;
	/* Dérive de la page : vers le haut à la sortie, depuis le bas à
	   l'arrivée. 0 pour un panneau seul, sans mouvement de page.
	   En vh à la sortie : la course reste proportionnée à l'écran, et assez
	   ample pour se lire sous le bandeau qui monte. */
	--bandeau-derive-sortie: 40vh;
	--bandeau-derive-arrivee: 50px;
	/* La dérive de sortie dure PLUS LONGTEMPS que la montée du bandeau : avec
	   la même durée, les deux freinent ensemble et la page paraît s'arrêter
	   avant d'être couverte. Plus longue, elle est encore en mouvement quand
	   le bandeau achève sa course. Swup attend la plus longue des deux avant
	   d'échanger le contenu : l'écart s'ajoute donc au temps couvert, et doit
	   rester court. */
	--t-page-sortie: calc(var(--t-bandeau) + 300ms);
}

.transition-bandeau {
	position: fixed;
	inset: 0;
	/* Au-dessus du contenu et du bouton "i" (20), SOUS la barre de menu (29
	   et 30) : le menu est le cadre du site, il reste en place pendant que
	   le contenu, lui, est recouvert. */
	z-index: 25;
	/* Le chargeur au centre (voir .chargeur plus bas). */
	display: grid;
	place-items: center;
	background: var(--bandeau-fond);
	pointer-events: none;
	/* Hors transition : garé sous l'écran, et inerte. */
	transform: translate3d(0, 100%, 0);
	visibility: hidden;
}

/* --- Le CHARGEUR, au centre du bandeau -------------------------------------
   Le logo du menu en trois morceaux — Z, S, PARIS, découpés dans
   logo_top.svg — qui tournent d'une place à l'autre : Z va prendre la place
   de PARIS, PARIS celle de S, S celle de Z, et ainsi de suite, en deux
   secondes par tour. C'est la ronde des trois glyphes de l'ancien site,
   avec le logo d'aujourd'hui. Au repos (0 %), les trois morceaux forment
   le logo tel qu'on le voit dans le menu.

   Tout est en pourcentage de la boîte, elle-même dans les proportions du
   logo (1650 × 1987) : --chargeur-largeur est le seul réglage. Les trois
   places sont les centres des morceaux dans le logo entier.

   L'animation ne tourne que pendant que le bandeau couvre (is-changing) :
   le reste du temps elle est en pause, invisible, sans coût. */
.chargeur {
	--chargeur-largeur: 3.44rem;
	--w: var(--chargeur-largeur);
	--h: calc(var(--chargeur-largeur) * 1987 / 1650);
	/* Les trois places : centre de Z, de S, de PARIS. */
	--place-z-x: calc(var(--w) * 0.549);
	--place-z-y: calc(var(--h) * 0.229);
	--place-s-x: calc(var(--w) * 0.151);
	--place-s-y: calc(var(--h) * 0.767);
	--place-paris-x: calc(var(--w) * 0.750);
	--place-paris-y: calc(var(--h) * 0.765);

	position: relative;
	width: var(--w);
	height: var(--h);
}

.chargeur__piece {
	position: absolute;
	top: 0;
	left: 0;
	height: auto;
	max-width: none;
	/* Les morceaux sont BLANCS (c'est le logo du menu, fait pour le mélange
	   en `difference`) : sur le bandeau blanc, on les assombrit en gris très
	   clair. invert(p) fait passer le blanc à (1 - p) : 0.12 donne #e0e0e0.
	   1 donnerait du noir. */
	filter: invert(0.08	);
	animation-duration: 2s;
	animation-iteration-count: infinite;
	animation-play-state: paused;
	/* `translate` et non `transform` : le -50% (moitié du morceau) et la
	   place (une longueur) se composent dans un seul calc. */
}

.chargeur__z {
	width: calc(var(--w) * 0.403);
	translate: calc(var(--place-z-x) - 50%) calc(var(--place-z-y) - 50%);
	animation-name: zs-chargeur-z;
}

.chargeur__s {
	width: calc(var(--w) * 0.302);
	translate: calc(var(--place-s-x) - 50%) calc(var(--place-s-y) - 50%);
	animation-name: zs-chargeur-s;
}

.chargeur__paris {
	width: calc(var(--w) * 0.499);
	translate: calc(var(--place-paris-x) - 50%) calc(var(--place-paris-y) - 50%);
	animation-name: zs-chargeur-paris;
}

html.is-changing .transition-bandeau .chargeur__piece {
	animation-play-state: running;
}

@keyframes zs-chargeur-z {

	0%, 100% {
		translate: calc(var(--place-z-x) - 50%) calc(var(--place-z-y) - 50%);
	}

	33% {
		translate: calc(var(--place-paris-x) - 50%) calc(var(--place-paris-y) - 50%);
	}

	66% {
		translate: calc(var(--place-s-x) - 50%) calc(var(--place-s-y) - 50%);
	}
}

@keyframes zs-chargeur-s {

	0%, 100% {
		translate: calc(var(--place-s-x) - 50%) calc(var(--place-s-y) - 50%);
	}

	33% {
		translate: calc(var(--place-z-x) - 50%) calc(var(--place-z-y) - 50%);
	}

	66% {
		translate: calc(var(--place-paris-x) - 50%) calc(var(--place-paris-y) - 50%);
	}
}

@keyframes zs-chargeur-paris {

	0%, 100% {
		translate: calc(var(--place-paris-x) - 50%) calc(var(--place-paris-y) - 50%);
	}

	33% {
		translate: calc(var(--place-s-x) - 50%) calc(var(--place-s-y) - 50%);
	}

	66% {
		translate: calc(var(--place-z-x) - 50%) calc(var(--place-z-y) - 50%);
	}
}

@media (prefers-reduced-motion: reduce) {

	/* Le logo, immobile. */
	.chargeur__piece {
		animation: none;
	}
}

/* SORTIE : le bandeau monte et couvre. */
html.is-animating.is-leaving .transition-bandeau {
	visibility: visible;
	animation: zs-bandeau-couvre var(--t-bandeau) var(--bandeau-courbe) forwards;
}

/* ARRIVÉE, déclarée DÈS LE DÉBUT de la visite mais EN PAUSE.

   Pourquoi pas simplement sous `:not(.is-animating)` : Swup lit la durée de
   l'animation d'arrivée (getComputedStyle) AVANT de retirer `is-animating`
   — voir animatePageIn dans le bundle, l'appel à awaitAnimations précède le
   retrait de la classe. Une animation qui n'existe que sans `is-animating`
   a donc une durée nulle au moment où il regarde : il n'attend rien, clôt
   la visite, retire ses classes, et le bandeau retombe à sa position de
   repos au lieu de poursuivre vers le haut. Déclarée ici, elle existe quand
   Swup lit sa durée ; `paused` l'empêche de partir avant l'heure.

   Effet de bord utile : en pause, une animation fige sa PREMIÈRE image,
   soit `translate3d(0, 0, 0)` — le bandeau couvrant. C'est aussi l'ATTENTE :
   `is-leaving` retiré, contenu échangé, `is-animating` encore là, la règle de
   sortie ne s'applique plus et c'est cette première image qui tient le
   bandeau en place. Pendant la sortie, la règle ci-dessus l'emporte (plus
   spécifique) ; le bandeau y finit à 0 en `forwards`, exactement là où la
   première image de l'arrivée le reprend. */
html.is-changing .transition-bandeau {
	visibility: visible;
	animation: zs-bandeau-decouvre var(--t-bandeau) var(--bandeau-courbe) paused;
}

/* `is-animating` retiré : on relâche. Pas de `forwards` — l'animation finie,
   le bandeau retombe de lui-même sur sa position de repos, sous l'écran, prêt
   pour la navigation suivante. */
html.is-changing:not(.is-animating) .transition-bandeau {
	animation-play-state: running;
}

@keyframes zs-bandeau-couvre {

	from {
		transform: translate3d(0, 100%, 0);
	}

	to {
		transform: translate3d(0, 0, 0);
	}
}

@keyframes zs-bandeau-decouvre {

	from {
		transform: translate3d(0, 0, 0);
	}

	to {
		transform: translate3d(0, -100%, 0);
	}
}

/* LA PAGE : dérive vers le haut pendant qu'elle est couverte, un peu plus
   longtemps que la montée du bandeau (voir --t-page-sortie)… */
html.is-animating.is-leaving .transition-fade {
	animation: zs-page-part var(--t-page-sortie) var(--bandeau-courbe) forwards;
}

/* …et la nouvelle se cale en montant, depuis un peu plus bas. Même mécanique
   que le bandeau, pour la même raison : déclarée dès le début en pause, sa
   première image (+50px) tient la page pendant l'attente, à couvert — le
   saut de -250px à +50px se fait derrière le bandeau, personne ne le voit.
   Pas de `forwards` : l'animation finie, la page retombe sur sa position
   normale, qui est aussi sa dernière image. */
html.is-changing .transition-fade {
	animation: zs-page-arrive var(--t-bandeau) var(--bandeau-courbe) paused;
}

html.is-changing:not(.is-animating) .transition-fade {
	animation-play-state: running;
}

@keyframes zs-page-part {

	from {
		transform: translate3d(0, 0, 0);
	}

	to {
		transform: translate3d(0, calc(var(--bandeau-derive-sortie) * -1), 0);
	}
}

@keyframes zs-page-arrive {

	from {
		transform: translate3d(0, var(--bandeau-derive-arrivee), 0);
	}

	to {
		transform: translate3d(0, 0, 0);
	}
}

@media (prefers-reduced-motion: reduce) {

	.site-bar {
		transition: none;
	}

	/* Le bandeau ne glisse plus : il apparaît et disparaît d'un coup. Sa
	   durée est conservée — Swup se cale dessus pour échanger le contenu —
	   mais il est en place dès la première image. */
	html.is-animating.is-leaving .transition-bandeau,
	html.is-changing:not(.is-animating) .transition-bandeau {
		animation-timing-function: steps(1, start);
	}

	/* Et la page ne dérive plus. Mêmes sélecteurs que plus haut, à la
	   spécificité près : placés après, ils l'emportent. */
	html.is-changing .transition-fade,
	html.is-animating.is-leaving .transition-fade {
		animation: none;
		transform: none;
	}
}

/* --- L'INTRO DU SITE ------------------------------------------------------
   Au chargement complet de l'accueil seulement (voir header.php) : un bloc
   blanc couvre tout, le logo au centre. Après un temps d'arrêt, le logo se
   floute sur place et s'efface, et le bloc s'efface avec lui pour découvrir
   la page. Tout est en animations CSS : sans JS, l'intro se joue et se
   termine quand même (visibility: hidden en fin de course, donc plus rien
   ne bloque les clics). app.js ne fait que retirer l'élément.

   Les réglages, tous ici :
     --intro-attente       temps d'arrêt avant que le logo ne commence
     --intro-flou-duree    durée du flou du logo
     --intro-flou          flou final du logo
     --intro-fondu-attente délai du fondu du bloc, depuis le début
     --intro-fondu-duree   durée du fondu
   Pour que le bloc et le logo partent et finissent ensemble : même attente
   et même durée des deux côtés.

   z-index 9990 : au-dessus de tout le site (menu 30, bandeau 25, bouton "i"
   20), SOUS le curseur dessiné en JS (9999), qui reste donc visible. */
.intro {
	--intro-attente: 1s;
	--intro-flou-duree: 2.5s;
	--intro-flou: 20px;
	--intro-fondu-attente: 2.2s;
	--intro-fondu-duree: 1.3s;

	position: fixed;
	inset: 0;
	z-index: 9990;
	display: grid;
	place-items: center;
	background: #fff;
	animation: zs-intro-fond var(--intro-fondu-duree) var(--bandeau-courbe) var(--intro-fondu-attente) forwards;
}

/* Le logo ne bouge pas, il se dissout : le flou monte et l'opacité descend
   ensemble, sur une courbe douce. */
.intro__logo {
	display: block;
	width: clamp(220px, 40vw, 720px);
	height: auto;
	animation: zs-intro-logo var(--intro-flou-duree) ease-in-out var(--intro-attente) forwards;
}

@keyframes zs-intro-logo {

	from {
		filter: blur(0);
		opacity: 1;
	}

	to {
		filter: blur(var(--intro-flou));
		opacity: 0;
	}
}

/* `visibility` ne bascule qu'à la toute fin : le bloc reste opaque aux clics
   tant qu'il se voit, et libère la page une fois transparent. */
@keyframes zs-intro-fond {

	from {
		opacity: 1;
		visibility: visible;
	}

	to {
		opacity: 0;
		visibility: hidden;
	}
}

@media (prefers-reduced-motion: reduce) {

	/* Pas d'intro : la page, directement. */
	.intro {
		display: none;
	}
}

/* --- Transition Swup : le RIDEAU AU POUCE (.nav-zoom) --------------------
   Quand on ENTRE DANS UN PROJET, depuis l'accueil comme depuis une archive,
   le bandeau cède la place à la main : elle grandit par-dessus la page en
   s'ouvrant du poing au pouce levé (une séquence de 24 images, jouée par
   app.js pendant --pouce-zoom-duree), la page s'efface sous elle pendant la
   seconde moitié du zoom, le contenu est échangé — le pouce reste levé le
   temps du chargement — puis la nouvelle page apparaît pendant qu'il se
   retire, toujours levé.

   C'est app.js qui décide (typeDeNavigation) et pose `nav-zoom` sur <html>
   pour la durée de la visite ; le CSS ne fait que lire cette classe. Sans
   elle, rien de ce bloc ne s'applique et le bandeau joue comme d'habitude.

   Mêmes classes de Swup que pour le bandeau (is-changing, is-animating,
   is-leaving), même attente : Swup se cale sur la plus longue des
   transitions de ce qui porte une classe en "transition-", donc sur le
   pouce (0.8s à l'aller) et sur le fondu de la page (0.4s après 0.4s de
   délai : 0.8s aussi, EXACTEMENT — un fondu qui dépasserait le pouce, même
   de 50ms, le figerait à pleine taille le temps de l'écart). */

/* Sous nav-zoom, le bandeau et la dérive de la page se taisent. Mêmes
   spécificités que leurs règles plus haut ; placées après, elles gagnent. */
html.nav-zoom.is-changing .transition-bandeau,
html.nav-zoom.is-animating.is-leaving .transition-bandeau,
html.nav-zoom.is-changing:not(.is-animating) .transition-bandeau {
	animation: none;
	visibility: hidden;
}

html.nav-zoom.is-changing .transition-fade,
html.nav-zoom.is-animating.is-leaving .transition-fade {
	animation: none;
	transform: none;
}

/* La page : fondu. Aller et retour n'ont pas la même durée, et la règle qui
   compte est celle de l'état en cours : `is-leaving` pendant la sortie,
   `is-changing` seul pendant le retour sur la nouvelle page. */
html.nav-zoom.is-changing .transition-fade {
	transition: opacity 0.55s ease;
}

/* La page reste entièrement visible pendant que le pouce grandit, puis
   s'efface. 0.4 + 0.4 = 0.8s : la durée du zoom, pas une de plus. */
html.nav-zoom.is-leaving .transition-fade {
	transition: opacity 0.4s ease 0.4s;
}

html.nav-zoom.is-animating .transition-fade {
	opacity: 0;
}

/* Le rideau : plein écran, SANS AUCUN FOND. Il ne sert qu'à porter le pouce
   au-dessus de la page ; c'est le fondu de la page qui masque l'échange.

   z-index 10000 : AU-DESSUS du curseur dessiné en JS (9999). Le pouce passe
   donc devant lui pendant la transition, au lieu d'être traversé par un
   curseur qui n'a plus rien à désigner à ce moment-là. */
.transition-zoom {
	/* Durée du zoom à l'aller — et de la séquence d'images, que app.js
	   cale dessus en lisant cette variable. UNE valeur pour les deux. */
	--pouce-zoom-duree: 0.8s;

	position: fixed;
	inset: 0;
	/* Un élément en `inset: 0` se cale sur le GRAND viewport mobile, barre
	   d'URL masquée. `dvh` suit la hauteur RÉELLEMENT visible et l'emporte
	   sur le `bottom: 0` ; un navigateur qui ignore l'unité garde inset. */
	height: 100dvh;
	z-index: 10000;
	display: grid;
	place-items: center;
	visibility: hidden;
	pointer-events: none;
	/* Retour : un peu plus court que l'aller, sans délai. */
	transition: visibility 0.55s;
}

/* La main est dimensionnée à sa taille d'arrivée et part à scale(0) : on
   anime `transform` et non `height`, seul le premier est calculé par le
   compositeur. Toutes les images de la séquence ont le même cadre
   (834 × 1112, portrait) : le changement d'image ne change pas la boîte.

   vmin et NON vh : la main est dimensionnée par sa hauteur. En vh, sur un
   téléphone en portrait, elle déborderait en largeur et son rognage se
   lirait comme un décentrage. vmin prend la plus petite dimension : vh sur
   un bureau, vw sur un portrait. */
.transition-zoom__thumb {
	--pouce-taille: 70vmin;

	width: auto;
	height: var(--pouce-taille);
	transform: scale(0);
	/* Retour : même courbe qu'à l'aller, à peine plus court — le geste se
	   lit comme le même mouvement rejoué à l'envers. */
	transition: transform 0.55s cubic-bezier(0.22, 1, 0.36, 1);
}

/* Aller : c'est la règle de l'état d'ARRIVÉE qui fixe la durée. Le rideau
   reste simplement visible le temps du zoom. */
html.nav-zoom.is-animating .transition-zoom {
	transition: visibility var(--pouce-zoom-duree);
}

/* Le rideau ne capte JAMAIS le pointeur : il garde le `pointer-events: none`
   de sa base, on ne fait que le rendre visible. S'il captait le pointeur,
   la vignette cliquée perdrait son survol le temps de la navigation. */
html.nav-zoom.is-changing .transition-zoom {
	visibility: visible;
}

/* `is-animating` : le dézoom ne part qu'une fois la nouvelle page construite
   et les hooks d'arrivée passés. Déclencher plus tôt, dès l'échange, faisait
   sauter au pouce une partie de sa course, pendant les images non peintes. */
html.nav-zoom.is-animating .transition-zoom__thumb {
	transform: scale(1);
	transition: transform var(--pouce-zoom-duree) cubic-bezier(0.22, 1, 0.36, 1);
}

/* Sur un petit écran, 70vmin laisse le pouce flotter dans beaucoup de vide.
   Seule la variable change, la mécanique du zoom n'est pas touchée. */
@media (max-width: 768px) {

	.transition-zoom__thumb {
		--pouce-taille: 80vmin;
	}
}

@media (prefers-reduced-motion: reduce) {

	/* Pas de zoom : le pouce n'apparaît pas, la page passe par son fondu
	   seul, dont Swup garde la durée pour échanger le contenu. */
	.transition-zoom,
	.transition-zoom__thumb {
		transition: none;
	}

	html.nav-zoom.is-changing .transition-zoom {
		visibility: hidden;
	}
}

/* --- Page 404 ----------------------------------------------------------
   Gabarit 404.php. Même composition que la page About : plein écran, le
   contenu calé en bas à gauche sous la barre de menu, gros titre en
   Albertus. Fond blanc, celui du site — la page manquante ne mérite pas de
   décor, juste la même voix. */
.erreur {
	display: flex;
	flex-direction: column;
	justify-content: flex-end;
	min-height: 100svh;
	color: #1c2a54;
}

.erreur__contenu {
	padding: 0 3rem 3rem;
	font-weight: 600;
}

/* Le chiffre, en Albertus Light comme le titre d'About, mais très grand :
   en vw pour garder la même proportion de l'écran à toute largeur. */
.erreur__titre {
	margin: 0 0 1rem;
	font-family: var(--font-display);
	font-weight: 300;
	font-size: 14vw;
	line-height: 0.9;
}

.erreur__texte {
	margin: 0 0 1.5rem;
	font-size: 1.45rem;
	line-height: 1.25;
}

.erreur__liens {
	display: flex;
	flex-wrap: wrap;
	gap: 0.5rem 2rem;
	margin: 0;
	font-size: 1rem;
}

.erreur__lien {
	color: inherit;
	text-decoration: underline;
	text-underline-offset: 0.2em;
	text-decoration-thickness: 1px;
}

.erreur__lien:hover {
	text-decoration-thickness: 2px;
}

/* Sur mobile le menu occupe le bas de l'écran : le contenu s'arrête
   au-dessus, comme sur About. */
@media (max-width: 768px) {
	.erreur__contenu {
		padding: 0 1.5rem calc(3rem + env(safe-area-inset-bottom, 0px) + 6vw);
	}
}

/* --- Pages ordinaires (page.php) -----------------------------------------
   Politique de cookies, mentions légales… : des pages de texte, sans décor.
   Même voix qu'About et que la 404 : bleu nuit, Albertus Light pour le
   titre, la police du corps pour le reste, mêmes retraits (3rem, 1.5rem en
   mobile), même largeur de colonne (870px).

   En haut, la place de la barre de menu : sa hauteur est mesurée par app.js
   (--barre-hauteur), avec une estimation sans JS. En mobile, le logo est en
   haut et le menu en bas : marge haute du logo (même calcul que
   .about__contenu), marge basse de la barre. */
.page-single {
	max-width: 870px;
	padding: calc(var(--barre-hauteur, 7rem) + 3rem) 3rem 3rem;
	color: #1c2a54;
	font-size: 1rem;
	line-height: 1.5;
}

.page-title {
	margin: 0 0 2rem;
	font-family: var(--font-display);
	font-weight: 300;
	font-size: 2.5rem;
	line-height: 1;
	text-transform: uppercase;
}

.entry-content h2 {
	margin: 2.5rem 0 0.75rem;
	font-size: 1.15rem;
	font-weight: 600;
}

.entry-content h3 {
	margin: 1.75rem 0 0.5rem;
	font-size: 1rem;
	font-weight: 600;
}

.entry-content p,
.entry-content ul,
.entry-content ol {
	margin: 0 0 1em;
}

.entry-content a {
	color: inherit;
	text-decoration: underline;
	text-underline-offset: 0.2em;
	text-decoration-thickness: 1px;
}

.entry-content a:hover {
	text-decoration-thickness: 2px;
}

@media (max-width: 768px) {

	.page-single {
		padding: calc(4.5rem + env(safe-area-inset-top, 0px) + (100vw - 3rem) * 827 / 7667) 1.5rem calc(var(--barre-hauteur, 5rem) + 1.5rem);
	}

	.page-title {
		font-size: 2rem;
	}
}

/* --- Accessibilité : skip link -------------------------------------- */
.screen-reader-text {
	position: absolute;
	width: 1px;
	height: 1px;
	overflow: hidden;
	clip: rect(1px, 1px, 1px, 1px);
	white-space: nowrap;
}

.skip-link:focus {
	position: fixed;
	top: 0.5rem;
	left: 0.5rem;
	width: auto;
	height: auto;
	clip: auto;
	padding: 0.5rem 1rem;
	background: #000;
	color: #fff;
	z-index: 1000;
}

/* --- Placeholders layout (à remplacer par ta maquette) -------------- */
/* En-tête persistant (hors #swup) : il ne contient que la barre de filtres,
   qui gère elle-même son retrait latéral.

   Fixe en haut de l'écran : le contenu défile DESSOUS, sans décalage ajouté
   — c'est voulu, le menu se lit par-dessus les images. z-index 30 le met
   au-dessus de tout le reste (médias, bouton "i" à 20, overlay texte à 15) :
   le menu reste donc lisible et cliquable même texte ouvert. */
/* Géométrie commune aux deux couches : elles doivent se superposer au pixel
   près, donc tout ce qui positionne est ici et nulle part ailleurs. */
.site-bar {
	position: fixed;
	top: 0;
	right: 0;
	left: 0;
	padding: 0;
	background: var(--header-bg, transparent);
	/* Le glissement hors écran de l'option "Hide menu on arrival" : même
	   courbe que le bandeau, un peu plus court. */
	transition: transform 0.5s var(--bandeau-courbe);
}

/* MENU CACHÉ À L'ARRIVÉE (option du projet, voir menuCache dans app.js) :
   les deux couches sortent par le haut, ensemble. `zs-menu-cache` est
   retirée au premier geste de défilement et la barre redescend. Sur mobile
   la barre reste en place : c'est le logo du haut (.site-footer) qui sort,
   voir les blocs mobiles. */
html.zs-menu-cache .site-bar {
	/* -100 % ne suffit pas tout à fait : les jambages des mots dépassent la
	   boîte de la barre de quelques pixels et restaient visibles au bord de
	   l'écran. Les 2px les emportent avec le reste. */
	transform: translate3d(0, calc(-100% - 2px), 0);
}

/* Sur mobile, la barre passe EN BAS de l'écran — les DEUX couches, la classe
   commune les déplaçant ensemble, donc toujours superposées au pixel près.
   Le menu garde sa proportion du desktop (--filter-size en vw) : c'est ce qui
   le fait tenir sur une ligne, comme en grand.
   `env(safe-area-inset-bottom)` : sur les téléphones à barre de geste, le
   menu remonte au-dessus de la zone du système au lieu de passer dessous. */
@media (max-width: 768px) {

	.site-bar {
		top: auto;
		bottom: 0;
		/* Le premier terme DÉCOLLE le menu du bas de l'écran — c'est LE
		   réglage. En padding et non en `bottom` : la bande reste collée au
		   bord et continue d'intercepter la souris jusqu'en bas, seul le texte
		   remonte. Le env() s'y ajoute sur les téléphones à barre de geste. */
		padding-bottom: calc(1.5rem + env(safe-area-inset-bottom, 0px));
	}

	/* Sur mobile, l'option « menu caché » ne touche PAS à la barre : c'est
	   le logo du haut qui s'efface (voir .site-footer, dans son bloc
	   mobile). La barre reste en bas, en place. */
	html.zs-menu-cache .site-bar {
		transform: none;
	}

	/* Les overrides mobiles de .projects-filter et .projects-filter__item ne
	   sont PAS ici mais avec leurs règles de base, plus bas dans le fichier.
	   Ce n'est pas un choix d'organisation : une media query n'ajoute aucune
	   spécificité, et placées ici — AVANT les règles de base — elles seraient
	   écrasées par elles. Seul .site-bar peut être surchargé d'ici, sa règle
	   de base étant au-dessus. */
}

/* Couche du dessous : les mots, en blanc, mélangés avec la page.
   difference calcule |fond - texte| : avec du blanc on obtient l'inverse du
   fond (noir sur clair, blanc sur sombre) ; avec du noir on obtiendrait le
   fond lui-même, donc du texte invisible. Le fond doit rester transparent,
   sinon c'est lui qui servirait de calque de mélange.

   Le mélange doit rester sur CETTE couche et ne peut pas descendre sur les
   mots eux-mêmes : `position: fixed` fait d'elle un contexte d'empilement, et
   un élément en mix-blend-mode ne se mélange qu'avec le fond de son PROPRE
   contexte. Posé sur les liens, il les mélangerait avec le vide de la barre
   au lieu de la page — les mots ressortiraient en blanc plat.

   AUCUNE géométrie propre ici — ni padding, ni position : tout ce qui place
   ou espace vit sur les classes COMMUNES (.site-bar, .projects-filter,
   .projects-filter__item), partagées par les deux couches. C'est ce qui
   garantit leur superposition au pixel près : un réglage de padding fait à un
   seul endroit vaut automatiquement pour les deux navs. */
.site-bar--blend {
	z-index: 29;
	color: #fff;
	mix-blend-mode: difference;
	pointer-events: none;

	/* Compense l'amaigrissement dû au mélange. Un élément en mix-blend-mode
	   est composité à part, ce qui désactive le lissage sous-pixel : les
	   glyphes paraissent plus fins qu'ils ne le sont, au point de faire
	   croire à la Light alors que c'est bien la régulière qui est chargée.
	   Ce contour très fin leur rend leur épaisseur apparente.
	   Mets 0 pour voir la différence, monte à 0.02em si c'est encore trop
	   léger — au-delà les angles d'Albertus commencent à s'empâter. */
	-webkit-text-stroke: 0.012em currentColor;
}




/* Pas de trait ici : il est peint par la couche du dessus, en rose pur. */
.site-bar--blend .projects-filter__item::after {
	display: none;
}

/* Couche du dessus : texte transparent (les mots lisibles sont ceux d'en
   dessous), mais liens bien présents — c'est elle qui reçoit les clics, le
   focus clavier et qui porte le trait rose. */
/* La barre intercepte la souris sur toute sa surface, y compris ses zones
   vides. C'est volontaire : sinon le survol traverserait jusqu'à la vignette
   qui défile dessous et déclencherait son inversion, alors que le pointeur
   est visuellement sur le menu.
   Elle porte OPEN sur TOUTE sa surface, intervalles entre les mots compris :
   la bande se lit ainsi comme une seule zone, au lieu de basculer entre deux
   curseurs selon qu'on est ou non exactement sur un mot. Le repli système est
   `default` et non `pointer`, parce que le fond de la barre, lui, n'est pas
   cliquable.
   Les mots eux-mêmes tirent leur OPEN de la règle générale sur `a` — une
   valeur posée sur l'élément l'emporte sur une valeur héritée, mais c'est la
   même image, donc la bande reste homogène.

   Le z-index, lui, est indispensable : il place cette couche au-dessus de
   celle du mélange (29), ce qui garde le trait rose en couleur pure, et
   au-dessus du bouton "i" (20) et de l'overlay texte (15), ce qui garde le
   menu lisible et cliquable même texte ouvert.

   La barre est le SEUL endroit du site à porter OPEN sans être un lien. En
   particulier, l'overlay de texte d'un projet se referme aussi au clic sur
   toute sa surface, mais garde l'INDEX : sa surface est la page entière, un
   curseur d'action y serait un contresens. */
.site-bar--main {
	z-index: 30;
	cursor: url("../cursor/OPEN.png") 0 0, default;
	cursor: -webkit-image-set(url("../cursor/OPEN.png") 1x, url("../cursor/OPEN_2.png") 2x) 0 0, default;
	cursor: image-set(url("../cursor/OPEN.png") 1x, url("../cursor/OPEN_2.png") 2x) 0 0, default;
}

/* Texte transparent : les mots lisibles sont ceux de la couche du dessous.
   Cette couche-ci ne porte que les clics, le focus et le trait rose. */
.site-bar--main .projects-filter__item {
	color: transparent;
}

/* Pied de page : le logo occupe toute la largeur, hauteur proportionnelle. */
.site-footer {
	padding: 2.5rem;
	transition: filter var(--flou-duree) ease;
}

/* Le pied de page se floute avec le reste quand le texte d'un projet s'ouvre.

   La classe est posée sur <html> par le JS, et non déduite du DOM : le pied
   de page est en dehors de l'article `.project` qui porte `is-text-open`, et
   aucun sélecteur ne remonte d'un élément vers un de ses oncles. `:has()` sur
   l'enveloppant le permettrait, mais lierait la règle à la structure exacte
   du gabarit — une classe sur la racine ne dépend de rien.

   Le flou reste sur le pied de page LUI-MÊME plutôt que sur l'enveloppant
   commun : un `filter` sur un ancêtre flouterait aussi le bouton "i" et le
   texte, qui doivent rester nets, et en ferait le bloc conteneur de leurs
   `position: fixed`. */
html.projet-texte-ouvert .site-footer {
	filter: blur(var(--flou-texte));
}

/* Le lien qui entoure le logo. `display: block` est nécessaire : resté en
   ligne, il réserverait la place des jambages sous l'image et laisserait une
   bande blanche au bas de la page. */
.site-footer__link {
	display: block;
}

.site-footer__logo {
	display: block;
	width: 100%;
	height: auto;
}

/* En mobile, le logo passe EN HAUT : le menu ayant pris le bas de l'écran,
   les deux se disputaient le même bord. Ils forment maintenant un cadre —
   logo en haut, menu en bas — avec le même retrait de part et d'autre.

   Les valeurs viennent du bloc mobile du menu : 1.5rem de retrait latéral
   (.projects-filter) et 1.5rem de décollement du bord (.site-bar). Les
   changer ici sans les changer là-bas casserait la symétrie.

   `env(safe-area-inset-top)` est le pendant du `inset-bottom` du menu : sur
   un téléphone à encoche, le logo descend sous elle au lieu de passer
   dessous. */
@media (max-width: 768px) {

	.site-footer {
		position: fixed;
		top: 0;
		right: 0;
		left: 0;

		/* Au-dessus du contenu de la page, mais pas du menu (29 et 30). Le
		   logo étant DANS .transition-fade, il est de toute façon balayé avec
		   la page pendant une transition, là où le menu, lui, reste : c'est la
		   bonne lecture — le menu est le cadre, le logo appartient à la page. */
		z-index: 5;

		padding: calc(1.5rem + env(safe-area-inset-top, 0px)) 1.5rem 0;

		/* Même traitement que le menu, et pour la même raison : posé par-dessus
		   les médias, un logo noir disparaîtrait sur une image sombre.
		   `difference` affiche l'inverse exact du fond, quelle que soit sa
		   dominante. */
		mix-blend-mode: difference;
	}

	/* Le fichier est noir sur transparent ; `difference` avec du noir ne
	   change rien au fond — le logo serait invisible. Il faut donc l'inverser
	   d'abord : il devient blanc sur transparent, l'alpha étant préservé, et
	   c'est ce blanc que `difference` retourne ensuite. */
	.site-footer__logo {
		filter: invert(1);
	}

	/* L'option « Hide menu on arrival » du projet, VERSION MOBILE : c'est ce
	   logo du haut qui sort de l'écran, pas la barre (voir .site-bar). Même
	   classe zs-menu-cache, même retour au premier geste de défilement, même
	   courbe que le bandeau. Les 2px emportent l'anticrénelage du bord. */
	.site-footer {
		transition:
			filter var(--flou-duree) ease,
			transform 0.5s var(--bandeau-courbe);
	}

	html.zs-menu-cache .site-footer {
		transform: translate3d(0, calc(-100% - 2px), 0);
	}
}

/* SUR LA PAGE ABOUT, le logo se mélange AUSSI sur ordinateur : il est posé
   sur le fond liquide, comme le texte et le menu, et resté noir il se
   perdrait dans les palettes sombres du soir et de la nuit. Ailleurs il reste
   noir sur la page blanche, rien ne change.

   `:has()` et non une classe sur <html> : le pied de page est hors de #swup,
   il ne sait pas quelle page est affichée ; l'enveloppant commun, lui, la
   contient. La règle suit donc toute seule l'échange de contenu de Swup,
   sans JS. Support : Safari 15.4+, Chrome 105+, Firefox 121+ ; ailleurs le
   logo reste noir.

   Même mécanique qu'en mobile, plus haut : le fichier est noir, on
   l'inverse en blanc, et c'est ce blanc que `difference` retourne. Et même
   couche propre que le texte d'About (voir .about__contenu). */
@media (min-width: 769px) {

	.transition-fade:has(.about) .site-footer {
		mix-blend-mode: difference;
		transform: translateZ(0);
		backface-visibility: hidden;
	}

	.transition-fade:has(.about) .site-footer__logo {
		filter: invert(1);
	}
}

/* --- Curseurs -------------------------------------------------------
   TROIS règles, et c'est tout le système :

     INDEX  partout, en remplacement de la flèche ;
     OPEN   au survol de ce qui est cliquable ;
     GRAB   tant que le bouton de la souris est enfoncé.

   Rien n'est déclaré élément par élément : les liens et les boutons sont
   pris en bloc. Un bouton "i", une vignette, un mot du menu, le logo du
   pied de page relèvent tous de la même règle — il n'y a donc pas d'endroit
   à penser quand un nouveau lien apparaît.

   Trois déclarations par règle, dans cet ordre :
   1. `url(...)` seule — le repli, comprise partout ;
   2. `-webkit-image-set(...)` — la forme que Safari comprend depuis
      longtemps ; sans elle, les versions antérieures à Safari 17 ignorent
      la ligne suivante et se rabattent sur le fichier 1x, étiré sur retina ;
   3. `image-set(...)` — la forme standard, pour tout le reste.
   Un navigateur qui ne connaît pas une forme ignore la déclaration entière
   et garde la précédente.

   Les deux nombres après l'image sont le POINT ACTIF : le pixel qui
   « touche » réellement l'élément, exprimé dans l'image 1x.

   Le mot-clé après la virgule est le repli système. Il n'est pas
   décoratif : sans lui, la déclaration entière serait invalide.

   ATTENTION, comportement Safari connu : Safari ne réévalue le curseur
   QU'AU MOUVEMENT de la souris. Quand la page change sous un pointeur
   immobile — filtrage de la grille, défilement, navigation Swup — il
   retombe sur la flèche système et y reste jusqu'au geste suivant. Ce n'est
   contournable ni en CSS ni proprement en JS. C'est la raison pour laquelle
   ces curseurs avaient été retirés une première fois. */
html {
	cursor: url("../cursor/INDEX.png") 10 10, default;
	cursor: -webkit-image-set(url("../cursor/INDEX.png") 1x, url("../cursor/INDEX_2.png") 2x) 10 10, default;
	cursor: image-set(url("../cursor/INDEX.png") 1x, url("../cursor/INDEX_2.png") 2x) 10 10, default;
}

/* `cursor` s'hérite, mais le navigateur applique sa propre règle
   `a { cursor: pointer }`, et une valeur posée sur l'élément l'emporte
   toujours sur une valeur héritée. D'où cette seconde règle, sans laquelle
   les liens retomberaient sur la main système. */
a,
button,
label,
summary,
[role="button"] {
	cursor: url("../cursor/OPEN.png") 0 0, pointer;
	cursor: -webkit-image-set(url("../cursor/OPEN.png") 1x, url("../cursor/OPEN_2.png") 2x) 0 0, pointer;
	cursor: image-set(url("../cursor/OPEN.png") 1x, url("../cursor/OPEN_2.png") 2x) 0 0, pointer;
}

/* La main se referme sur ce qu'elle attrape. `:active` dure exactement le
   temps de l'appui, du clic enfoncé au relâchement.
   Le repli système est `grabbing` et non `pointer` — c'est le geste
   équivalent si l'image ne se charge pas. */
a:active,
button:active,
label:active,
summary:active,
[role="button"]:active {
	cursor: url("../cursor/GRAB.png") 0 0, grabbing;
	cursor: -webkit-image-set(url("../cursor/GRAB.png") 1x, url("../cursor/GRAB_2.png") 2x) 0 0, grabbing;
	cursor: image-set(url("../cursor/GRAB.png") 1x, url("../cursor/GRAB_2.png") 2x) 0 0, grabbing;
}

/* SEULE exception au système : TOUT CE QUI MÈNE À UN PROJET prend PRESENT au
   lieu d'OPEN — les vignettes de l'archive comme les blocs de l'accueil.

   Les deux ont un balisage différent (`.project-card__link` sur l'archive,
   `.featured-link` sur l'accueil) mais désignent le même geste : entrer dans
   un projet. C'est la même distinction que fait typeDeNavigation() dans
   app.js pour leur réserver la transition au pouce.

   PRESENT vaut aussi PENDANT LE CLIC : entrer dans un projet ne referme pas
   la main. D'où les sélecteurs `:active` ci-dessous, sans lesquels la règle
   générale reprendrait la main — `a:active` (un type + une pseudo-classe)
   étant plus spécifique qu'une classe seule. Avec deux classes, celle-ci
   l'emporte à son tour. */
.project-card__link,
.project-card__link:active,
.featured-link,
.featured-link:active {
	cursor: url("../cursor/PRESENT.png") 0 0, pointer;
	cursor: -webkit-image-set(url("../cursor/PRESENT.png") 1x, url("../cursor/PRESENT_2.png") 2x) 0 0, pointer;
	cursor: image-set(url("../cursor/PRESENT.png") 1x, url("../cursor/PRESENT_2.png") 2x) 0 0, pointer;
}

/* --- Curseur dessiné par la page ------------------------------------
   Les règles natives ci-dessus restent en place et servent de REPLI : si le
   JS ne s'exécute pas, ou sur un appareil sans souris, le site retrouve les
   curseurs du navigateur. Rien de ce qui suit ne s'applique tant qu'app.js
   n'a pas posé `zs-curseur-js` sur <html>.

   Pourquoi cette bascule existe : Safari ne relit le curseur qu'au MOUVEMENT
   de la souris. Quand la page change sous un pointeur immobile — filtrage,
   navigation — il retombe sur la flèche système et y reste. Aucun changement
   de valeur CSS ne le force à relire, on l'a vérifié trois fois.

   La parade est de ne plus utiliser le curseur natif du tout : `cursor: none`
   est posé UNE FOIS au chargement et ne change plus jamais, donc Safari n'a
   plus rien à réévaluer. Ce qu'on voit est un <div> que le JS déplace.

   Le prix, assumé : ce div est peint PAR la page, il ne peut donc pas être à
   l'écran avant l'image suivante. Le curseur natif, lui, est dessiné par le
   système. Il y a donc un retard d'une image, irréductible. */
/* Un PNG TRANSPARENT DE 32 × 32 EN DATA URI, et non `cursor: none`.

   Deux raisons, et il fallait les deux :

   1. LE DATA URI. Safari re-résout la propriété `cursor` dès que l'élément
      sous le pointeur change — défilement, échange de contenu, bascule de
      classe — et re-télécharge l'image à ce moment-là. Pendant ce chargement
      il affiche le curseur système. Chrome et Firefox gardent la leur en
      cache, pas Safari. Un data URI n'a rien à aller chercher : la
      re-résolution est instantanée, plus de fenêtre pendant laquelle un autre
      curseur s'affiche.

   2. LA TAILLE. Un premier essai en 1 × 1 n'a rien changé sur Safari : WebKit
      rejette les images de curseur trop petites, et une déclaration rejetée
      laisse la cascade remonter jusqu'au curseur natif. 32 × 32 est la taille
      sûre — assez grande pour être acceptée, assez petite pour ne peser que
      83 octets (112 en base64).

   Image générée en RVBA entièrement transparente, signature et dimensions
   vérifiées à la génération.

   `none` reste en repli après la virgule : sans mot-clé de repli, la
   déclaration entière serait invalide. */
html.zs-curseur-js,
html.zs-curseur-js * {
	cursor: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAGklEQVR42u3BAQEAAACCIP+vbkhAAQAAAO8GECAAAcm1w7EAAAAASUVORK5CYII=") 0 0, none !important;
}

.zs-cursor {
	/* TAILLE : la LARGEUR affichée. Les PNG sources font 100 px de large
	   (200 en 2x) et ne sont PAS carrés — chaque état pose son --ratio pour
	   que la hauteur suive, sans jamais déformer l'image. Réduire ou
	   agrandir se fait ici, et uniquement ici. */
	--taille: 80px;
	--ratio: 1;

	/* ÉCHELLE par état, multipliée à --taille. À 1 partout sauf pour les
	   images dont les proportions trompent l'œil : PRESENT, main à plat,
	   large et basse, paraît minuscule à largeur égale et la relève. */
	--echelle: 1;

	/* --px : UN PIXEL DE L'IMAGE SOURCE, rendu à la taille affichée. Les
	   points actifs s'écrivent en pixels de l'image 1x et se rapportent
	   d'eux-mêmes à la taille affichée, échelle comprise. */
	--px: calc(var(--taille) * var(--echelle) / 100);

	/* POINT ACTIF par défaut : le pixel de l'image qui « touche »
	   réellement l'élément. Chaque état le redéfinit plus bas, c'est donc là
	   qu'on le règle cursor par cursor, en pixels de l'image SOURCE :
	     0 0                    coin haut-gauche de l'image
	     calc(50 * var(--px))   la moitié de la largeur
	     calc(H/2 * var(--px))  la moitié de la hauteur, H étant celle du PNG
	   Appliqué en marge négative : le JS ne s'occupe que de la position,
	   jamais du décalage. */
	--hot-x: 0px;
	--hot-y: 0px;

	/* SURSAUT — un déplacement d'un pixel, au relâchement du bouton ET au
	   défilement, les deux moments où le curseur natif se manifeste.

	   Aller et retour sont des sauts SECS : il n'y a aucune transition sur le
	   curseur, ici ni ailleurs. Seule la durée du maintien est réglable — une
	   seule image s'était révélée trop brève pour se voir.

	   --clic-decalage à 0 supprime l'effet. app.js lit --clic-duree, il n'y a
	   donc qu'un endroit à modifier. */
	--clic-decalage: 1px;
	--clic-duree: 120ms;

	position: fixed;
	top: 0;
	left: 0;
	z-index: 9999;
	width: calc(var(--taille) * var(--echelle));
	/* Hauteur déduite du ratio de l'image, jamais forcée : un carré imposé
	   écrasait INDEX et OPEN, étirait PRESENT. */
	height: auto;
	aspect-ratio: var(--ratio);
	margin: calc(var(--hot-y) * -1) 0 0 calc(var(--hot-x) * -1);
	pointer-events: none;
	background-repeat: no-repeat;
	background-size: 100% 100%;
	/* Prévient le compositeur : le transform changera à chaque image. */
	will-change: transform;

	/* AUCUNE transition sur le curseur. Ni à l'aller, ni au retour.

	   Le déplacement du clic est un saut sec d'un pixel, pas une animation :
	   toute courbe, même de 120 ms, le ferait arriver après le clic au lieu
	   d'être simultané.

	   Et surtout, jamais de transition sur `transform` : c'est lui que le JS
	   réécrit à chaque mouvement pour la position, et l'animer ferait traîner
	   le curseur derrière la souris. `translate` est une propriété
	   INDIVIDUELLE, distincte de `transform` et composée avec lui — c'est ce
	   qui permet de déplacer le curseur sans toucher à son suivi. */
}

/* Clic pris en compte : un pixel de décalage, immédiat, sans transition
   d'aucune sorte. L'attribut est retiré après --clic-duree et le curseur
   retrouve sa position, tout aussi sèchement.
   Pour qu'il ne revienne jamais : retirer le setTimeout dans app.js. */
.zs-cursor[data-clic="oui"] {
	translate: 0 var(--clic-decalage);
}

.zs-cursor[data-etat="index"] {
	/* PNG 100 × 125. Point actif : bord gauche, un peu sous le bout du doigt. */
	--ratio: 100 / 125;
	--echelle: 0.9;
	--hot-x: 0px;
	--hot-y: calc(40 * var(--px));

	background-image: url("../cursor/INDEX.png");
	background-image: -webkit-image-set(url("../cursor/INDEX.png") 1x, url("../cursor/INDEX_2.png") 2x);
	background-image: image-set(url("../cursor/INDEX.png") 1x, url("../cursor/INDEX_2.png") 2x);
}

.zs-cursor[data-etat="open"] {
	/* PNG 100 × 120. Point actif : centre. */
	--ratio: 100 / 120;
	--echelle: 1;
	--hot-x: calc(50 * var(--px));
	--hot-y: calc(60 * var(--px));

	background-image: url("../cursor/OPEN.png");
	background-image: -webkit-image-set(url("../cursor/OPEN.png") 1x, url("../cursor/OPEN_2.png") 2x);
	background-image: image-set(url("../cursor/OPEN.png") 1x, url("../cursor/OPEN_2.png") 2x);
}

.zs-cursor[data-etat="present"] {
	/* PNG 100 × 79. Point actif : centre.
	   Relevé d'un quart : à largeur égale, cette main à plat paraissait
	   deux fois plus petite que les autres. À 1.25 elle fait 100 × 79. */
	--ratio: 100 / 79;
	--echelle: 1.18;
	--hot-x: calc(50 * var(--px));
	--hot-y: calc(39.5 * var(--px));

	background-image: url("../cursor/PRESENT.png");
	background-image: -webkit-image-set(url("../cursor/PRESENT.png") 1x, url("../cursor/PRESENT_2.png") 2x);
	background-image: image-set(url("../cursor/PRESENT.png") 1x, url("../cursor/PRESENT_2.png") 2x);
}

.zs-cursor[data-etat="grab"] {
	/* PNG 100 × 101. Point actif : centre.
	   Un cran sous les autres : la main fermée, pleine, paraissait un peu
	   grosse à largeur égale. À 0.9 elle fait 72 × 73. */
	--ratio: 100 / 101;
	--echelle: 0.95;
	--hot-x: calc(50 * var(--px));
	--hot-y: calc(50.5 * var(--px));

	background-image: url("../cursor/GRAB.png");
	background-image: -webkit-image-set(url("../cursor/GRAB.png") 1x, url("../cursor/GRAB_2.png") 2x);
	background-image: image-set(url("../cursor/GRAB.png") 1x, url("../cursor/GRAB_2.png") 2x);
}

/* Souris sortie de la fenêtre : on le retire, sinon il reste collé au bord. */
.zs-cursor[hidden] {
	display: none;
}

/* --- Home : lignes de projets mis en avant --------------------------- */
.home-featured {
	--fp-gap: 0;
	/* Hauteur de chaque ligne. Voir la règle des médias plus bas pour le
	   détail de svh / vh. Une seule valeur à changer pour tout ajuster. */
	--fp-height: 100svh;

	display: flex;
	flex-direction: column;
	gap: var(--fp-gap);
}

/* Une ligne = 1 ou 2 colonnes, largeurs égales. */
.featured-row {
	display: grid;
	gap: var(--fp-gap);
}

.featured-row--1 {
	grid-template-columns: 1fr;
}

.featured-row--2 {
	grid-template-columns: 1fr 1fr;
}

/* Les médias remplissent leur colonne sur toute la hauteur d'écran, recadrés
   au centre. `100svh` = hauteur d'écran hors barres du navigateur mobile :
   sans lui, le bloc dépasse tant que la barre d'adresse est visible, puis
   saute quand elle se rétracte. `100vh` sert de repli aux vieux navigateurs.

   Une seule valeur à changer, --fp-height (déclarée sur .home-featured),
   pour passer à un demi-écran (50svh) ou à une hauteur fixe. */
.featured-media img,
.featured-media video {
	display: block;
	width: 100%;
	height: 100vh;
	height: var(--fp-height);
	object-fit: cover;
}

.featured-link {
	/* Repère de la légende, posée en absolu par-dessus le média. */
	position: relative;
	display: block;
	color: inherit;
	text-decoration: none;
}

/* Sur mobile : tout repasse en pleine largeur.
   Breakpoint à aligner sur ta maquette (768px = tablette portrait courante). */
@media (max-width: 768px) {
	.featured-row--2 {
		grid-template-columns: 1fr;
	}
}

/* --- Bascule des médias desktop / mobile ---------------------------- */
/* Chaque bloc rend les deux variantes ; le CSS n'en montre qu'une.
   Breakpoint aligné sur celui de la grille (768px).

   La galerie est doublée (.project-gallery.project-gallery--desktop) et
   c'est indispensable : sa règle de grille, plus bas dans le fichier, pose
   `display: grid` avec la même spécificité qu'une classe seule — plus bas,
   elle l'emportait, et les DEUX galeries s'affichaient. Une media query
   n'ajoute aucune spécificité ; la classe doublée, si. Toute variante qui
   recevrait un jour un `display` plus bas est à doubler de même. */
@media (max-width: 768px) {

	.featured-media--desktop,
	.project-media--desktop,
	.project-card__thumb--desktop,
	.project-gallery.project-gallery--desktop {
		display: none;
	}
}

@media (min-width: 769px) {

	.featured-media--mobile,
	.project-media--mobile,
	.project-card__thumb--mobile,
	.project-gallery.project-gallery--mobile {
		display: none;
	}
}

/* --- Single project ------------------------------------------------- */
/* `overflow: clip` : c'est l'article qui ROGNE la pile des médias quand
   elle grossit sous le flou (voir --flou-zoom). Sans effet sur le bouton "i"
   et le texte, en `position: fixed` : un overflow ne les retient pas tant
   que l'article n'a ni transform ni filter — et il n'en a pas.

   `clip` et NON `hidden` : un `overflow: hidden` fait de l'article un
   conteneur de défilement, ce qui change le repère de tout `position:
   sticky` à l'intérieur. Rien n'en dépend aujourd'hui, mais `clip` rogne
   de la même façon sans cet effet de bord. */
.project {
	--project-gap: 0;
	position: relative;
	overflow: clip;
}

/* Pile des media rows. Le flou est porté par ce conteneur uniquement :
   le bouton "i" et le texte doivent rester nets (et un `filter` créerait
   un bloc conteneur pour leurs `position: fixed`). */
.project-rows {
	display: flex;
	flex-direction: column;
	/* Le centre du zoom : posé par app.js à l'ouverture, au milieu de ce
	   qu'on voit à l'écran. Le repli, si le JS n'a rien posé, est le centre
	   de la pile. */
	transform-origin: var(--flou-origine, 50% 50%);
	transition:
		filter var(--flou-duree) ease,
		transform var(--flou-duree) ease;
}

.project.is-text-open .project-rows {
	filter: blur(var(--flou-texte));
	transform: scale(var(--flou-zoom));
}

/* Une ligne = 1, 2 ou 3 colonnes de largeurs égales. */
.project-row {
	display: grid;
	/* gap: var(--project-gap); */
}

.project-row--1 {
	grid-template-columns: 1fr;
}

.project-row--2 {
	grid-template-columns: repeat(2, 1fr);
}

.project-row--3 {
	grid-template-columns: repeat(3, 1fr);
}

.project-media img,
.project-media video {
	display: block;
	width: 100%;
	height: auto;
}

/* Height = % de la hauteur d'écran : le média est recadré à cette hauteur. */
.project-col--fixed .project-media img,
.project-col--fixed .project-media video {
	height: var(--col-height);
	object-fit: cover;
}

/* Galerie : espacement uniforme. Le gap sépare les médias, le padding de
   même valeur l'applique aussi sur les bords — d'où « pareil partout ».
   Sauf en bas, si l'option « Bottom edge » du bloc le demande : --gal-bas
   vaut alors 0 et la galerie s'arrête contre le bloc suivant (functions.php,
   zonestudio_render_project_gallery). Colonnes, gap et bas ont chacun leur
   valeur mobile (voir la media query). Quand une galerie mobile distincte
   est renseignée, deux grilles sont rendues (--desktop / --mobile) et la
   bascule d'affichage est plus haut. */
.project-gallery {
	display: grid;
	grid-template-columns: repeat(var(--gal-cols, 3), 1fr);
	gap: var(--gal-gap, 0);
	padding: var(--gal-gap, 0);
	padding-bottom: var(--gal-bas, var(--gal-gap, 0));
}

.project-gallery__item img,
.project-gallery__item video {
	display: block;
	width: 100%;
	height: auto;
}

/* --- Bouton son des vidéos ------------------------------------------- */
/* Rendu par PHP juste après la <video> (zonestudio_bouton_son), quand
   l'option "Sound button" de la colonne ou de la galerie est cochée.

   Placé EN MIROIR DU BOUTON "i" : FIXE, à mi-hauteur de l'ÉCRAN et à la
   même distance du bord droit que le "i" du bord gauche (voir
   .project-toggle), quelle que soit la taille de la vidéo. Il ne bouge
   pas : c'est le bloc SUIVANT qui lui passe dessus.

   Deux choses sur les blocs de média le permettent, sans JS :
   · `isolation: isolate` fait de chaque bloc un contexte d'empilement ; des
     contextes frères se peignent dans l'ordre de la page, le suivant
     par-dessus le précédent. Le bouton, fixe mais DANS le contexte de son
     bloc, est donc recouvert par le bloc d'après quand celui-ci monte
     jusqu'au milieu de l'écran — c'est l'incrément de z-index, sans
     numéroter ;
   · `clip-path: inset(0)` rogne le bloc à sa boîte, ses descendants fixes
     compris : le bouton n'existe à l'écran que là où son bloc est. Sans
     ça, celui de la dernière vidéo, tout en haut de la pile, se verrait dès
     le haut de la page. Un clip-path ne change pas le repère d'un élément
     fixe, il reste calé sur l'écran (vérifié Chrome et Safari).

   Même famille que le bouton "i" : cercle au trait, blanc en `difference`,
   donc l'inverse de ce qu'il recouvre. Une seule valeur, --son-taille, pour
   le redimensionner. */
.project-media,
.project-gallery__item {
	position: relative;
	isolation: isolate;
	clip-path: inset(0);
}

.zs-son {
	--son-taille: 2.3rem;

	position: fixed;
	top: 50%;
	right: 1.7rem;
	z-index: 2;
	transform: translateY(-50%);
	display: block;
	box-sizing: border-box;
	/* --son-taille est la LARGEUR ; la hauteur suit les proportions du
	   dessin (viewBox serré, voir zonestudio_bouton_son) : aucun vide. */
	width: var(--son-taille);
	height: auto;
	padding: 0;
	border: 0;
	background: transparent;
	color: #fff;
	mix-blend-mode: difference;
	/* Il ne REVIENT qu'après --flou-duree : à la fermeture du texte, la pile
	   des médias met ce temps à quitter son zoom, et tant qu'elle porte une
	   transformation elle reste le repère du bouton fixe — il réapparaîtrait
	   au mauvais endroit, puis sauterait. S'y ajoute --boutons-glisse : sur
	   mobile, l'icône info se décale pendant ce temps-là, et le bouton
	   n'apparaît qu'une fois la place faite, sans être mordu par elle.
	   Durée nulle, tout est dans le délai. */
	transition: visibility 0s linear calc(var(--flou-duree) + var(--boutons-glisse));
}

/* DANS UNE GALERIE, le bouton recule de la largeur du gap. La galerie a un
   padding égal à son gap sur ses quatre bords : le média de droite s'arrête
   à un gap du bord de l'écran, et le bouton, calé sur l'écran, tombait dans
   ce vide — rogné par son bloc, donc invisible. Reculé d'autant, il retrouve
   sa place à 1.7rem du bord du média. --gal-gap vient de la galerie
   (style en ligne, functions.php) et descend jusqu'ici par héritage.
   Même valeur en % que le padding, qui se calcule sur la même largeur.
   Dans les colonnes de gauche d'une galerie à plusieurs colonnes, le bouton
   reste hors du bloc, donc invisible : c'est le parti pris du bouton fixe.
   Sur mobile il est centré, le gap n'y change rien (voir le bloc mobile). */
.project-gallery .zs-son {
	right: calc(1.7rem + var(--gal-gap, 0px));
}

/* Caché quand un `transform` remonte dans ses ancêtres, ce qui en ferait le
   repère du bouton fixe : texte du projet ouvert (la pile des médias est
   zoomée) et transition de page (la page dérive). Il reviendrait à un
   endroit sans rapport avec l'écran. Se cache SANS délai, lui. */
.project.is-text-open .zs-son,
html.is-changing .zs-son {
	visibility: hidden;
	transition-delay: 0s;
}

/* L'icône remplit le bouton : même boîte, aucun vide autour. Le viewBox est
   celui de la géométrie pure ; `overflow: visible` laisse le trait dépasser
   de sa demi-épaisseur au lieu d'être rogné. */
.zs-son__icone {
	display: block;
	width: 100%;
	height: auto;
	overflow: visible;
	fill: none;
	stroke: currentColor;
	stroke-width: 1;
	stroke-linecap: round;
	stroke-linejoin: round;
}

/* Un seul dessin à la fois : la croix tant que c'est muet, les ondes quand
   le son est actif. aria-pressed est posé par PHP puis basculé par app.js. */
.zs-son[aria-pressed="false"] .zs-son__onde,
.zs-son[aria-pressed="true"] .zs-son__croix {
	display: none;
}

@media (max-width: 768px) {

	.project-row--2,
	.project-row--3 {
		grid-template-columns: 1fr;
	}

	.project-gallery {
		grid-template-columns: repeat(var(--gal-cols-mobile, 1), 1fr);
		gap: var(--gal-gap-mobile, var(--gal-gap, 0));
		padding: var(--gal-gap-mobile, var(--gal-gap, 0));
		padding-bottom: var(--gal-bas-mobile, var(--gal-gap-mobile, var(--gal-gap, 0)));
	}
}

/* Bouton "i" cerclé, fixe au milieu de l'écran à gauche.
   Proportions calées sur la maquette : trait ≈ 1/24 du diamètre, lettre
   ≈ 45 % du diamètre. Tout est dérivé de --toggle-size, une seule valeur
   à changer pour redimensionner le bouton. */
.project-toggle {
	--toggle-size: 2.15rem;

	position: fixed;
	top: 50%;
	left: 2rem;
	z-index: 20;
	display: grid;
	place-items: center;
	box-sizing: border-box;
	width: var(--toggle-size);
	height: var(--toggle-size);
	padding: 0;
	border: 2px solid currentColor;
	border-radius: 50%;
	background: transparent;
	transform: translateY(-50%);

	/* Même traitement que le texte de l'overlay, le menu et les légendes :
	   blanc mélangé en `difference`, donc l'inverse du fond — le bouton reste
	   lisible qu'il passe sur un média clair ou sombre.

	   Il couvre TOUT le bouton d'un coup : la lettre, les deux barres de la
	   croix (des pseudo-éléments, donc des enfants) et le contour, puisque
	   `border: 2px solid currentColor` suit la même couleur. C'est aussi la
	   raison de garder le fond transparent : un fond opaque se mélangerait
	   lui aussi et remplirait le cercle d'un disque inversé.

	   Le blanc n'est pas décoratif, il est ce que `difference` exige : c'est
	   la valeur qui donne l'inverse exact du fond. Une autre couleur
	   déplacerait la teinte au lieu de l'inverser. */
	color: #fff;
	mix-blend-mode: difference;
}

/* ZONE DE CONTACT du bouton "i", plus large que le cercle visible.

   Le cercle fait 2.75rem et se vise mal au curseur — surtout avec un point
   actif qui n'est pas au centre de l'image. Ce span débordant élargit la
   surface cliquable sans rien changer à l'apparence.

   Un span vide et non un pseudo-élément : ::before et ::after sont déjà pris
   par les deux barres de la croix.

   Il est DANS le bouton, donc un clic dessus lui remonte naturellement, et
   `closest('.project-toggle')` le retrouve — aussi bien pour l'ouverture du
   texte que pour le curseur, qui passe en OPEN dès l'entrée dans la zone.

   `inset` négatif : la zone déborde du bouton de --toggle-hit sur les quatre
   côtés. 0.75rem porte la surface de 2.75rem à 4.25rem, soit une fois et
   demie. Monter si c'est encore juste. */
.project-toggle {
	--toggle-hit: 1rem;
}

/* Débordement en HAUT, à DROITE et en BAS, mais pas à gauche : le bouton est
   déjà collé au bord de l'écran, une zone qui déborde de ce côté ne gagnerait
   rien et empiéterait sur la marge de la page.
   Ordre de `inset` : haut, droite, bas, gauche. */
.project-toggle__hit {
	position: absolute;
	inset: calc(var(--toggle-hit) * -1) calc(var(--toggle-hit) * -1) calc(var(--toggle-hit) * -1) 0;
}

.project-toggle__icon {
	display: block;
	/* Même police et même graisse que le texte du projet (.project-text).
	   Pas `inherit` : un <button> a sa propre police dans la feuille du
	   navigateur (système), et rien ne la remet à celle du site. */
	font-family: var(--font-display);
	font-weight: 300;
	font-size: 1.6rem;
	font-style: normal;
	line-height: 1.4;
	/* Et même compensation d'épaisseur : le bouton est en `difference` comme
	   le texte, ce qui désactive le lissage sous-pixel et amaigrit la lettre. */
	-webkit-text-stroke: 0.012em currentColor;
	/* Uniquement la RÉAPPARITION est en fondu (retour à l'état fermé) :
	   la disparition, elle, est instantanée — voir l'état ouvert. */
	transition: opacity 0.2s ease;
}

/* Les deux barres de la croix, superposées à la lettre et masquées au repos.
   Ancrées sur le bouton (`position: fixed` fait de lui leur bloc conteneur).

   Au repos elles sont VERTICALES et confondues, au centre du cercle : une
   seule barre à l'œil. À l'ouverture, chacune pivote de 45° dans un sens
   opposé — la barre s'ouvre en croix, comme une paire de ciseaux. */
.project-toggle::before,
.project-toggle::after {
	content: "";
	position: absolute;
	top: 50%;
	left: 50%;
	width: 45%;
	height: calc(var(--toggle-size) / 24);
	background: currentColor;
	opacity: 0;
	transition: opacity 0.2s ease, transform 0.3s ease;
}

.project-toggle::before {
	transform: translate(-50%, -50%) rotate(90deg);
}

/* -90° plutôt que 90° : visuellement identique au repos, mais les deux
   barres partent ensuite dans des sens opposés, à 45° chacune. */
.project-toggle::after {
	transform: translate(-50%, -50%) rotate(-90deg);
}

/* Ouvert : le "i" s'efface, la croix apparaît.
   Le second sélecteur vise n'importe quel enfant visible du bouton, pas
   seulement .project-toggle__icon : si le markup a changé (classe renommée,
   lettre sortie du span), la lettre disparaît quand même. Seul le libellé
   pour lecteurs d'écran est épargné — il est déjà hors écran. */
.project.is-text-open .project-toggle__icon,
.project.is-text-open .project-toggle>*:not(.screen-reader-text) {
	opacity: 0;
	visibility: hidden;
	/* `transition: none` sur l'état d'arrivée = bascule immédiate. Sans ça,
	   `visibility` s'anime de façon discrète et ne passe à hidden qu'à la FIN
	   de sa durée : la lettre restait visible pendant toute la rotation des
	   barres. Ici elle s'efface dès le premier instant du clic. */
	transition: none;
}

.project.is-text-open .project-toggle::before {
	opacity: 1;
	transform: translate(-50%, -50%) rotate(45deg);
}

.project.is-text-open .project-toggle::after {
	opacity: 1;
	transform: translate(-50%, -50%) rotate(-45deg);
}

/* SUR MOBILE, le "i" et le bouton son quittent le milieu de l'écran : ils
   se posent juste au-dessus du menu du bas, CENTRÉS, et plus petits.

   · À deux, ils forment une paire centrée : le "i" à gauche de l'axe, le
     son à droite, --boutons-ecart entre eux, leurs centres à la même
     hauteur.
   · Quand aucun bouton son n'est à l'écran — pas de vidéo sonore sous cet
     endroit, texte du projet ouvert — le "i" GLISSE seul au centre. C'est
     app.js qui le sait (majBoutons) et le dit par la classe zs-son-visible
     sur <html> : le bouton son est montré ou caché par le rognage de son
     bloc de média, le CSS seul ne peut pas en déduire la place du "i".
   · Sans "i" sur la page (projet sans texte : pas de zs-info), c'est le
     bouton son qui prend le centre.

   Ils restent FIXES : le menu l'est, et c'est à lui qu'ils se calent, avec
   AUTANT D'ESPACE au-dessus des mots du menu qu'il y en a dessous, entre
   eux et le bas de l'écran. Les deux mesures viennent d'app.js, prises sur
   les mots eux-mêmes (--barre-mots-haut, --barre-marge-bas) ; sans JS, une
   estimation.

   Placé ICI, après toutes les règles de base des deux boutons : une media
   query n'ajoute aucune spécificité, plus haut elle serait écrasée. */
@media (max-width: 768px) {

	:root {
		--boutons-glisse: 0.35s;
		--bouton-info: 1.6rem;
		--bouton-son: 1.8rem;
		/* Hauteur de l'icône son : sa largeur × 13/17 (son viewBox). */
		--bouton-son-h: calc(var(--bouton-son) * 13 / 17);
		--boutons-ecart: 1.2rem;
		/* Bas du "i" : le haut des mots du menu, plus l'espace qu'il y a SOUS
		   ces mots. Cet espace est le padding-bottom de .site-bar : le changer
		   là-bas déplace les icônes d'autant, l'égalité tient toute seule.
		   Pour décoller ou rapprocher les icônes malgré tout, ajouter un
		   terme à la somme. */
		--boutons-bas: calc(var(--barre-mots-haut, calc(1.5rem + env(safe-area-inset-bottom, 0px) + 3vw)) + var(--barre-marge-bas, calc(1.5rem + env(safe-area-inset-bottom, 0px))));
	}

	.project-toggle {
		--toggle-size: var(--bouton-info);

		top: auto;
		bottom: var(--boutons-bas);
		left: 50%;
		border-width: 1.5px;
		/* Seul : centré. */
		transform: translateX(-50%);
		transition: transform var(--boutons-glisse) var(--bandeau-courbe);
	}

	/* À deux : son bord droit à un demi-écart de l'axe. */
	html.zs-son-visible .project-toggle {
		transform: translateX(calc(-100% - var(--boutons-ecart) / 2));
	}

	/* La lettre suit le cercle : même rapport qu'en grand (1.6 / 2.15). */
	.project-toggle__icon {
		font-size: calc(var(--toggle-size) * 0.744);
	}

	.zs-son {
		--son-taille: var(--bouton-son);

		top: auto;
		/* Remonté de la demi-différence des hauteurs : centres alignés. */
		bottom: calc(var(--boutons-bas) + (var(--bouton-info) - var(--bouton-son-h)) / 2);
		right: auto;
		left: 50%;
		/* Avec le "i" : son bord gauche à un demi-écart de l'axe. */
		transform: translateX(calc(var(--boutons-ecart) / 2));
	}

	/* Sans "i" : seul, centré. */
	html:not(.zs-info) .zs-son {
		transform: translateX(-50%);
	}

	/* Dans une galerie, le recul du gap (règle de base) n'a pas lieu d'être :
	   le bouton est centré, et les paddings de la galerie, égaux des deux
	   côtés, laissent le centre au centre. On annule le `right`, qui
	   l'emporterait sur celui du bloc mobile par sa spécificité. */
	.project-gallery .zs-son {
		right: auto;
	}
}

@media (max-width: 768px) and (prefers-reduced-motion: reduce) {

	.project-toggle {
		transition: none;
	}
}

/* Texte du projet : overlay centré, masqué par défaut. */
/* La police d'affichage en Light — la graisse 300 est déclarée dans
   assets/fonts/stylesheet.css et pointe AlbertusMTStd-Light. C'est la seule
   autre famille du site avec celle du menu.

   Le texte est mis en majuscules par le CSS et non dans l'admin : la casse
   saisie reste intacte en base, donc récupérable si le parti pris change, et
   les lecteurs d'écran lisent le texte d'origine plutôt que d'épeler des
   capitales. */
.project-text {
	position: fixed;
	inset: 0;
	z-index: 15;
	display: grid;
	place-items: center;
	padding: 2rem;
	font-family: var(--font-display);
	font-weight: 300;
	/* text-transform: uppercase; */

	/* Même traitement que le menu et les légendes : blanc mélangé en
	   `difference`, donc l'inverse du fond — lisible par-dessus les médias
	   floutés quelle que soit leur dominante. Et même compensation
	   d'épaisseur : le mélange désactive le lissage sous-pixel, qui amaigrit
	   les glyphes. */
	color: #fff;
	mix-blend-mode: difference;
	-webkit-text-stroke: 0.012em currentColor;

	opacity: 0;
	visibility: hidden;
	transition: opacity 0.4s ease, visibility 0.4s;
}

.project.is-text-open .project-text {
	opacity: 1;
	visibility: visible;
}

/* Les deux tailles de l'overlay, définies plutôt qu'héritées des défauts du
   navigateur. Celle de l'inner porte tout le texte du wysiwyg (p, listes…) ;
   le titre a la sienne par-dessus. */
/* --texte-echelle : l'option "Text size (%)" du projet, posée par
   single-projet.php sous forme de facteur (120 % → 1.2). Elle multiplie la
   taille de base, ici comme dans la version mobile plus bas — la mobile en
   hérite donc sans autre réglage. Absente = 1, taille inchangée. Le titre
   n'y touche pas. */
.project-text__inner {
	--texte-taille: calc(1.6rem * var(--texte-echelle, 1));
	--titre-taille: 2.3rem;

	width: 73vw;
	text-align: center;
	max-width: 1250px;
	font-size: var(--texte-taille);
	line-height: 1.4;
}

/* Lignes équilibrées sur le texte du projet : centré et court (4-5 lignes),
   c'est le cas d'école de `balance` — sans lui, la dernière ligne peut se
   retrouver à un mot seul. Les navigateurs le débraient au-delà de ~6 lignes
   (Chrome) : si un texte de projet dépassait, il retomberait simplement sur
   le retour à la ligne normal, comme sur les navigateurs qui ignorent la
   propriété. Rien ne casse dans aucun cas.
   Sur les <p> et non sur l'inner : le titre garde son rendu actuel. */
.project-text__inner p {
	text-wrap: balance;
}

/* Light comme le reste de l'overlay. Un h1 porte la graisse grasse du
   navigateur, il faut donc le dire ici — le `font-weight: 300` posé sur
   .project-text ne suffit pas, une valeur PROPRE à l'élément l'emporte
   toujours sur une valeur héritée. (La règle globale `h1 { font-weight: 300 }`
   du haut du fichier fait déjà ce travail ; celle-ci le garantit même si
   elle venait à bouger.) */
.project-text__title {
	margin: 0 0 1.5rem;
	font-size: var(--titre-taille);
	font-weight: 300;
	line-height: 1.1;
	text-transform: uppercase;
}

/* Les liens du wysiwyg : blanc comme le reste, sinon le bleu du navigateur
   ressortirait en orangé changeant à travers le `difference`. Le souligné est
   conservé — dans un bloc entièrement blanc, c'est lui qui signale le lien. */
.project-text__inner a {
	color: inherit;
	text-decoration: underline;
}

/* Overlay en mobile : les deux tailles redescendent. Elles sont posées sur le
   même élément que leurs valeurs de bureau, donc APRÈS elles dans le fichier —
   un `@media` n'ajoute aucune spécificité, placé plus haut il se ferait
   écraser.

   Seules les deux variables changent : `font-size` continue de les lire, rien
   n'est redéclaré en double. */
@media (max-width: 768px) {

	.project-text__inner {
		--texte-taille: calc(1rem * var(--texte-echelle, 1));
		--titre-taille: 1.2rem;

		/* 60vw ne vaut plus rien sur un écran étroit : le texte se retrouverait
		   dans une colonne de 230px. La largeur passe à ce que laisse l'écran,
		   le padding de .project-text ménageant déjà les bords. */
		width: 100%;
	}
}

@media (prefers-reduced-motion: reduce) {

	.project-rows,
	.project-text,
	.project-toggle,
	.project-toggle__icon,
	.project-toggle::before,
	.project-toggle::after {
		transition: none;
	}
}

/* --- Archive projets / catégories : grille de vignettes -------------- */
.projects-archive {
	--grid-gap: 0;
	--grid-cols: 4;
}

/* Barre de filtres : liens vers les archives de terme, interceptés par le JS.
   Grandes capitales en Albertus, réparties sur toute la largeur. */
.projects-filter {
	/* Taille en vw : le menu occupe la MÊME proportion de l'écran quelle que
	   soit sa largeur. Pas de clamp() ici — il figerait la taille au-delà de
	   ses bornes et casserait justement ce rapport.
	   --filter-size est le seul réglage : tout le reste en découle. */
	--filter-size: 4.2vw;
	/* Retrait latéral exprimé en em, donc relatif à la taille du texte :
	   il grandit et rétrécit avec lui, le rapport reste constant. */
	--filter-inset: 0.5em;

	/* Le logo (voir .projects-filter__logo plus bas) : sa hauteur en em de la
	   barre, sa largeur qui en découle (le fichier fait 1640 × 1975), la
	   durée de son glissement. Déclarés ICI et non sur le logo : le retrait
	   gauche de la barre en dépend. */
	--logo-hauteur: 0.72em;
	--logo-largeur: calc(var(--logo-hauteur) * 1640 / 1975);
	--logo-duree: 0.6s;
	--filter-retrait: 2.5rem;

	position: relative;
	display: flex;
	flex-wrap: wrap;
	justify-content: space-between;
	gap: 0 var(--filter-inset);
	/* À gauche, la place du logo s'ajoute au retrait. Sur l'accueil elle
	   disparaît (voir plus bas) et c'est ce padding qui s'anime : les mots
	   glissent, tous ensemble, vers le bord. */
	padding: 0 var(--filter-retrait) 0 calc(var(--filter-retrait) + var(--logo-largeur) + var(--filter-inset));
	transition: padding-left var(--logo-duree) var(--bandeau-courbe);
	font-family: var(--font-display);
	font-size: var(--filter-size);
	line-height: 1.05;
	text-transform: uppercase;
	font-weight: normal;

}

/* La boîte d'un mot est plus haute que le mot lui-même : une police réserve
   toujours la place de ses jambages et de ses accents, même quand le texte
   n'en a pas — ici que des capitales. `line-height` ne supprime pas cette
   réserve, il ne fait que l'entourer, d'où l'espace résiduel.

   `text-box` la retranche pour de bon : la boîte s'arrête en haut à la
   hauteur des capitales et en bas à la ligne de base. La zone cliquable
   colle alors aux lettres, ce qui règle le survol et le curseur.

   Support : Chrome 133+ et Safari 18.2+. Ailleurs la propriété est ignorée
   et on retombe simplement sur le comportement actuel — rien ne casse. */
/* Aucune opacité ici, et c'est délibéré. Les mots visibles sont ceux de la
   couche mélangée : une opacité qui s'anime dessus (0.85 → 1 au survol et au
   clic) obligeait Safari à recomposer toute la bande mélangée pendant chaque
   transition. Le seul retour visuel est maintenant le trait rose, qui vit sur
   la couche du dessus — hors du mélange, donc sans ce coût. */
.projects-filter__item {
	/* Le même retrait en haut que sur les côtés de la barre : une seule
	   valeur, --filter-retrait. Grâce à `text-box` plus bas, il se mesure
	   jusqu'au haut des capitales, pas jusqu'à la boîte de la ligne. */
	padding-top: var(--filter-retrait);
	position: relative;
	color: inherit;
	text-decoration: none;
	line-height: 1;
	text-box: trim-both cap alphabetic;
}

/* LE LOGO, première entrée de la barre, à gauche des mots. Il mène à
   l'accueil (voir projects-filter.php). Même padding-top que les mots pour
   que son haut soit à la hauteur de leurs capitales.

   --logo-hauteur est LE réglage, en em de la barre : il suit donc la taille
   du menu (--filter-size). 0.72em ≈ la hauteur des capitales d'Albertus,
   le logo tient entre le haut des lettres et leur ligne de base. Sa largeur
   en découle (l'image fait 1640 × 1975).

   Pas de trait ni de survol : ce n'est pas un filtre, et la couche mélangée
   n'anime rien (voir la note sur l'opacité des items). */
/* SUR L'ACCUEIL, le logo s'efface et les mots glissent prendre toute la
   largeur ; sur les autres pages il revient et les mots reculent pour lui
   faire place. C'est la classe `zs-accueil` sur <html> qui commande
   (header.php au premier affichage, app.js ensuite), et la transition se
   joue pendant celle de la page.

   EN ABSOLU, et non comme une entrée de la barre : `space-between` répartit
   l'espace entre TOUTES les entrées, y compris une entrée de largeur nulle —
   le logo replié gardait sa part d'espace, et « All » restait décollé du
   bord sur l'accueil, la barre avait l'air centrée. Hors du flux, il ne
   compte plus ; c'est le retrait gauche de .projects-filter qui lui fait
   place et qui s'anime.

   Sa largeur est ÉCRITE et non laissée à l'image : une largeur `auto` ne
   s'anime pas. */
.projects-filter__logo {
	position: absolute;
	top: var(--filter-retrait);
	left: var(--filter-retrait);
	display: block;
	width: var(--logo-largeur);
	height: var(--logo-hauteur);
	line-height: 0;
	overflow: hidden;
	transition: width var(--logo-duree) var(--bandeau-courbe);
}

.projects-filter__logo-img {
	display: block;
	width: var(--logo-largeur);
	height: var(--logo-hauteur);
	max-width: none;
	transition: opacity calc(var(--logo-duree) / 2) ease;
}

html.zs-accueil .projects-filter {
	padding-left: var(--filter-retrait);
}

html.zs-accueil .projects-filter__logo {
	width: 0;
}

html.zs-accueil .projects-filter__logo-img {
	opacity: 0;
}

@media (prefers-reduced-motion: reduce) {

	.projects-filter,
	.projects-filter__logo,
	.projects-filter__logo-img {
		transition: none;
	}
}

/* Couche du dessus : l'image y est invisible mais bien là, pour le clic —
   `opacity: 0` et non `visibility: hidden`, qui couperait le pointeur. */
.site-bar--main .projects-filter__logo-img {
	opacity: 0;
}

/* Les overrides mobiles du menu, APRÈS leurs règles de base — condition pour
   qu'ils l'emportent, une media query n'ajoutant aucune spécificité. Le
   placement de la barre elle-même (bottom) vit dans le bloc mobile de
   .site-bar, plus haut. */
@media (max-width: 768px) {

	.projects-filter {
		--filter-retrait: 1.5rem;
		flex-wrap: nowrap;
		/* PAS DE LOGO sur mobile : la barre est en bas de l'écran et déjà
		   serrée. Le retrait gauche redevient le simple retrait, accueil ou
		   non, et sans transition — rien ne glisse. */
		padding-left: var(--filter-retrait);
		transition: none;
	}

	/* Le grand padding-top des items sert au trait et au survol en haut
	   d'écran ; menu passé en bas, il n'a plus lieu d'être. */
	.projects-filter__item {
		padding-top: 0;
	}

	.projects-filter__logo {
		display: none;
	}
}

/* Terme actif : trait rose légèrement incliné, tracé par-dessus le mot.
   Un ::after plutôt qu'un text-decoration, pour l'inclinaison et le débord.
   Il est TOUJOURS présent mais à scaleX(0) : c'est ce qui permet de l'animer
   quand la classe arrive — un pseudo-élément créé au même moment que la
   classe n'aurait aucun état de départ à animer. */
.projects-filter__item::after {
	content: "";
	position: absolute;
	/* Ancré par le BAS et non par un pourcentage du haut : le mot vit au bas
	   de la boîte (le padding-top des items ne sert qu'au survol), et un
	   `top: N%` se mesurerait sur padding compris — le trait montait avec
	   chaque ajustement de padding. Depuis le bas, il colle au mot quel que
	   soit le padding, desktop comme mobile. En em : il suit la taille du
	   texte. C'est LA valeur à régler pour monter/descendre le trait. */
	bottom: 0.28em;
	right: -0.12em;
	left: -0.12em;
	height: 0.055em;
	border-radius: 1em;
	background: var(--filter-strike, #f39ac8);
	transform: rotate(-3deg) scaleX(0);
	transform-origin: left center;
	transition: transform 0.45s cubic-bezier(0.65, 0, 0.35, 1);
}

/* Le trait se tire de la gauche vers la droite, comme tracé à la main.
   Deux moments le déclenchent :
   · le SURVOL, qui se lit comme un aperçu du filtre — le trait se retire
     quand le pointeur repart ;
   · le CLIC, où app.js pose .is-active et où le trait reste en place.

   Les deux visent la MÊME valeur, et c'est ce qui fait le geste : au clic, le
   trait déjà tracé par le survol ne rejoue rien, il est simplement retenu.
   Le passage de l'un à l'autre est donc invisible.

   :focus-visible est ici et non dans le bloc de survol : il ne dépend pas
   d'un pointeur, un utilisateur au clavier doit voir la même chose. */
.projects-filter__item.is-active::after,
.projects-filter__item:focus-visible::after {
	transform: rotate(-3deg) scaleX(1);
}

/* `@media (hover: hover)` réserve l'effet aux vrais pointeurs : sur écran
   tactile, un survol simulé resterait « collé » après le tap et laisserait un
   trait sur une entrée qui n'est pas la catégorie active. Même précaution que
   sur les vignettes d'archive. */
@media (hover: hover) {
	.projects-filter__item:hover::after {
		transform: rotate(-3deg) scaleX(1);
	}
}

@media (prefers-reduced-motion: reduce) {
	.projects-filter__item::after {
		transition: none;
	}
}

/* --- Le trait DESSINÉ : SVG inline par mot ------------------------------
   Rendu par zonestudio_menu_trait() (functions.php) dans chaque lien de la
   couche du dessus, à partir de assets/traits/MOT_Contour.svg et
   MOT_vector.svg. Le contour du coup de pinceau est peint en rose et masqué
   par son axe, tracé épais ; c'est le pointillé de cet axe qui s'anime — le
   trait se dessine avec sa vraie épaisseur, éclats compris, de gauche à
   droite. Mêmes déclencheurs et même courbe que le trait CSS : survol,
   .is-active posée par app.js, focus clavier.

   Le SVG est posé sur la boîte du MOT : `bottom: 0` = la ligne de base
   (text-box coupe la boîte du lien à la ligne de base et à la hauteur de
   capitale), `1cap` = la hauteur de capitale, ce que représente le viewBox
   des fichiers. `preserveAspectRatio="none"` étire le dessin à cette boîte
   exactement. 0.7em est le repli des navigateurs sans l'unité `cap` : la
   hauteur de capitale d'Albertus, à peu de chose près. */
.projects-filter__trait {
	position: absolute;
	left: 0;
	right: 0;
	bottom: 0;
	width: 100%;
	height: 0.7em;
	height: 1cap;
	overflow: visible;
	pointer-events: none;
}

/* Un mot qui a son SVG n'a plus besoin du trait CSS. */
.projects-filter__item--trait::after {
	display: none;
}

.projects-filter__contour {
	fill: var(--filter-strike, #f39ac8);
}

/* L'axe, dans le masque : blanc = visible. `stroke-width` en unités du
   viewBox (le mot fait ~56 de haut) : 24 couvre le contour et ses éclats de
   part et d'autre de l'axe. Le pointillé va de « rien » à 0 (tout), grâce au
   pathLength="1" posé par PHP — aucune longueur réelle à connaître.

   « Rien » vaut 1.1 et NON 1, et le vide du pointillé fait 2 : à 1 pile, le
   trait caché finit exactement au départ de l'axe, et selon l'arrondi de
   calcul — donc selon la taille d'écran, le SVG étant étiré à la boîte du
   mot — son embout ROND, large de 24, restait visible : une pastille rose
   figée au début du mot, avant tout survol. Avec 0.1 de marge, le trait
   caché finit franchement AVANT l'axe, rien ne dépasse. Le vide de 2 évite
   que le motif suivant n'entre dans l'axe par l'autre bout. Le prix : le
   trait part 0.1 plus loin, soit quelques centièmes de seconde. */
.projects-filter__trace {
	fill: none;
	stroke: #fff;
	stroke-width: 24;
	stroke-linecap: round;
	stroke-dasharray: 1 2;
	stroke-dashoffset: 1.1;
	transition: stroke-dashoffset 0.45s cubic-bezier(0.65, 0, 0.35, 1);
}

.projects-filter__item.is-active .projects-filter__trace,
.projects-filter__item:focus-visible .projects-filter__trace {
	stroke-dashoffset: 0;
}

@media (hover: hover) {

	.projects-filter__item:hover .projects-filter__trace {
		stroke-dashoffset: 0;
	}
}

@media (prefers-reduced-motion: reduce) {

	.projects-filter__trace {
		transition: none;
	}
}

.projects-archive__description {
	padding: 0 var(--filter-inset, 2.5rem);
}

/* `overflow: hidden` : au filtrage, une vignette qui change de ligne entre par
   le côté (voir app.js). Elle démarre donc une largeur de grille plus loin,
   hors cadre — sans ce masquage on la verrait passer à côté de la grille, et
   le document gagnerait une barre de défilement horizontale le temps du
   trajet. En dehors du filtrage, rien ne dépasse : la règle est sans effet. */
/* `overflow: hidden` : au filtrage, une vignette qui change de ligne revient
   par la droite (voir app.js). Elle démarre donc une largeur de grille plus
   loin, hors cadre — sans ce masquage on la verrait passer à côté de la
   grille, et le document gagnerait une barre de défilement horizontale le
   temps du trajet. En dehors du filtrage, rien ne dépasse. */
.projects-grid {
	display: grid;
	grid-template-columns: repeat(var(--grid-cols), 1fr);
	gap: var(--grid-gap);
	overflow: hidden;
}

/* CHEVAUCHEMENT D'UN PIXEL entre vignettes voisines, contre les filets
   clairs. Une grille en `1fr` donne des colonnes à largeur fractionnaire
   (1366px / 5 = 273,2px) : à chaque frontière, le navigateur arrondit les
   deux bords indépendamment, et selon la largeur de l'écran il reste un
   pixel où aucune image ne couvre tout à fait — on voit le fond à travers,
   un trait clair qui apparaît ou disparaît quand on redimensionne. Vu
   surtout dans Safari, entre deux lignes aussi (hauteurs fractionnaires,
   par le ratio des vignettes).

   Les marges négatives : la vignette déborde d'1px à droite et en bas de sa
   cellule, et la suivante, peinte après elle, la recouvre d'autant. La
   frontière est alors toujours sous une image. Une 1px de vignette cachée
   sous sa voisine, ça ne se voit pas ; un filet clair, si. Le dernier pixel
   de la dernière colonne sort de la grille, dont l'overflow le rogne. */
.project-card {
	margin-right: -1px;
	margin-bottom: -1px;
}

/* Vignette masquée par le filtre : retirée du flux, la grille se recompose
   et l'animation FLIP (app.js) rejoue le déplacement des autres. */
.project-card.is-hidden {
	display: none;
}

.project-card__link {
	/* Repère de la légende, posée en absolu par-dessus la vignette. */
	position: relative;
	display: block;
	color: inherit;
	text-decoration: none;
}

/* Ratio des vignettes : 1200 × 1600 (3:4 portrait). Le média remplit le
   cadre et est recadré au centre, quelles que soient ses dimensions — le
   ratio ne dépend donc jamais du fichier uploadé, image ou vidéo. */
.project-card__thumb {
	overflow: hidden;
	aspect-ratio: 1200 / 1600;
	background: #f4f4f4;
}

.project-card__thumb img,
.project-card__thumb video {
	display: block;
	width: 100.5%;
	height: 100.5%;
	object-fit: cover;
}

/* Survol : la vignette s'inverse. `filter` s'applique au conteneur, donc à
   l'image comme à la vidéo, sans distinguer les deux. Il s'anime aussi, ce
   qu'un mix-blend-mode ne fait pas : ici l'inversion monte progressivement
   de 0 à 1 au lieu de basculer d'un coup.

   `@media (hover: hover)` réserve l'effet aux pointeurs : sur un écran
   tactile, un survol simulé resterait « collé » après le tap. */
@media (hover: hover) {

	.project-card__thumb {
		/* Les trois réglages de l'inversion. Ils se combinent :
		   --hover-invert   1 = négatif complet · 0.85 = plus doux, moins
		                    contrasté · 0.5 = image délavée, presque grise
		   --hover-hue      0deg = teintes inversées (le ciel bleu vire
		                    orange) · 180deg = teintes d'origine conservées,
		                    seule la luminosité s'inverse — bien plus doux
		   --hover-saturate 1 = couleurs telles quelles · 0 = négatif noir et
		                    blanc · 1.5 = couleurs saturées */
		--hover-invert: 1;
		--hover-hue: 0deg;
		--hover-saturate: 3.4;

		transition: filter 0.3s ease;
	}

	.project-card:hover .project-card__thumb {
		filter:
			invert(var(--hover-invert)) hue-rotate(var(--hover-hue)) saturate(var(--hover-saturate));
	}
}

@media (prefers-reduced-motion: reduce) {
	.project-card__thumb {
		transition: none;
	}
}

/* --- Légende des projets ---------------------------------------------
   Titre puis sous-titre, sur deux lignes, en bas à gauche du média. Même
   balisage sur l'accueil et sur les vignettes d'archive (voir
   zonestudio_render_legende) : une seule règle pour les deux.

   Posée en ABSOLU par-dessus le média. Ses deux parents — .featured-link et
   .project-card__link — portent donc `position: relative`, et comme la
   légende sort du flux, le bas du lien coïncide avec le bas du média.

   `right` autant que `left` : le bloc reste aligné à gauche mais se voit
   borné à droite, sinon un titre long filerait hors du média.

   MÊME TRAITEMENT QUE LE MENU : blanc + `mix-blend-mode: difference`. Le
   mélange calcule |fond - texte| : sur du blanc on obtient l'inverse du fond,
   donc du sombre sur une image claire et du clair sur une image sombre. C'est
   ce qui rend la légende lisible quelle que soit la photo, sans avoir à
   deviner sa dominante ni à poser un dégradé derrière.
   La police, elle, reste celle du corps de texte — le menu est le seul à
   utiliser l'Albertus.

   Taille en vw et non en rem : la légende garde la même proportion que le
   média qui la porte, quelle que soit la largeur de l'écran. L'accueil, dont
   les médias occupent toute la page, a la sienne juste en dessous. */
.zs-legende {
	--legende-marge: 1.8rem;
	--legende-taille: 1rem;

	position: absolute;
	bottom: var(--legende-marge);
	left: var(--legende-marge);
	margin: 0;
	color: #fff;
	mix-blend-mode: difference;
	font-size: var(--legende-taille);
	font-weight: 400;
	line-height: 1.15;

	/* Compense l'amaigrissement dû au mélange, exactement comme sur le menu :
	   un élément en mix-blend-mode est composité à part, ce qui désactive le
	   lissage sous-pixel et fait paraître les glyphes plus fins qu'ils ne le
	   sont. Mets 0 pour voir la différence. */
	-webkit-text-stroke: 0.012em currentColor;
}

/* L'accueil : les médias font toute la page, la légende suit. */
.featured-link .zs-legende {
	--legende-marge: 3rem;
	--legende-taille: 1.4rem;
}

/* Les vignettes d'archive. Le mobile a sa propre taille, plus bas. */
.project-card .zs-legende {
	--legende-marge: 1.5rem;
	--legende-taille: 1.2rem;
}

/* Sur les VIGNETTES D'ARCHIVE seulement, la légende n'apparaît qu'au survol.
   Sur l'accueil elle reste affichée en permanence : les médias y sont pleine
   page, il n'y a rien à découvrir.

   La durée est calée sur celle de l'inversion de la vignette (0.3s), pour que
   les deux effets de survol se lisent comme un seul geste.

   `@media (hover: hover)` réserve tout ça aux vrais pointeurs. Sans cette
   garde, la légende serait INVISIBLE sur écran tactile — il n'y a pas de
   survol pour la révéler. Avec, la règle ne s'applique pas du tout et les
   titres restent affichés, ce qui est le bon comportement là-bas. */
/* L'état caché vit DANS le @media, et la double condition décide du reste :
   partout où elle n'est pas remplie, rien ne masque jamais les légendes des
   vignettes, elles restent affichées en permanence.

   · `hover: hover`   — sur tactile il n'y a pas de survol pour les révéler,
                        les masquer les rendrait introuvables ;
   · `min-width: 769px` — la garde pointeur ne teste QUE le pointeur : un
                        navigateur de bureau rétréci pour juger le rendu
                        mobile garde sa souris, et les légendes restaient
                        masquées. Sous le point de rupture mobile du site,
                        elles sont donc visibles même avec une souris.

   Le jeu masqué-puis-révélé est réservé à : vrai pointeur ET grand écran. */
@media (hover: hover) and (min-width: 769px) {

	.project-card .zs-legende {
		opacity: 0;
		transition: opacity 0.3s ease;
	}

	.project-card:hover .zs-legende {
		opacity: 1;
	}
}

/* Sur mobile, le PREMIER média de l'accueil remonte sa légende de 3rem :
   c'est celui qu'on voit en arrivant, et le logo du haut est posé dessus.
   Les vignettes d'archive, elles, gardent toutes la même légende. La marge
   d'un élément en absolu joue sur son `bottom` : 3rem de plus entre la
   légende et le bas du média. */
@media (max-width: 768px) {

	/* L'accueil : sur un petit écran, son 1.4rem (fait pour des médias
	   pleine page de bureau) est trop gros. Les marges ne changent pas. */
	.featured-link .zs-legende {
		--legende-taille: 1.2rem;
	}

	/* L'archive : 0.72rem, soit 1.2rem réduit de 40 %. La grille mobile est
	   à deux colonnes, 1.2rem pesait trop sur des vignettes étroites. */
	.project-card .zs-legende {
		--legende-taille: 0.72rem;
	}

	/* Et la même marge GAUCHE que l'archive (1.5rem, sa --legende-marge) :
	   les titres de l'accueil s'alignent sur ceux des vignettes. La marge
	   basse de l'accueil, elle, garde ses 3rem. */
	.featured-link .zs-legende {
		left: 1.5rem;
	}

	/* Le premier ET le dernier média de la page : même légende remontée. Le
	   dernier est celui sur lequel on s'arrête en bas de page, le menu du
	   bas posé dessus. */
	.home-featured > .featured-row:first-child .featured-item:first-child .zs-legende,
	.home-featured > .featured-row:last-child .featured-item:last-child .zs-legende {
		margin-bottom: 2rem;
	}
}

/* En bloc, donc l'un sous l'autre. C'est tout ce qui fait les deux lignes —
   un <br> aurait marché aussi, mais il n'aurait pas permis de styler les deux
   parties séparément. */
.zs-legende__titre,
.zs-legende__soustitre {
	display: block;
}

/* Sur petits écrans, le réglage par terme est ignoré : 2 colonnes puis 1,
   sinon 4 ou 5 colonnes deviennent illisibles. (Pas de calc() ni de min()
   ici : repeat() n'accepte pas de fonction mathématique en 1er argument.) */
@media (max-width: 1024px) {
	.projects-grid {
		grid-template-columns: repeat(2, 1fr);
	}
}

@media (max-width: 600px) {

	/* Pas de passage à une colonne : la grille reste à 2 quel que soit
	   l'écran — c'est le plancher posé par le point de rupture à 1024px
	   ci-dessus, qui s'applique donc jusqu'en bas. */

	/* Pas d'agrandissement du menu ici : il garde sa proportion du desktop
	   (--filter-size en vw), condition pour qu'il tienne sur UNE ligne en bas
	   de l'écran — voir le bloc mobile de .site-bar. */
}

/* --- Réservé au futur dégradé animé selon l'heure ------------------- */
/* --- Page About ------------------------------------------------------
   Gabarit template-about.php : fond dégradé grainé plein écran, contenu en
   bas à gauche. Les trois champs viennent du groupe ACF "About". */

/* Les teintes du dégradé, ENREGISTRÉES comme de vraies couleurs : c'est ce
   qui les rend animables. Une variable ordinaire change d'un coup ; une
   variable typée <color> s'interpole — app.js écrit de nouvelles valeurs
   (setProperty) et la transition déclarée plus bas fait le trajet.

   Les valeurs initiales sont celles de la maquette « début de soirée » : ce
   qu'on voit si le JS ne s'exécute pas. Les palettes des six moments de la
   journée sont plus bas, sur .about. */
@property --about-rouille {
	syntax: "<color>";
	inherits: true;
	initial-value: #bc5a1c;
}

@property --about-brun {
	syntax: "<color>";
	inherits: true;
	initial-value: #532a10;
}

@property --about-creme {
	syntax: "<color>";
	inherits: true;
	initial-value: #f4e7c3;
}

@property --about-jaune {
	syntax: "<color>";
	inherits: true;
	initial-value: #eed49b;
}

/* La traînée de lumière diagonale : une couleur AVEC alpha, c'est lui qui
   règle sa force. */
@property --about-lumiere {
	syntax: "<color>";
	inherits: true;
	initial-value: rgba(253, 247, 226, 0.6);
}

/* Le texte : sombre sur les palettes claires, clair sur les sombres. Choisi
   par app.js selon la luminance du crème, là où le texte est posé. */
@property --about-texte {
	syntax: "<color>";
	inherits: true;
	initial-value: #1c2a54;
}

.about {
	/* Le contenu se cale au bas de l'écran ; sur mobile, au-dessus du menu,
	   passé en bas lui aussi (voir .site-bar). */
	display: flex;
	flex-direction: column;
	justify-content: flex-end;
	min-height: 100svh;
	color: var(--about-texte);

	/* --- LES SIX MOMENTS DE LA JOURNÉE -----------------------------------
	   Le fond suit l'heure LOCALE du visiteur. Chaque moment a sa palette ;
	   entre deux moments, app.js mélange les deux en continu (en OKLab, pour
	   ne pas passer par des gris boueux entre un orange et un violet) et
	   remet à jour chaque minute. Il n'y a donc jamais de saut : à 19h30, on
	   est à mi-chemin entre « soir » et « crépuscule ».

	   --about-heures : l'heure où chaque moment est atteint EN PLEIN, dans
	   l'ordre croissant, en décimal (21.5 = 21h30). Le dernier rejoint le
	   premier en passant par minuit. Les noms de --about-moments servent de
	   préfixe aux couleurs : --about-<moment>-<rôle>.

	   Les cinq rôles, du plus lointain au plus proche :
	     creme    la nappe de base, côté bas-gauche — c'est sur elle qu'est
	              posé le texte ;
	     jaune    la même nappe, côté bas-droit ;
	     rouille  la grande tache qui descend du haut de l'écran ;
	     brun     son flanc droit, plus foncé ;
	     lumiere  la traînée diagonale ; son alpha en règle la force.

	   Pour voir une heure précise sans attendre : ?heure=21.5 dans l'URL. */
	--about-heures: 0 5 9 13 18 21;
	--about-moments: nuit aube matin midi soir crepuscule;

	/* Nuit — bleu profond, la tache du haut plus sombre que l'horizon. */
	--about-nuit-creme: #1f2650;
	--about-nuit-jaune: #2f2a5e;
	--about-nuit-rouille: #101538;
	--about-nuit-brun: #070a22;
	--about-nuit-lumiere: rgba(170, 180, 255, 0.16);

	/* Aube — rose pâle et pêche, une nuée lilas encore froide en haut. */
	--about-aube-creme: #f3e4e4;
	--about-aube-jaune: #f6cfae;
	--about-aube-rouille: #a889c4;
	--about-aube-brun: #5b4a8f;
	--about-aube-lumiere: rgba(255, 238, 225, 0.55);

	/* Matin — blanc chaud, or léger. */
	--about-matin-creme: #f8f4e8;
	--about-matin-jaune: #f5e6b9;
	--about-matin-rouille: #f2c377;
	--about-matin-brun: #d08d3a;
	--about-matin-lumiere: rgba(255, 255, 250, 0.6);

	/* Midi — le plus saturé : jaune franc, orange vif. */
	--about-midi-creme: #fbf3d3;
	--about-midi-jaune: #f7d66c;
	--about-midi-rouille: #f19340;
	--about-midi-brun: #c25a1b;
	--about-midi-lumiere: rgba(255, 250, 225, 0.7);

	/* Soir — la maquette d'origine : rouille et brun sur crème. */
	--about-soir-creme: #f4e7c3;
	--about-soir-jaune: #eed49b;
	--about-soir-rouille: #bc5a1c;
	--about-soir-brun: #532a10;
	--about-soir-lumiere: rgba(253, 247, 226, 0.6);

	/* Crépuscule — rose poussiéreux, mauve, la tache vire au violet. */
	--about-crepuscule-creme: #d9a4a6;
	--about-crepuscule-jaune: #b47990;
	--about-crepuscule-rouille: #4d3373;
	--about-crepuscule-brun: #1e1642;
	--about-crepuscule-lumiere: rgba(255, 205, 190, 0.35);

	/* Le texte n'est pas mélangé : il BASCULE entre ces deux valeurs quand
	   le crème passe sous le seuil de luminance fixé dans app.js. Un mélange
	   donnerait un gris moyen sur un fond moyen, illisible, au milieu du
	   trajet. */
	--about-texte-sombre: #1c2a54;
	--about-texte-clair: #f3e9dc;
}

/* La transition ne s'active qu'une fois la première palette posée par
   app.js (data-anime) : sans ça, chaque arrivée sur la page partirait des
   valeurs initiales « soir » pour fondre en 2 s vers l'heure réelle. */
.about[data-anime="oui"] {
	transition:
		--about-rouille 2s ease,
		--about-brun 2s ease,
		--about-creme 2s ease,
		--about-jaune 2s ease,
		--about-lumiere 2s ease,
		--about-texte 2s ease;
}

/* Le fond : la NAPPE de base sur l'élément, puis trois calques par-dessus :
   la tache rouille (::before), son cœur brun (::after) et la transversale
   de lumière (.about-fond__lumiere, un enfant, pour passer au-dessus des
   deux autres). Du plus lointain au plus proche :
   1. la nappe crème → jaune qui porte tout ;
   2. la tache rouille, large, qui descend du haut de l'écran ;
   3. le brun, flanc droit de la tache ;
   4. la traînée de lumière diagonale.

   LE GRAIN — un dégradé « dissous », d'après le pen Dithered CSS Gradients
   de David J Aldred. Plus de bruit posé PAR-DESSUS : chaque tache porte un
   MASQUE fait de deux couches, une tuile de bruit (pixels noirs d'alpha
   aléatoire, 64 × 64, répétée) et son propre dégradé radial, opaque au
   cœur, transparent au bord. Les deux s'additionnent : au cœur la tache est
   pleine ; sur son bord fondu, seuls des pixels épars passent — le bord se
   DISSOUT en grain au lieu de se fondre. Le plein reste net, le grain ne vit
   que dans la transition. C'est ce qui donne l'aspect imprimé.

   Tout ce qui dessine est en variables, réglées avec
   _notes/reglage-degrade.html : positions et tailles de repos, plateau
   (jusqu'où la couleur est pleine) et fondu (où elle a disparu), lumière,
   nappe, taille du grain. Les couleurs viennent de .about (six moments).

   Couche fixe dédiée, derrière le contenu (z-index -1). Les couleurs sont
   les variables héritées de .about, où app.js les écrit ; la transition qui
   lisse leurs changements est déclarée là-bas aussi. */
.about-fond {
	/* --- TACHE ROUILLE : position de repos, taille, plateau, fondu -------- */
	--tache-x: 44%;
	--tache-y: -23%;
	--tache-w: 68%;
	--tache-h: 101%;
	--tache-plateau: 24%;
	--tache-fondu: 66%;

	/* --- CŒUR BRUN : le flanc droit de la tache, il garde cet écart ------ */
	--coeur-x: 56%;
	--coeur-y: -20%;
	--coeur-w: 28%;
	--coeur-h: 48%;
	--coeur-plateau: 20%;
	--coeur-fondu: 77%;

	/* --- LUMIÈRE : une bande qui COURT de haut-droite vers bas-gauche (axe
	   perpendiculaire, 135deg). Elle PART du coin haut droit — qui est donc
	   CLAIR, pas brun. Sur cet axe, le coin tombe à ~50% : la bande centrée
	   à 52% le couvre pile. */
	--lumiere-angle: 135deg;
	--lumiere-debut: 38%;
	--lumiere-centre: 52%;
	--lumiere-fin: 66%;

	/* --- NAPPE : le bas fonce vers la droite — bas-gauche crème pâle,
	   bas-droit jaune plus soutenu. */
	--nappe-angle: 156deg;
	--nappe-creme: 25%;
	--nappe-jaune: 85%;

	/* --- GRAIN : la taille d'un grain, en pixels CSS. 1px = fin comme le
	   pen, 2px = visible, 3px = gros sel. La tuile de bruit fait 64 grains
	   de côté ; --grain-bord élargit la zone dissoute au-delà du fondu de la
	   couleur (le masque va plus loin que la couleur, sinon la tache paraît
	   rognée). */
	--grain-taille: 1px;
	--grain-bord: 10%;
	--grain-tuile: calc(64 * var(--grain-taille));
	--grain-bruit: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAMAAACdt4HsAAAAwFBMVEUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALOy8QAAAAQHRSTlMAAwYICw4RFBcZHB8iJSgqLTAzNjk7PkFER0lMT1JVWFpdYGNmaWtucXR3eXx/goWIio2Qk5aZm56hpKeqrK+ytdsZgwAADFlJREFUeNoVlwWi40AMQydMDTTMzNAw0/1vtX9vMGPJejIgVotHLtqgn50gyxfb2WsjJUdgHa+U8y+r2Dsb5ThaOMtJSqJCWetMMbr+UxhzxkgL+CxftYrVqhuaXN23JHsPf3a27755fPuMahI6cjCWTNC408aM01BX+np6oo6xFE96CJyU3tOftyJbVKhtnH3JXiC33sKeHFmCBOmUsUPCLDQRNxqXoZLHgQx7W9FojG5LHwPM2468teAk3MRwoASbnvnv3lupqUCeiXl977hs/L3oUxfwcPtBd96MFOyLYWkUHRyCDTeUlZI+zAmWWWo5XcytTS2uzNiM9HaxkHKoiFITSya/CXLTi71s8q/LDUEuS0iUQDwaq5haeSC1Bj84bDEIBdyJONs1OD61qXNZIz1EwSfGOUtSkJ/45Xh95MnGqRL/JwNL04qCiVEvVgmHaBdsktv4XOZUe5yiIuC0vtZmSRfCOygp9nmRpOqVRVy43/jtpAUgfVPZbqd8tEj9LK8PT1Kdo3G9ZzNeAV/cfTJk/Htq1oYjx6EK9YJej9Fp/tVVDZILgOZgwi2rgrYPiZeiCLRDNFqU+Fa8Jf9m2meREFszkrYadLOtGsFpjVlg/9hi861BBfhIwTnyxVItqeLXZjhzzuttJyphrqVzuRTWm8oXVQXQgfIH5YKukuiDlkoiK8T7CYGerOWzdQx9hy7YgTMkdmK18RhWO+GjcIl1QORSxSUAW+WgRStSds68RyVsE45iDcEATxgabhaoU/A05Bg78aZmbPJtIXS9ZD1meGms7OvRnrzB9vMjP71j1iBqbAJLIAZYWRP8bms9CviI+0Rrinao/3QAAGOaOYEpMV2JYft2wkeSaGt5Iyo9lt9A88pPGEIdvLnzq/ebzaQj6rSfT1Xq9XPp8xwsaOB7SbCPuiZmfGqarWck4S1iiGA9Wn5jD05kBggdNAhb5YocW067neumzPHszkSpYHJxuClF2E0w4Vw+O/64M2E0Vi6ZXkQAF4V+HQDqtLNGvhErcn4BbRGo6+NVnwLBZOobHI5f6zEXjLSQHFf2J7e7I3WewYOHsdDk5hkwUZVVIpjXFrYtdVdxP5UpWtjaXn95cG5hknDr2OGHiXTcbtQ8bQ9sF+PJLhg9S5AzOPqHgfSI+8BiFmTuR8rEtWAfHb55P9B6Us4P1D+CdqlAusHcshzMicmULpdO9e1GBCgm2HdXmrdrQydFQNp5HN1YNgJ+VWvDGrfql3yKGaVty2ASVMsCf/tqbHdKiDJM++fPuepE7uf+jruSdbVKozZ/no6JoRY1trI4Ppc56EpfgBdB+tKN9aLfGmXD78zZBnUHUNmJ8GserbfJUFKRv235lpFg2THuW/E9mae+TXQbzePCWIWsuWvPGyroKCw1lJcjATbvfO/1RTw8jbiJMJJpCvJmMy6mYvrbOeIiGX2QWC9qLMb67YswsuFtdXR9Mw2bD0DNieyh5sngGaYFCgkCoT1xRPxO2G+l5ojPNR8LWUGuYgba6MWM1C1yzwEuFqKEcQycBe1VB4Xmf56dkYyBOx11RHLN2kse70heMIAof7/uj1agzWwxzEZSTKfolE6ZSoMB/GC+ncKdGP+xUtVWUOUJsIytVEZqXHfIcWHLfC4ZwwfdL6iX8InTnmdiP/bq31S9A9rcxSwMxpIb6oK4SbAqXXo+2MtgBlf7kPOaf0u+rNExjp0nA69CTJEojWMfKMxoGeDsF6h1yqufxMk8NqwH/qj7xnnF10OTxr9SyfgpcYVdYV39hRdSlFvW2FO1xtCrKhp4njjnkKIwYCW4NJKfhmfTZ1U4EsUukHDI2PLgjPb6LMC/hGKD75kAmdBRlpG//WGDAEff8jilNR8TU8gz+WmPkAVQFnEICfXsHyd6Tmf2qt0RieCN5O3vQTXj3mWhw40FsK9odT0gp+b1u9RZx1a6bv+F+7TNnnNVn5K70+oiXgS8Ce+0INRdmptWoYu+pFOjGijCg/416NKtn4M/uzj6o65R+rl9XXjbkabUaEPRR/NEIFpM6KyY9mahPUzzUvLIJz1AUueYX7NZDGnvoRhWEhookFKi7o6ih8wfRn7yeLF3pNF6eWvhNmWK88uVbRE8aQmB/qgw9grUIJQpRP0bKIZMu3mYjrzBaTgVcrOoemCT94bbnz22OUddyNBJ92QnuBH7AiQyXjnPS6VvuPTYpDy4U3ZGlufBdwlBBYXI5gg3uCWkOIaE83mwO2jiG3VuLPytDpBCjWJba3Ah2gNm7n3qilK5VBJabHASKxemFhn511aL+OdjVm5at8l5FzZr5yq/DweQ3odVI5dONDsKHI7dk7ia7fydITaUhmLdxq83F9DSJ8EpUmHFvqGPjuTdtMWkFeSCbGQ1xh6x16T0WPCETB+pVnGNekvI+jOniL4MV39yQfB2pO5YQh5AmpuTEW/R5Vg84IcMZNCaYRCY6qVZSQA9UmxdXzAt0K+SJyGkANt4hZDeApLavf3Cksb9RA5nmTY4RJAlTdpa2KTBBK9BmSc6fzaYj+ktELpVraF+gzSioHNx+MVDSelci0JTmzQh9fSOPAEojaXcLP7hP61lxSRR5hzbrRA5/RwK+NXwu0ePgU0/0RjYCA0ffXEjalkimiuuExkejOPdM6Kbh3WL6CBZoLWYUkLk7b9FZRW4PKRrzjA5E09VJp2xldKH75BtqqwMfAN1BWSmEvZ9/fHKIylDLoLTLInE2oWqG4mVBql7RG9HTyIff3pTWdv1C7ukckJG91cndAxoEzEGR58RgeZFooP3AW+gCYYj9sVEk2+93JgtUc2r0aAITz+mGmDD8Y+ylZLOousBHyRtbQfQ77HCSvw1fInuQN43W8D2PqZiV8y5DmJDSRTyo5xmrfnZ0ulz8sFciHURwKx8khcBJKh3Krl/pOdK8CE5MhIGQuITDYuDfZgEYtoTIVPYbOi+PWJ0v9NcYYMqbeCmYj0kGJlFYUYU9vk/KsWRro64QsTC/PyJE395hEdEtzZEucqCYjcTIvj4Avqrax6EICGJv36ogg87BR9yvgu/LyHK98J1VV9rDpQkOMvlPp45MOG5GoKhvUahaiGytU4LYK74q780olIRTXlW9TikXm1Jmdc27qSTItmHJRFdpbFwQ5vWR20jb5xyNvjsqn3YLTDZBXV/6suk/jZZ8/xmlOlsX7t3MonZ02brUG9Eadiwi2+bVym3F/r+w286+TinqBZAFuG/B8jrGZ9kz37P+GoC+3QWlP48cid68A3ygtVvha1LP4HtMP6WkMAhnA//wi6mAEL094S8q5cMJyLO10LCNWe2sacK9EDO2ocofzrbpBslwDcbxmuIctYaIbYpQ8iLX6B6j5ZWUOz5HWXaCGs/MIX0+74oNByV03Vob1DbFmHfV7DkXQ1NgEBwOiD72xGkp7BgTG0v3uTxwauyrQO/NN5AfF580itezPjuciOKQrAwmsgBmY1Moe49SQTPUKQGOyoPsNsyGGNrad/lkAOzNdbcoJ4HQfDrzGuRj4jU1zFbhnefkhuj6LmkOy5ZyIidPeD5A0Z3KrKjspTNlIde6aTh+6fVh5uIMNXMFxvSQq5hAnEgmbxElAQDctebgsHPOPIu/scbz+0Ks7rfkUEZbmm7V/nCKcaOz8Rly9DSdQ+vH9nckmWG1o51D4W79XkQsum2/sYIUFk+lxal0Tq7ksY94oMCsw7XovCM36+MVbir1Y9hIs9ep1DI34tZReVlzZ/kpYyWA3ZQ10YSka+Fq07kFiH+CUvrkEr3fd+ldrjTi5ZqUbF63J2oSv4OujrSN+M0NDjAHQJsLIXOhGb/+qE5IPiDJW0w2ZdEkXmU6KthiMlJL569i/Rp8M+ubUhd4wGT4831CYMGzOWlx8pqQENv9gXWrbIgioK6fHuD1I4f6frnXxUbK24obezT4b83HOKdtk1q1tbzD67sfTmW8rMLbQqp/I/wTnuj9URblpiDziGbme0gtxKoGyf1/Csi19h1fsH1wP+7MHYcKIOHrCu0S2mKR159qLHWr54oN8RzfMjOzurknNHQaY7xeArYtzsxXL84GaeTHIvCBRAaT9G2uPEf5pWfiWeZS00Xtucemm7xnHwYlpWBJCU1smjZiMe/5nCoRps2+p6twAWmUSG4p3gIfXoDZdB4Etc+fj8iiCzsE41iStjz9rfBupttp1p+FsR+GlBidxuS2Qz9Axp8+f9hDtxjAAAAAElFTkSuQmCC");

	/* --- LE LIQUIDE (WebGL, assets/js/about-liquide.js). Par-dessus les
	   calques CSS, qui ne servent plus que de REPLI : sans WebGL, sous
	   prefers-reduced-motion, et pendant le démarrage. Des volutes de bruit
	   qui coulent seules, et un pointeur qui pousse la matière comme
	   l'outil Fluidité de Photoshop. Réglé dans _notes/reglage-journee.html.

	   Les volutes :
	     --liquide-vitesse    multiplicateur du temps : 0.05 = très lent,
	                          0.2 = vif, 0 = figé
	     --liquide-echelle    taille des formes : 1 = larges nappes,
	                          2.5 = plus petites, plus nombreuses
	     --liquide-passes     combien de fois le bruit se déforme lui-même :
	                          0 = taches rondes, 3 = volutes, 6 = filaments
	     --liquide-intensite  force de chaque passe : 0.1 = souple,
	                          0.3 = très tourmenté
	   Le pointeur :
	     --liquide-pression   force du pinceau : 0 = aucune réaction
	     --liquide-pinceau    son rayon, en part de la largeur d'écran
	     --liquide-relache    en combien de SECONDES la déformation revient
	                          (0 = jamais, elle reste)
	     --liquide-portee     déplacement maximal accumulable, en part
	                          d'écran — le garde-fou quand on insiste
	   Le grain :
	     --liquide-grain      force du grain dissous entre les couleurs :
	                          0 = passages lisses, 0.1 = grain net
	   Sa taille est --grain-taille, commune avec le repli CSS.

	   Valeurs réglées dans _notes/test-liquide-cosinus.html. Les précédentes,
	   celles du mode « palettes » : vitesse 0.05, échelle 1, passes 3,
	   intensité 0.1, pression 0.9, pinceau 0.14, relâche 0.5, portée 0.2. */
	--liquide-vitesse: 0.1;
	--liquide-echelle: 0.8;
	--liquide-passes: 1;
	--liquide-intensite: 0.36;
	--liquide-pression: 0.68;
	--liquide-pinceau: 0.22;
	--liquide-relache: 12.5;
	--liquide-portee: 0.15;
	--liquide-grain: 0;

	/* LES COULEURS DU LIQUIDE, deux modes (le « Mode » de l'outil) :
	     cosinus    le dégradé cosinus ci-dessous, UNE gamme, la même à toute
	                heure du jour ;
	     palettes   les six moments de la journée, plus haut dans .about.
	   En « cosinus », les six moments ne colorent plus que le REPLI CSS
	   (sans WebGL, sous prefers-reduced-motion) : ils restent en place, il
	   suffit de remettre `palettes` pour y revenir.

	   Le dégradé : couleur = a + b·cos(2π(c·t + d)), trois nombres par
	   vecteur, rouge vert bleu. a = base, b = amplitude, c = fréquence,
	   d = phase. Ici le préréglage « orange-blue » de dev.thi.ng/gradients :
	   orange, bleu nuit, puis cyan pâle. */
	--liquide-couleurs: cosinus;
	--liquide-cos-a: 0.5 0.5 0.5;
	--liquide-cos-b: 0.5 0.5 0.5;
	--liquide-cos-c: 0.8 0.8 0.5;
	--liquide-cos-d: 0 0.2 0.5;

	/* --- MOUVEMENT DU REPLI CSS : la LAMPE À LAVE. Le fond bouge seul, sans
	   jamais s'arrêter — app.js ajoute aux valeurs de repos des décalages
	   (--tache-d*, --coeur-d*, --lumiere-d) faits de sinus lents aux
	   périodes sans rapport entre elles : les taches dérivent, s'étirent,
	   se contractent, et la figure ne se répète pas. Aucun lien avec la
	   souris. Deux réglages :
	     --about-lave-amplitude   1 = réglage de base, 2 = deux fois plus
	                              de course, 0 = fond immobile ;
	     --about-lave-vitesse     1 = un cycle lent d'une trentaine de
	                              secondes, 2 = deux fois plus vif.
	   Sous prefers-reduced-motion, app.js ne lance rien : repos. */
	--about-lave-amplitude: 1;
	--about-lave-vitesse: 1;

	/* Les décalages courants, écrits par app.js. */
	--tache-dx: 0%;
	--tache-dy: 0%;
	--tache-dw: 1;
	--tache-dh: 1;
	--coeur-dx: 0%;
	--coeur-dy: 0%;
	--coeur-dw: 1;
	--coeur-dh: 1;
	--lumiere-d: 0%;

	/* TAILLE D'ÉCRAN EXPLICITE, et non `inset: 0`. Pendant une transition
	   Swup, .transition-fade porte un `transform` : il devient alors le bloc
	   conteneur de ses descendants fixes, et un `inset: 0` prenait la taille
	   de la PAGE ENTIÈRE (pied de page compris) au lieu de l'écran. Le fond
	   changeait de proportions le temps de la transition — les formes
	   sautaient à l'entrée comme à la sortie. En vw/vh, la boîte reste celle
	   de l'écran quoi qu'il arrive. `lvh` : le plus grand viewport mobile,
	   pour couvrir aussi quand la barre d'URL se replie. Le décalage de
	   défilement, lui, est posé par app.js au départ d'une visite. */
	position: fixed;
	top: 0;
	left: 0;
	width: 100vw;
	height: 100vh;
	height: 100lvh;
	z-index: -1;
	background: linear-gradient(var(--nappe-angle), var(--about-creme) var(--nappe-creme), var(--about-jaune) var(--nappe-jaune));
}

/* Les deux taches : même géométrie pour la couleur et pour son masque. Le
   masque va --grain-bord plus loin que la couleur, pour que la dissolution
   couvre tout le fondu au lieu de le rogner. `image-rendering: pixelated`
   garde les grains carrés quand la tuile est agrandie. */
.about-fond::before,
.about-fond::after {
	content: "";
	position: absolute;
	inset: 0;
	image-rendering: pixelated;
	-webkit-mask-repeat: repeat, no-repeat;
	mask-repeat: repeat, no-repeat;
}

.about-fond::before {
	z-index: 1;
	background: radial-gradient(
		calc(var(--tache-w) * var(--tache-dw)) calc(var(--tache-h) * var(--tache-dh))
		at calc(var(--tache-x) + var(--tache-dx)) calc(var(--tache-y) + var(--tache-dy)),
		var(--about-rouille) 0%, var(--about-rouille) var(--tache-plateau), transparent var(--tache-fondu));
	-webkit-mask-image: var(--grain-bruit), radial-gradient(
		calc(var(--tache-w) * var(--tache-dw)) calc(var(--tache-h) * var(--tache-dh))
		at calc(var(--tache-x) + var(--tache-dx)) calc(var(--tache-y) + var(--tache-dy)),
		#000 0%, #000 var(--tache-plateau), transparent calc(var(--tache-fondu) + var(--grain-bord)));
	mask-image: var(--grain-bruit), radial-gradient(
		calc(var(--tache-w) * var(--tache-dw)) calc(var(--tache-h) * var(--tache-dh))
		at calc(var(--tache-x) + var(--tache-dx)) calc(var(--tache-y) + var(--tache-dy)),
		#000 0%, #000 var(--tache-plateau), transparent calc(var(--tache-fondu) + var(--grain-bord)));
	-webkit-mask-size: var(--grain-tuile) var(--grain-tuile), 100% 100%;
	mask-size: var(--grain-tuile) var(--grain-tuile), 100% 100%;
}

.about-fond::after {
	z-index: 2;
	background: radial-gradient(
		calc(var(--coeur-w) * var(--coeur-dw)) calc(var(--coeur-h) * var(--coeur-dh))
		at calc(var(--coeur-x) + var(--coeur-dx)) calc(var(--coeur-y) + var(--coeur-dy)),
		var(--about-brun) 0%, var(--about-brun) var(--coeur-plateau), transparent var(--coeur-fondu));
	-webkit-mask-image: var(--grain-bruit), radial-gradient(
		calc(var(--coeur-w) * var(--coeur-dw)) calc(var(--coeur-h) * var(--coeur-dh))
		at calc(var(--coeur-x) + var(--coeur-dx)) calc(var(--coeur-y) + var(--coeur-dy)),
		#000 0%, #000 var(--coeur-plateau), transparent calc(var(--coeur-fondu) + var(--grain-bord)));
	mask-image: var(--grain-bruit), radial-gradient(
		calc(var(--coeur-w) * var(--coeur-dw)) calc(var(--coeur-h) * var(--coeur-dh))
		at calc(var(--coeur-x) + var(--coeur-dx)) calc(var(--coeur-y) + var(--coeur-dy)),
		#000 0%, #000 var(--coeur-plateau), transparent calc(var(--coeur-fondu) + var(--grain-bord)));
	-webkit-mask-size: var(--grain-tuile) var(--grain-tuile), 100% 100%;
	mask-size: var(--grain-tuile) var(--grain-tuile), 100% 100%;
}

/* Le canvas du liquide, au-dessus de tous les calques CSS. Invisible tant
   que le shader n'a pas rendu sa première image (est-pret, posée par
   about-liquide.js), puis affiché D'UN COUP : un fondu depuis le dégradé
   CSS, qui ne lui ressemble pas, se lisait comme un saut de forme à
   l'arrivée sur la page. La première image tombe une ou deux images après
   la création, sous le bandeau de transition. */
.about-fond__liquide {
	position: absolute;
	inset: 0;
	z-index: 4;
	width: 100%;
	height: 100%;
	opacity: 0;
}

.about-fond__liquide.est-pret {
	opacity: 1;
}

/* La transversale de lumière, au-dessus des taches. Sans grain : c'est un
   reflet, pas une matière. Un enfant et non un pseudo-élément, il n'en
   restait plus. */
.about-fond__lumiere {
	position: absolute;
	inset: 0;
	z-index: 3;
	background: linear-gradient(var(--lumiere-angle),
		transparent calc(var(--lumiere-debut) + var(--lumiere-d)),
		var(--about-lumiere) calc(var(--lumiere-centre) + var(--lumiere-d)),
		transparent calc(var(--lumiere-fin) + var(--lumiere-d)));
}

.about__contenu {
	padding: 0 3rem 3rem;
	font-weight: 600;
	max-width: 870px;

	/* Même traitement que le menu, les légendes et le texte des projets :
	   blanc mélangé en `difference`, donc l'inverse du fond. Le texte prend
	   la couleur opposée à ce que le liquide fait passer sous lui, lettre
	   par lettre, et reste lisible à toute heure de la journée.
	   Le blanc remplace ici --about-texte, que .about continue de porter :
	   retirer ces trois lignes rend au texte sa couleur unie, choisie par
	   app.js selon la palette. Les liens du contact héritent du blanc.
	   Le contour compense l'amaigrissement dû au mélange, comme ailleurs. */
	color: #fff;
	mix-blend-mode: difference;
	-webkit-text-stroke: 0.012em currentColor;

	/* Sa PROPRE couche, comme la barre de menu. Le fond est un canvas WebGL
	   dans une couche fixe, composé par la carte graphique : pour s'y
	   mélanger à coup sûr, le texte doit être composé au même niveau que
	   lui, et non peint dans la couche de la page. Sans effet sur la mise en
	   page : le bloc n'a aucun descendant en `position: fixed`. */
	transform: translateZ(0);
	backface-visibility: hidden;
}

/* SUR MOBILE : le bloc est EN HAUT, sous le logo. La page ne défile pas, et
   le doigt agit sur le LIQUIDE : faire glisser le doigt n'importe où, texte
   compris, remue le fond comme la souris sur ordinateur.

   · `touch-action: none` est ce qui le permet. Par défaut le navigateur
     réserve le glissement du doigt au défilement et au zoom : il annule le
     geste dès qu'il bouge, et le liquide ne reçoit qu'un premier contact.
     Avec `none`, il nous laisse tout le geste. Le texte n'est PAS passé en
     `pointer-events: none` : les liens du contact (email, Instagram)
     doivent rester touchables, et un simple appui reste un clic.
   · Pas de sélection de texte : un appui long pendant qu'on remue le fond
     ouvrirait la loupe et le menu de copie. Les liens gardent la leur.
   · Marges latérales : 1.5rem, le retrait du reste du site en mobile
     (--filter-retrait de la barre, padding du logo).
   · Marge haute : le bas du logo — son retrait (1.5rem + encoche), sa
     hauteur (il fait toute la largeur moins 3rem, dans les proportions du
     fichier, 7667 × 827) — plus 3rem d'air.
   · « Selected clients » est masqué en mobile : sans défilement, le bloc
     doit tenir dans l'écran. Un texte plus long que l'écran serait coupé
     par le menu du bas — c'est le prix de l'absence de défilement. */
@media (max-width: 768px) {

	.about {
		justify-content: flex-start;
		height: 100svh;
		min-height: 0;
		overflow: hidden;
		touch-action: none;
	}

	.about__contenu {
		margin-top: calc(4.5rem + env(safe-area-inset-top, 0px) + (100vw - 3rem) * 827 / 7667);
		padding: 0 1.5rem;
		-webkit-user-select: none;
		user-select: none;
		-webkit-touch-callout: none;
	}

	.about__contenu a {
		-webkit-user-select: auto;
		user-select: auto;
		-webkit-touch-callout: default;
	}

	.about__clients {
		display: none;
	}
}

.about__titre {
	margin: 0 0 1.5rem;
	font-family: var(--font-display);
	font-weight: 300;
	font-size: 1.5rem;
	text-transform: uppercase;
}

.about__texte {
	font-size: 1.45rem;
	line-height: 1.25;
}

.about__texte p {
	margin: 0 0 1em;
}

/* Le petit intertitre "Selected clients" de la maquette : minuscule, gras. */
.about__label {
	margin: 2.5rem 0 0.75rem;
	font-size: 0.65rem;
	font-weight: 600;
	letter-spacing: 0.04em;
	text-transform: uppercase;
}

.about__clients p {
	margin: 0;
	font-size: 0.85rem;
	line-height: 1.55;
}

.about__clients {
	max-width: 825px;
}
	


/* LES SÉPARATEURS de la liste des clients. Chaque nom porte son « / » À SA
   GAUCHE, dans son propre retrait ; la liste est décalée d'autant vers la
   gauche, et le cadre rogne ce qui dépasse. Le « / » du premier nom de
   chaque ligne tombe donc hors du cadre : toute ligne commence par un nom,
   aucune ne finit par « / », où que le navigateur coupe. Pas de JS, et ça
   suit la largeur de l'écran.

   --sep est la place du séparateur : un « / » et son air de chaque côté. */
.about__clients-cadre {
	overflow: hidden;
}

/* Sélecteur doublé : `.about__clients p`, plus haut, pose `margin: 0` avec
   plus de poids qu'une classe seule et annulerait le décalage. */
.about__clients .about__clients-liste {
	--sep: 1.1em;

	display: flex;
	flex-wrap: wrap;
	margin-left: calc(var(--sep) * -1);
	/* Le max-width de la règle générale vaut pour la liste décalée : on lui
	   rend la largeur du décalage, sinon elle serait plus étroite d'autant. */
	max-width: calc(850px + var(--sep));
}

.about__client {
	position: relative;
	padding-left: var(--sep);
}

.about__client::before {
	content: "/";
	position: absolute;
	left: 0;
	width: var(--sep);
	text-align: center;
}

/* Le saut de ligne voulu dans l'admin. */
.about__clients-saut {
	flex-basis: 100%;
	height: 0;
}

.about__contact {
	margin-top: 2.5rem;
	font-size: 1.15rem;
	line-height: 1.5;
}

.about__contact a {
	color: inherit;
	text-decoration: none;
}