<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>Présent Composé design</title>
	<atom:link href="https://presentcomposedesign.fr/feed/" rel="self" type="application/rss+xml" />
	<link>https://presentcomposedesign.fr/</link>
	<description>Direction Artistique, concepts &#38; innovations, Design 2D/3D/ia/AR/VR</description>
	<lastBuildDate>Wed, 07 Oct 2026 10:40:17 +0000</lastBuildDate>
	<language>fr-FR</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.1.4</generator>

<image>
	<url>https://presentcomposedesign.fr/wp-content/uploads/2025/07/cropped-logo_PCd_2025-512-32x32.png</url>
	<title>Présent Composé design</title>
	<link>https://presentcomposedesign.fr/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>StereoptiX</title>
		<link>https://presentcomposedesign.fr/stereoptix-infos/</link>
		
		<dc:creator><![CDATA[admin]]></dc:creator>
		<pubDate>Tue, 06 Oct 2026 21:21:03 +0000</pubDate>
				<category><![CDATA[[VR]]]></category>
		<category><![CDATA[Applications]]></category>
		<category><![CDATA[Design graphique]]></category>
		<guid isPermaLink="false">https://presentcomposedesign.fr/?p=36980</guid>

					<description><![CDATA[<p>Étude de cas &#183; Application web sur mesure StereoptiX Une application web qui transforme chaque photo en relief : anaglyphe rouge-cyan pour les lunettes 3D, côte à côte, ou wiggle, ce GIF animé qui se voit en relief sans rien sur le nez. La profondeur est calculée par une intelligence artificielle qui tourne dans le navigateur : aucune photo n&#8217;est envoyée. Imaginée, nommée, dessinée, développée et mise en ligne à Toulouse. Idéation Naming Identité graphique UX / UI IA embarquée WebGL Paiement en ligne Hébergement Voir l&#8217;application Demander un rendez-vous Le cœur de l&#8217;application Une photo, et le relief apparaît Un modèle d&#8217;intelligence artificielle estime, pour chaque pixel, ce qui est proche et ce qui est loin. L&#8217;application en tire deux points de vue, celui de l&#8217;œil gauche et celui de l&#8217;œil droit, puis les recompose selon le type de relief choisi. Essayez ci-dessous : la scène est calculée en direct dans cette page, avec les réglages de l&#8217;application. Calculé en direct StereoptiX Wiggle Rouge-cyan Dubois Côte à côte croisé Carte de profondeur Sans lunettes. Glissez sur l&#8217;image pour tourner autour de la scène. Pour la démonstration, la scène est dessinée en plans dont la profondeur est connue. Dans l&#8217;application, cette profondeur est déduite de votre photo : le relief d&#8217;une seule image est une reconstruction, pas une mesure. 1. CapterUne photo, deux photos décalées d&#8217;un pas, ou une vidéo de cinq secondes. Prises à la caméra ou importées. 2. Estimer la profondeurLe modèle Depth Anything V2, exécuté sur l&#8217;appareil, par sa carte graphique quand elle le permet. Aucun serveur. 3. RecomposerDeux vues calculées en WebGL2, puis l&#8217;un des dix types de relief, à enregistrer ou partager. Points clés Ce que j&#8217;ai réalisé Idéation, positionnement et naming de l&#8217;application Création du logo, de l&#8217;icône, de la signature des images et de l&#8217;identité graphique, en thème clair et sombre Conception de l&#8217;atelier et des parcours, pensés d&#8217;abord pour le téléphone Intelligence artificielle de profondeur exécutée dans le navigateur, et rendu de dix types de relief Prise de vue guidée à la caméra : une photo, deux photos ou une vidéo Modèle économique, paiement en ligne et licences signées, sans compte client Bibliothèque en ligne, sécurisation, mise en ligne et documentation Sans magasin d&#8217;applications Elle s&#8217;installe sur tous les téléphones, gratuitement StereoptiX est une application web. Elle s&#8217;ouvre dans un navigateur, puis s&#8217;installe d&#8217;un geste sur l&#8217;écran d&#8217;accueil ou le bureau, avec son icône. Aucun magasin d&#8217;applications, aucun compte, aucun abonnement. Une seule adresse, et le même atelier sur Android, sur iPhone, sur ordinateur. Gratuit Android iPhone et iPad Windows et macOS Ouvrir et installer 10types de relief 1photo suffit 0photo envoyée 559contrôles auto Prise de vue Une photo suffit. Deux font mieux. L&#8217;atelier a un seul départ, « Prendre 2 photos », et la caméra s&#8217;ouvre avec ses commandes posées sur l&#8217;image, comme dans un appareil photo. On y choisit entre trois prises, ou l&#8217;on importe une photo existante. Une photo Le modèle de profondeur devine ce qui est proche et ce qui est loin. Prise à la caméra ou importée : JPEG, PNG, WebP, AVIF, GIF, et HEIC sur Safari. Deux photos Un pas de côté entre les deux prises, et c&#8217;est le vrai relief de la scène. L&#8217;atelier guide le geste avec une image fantôme à superposer au sujet. Une vidéo Jusqu&#8217;à cinq secondes. Chaque image reçoit son relief, calculé sur l&#8217;appareil, et le tout s&#8217;enregistre en MP4, jusqu&#8217;en 1080p. le fond, plus lointain le sujet 1 2 6 à 7 cm 2 m La règle du trentième écart = distance du premier plan&#160;÷&#160;30 Pour un sujet à deux mètres, l&#8217;écart vaut 6&#160;à&#160;7&#160;cm, celui de vos yeux. Pour un paysage lointain, il monte à trente centimètres et plus. Entre les deux prises, l&#8217;appareil se décale sans tourner, et rien ne doit bouger. Dix sorties Dix façons de voir en relief Avec ou sans lunettes, pour un écran, une visionneuse ou un autre logiciel. L&#8217;anaglyphe conseillé applique les matrices calculées par Eric Dubois en lumière linéaire : les couleurs les plus justes, et le moins d&#8217;images fantômes à travers les filtres. Rouge-cyan DuboisLunettes rouge-cyanConseillé : les couleurs les plus justes et le moins d&#8217;images fantômes. WiggleSans lunettesUne animation qui balance entre les vues. S&#8217;exporte en GIF. Rouge-cyan couleurLunettes rouge-cyanCouleurs vives, mais les rouges francs scintillent d&#8217;un œil à l&#8217;autre. Rouge-cyan demi-couleurLunettes rouge-cyanUn bon compromis quand l&#8217;image contient beaucoup de rouge. Rouge-cyan grisLunettes rouge-cyanLe plus reposant pour les yeux, sans couleur. Rouge-vertLunettes rouge-vertPour les anciennes lunettes rouge-vert. Rouge-bleuLunettes rouge-bleuPour les lunettes rouge-bleu. GD Côte à côte parallèleCasque VR ou visionneuseVue gauche à gauche, pour un casque ou une visionneuse. DG Côte à côte croiséSans lunettes, en louchantLouchez jusqu&#8217;à voir une troisième image au milieu : elle est en relief. Carte de profondeurPour d&#8217;autres logicielsProche en blanc, loin en noir. Incliner le téléphone pour orienter les cartes Les dix sorties d&#8217;une même prise de vue, une route d&#8217;Occitanie. Chacune s&#8217;enregistre en PNG, JPEG ou WebP, le wiggle en GIF animé qui tourne en boucle, et la vidéo en MP4. Partagé, le GIF est allégé pour les messageries : 720&#160;pixels et 2&#160;Mo au plus, pour qu&#8217;il arrive animé. Modèle économique Le gratuit est un troc, pas un essai La formule gratuite n&#8217;expire pas et ne bride pas le relief : une photo, prise ou importée, les dix types de relief, l&#8217;enregistrement et le partage, sans compte. En échange, ce qui sort de l&#8217;atelier s&#8217;arrête au 720p, assez pour un écran ou une conversation, et porte une petite signature StereoptiX, celle que vous voyez en bas à gauche de la démonstration. Chaque image partagée fait ainsi connaître l&#8217;application : c&#8217;est elle qui paie la version gratuite. Les licences ouvrent le reste : deux photos pour le vrai relief d&#8217;une scène, la vidéo, la pleine définition sans signature, et avec Premium, une bibliothèque en ligne. Elles s&#8217;achètent une seule fois, sans abonnement et sans compte. Découverte 0 € pour toujours Tout le relief, sans compte et sans payer. Une photo, prise ou importée Les</p>
<p>Cet article <a href="https://presentcomposedesign.fr/stereoptix-infos/">StereoptiX</a> est apparu en premier sur <a href="https://presentcomposedesign.fr">Présent Composé design</a>.</p>
]]></description>
										<content:encoded><![CDATA[		<div data-elementor-type="wp-post" data-elementor-id="36980" class="elementor elementor-36980">
				<div class="elementor-element elementor-element-62b59ec e-con-full e-flex wpr-particle-no wpr-jarallax-no wpr-parallax-no wpr-sticky-section-no wpr-column-slider-no wpr-equal-height-no e-con e-parent" data-id="62b59ec" data-element_type="container" data-e-type="container">
				<div class="elementor-element elementor-element-1ac1979 elementor-widget elementor-widget-html" data-id="1ac1979" data-element_type="widget" data-e-type="widget" data-widget_type="html.default">
				<div class="elementor-widget-container">
					<!--
// StereoptiX, 3D & ANAGLYPH MODE for cameras, © All rights reserved.
// PCdlabs · Dev-Xprmnts · VR_Xprmnts · Present Compound Design     ║
// ║ Alban DESBARAX - Designer 360° / Creative Technologist         ║
// ║ Toulouse, FRANCE                                               ║
// ║                                                                ║
// ║   · Expertise & Support 2D, 3D, AR, VR, XR, AI                 ║
// ║   · Product Design & Industrial Design                         ║
// ║   · Digital communication                                      ║
// ║   · Immersive experiences  -ᯅ- ✔️                             ║
// ║                                                                ║                   
// · 🡺 https://presentcomposedesign.fr/      
//
//                                                       ++++++++++                                    
//                                                    =+++++++++===============                        
//                                                    +++++++++++++=====================+              
//                                                   ++++++++   *+++++=======================+*#       
//                                                    ==++++*          *++++==============*#%%%%       
//                                                     +==++=               *+======+*%%%%%%%%%%       
//       ==-===--=**+**                                                      @%%#%%%%%#-...=%%%%       
//      ==================+***                                          ##%%%%%%%%+:.......=%%%%       
//      =================------=+++***                           ##%#%%%%%%%%%%%%+::.......*%%%        
//      ====================-----------=+*++**             %%%%%%%%%%##%%%%%%%%%%=:::::::..#%%%        
//      -======     +=+==========-------------===+*   #%%%%%%%%#+==----=%%%%%%%%%=.......:#%%%@        
//       ----==            +======------------------=#%%%%%+=----------+%%%%%%%%%=..:+*%%%%%%          
//         ===+               ======----------------=#%%%%=------------#%%%%**%%%%%%%%%#+=             
//                              =======-------------=#%%%*=------------#%%%@  =*%%%***                 
//                                @+===---------=====#%%%*------------*%%%                             
//                                   +--------=======#%%%*------==*%%%%%%                              
//                                     =-----=======+#%%%#=-+#%%%%%%##%                                
//                                        ---=======+#%%%%%%%%###*#                                    
//                                               ===+##%%%%##@                                         
//                                                     +*                                              
//
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@//*
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%#*++==------===**%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#++-::::::::::::::::::::::-=*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#+-::::::::::::::::::::::::::::::::=*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@#+::::::::::::::::::::::::::::::::::::::::=%@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@%+:::::::::::::::::=++***+=-::::::::::::::::::::=%@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@#=::::::::::::::::+%@@@@@@@@@@%+-:::::::::::::::::::=#@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@#-::::::::::::::::#@@@@@@@@@@@@@@@@%-:::::::::::::::::::=#@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@%=::::::::::::::::*@@@@@@**==-=+##@@@@@*-:::::::::::::::::::=%@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@=:::::::::::::::::%@@@@@+::::::::::-*@@@@#-::::::::::::::::::::=@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@#::::::::::::::::::%@@@@=::::::::::::::-%@@@#-:::::::::::::::::::::#@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@=::::::::::::::::::*@@@@=::::::::::::::::-%@@@*::::::::::::::::::::::=@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@%-::::::::::::::::::-@@@@=::::::::::::::::::-%@@@-::::::::::::::::::::::-%@@@@@@@@@@@@@
// @@@@@@@@@@@@#::::::::::::::::::::*@@@#::::::::::::::::::::=@@@*::::::::::::::::::::::::*@@@@@@@@@@@@
// @@@@@@@@@@@#:::::::::::::::::::::%@@@+::::::::::::::::::::-@@@%:::::::::::::::::::::::::*@@@@@@@@@@@
// @@@@@@@@@@+::::::::::::::::::::::@@@@-:::::::::::::::::::::%@@@::::::::::::::::::::::::::*@@@@@@@@@@
// @@@@@@@@@*:::::::::::::::::::::::@@@@=:::::::::::::::::::::@@@@:::::::::::::::::::::::::::*@@@@@@@@@
// @@@@@@@@%::::::::::::::::::::::::@@@@*:::::::::::::::::::::@@@%::::::::::::::::::::::::::::*@@@@@@@@
// @@@@@@@%:::::::::::::::::::::::::@@@@%::::::::::::::::::::+@@@*:::::::::::::::::::::::::::::%@@@@@@@
// @@@@@@@::::::::::::::::::::::::::@@@@@*::::::::::::::::::-@@@@::::::::::::::::::::::::::::::-@@@@@@@
// @@@@@@=::::::::::::::::::::::::::@@@@@@*::::::::::::::::-@@@@+:::::::::::::::::::::::::::::::*@@@@@@
// @@@@@%:::::::::::::::::::::::::::@@@@@@@*::::::::::::::-%@@@#:::::::::::::::::::::::::::::::::*@@@@@
// @@@@@::::::::::::::::::::::::::::@@@@@@@@@*::::::::::+@@@@@*::::::::::::::::::::::::::::::::::-@@@@@
// @@@@*::::::::::::::::::::::::::::@@@#=@@@@@@%#*+=**%@@@@@@=::::::::::::::::::::::::::::::::::::*@@@@
// @@@@:::::::::::::::::::::::::::::@@@#::*@@@@@@@@@@@@@@@@*:::::::::::::::::::::::::::::::::::::::@@@@
// @@@*:::::::::::::::::::::::::::::@@@#::::=*@@@@@@@@@%#=:::::::::::::::::::::::::::::::::::::::::#@@@
// @@@=:::::::::::::::::::::::::::::@@@#::::::::-==+=-:::::::::::::::::::::::::::::::::::::::::::::=@@@
// @@@::::::::::::::::::::::::::::::@@@#:::::::::::::::::::::::::::::::::::::::::::::::::--:::::::::@@@
// @@*::::::::::::::::::::::::::::::@@@#:::::::::+######*:::::::::::::::::::::::::::::::+@=:::::::::#@@
// @@*::::::::::::::::::::::::::::::@@@#:::--::::===+*+==:::::=-:::::::=-::::::-::::::::+@=:::::::::+@@
// @@:::::::::::::::::::::::::::::::@@@#::+@#@@::-#@@%@%=::=@@@@+:::+@@@@@+-::@@@@%*-::#@@%#:::::::::@@
// @@:::::::::::::::::+*##%%#*=-::::@@@#::+@@=::-%@:::-*@=:%@-:=:::*@*:::-@*::@@-:=@%::=*@*=:::::::::@@
// @@::::::::::::::+%@@@@@@@@@@@%*-:@@@#::+@*:::*@@@@@@@@#:+@%*-:::@@@@@@@@@-:@%:::#@:::+@=::::::::::@@
// @%::::::::::::*@@@@@@@@@@@@@@@@@#+@@#::+@+:::+@=-------:::=%@*::@%-------::@%:::#@:::+@=::::::::::%@
// @%::::::::::-@@@@@@++-::::-=#@@@@@+*#::+@+:::-@%=-:+%%-:-=::#@::*@*-::+%=::@%:::#@:::+@=::::::::::#@
// @%:::::::::=@@@@%+::::::::::::#@@@@**::+@+::::-#@@@@%=::#@%%@+:::+@@@@@*:::@%:::#@:::+@=::::::::::%@
// @%::::::::-@@@@+:::::::::::::::-@@@@+:::-::::::::--:::::::=-:::::::-=-:::::--:::-=----=-::::::::::%@
// @@:::::::-@@@@*:::::::::::::::::-%%%*:::::::::::::::::::::::::::::::::::::::::::+%%%%%%#::::::::::@@
// @@:::::::*@@@#:::::::+#@%*-::::::=+*#*=+#*=-:::::+#%%#-::::::=#%%#=::::-#@@#-::::=##%#+-::::::::::@@
// @@-::::::@@@@-:::::-%@#+*%@*:::::%@#+#@@+*@*::::%@+==#@*::::%@#+=%@*-::%@-=*=:::*@*+=+@%-::::::::-@@
// @@+:::::-@@@@::::::#@=::::#@=::::%@::=@*::#@-::*@=::::-@+::*@*::::*@*::*@*-::::-@@####%@#::::::::*@@
// @@#:::::-@@@*::::::@@:::::=@*::::%@::=@*::#@-::%@::::::@*::%@-::::-@%:::=%@%-::*@#******+::::::::*@@
// @@@-::::-@@@%::::::#@+::::%@-::::%@::=@*::#@-::@@-::::=@-::*@*::::*@*:::-::@%::=@*::::-=-::::::::@@@
// @@@+:::::@@@@-::::::%@%*#@@+:::::%@::=@*::#@-::@@@#=+#@+::::*@%*+@@*:::#@*+@*:::*@%*+%@%::::::::-@@@
// @@@#:::::#@@@+:::::::=*##+-::::::+*::-*=::+*:::@%=*%#*-::::::=*%#*-:::::=*#+:::::-*##*=--=::::::*@@@
// @@@@-::::=@@@@-::::::::::::::::::=**+::::::::::@%:::::::::::::::::::::::-==++**##%%@@@@@@@-::::-@@@@
// @@@@*:::::*@@@@-::::::::::::::::+@@@#::::::::::::::::::::-==++**##=-#@@@@@@@@@@@@@@@@@@@@@=::::*@@@@
// @@@@@-:::::#@@@@+-:::::::::::::*@@@@::::::-==++--##%@@@@@@@@@#*#@@%%@@@@@@@@@@@#@@@@@*#@@@+:::-@@@@@
// @@@@@%::::::*@@@@@+-:::::::::+@@@@%:=@@@@@@@@@@==@@@**%@@@@*-#@*-@@*@@@*-+**@@@:%@@==@++@@*:::%@@@@@
// @@@@@@*::::::=@@@@@@@%%**##@@@@@@=::=@@@@#-:--=-=@@=+@+=@@@:%@@@=+@-*@*-@@@+:@@=#@:*@@%-@@#::+@@@@@@
// @@@@@@@-:::::::+%@@@@@@@@@@@@@@#-::::@@@+-%@@@*--@@:@@=*@@@%=*%@@@@*=@-*@@@*:+@=+=#@@@%:@@%:-@@@@@@@
// @@@@@@@@-::::::::=*%@@@@@@@##-:::::::@@@:#@@@@@*:@@:%==@@@@@@%+-%@@#-@*+@@@:+:@*:*@@@@@:%@@-@@@@@@@@
// @@@@@@@@#-:::::::::::::-:::::::::::::@@%:%@@@@@*-@@-#@@@#-@*%@@@-#@@:@@#=+:%@-#@:@@@@@@*=@@%@@@@@@@@
// @@@@@@@@@*:::::::::::::::::::::::::::#@@--@@@@%:*@@%*+*=*@@=+@@@=+@@-#@@@@@@@=#@#@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@*-:::::::::::::::::::::::::#@@@+-==-:#@@@@@@@@@@@@#++*#@@@@@@@#@@@%:@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@*-::::::::::::::::::::::::+@@@@@%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@-=-:+**++==--::%@@@@@@@@@@@
// @@@@@@@@@@@@#-:::::::::::::::::::::::=@@@@@@@@@@@@@@@@@@@@@%%##**++=---::::::::::::::::%@@@@@@@@@@@@
// @@@@@@@@@@@@@@-::::::::::::::::::::::-@@@@@@%###**++=--::::::::::::::::::::::::::::::-%@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@+::::::::::::::::::::::--::::::::::::::::::::::::::::::::::::::::::::+@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@%-::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::-%@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@+-:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::+@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@%=::::::::::::::::::::::::::::::::::::::::::::::::::::::::::+%@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@%=::::::::::::::::::::::::::::::::::::::::::::::::::::::=%@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@%=-:::::::::::::::::::::::::::::::::::::::::::::::::+%@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@*-:::::::::::::::::::::::::::::::::::::::::::::#@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@%+-:::::::::::::::::::::::::::::::::::::::+%@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%*=-:::::::::::::::::::::::::::::::+#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%**=-:::::::::::::::::::::=+#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%**++=====+++*##@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#****+++++****##%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@**+-::::::::::::::::::::==*#%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*+-::::::::::::::::::::::::::::::==#%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@#+-::::::::::::::::::::::::::::::::::::::=*%@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@*-::::::::::::::::::::::::::::::::::::::::::::=*%@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@#+:::::::::::::::++****%%@@@@@%%##**=--:::::::::::::=#@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@*=:::::::::::::+*%@@@@@@@@@@@@@@@@@@@@@@@#*=-:::::::::::=#@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@#=:::::::::::+*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#=-::::::::::=#@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@%=::::::::::+%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*=::::::::::=%@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@*::::::::::*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#+-:::::::::*%@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@%=::::::::=*@@@@@@@@@@@@@@%#*********#%%@@@@@@@@@@@@@@@@@@@%+-::::::::=%@@@@@@@@@@@@@@
// @@@@@@@@@@@@@#:::::::::#@@@@@@@@@@@**+-::::::::::::::::-+#%@%@@@@@@@@@@@@@@%+-::::::::*@@@@@@@@@@@@@
// @@@@@@@@@@@@+::::::::+@@@@@@@@@@*-::::::::::::::::::::::#@@@##%%@@@@@@@@@@@@@@+::::::::*%@@@@@@@@@@@
// @@@@@@@@@@@=:::::::=#@@@@@@@@@+::::::::::::::::::::::::-*@@@@@@#%%@@@@@@@@@@@@@*-:::::::=%@@@@@@@@@@
// @@@@@@@@@@=:::::::*@@@@@@@@@%=::::::::::::::::::::::::::*@@@@@@@@##%@@@@@@@@@@@@%+:::::::=%@@@@@@@@@
// @@@@@@@@@%=:::::::#@@@@@@@@@%:::::::::::::::::::::::::::+@@@@@@@@@@@%%@@@@@@@@@@@@@*-::::::-%@@@@@@@
// @@@@@@@@@=::::::-#@@@@@@@@@%-:::::::::::::::::::::::::==#@@@@@@@@@@@@%#@@@@@@@@@@@@@#-::::::=%@@@@@@
// @@@@@@@@=::::::-%@@@@@@@@@@=:::::::::::::::::::::::::*=#%@@@@@@@@@@@@@%#@@@@@@@@@@@@@#-::::::*@@@@@@
// @@@@@@@*::::::-#@@@@@@@@@@*::::::::::::::::::::::::*=#+%@@@%@@@@@@@@@@@%%@@@@@@@@@@@@@%-::::::*@@@@@
// @@@@@@%:::::::#@@@@@@@@@@%:::::::::::::::::::---:::-:-+*@%%@@@@@@@@@@@@@*%@@@@@@@@@@@@@%-::::::%@@@@
// @@@@@@-::::::#@@@@@@@@@@@-::::::::::::::::::::+=#-=#*#=+%%@@@@@@@@@@@@@@@%@@@@@@@@@@@@@@#-:::::-%@@@
// @@@@@*::::::*@@@@@@@@@@@#:::::::::::::::::::::-++=#==#+-%%%@@@@@@@@@@@@@@%@@@@@@@@@@@@@@@*::::::*@@@
// @@@@%::::::-@@@@@@@@@@@@=::::::::::::::::::::::::+=++#%=%*@@@@@@@@@@@@@@@@*@@@@@@@@@@@@@@@-::::::%@@
// @@@@*::::::#@@@@@@@@@@@%:::::::::::::::::::::::::+#:+@%+%@@@@@@@@@@@@@@@@@@#@@@@@@@@@@@@@@%-:::::*@@
// @@@%::::::=@@@@@@@@@@@@*::::::::::::::::::::::::-#*-#+=@@%@%@@@@@@@@@@@@@@@#@@@@@@@@@@@@@@@*::::::%@
// @@@*::::::%@@@@@@@@@@@@=:::::::::::::::::::::::+=#@++=+%@@@@@@@@@@@@@@#@@@+-%@@@@@@@@@@@@@@@-:::::*@
// @@::::::*@@@@@@@@@@@@@-:::::::::::::::::::::::-**+@+*=%@%@@@@@@@@@@@#@@%=::*@@@@@@@@@@@@@@@*::::::%@
// @%::::::#@@@@@@@@@@@@@::::::::::::::::::::::::::==#@%+@%+*%%@@@@@@@#==+::=-#@@@@@@@@@@@@@@@#::::::*@
// @*:::::-@@@@@@@@@@@@@@::::::::::::::::::::::::::::***%-@*@%%@@@@@@@%%=+*%%%@@@@@@@@@@@@@@@@@-:::::+@
// @=:::::*@@@@@@@@@@@@@@-::::::::::::::::::::::::::-+@+=+*%*%@@@@@@@%@@@%*=:*@@@@@@@@@@@@@@@@@+:::::=@
// %::::::*@@@@@@@@@@@@@@*::::::::::::::::::::::-=-=+#**#=%+@@*@@@@@@@@@@@+=**%@@@@@@@@@@@@@@@@#::::::%
// %::::::%@@@@@@@@@@@@@@#:::::::::::::::::::+#@++@%@@#*+*+#=@@@@@@@@@@@@@@*%@#%@@@@@@@@@@@@@@@#::::::#
// *::::::@@@@@@@@@@@@@@@@-:::::::::::::::::*@@@:::%@@@@*---**@@@@@@@@@@@@@@@@@@%@@@@@@@@@@@@@@%::::::*
// *::::::@@@@@@@@@@@@@@@@+:::::::::::::::::@@@@%::*@@@@@+-:=%+@@@@@@@@@@@@@@@@@@%@@@@@@@@@@@@@@::::::*
// *:::::-@@@@@@@@@@@@@@@@#:::::::::::::::::%=%@@-:+@@@@@*::%*@@@@@@@@@@@@@@@@@@@@%@@@@@@@@@@@@@-:::::*  
// *:::::=@@@@@@@@@@@@@@@@@+::::::::::::::::*:-@*::*-*@@@@-:-=%@%@@@@@@@@@@@@@@@@@#@@@@@@@@@@@@@::::::*
// *:::::-@@@@@@@@@@@@@@@@@%-:::::::::::::::*+:%@@:::-@@@@-:=+%%@%@@@@@@@@@@@*@@@@@@@@@@@@@@@@@@::::::*
// *::::::%@@@@@@@@@@@@@@@@@*::::::::::::::::*=-@*::::%@@@+:+*@@@%@@@@@@@@@@@%@@%#@@@@@@@@@@@@@%::::::*
// #::::::%@@@@@@@@@@@@@@@@@%-::::::::::::::::#+**:::::%@@*:-=%@*@@@@@@@@@@@#**%@@@@@@@@@@@@@@@%::::::#
// @::::::*@@@@@@@@@@@@@@@@@@*::::::::::::::::-%@@@@@**@@%=::=+@@@@@%@@@@@@@@@%@@@@@@@@@@@@@@@@*::::::@
// @-:::::*@@@@@@@@@@@@@@@@@@@=:::::::::::::::::#@@@@@@@@%:::+=%@@@@@@@@@@@@@@@%@@@@@@@@@@@@@@@*:::::-@
// @+:::::=@@@@@@@@@@@@@@@@@@@%-:::::::::::::::::*%@@@*%@-:::-*=*%@@@@@@@@@@@@@%@@@@@@@@@@@@@@@::::::*@
// @*::::::%@@@@@@@@@@@@@@@@@@@*:::::::::::::::::::=%%+#@=:::=*++@@@@@@@@@@@@@#@@@@@@@@@@@@@@@%::::::*@
// @@-:::::*@@@@@@@@@@@@@@@@@@@#::::::::::::::::----:=###::::=**%%@@@@@@@@@@@+%@@@@@@@@@@@@@@@@+:::::-@
// @@+:::::-%@@@@@@@@@@@@@@@@@@@-:::::::::::::=@@@@@+::::::::-*#%%%@@@@@@@@@%*%@@@@@@@@@@@@@@%::::::*@@
// @@#::::::*@@@@@@@@@@@@@@@@@@@+::::::::::::::%@@@@@-:::::::+%@%%%@@@@@@@@@%@@@@@@@@@@@@@@@@+::::::%@@
// @@@+::::::%@@@@@@@@@@@@@@@@@@*::::::::::::::*@@@@@-:::::::-+#@%%@%@@@@@@@%@@@@@@@@@@@@@@@%::::::=@@@
// @@@%-:::::*@@@@@@@@@@@@@@@@@@*::::::::::::::#@@@@@::::::::::*@%**%@@@@@@@#@@@@@@@@@@@@@@@=::::::%@@@
// @@@@*::::::%@@@@@@@@@@@@@@@@@*::::::::::::::*@@@@@#-::::::::===+-%%@@@@@@%%@@@@@@@@@@@@@*::::::*@@@@
// @@@@@-:::::-%@@@@@@@@@@@@@@@@+::::::::::::::=#%@@@@*--#=::::::--:=@@%%%@@%@@@@@@@@@@@@@#::::::-@@@@@
// @@@@@#-:::::-%@@@@@@@@@@@@@@@=::::::::::::::==@%@%%%#+%%*:::::::-=#%%*%@#*@@@@@@@@@@@@%:::::::#@@@@@
// @@@@@@*::::::*%@@@@@@@@@@**+-:::::::::::::::@#@@%@@@%#%@*::::-::::==::%%*@@@@@@@@@@@@%=::::::*@@@@@@
// @@@@@@@+::::::*@@@@@@@@*::::::::::::::::::-##%%@#@@@@%%%*-==*%:-::==+**@@@@@@@@@@@@@%=::::::=@@@@@@@
// @@@@@@@@-::::::*%@@@@*::::::::::::::::::::::%%%%%@@@@@@@#%@@%%@@@@@@@@@@@@@@@@@@@@@%=::::::=@@@@@@@@
// @@@@@@@@%-::::::=%@@%:::::::::::::::::::::#@%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#:::::::-@@@@@@@@@
// @@@@@@@@@@-::::::-%@#::::::::::::::::::::+@@@@@%@@@@@@@@@@@%%@@@@@@@@@@@@@@@@@@@@*:::::::=@@@@@@@@@@
// @@@@@@@@@@@-:::::::*@-:::::::::::::::::::-%@@@@@@@@@@@@@@@@*@@@@@@@@@@@@@@@@@@@@=:::::::=@@@@@@@@@@@
// @@@@@@@@@@@@+:::::::==:::::::::::::::::::-%@@@@@@@@@@@@@@@%@@@@@@@@@@@@@@@@@@@#-:::::::+@@@@@@@@@@@@
// @@@@@@@@@@@@@*-:::::::::::::::::::::::::::=@@@@@@@@@@@@@@%#@@@@@@@@@@@@@@@@@%=::::::::#@@@@@@@@@@@@@
// @@@@@@@@@@@@@@#-:::::::::::::::::::::::::::*@@@@@@@@@@@@#@@@@@@@@@@@@@@@@@@+::::::::=#@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@*-::::::::::::::::::::::::::*%@@@@@@@@@@*@@@@@@@@@@@@@@@@+:::::::::*@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@%=:::::::::::::::::::::::::::*%@@@@@@@@#@@@@@@@@@@@@@*=:::::::::=%@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@#-:::::::::::::::::::::::::::*%@@@@@@@%#@@@@@@@@*+-:::::::::=#@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@#-:::::::::::::::::::::::::::*%@@@@@@@%@@@@*+:::::::::::=#@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@#=-::::::::::::::::::::::::::+%@@@@%**--::::::::::::+#@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@%*-:::::::::::::::::::::::::::::::::::::::::::::*%@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@#*-:::::::::::::::::::::::::::::::::::::::+%@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@##=-:::::::::::::::::::::::::::::::+*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@##*=-::::::::::::::::::::-+**%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%##****+++++****%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
-->

<!--
  ============================================================================
  STEREOPTIX · ETUDE DE CAS
  Bloc HTML personnalise Elementor, pour https://presentcomposedesign.fr/stereoptix-infos/

  A COLLER TEL QUEL dans un widget "HTML" d'Elementor. Le bloc est autonome :
  styles et scripts vivent dedans et sont tous prefixes "sxa-", de sorte qu'ils
  ne peuvent ni deborder sur le theme ni en recevoir.

  RIEN N'EST A REMPLIR. Le signe, les declinaisons du logo et la
  demonstration sont dessines ici meme, en SVG et en canvas. Les images
  viennent du serveur de l'application, dossier assets/img :
    - l'en-tete, Header-stereoptix-wiggle-2026-10-07.gif ;
    - les dix sorties d'une meme prise de vue, route-d'occitanie-stereoptix-*.
  Une image absente laisse voir le schema de la carte, jamais un cadre vide.
  Elles s'affichent depuis presentcomposedesign.fr parce que le serveur de
  l'application n'envoie pas d'en-tete Cross-Origin-Resource-Policy : en
  ajouter un un jour (same-origin) les ferait disparaitre de cette page.

  LA DEMONSTRATION n'est pas une video. C'est une scene dessinee en plans
  successifs, chacun a sa profondeur, dont le script tire les memes sorties
  que l'application : le wiggle a la cadence du GIF exporte, l'anaglyphe
  rouge-cyan avec les matrices de Dubois appliquees en lumiere lineaire,
  le cote a cote croise et la carte de profondeur. Sans script, une image
  fixe de la meme scene prend sa place : la page reste entiere.

  Le second bloc (fiche de faits, questions frequentes, donnees structurees)
  est dans stereoptix-infos-seo-geo.html, a placer JUSTE SOUS celui-ci.
  ============================================================================
-->

<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">

<style>
/* ===========================================================================
   Tout est enferme sous #sxa. Aucune regle ne vise un selecteur global : un
   bloc insere dans un theme ne doit jamais pouvoir repeindre ce qui l'entoure.
   =========================================================================== */
#sxa {
  --sxa-blue: #1001ef;
  --sxa-soft: #eeecff;
  --sxa-ink: #0d1020;
  --sxa-mid: #5d6479;
  --sxa-line: #e4e6f0;
  --sxa-wash: #f7f8fc;
  --sxa-night: #12141f;
  /* Les couleurs du produit, recopiees de l'application (base.css). */
  --sxa-red: #ff2d55;
  --sxa-cyan: #00d4ff;
  --sxa-violet: #9b5cff;
  --sxa-cyan-text: #3fdcff;
  --sxa-tile: #0b0c10;

  box-sizing: border-box;
  margin: 0; padding: 0;
  font-family: "Inter Tight", "Helvetica Neue", Arial, system-ui, sans-serif;
  color: var(--sxa-ink); background: #fff;
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
}
#sxa *, #sxa *::before, #sxa *::after { box-sizing: border-box; }
#sxa p, #sxa h1, #sxa h2, #sxa h3, #sxa ul, #sxa li, #sxa figure { margin: 0; }
/* Un theme vise souvent les balises elles-memes (« h2 { font-family } »,
   « p { line-height: 2 } », « ul li { list-style } ») : une telle regle bat
   l'heritage. Chaque propriete qu'un theme aime poser est donc reprise ici,
   sur les balises, et les classes du bloc l'emportent ensuite. */
#sxa h1, #sxa h2, #sxa h3, #sxa p, #sxa li, #sxa figcaption, #sxa button, #sxa strong, #sxa em, #sxa b {
  font-family: inherit; color: inherit; text-transform: none;
}
#sxa p, #sxa li { font-size: inherit; line-height: 1.5; }
#sxa ul, #sxa li { padding: 0; list-style: none; }
#sxa img { border: 0; border-radius: 0; box-shadow: none; height: auto; }
#sxa img, #sxa svg, #sxa canvas { max-width: 100%; display: block; }
#sxa [hidden] { display: none; }
#sxa a { color: inherit; text-decoration: none; }
#sxa button { font-family: inherit; }

#sxa .sxa-wrap { width: 100%; max-width: 1060px; margin: 0 auto; padding: 0 20px; }

/* ------------------------------------------------------------------ en-tete
   Le GIF des lunettes, a sa taille native au plus (1080 px) : agrandi, il
   deviendrait flou. Sur un grand ecran, les bords sont remplis par le meme
   GIF, flou et etire, plutot que par un aplat qui ferait cadre. */
#sxa .sxa-banner { position: relative; overflow: hidden; background: #e7e6e3; }
#sxa .sxa-banner-fill {
  position: absolute; inset: -40px; width: calc(100% + 80px); max-width: none; height: calc(100% + 80px);
  object-fit: cover; filter: blur(30px) saturate(.85); opacity: .9;
}
#sxa .sxa-banner-img { position: relative; display: block; width: 100%; max-width: 1080px; height: auto; margin: 0 auto; }
/* Plus large que le GIF, l'ecran montrerait la couture entre l'image nette et
   son remplissage flou : les bords de l'image s'y fondent. */
@media (min-width: 1100px) {
  #sxa .sxa-banner-img {
    -webkit-mask-image: linear-gradient(90deg, transparent, #000 7%, #000 93%, transparent);
            mask-image: linear-gradient(90deg, transparent, #000 7%, #000 93%, transparent);
  }
}
#sxa .sxa-sec { padding: 76px 0; position: relative; overflow: hidden; }
#sxa .sxa-sec--wash { background: var(--sxa-wash); }
#sxa .sxa-sec--night { background: var(--sxa-night); color: #eef0f8; }

/* --------------------------------------------------------------- typographie */
#sxa .sxa-kicker {
  display: inline-flex; align-items: center; gap: 8px;
  font-size: 11.5px; font-weight: 700; letter-spacing: .16em; text-transform: uppercase;
  color: var(--sxa-blue); margin-bottom: 14px;
}
#sxa .sxa-kicker::before { content: ""; width: 24px; height: 2px; background: currentColor; }
/* Sur fond sombre, le bleu de marque devient illisible : le cyan de
   l'application prend le relais, celui de ses propres textes d'accent. */
#sxa .sxa-sec--night .sxa-kicker { color: var(--sxa-cyan-text); }
#sxa h1.sxa-h1 { font-size: clamp(2.6rem, 7vw, 4.6rem); font-weight: 800; line-height: .98; letter-spacing: -.04em; }
#sxa h2.sxa-h2 { font-size: clamp(1.55rem, 3.4vw, 2.3rem); font-weight: 800; line-height: 1.1; letter-spacing: -.03em; margin-bottom: 14px; }
#sxa .sxa-sec--night h2.sxa-h2 { color: #fff; }
#sxa h3.sxa-h3 { font-size: 1.05rem; font-weight: 700; letter-spacing: -.015em; margin-bottom: 6px; }
#sxa .sxa-lede { font-size: clamp(1.05rem, 2vw, 1.25rem); color: var(--sxa-mid); max-width: 58ch; }
#sxa .sxa-p { color: var(--sxa-mid); font-size: 15px; max-width: 62ch; }
#sxa .sxa-p + .sxa-p { margin-top: 10px; }
#sxa .sxa-sec--night .sxa-p, #sxa .sxa-sec--night .sxa-lede { color: #aeb5cb; }
#sxa .sxa-p strong, #sxa .sxa-lede strong { color: var(--sxa-ink); font-weight: 700; }
#sxa .sxa-sec--night .sxa-p strong { color: #fff; }
#sxa .sxa-p a, #sxa .sxa-cap a { color: var(--sxa-blue); font-weight: 600; text-decoration: underline; text-underline-offset: 3px; }
#sxa .sxa-sec--night .sxa-p a, #sxa .sxa-sec--night .sxa-cap a { color: var(--sxa-cyan-text); }

/* ---------------------------------------------------------- titre en relief
   Le procede de l'application, a l'identique : les couches rouge et cyan sont
   dessinees par le CSS (attr(data-text)), DERRIERE le texte, et se melent comme
   des encres sur fond clair (multiply). Ecrites en vraies balises, elles
   feraient lire « StereoptiX StereoptiX StereoptiX » a un moteur de recherche. */
#sxa .sxa-relief { position: relative; display: inline-block; isolation: isolate; }
#sxa .sxa-relief::before,
#sxa .sxa-relief::after {
  content: attr(data-text); position: absolute; inset: 0; z-index: -1;
  mix-blend-mode: multiply; pointer-events: none;
  transition: transform 1.1s cubic-bezier(.2,.8,.2,1) .55s;
}
#sxa .sxa-relief::before { color: var(--sxa-red); transform: translateX(-.05em); }
#sxa .sxa-relief::after { color: var(--sxa-cyan); transform: translateX(.05em); }
#sxa.sxa-js .sxa-relief::before, #sxa.sxa-js .sxa-relief::after { transform: none; }
#sxa.sxa-js .sxa-in .sxa-relief::before { transform: translateX(-.05em); }
#sxa.sxa-js .sxa-in .sxa-relief::after { transform: translateX(.05em); }

/* ---------------------------------------------------------------------- hero */
#sxa .sxa-hero {
  position: relative; overflow: hidden; padding: 92px 0 68px;
  background:
    radial-gradient(900px 460px at 82% -8%, rgba(0,212,255,.14), transparent 60%),
    radial-gradient(760px 420px at 0% 110%, rgba(255,45,85,.11), transparent 58%),
    #fff;
}
#sxa .sxa-hero-grid { display: grid; gap: 40px; align-items: center; grid-template-columns: minmax(0,1.2fr) minmax(0,.8fr); }
#sxa .sxa-mark { width: min(280px, 72%); margin-inline: auto; }
#sxa .sxa-mark svg { width: 100%; filter: drop-shadow(0 26px 46px rgba(11,12,16,.28)); }

/* Les deux disques : ils arrivent confondus, se separent, puis se rejoignent
   de temps en temps, comme pendant le chargement de l'atelier. Sans script,
   ils restent a leur place definitive. Les decalages sont en unites du
   dessin (512), que le CSS lit comme des pixels sur un element SVG. */
#sxa.sxa-js .sxa-in .sxa-disc-r { animation: sxa-join-r 8s cubic-bezier(.45,0,.2,1) .35s infinite both; }
#sxa.sxa-js .sxa-in .sxa-disc-c { animation: sxa-join-c 8s cubic-bezier(.45,0,.2,1) .35s infinite both; }
@keyframes sxa-join-r { 0% { transform: translateX(50px); } 16%, 78% { transform: none; } 92%, 100% { transform: translateX(50px); } }
@keyframes sxa-join-c { 0% { transform: translateX(-50px); } 16%, 78% { transform: none; } 92%, 100% { transform: translateX(-50px); } }

#sxa .sxa-tags { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 22px; }
#sxa .sxa-tag {
  font-size: 12px; font-weight: 600; padding: 6px 12px; border-radius: 999px;
  border: 1px solid var(--sxa-line); background: #fff; color: var(--sxa-mid);
}
#sxa .sxa-tag--on { background: var(--sxa-blue); border-color: var(--sxa-blue); color: #fff; }

/* ------------------------------------------------------------------- boutons */
#sxa .sxa-cta { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 26px; }
#sxa .sxa-btn {
  display: inline-flex; align-items: center; gap: 8px;
  padding: 13px 22px; border-radius: 13px; font-weight: 700; font-size: 14.5px;
  border: 1px solid var(--sxa-blue); background: var(--sxa-blue); color: #fff;
  transition: transform .16s ease, box-shadow .16s ease, background .16s ease;
}
#sxa .sxa-btn:hover { transform: translateY(-2px); box-shadow: 0 14px 28px rgba(16,1,239,.3); }
#sxa .sxa-btn--ghost { background: transparent; color: var(--sxa-blue); }
#sxa .sxa-btn--ghost:hover { background: var(--sxa-soft); box-shadow: none; }
#sxa .sxa-sec--night .sxa-btn--ghost { color: #bfefff; border-color: rgba(63,220,255,.45); }
#sxa .sxa-sec--night .sxa-btn--ghost:hover { background: rgba(63,220,255,.12); }

/* =========================================================================
   DEMONSTRATION
   ========================================================================= */
#sxa .sxa-demo {
  position: relative; border-radius: 18px; overflow: hidden; margin-top: 26px;
  border: 1px solid rgba(255,255,255,.12); background: #121433;
  aspect-ratio: 16 / 9; container-type: inline-size;
  touch-action: pan-y; cursor: ew-resize;
}
#sxa .sxa-demo canvas, #sxa .sxa-demo-fond {
  position: absolute; inset: 0; width: 100%; height: 100%; display: block;
}
#sxa .sxa-demo-fond { object-fit: cover; }
#sxa .sxa-demo-legend {
  position: absolute; left: 14px; top: 12px; display: flex; gap: 8px; align-items: center;
  font-size: 11px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; color: #fff;
  pointer-events: none; text-shadow: 0 1px 6px rgba(11,12,16,.7);
}
#sxa .sxa-demo-legend i {
  width: 7px; height: 7px; border-radius: 50%; background: var(--sxa-cyan);
  animation: sxa-pulse 1.8s ease-out infinite;
}
@keyframes sxa-pulse {
  0% { box-shadow: 0 0 0 0 rgba(0,212,255,.55); }
  70% { box-shadow: 0 0 0 10px rgba(0,212,255,0); }
  100% { box-shadow: 0 0 0 0 rgba(0,212,255,0); }
}
/* La signature des versions gratuites, comme dans l'apercu de l'atelier : en
   bas a gauche, disques et nom, a 4,5 % du petit cote de l'image. Pour une
   image en 16/9, le petit cote vaut 56,25 % de la largeur, d'ou 2,53cqw. */
#sxa .sxa-stamp {
  position: absolute; left: 2.2cqw; bottom: 1.8cqw;
  display: inline-flex; align-items: center; gap: .32em;
  font-size: max(11px, 2.53cqw); font-weight: 800; line-height: 1; color: #fff;
  /* Double ombre : la carte de profondeur est blanche au premier plan, et le
     nom s'y effacait. */
  text-shadow: 0 1px 3px rgba(11,12,16,.7), 0 0 10px rgba(11,12,16,.55); pointer-events: none;
}
#sxa .sxa-stamp svg { width: 1.45em; height: 1em; }

#sxa .sxa-modes { display: none; }
#sxa.sxa-js .sxa-modes { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
#sxa .sxa-mode {
  appearance: none; cursor: pointer;
  font-size: 13px; font-weight: 700; padding: 9px 15px; border-radius: 999px;
  border: 1px solid rgba(255,255,255,.18); background: rgba(255,255,255,.05); color: #d6dbea;
  transition: background .18s ease, border-color .18s ease, color .18s ease;
}
#sxa .sxa-mode:hover { border-color: rgba(63,220,255,.5); color: #fff; }
#sxa .sxa-mode[aria-pressed="true"] {
  color: #0b0c10; border-color: transparent;
  background: linear-gradient(100deg, #ff6b88, #c9a2ff 50%, #5fe3ff);
}
#sxa .sxa-cap { font-size: 12.5px; color: var(--sxa-mid); margin-top: 10px; font-style: italic; max-width: 74ch; }
#sxa .sxa-sec--night .sxa-cap { color: #8b92a8; }
#sxa .sxa-hint { font-size: 14px; font-weight: 600; color: #dfe3f0; margin-top: 12px; min-height: 1.5em; }
#sxa .sxa-hint { display: none; }
#sxa.sxa-js .sxa-hint { display: block; }

#sxa .sxa-chain { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 12px; margin-top: 22px; }
#sxa .sxa-chain div {
  border: 1px solid rgba(255,255,255,.12); border-radius: 14px; padding: 16px;
  background: rgba(255,255,255,.04);
}
#sxa .sxa-chain b { display: block; font-size: 13px; color: #fff; font-weight: 700; margin-bottom: 4px; }
#sxa .sxa-chain span { font-size: 12.5px; color: #aeb5cb; }
#sxa .sxa-chain a { color: var(--sxa-cyan-text); font-weight: 600; }

/* --------------------------------------------------------------- points cles */
#sxa .sxa-keys { display: grid; gap: 10px; margin-top: 8px; }
#sxa .sxa-keys li {
  display: flex; gap: 12px; align-items: flex-start;
  font-size: 15.5px; font-weight: 600; color: var(--sxa-ink);
  padding: 13px 16px; border: 1px solid var(--sxa-line); border-radius: 13px; background: #fff;
  transition: transform .2s ease, border-color .2s ease, box-shadow .2s ease;
}
#sxa .sxa-keys li:hover { transform: translateX(4px); border-color: rgba(16,1,239,.3); box-shadow: 0 10px 26px rgba(13,16,32,.07); }
/* La puce est le signe en miniature : deux disques qui se recouvrent. */
#sxa .sxa-keys li::before {
  content: ""; flex: 0 0 auto; width: 15px; height: 9px; margin-top: 7px;
  background:
    radial-gradient(circle at 4.5px 4.5px, var(--sxa-red) 4.4px, transparent 4.6px),
    radial-gradient(circle at 10.5px 4.5px, var(--sxa-cyan) 4.4px, transparent 4.6px);
}

/* ------------------------------------------------- installable, et chiffres */
#sxa .sxa-os { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 22px; }
#sxa .sxa-os li {
  display: inline-flex; align-items: center; gap: 7px;
  font-size: 12.5px; font-weight: 600; color: var(--sxa-ink);
  border: 1px solid var(--sxa-line); background: #fff;
  border-radius: 999px; padding: 7px 13px;
}
#sxa .sxa-os svg { width: 15px; height: 15px; flex: 0 0 auto; fill: none; stroke: var(--sxa-blue); stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
#sxa .sxa-os .sxa-os--free { background: var(--sxa-blue); border-color: var(--sxa-blue); color: #fff; letter-spacing: .06em; text-transform: uppercase; font-weight: 800; font-size: 11.5px; }
#sxa .sxa-os .sxa-os--free svg { stroke: #fff; }
#sxa .sxa-stats { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 14px; margin-top: 30px; }
#sxa .sxa-stat { text-align: center; padding: 20px 10px; border-radius: 16px; background: #fff; border: 1px solid var(--sxa-line); }
#sxa .sxa-stat b {
  display: block; font-size: clamp(1.6rem, 3.6vw, 2.3rem); font-weight: 800; letter-spacing: -.04em; color: var(--sxa-ink);
  text-shadow: -.04em 0 rgba(255,45,85,.55), .04em 0 rgba(0,212,255,.55);
}
#sxa .sxa-stat span { font-size: 11px; text-transform: uppercase; letter-spacing: .1em; color: var(--sxa-blue); font-weight: 700; }

/* --------------------------------------------------------------------- etapes */
#sxa .sxa-steps { display: grid; gap: 14px; margin-top: 30px; grid-template-columns: repeat(2, minmax(0,1fr)); }
#sxa .sxa-steps--3 { grid-template-columns: repeat(3, minmax(0,1fr)); }
#sxa .sxa-step {
  border: 1px solid var(--sxa-line); border-radius: 16px; padding: 22px; background: #fff;
  transition: transform .2s ease, box-shadow .2s ease, border-color .2s ease;
}
#sxa .sxa-step:hover { transform: translateY(-3px); border-color: rgba(16,1,239,.35); box-shadow: 0 16px 36px rgba(13,16,32,.08); }
#sxa .sxa-num {
  display: inline-grid; place-items: center; width: 30px; height: 30px; border-radius: 9px;
  background: var(--sxa-soft); color: var(--sxa-blue); font-weight: 800; font-size: 13px; margin-bottom: 12px;
}
#sxa .sxa-step svg.sxa-ico { width: 34px; height: 34px; margin-bottom: 12px; fill: none; stroke: var(--sxa-blue); stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }

/* ------------------------------------------------------- la regle du trentieme */
#sxa .sxa-rule {
  display: grid; grid-template-columns: minmax(0,1.15fr) minmax(0,.85fr); gap: 26px; align-items: center;
  margin-top: 26px; border: 1px solid var(--sxa-line); border-radius: 18px; padding: 24px; background: var(--sxa-wash);
}
#sxa .sxa-rule svg { width: 100%; height: auto; }
#sxa .sxa-rule text { font-family: inherit; }
#sxa .sxa-rule b.sxa-formula {
  display: block; font-size: clamp(1.2rem, 2.6vw, 1.6rem); font-weight: 800; letter-spacing: -.02em; margin: 4px 0 10px;
}

/* ------------------------------------------------------------ les dix types
   Deux grandes cartes (Dubois, le conseille ; wiggle, le plus partage), puis
   huit, quatre par ligne. Chaque carte montre l'image REELLE qu'enregistre
   l'application, une route d'Occitanie ; le schema reste dessous, et
   reprend sa place si l'image ne se charge pas. */
#sxa .sxa-types { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 14px; margin-top: 30px; }
#sxa .sxa-cell { display: flex; }
#sxa .sxa-cell > .sxa-tilt { flex: 1 1 auto; min-width: 0; }
#sxa .sxa-cell--wide { grid-column: span 2; }
#sxa .sxa-type {
  border: 1px solid rgba(255,255,255,.12); border-radius: 16px; overflow: hidden;
  background: rgba(255,255,255,.04); display: flex; flex-direction: column;
}
#sxa .sxa-type:hover { border-color: rgba(63,220,255,.45); background: rgba(255,255,255,.07); }
#sxa .sxa-shot { position: relative; aspect-ratio: 16 / 9; background: #0b0c10; overflow: hidden; }
#sxa .sxa-shot svg { position: absolute; inset: 0; margin: auto; width: 70%; height: 60%; }
#sxa .sxa-shot img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
/* Un cote a cote montre ses deux vues entieres, sur fond noir. */
#sxa .sxa-shot--dual img { object-fit: contain; }
#sxa .sxa-type-text { padding: 14px 15px 16px; }
#sxa .sxa-type b { display: block; font-size: 14px; font-weight: 700; color: #fff; line-height: 1.2; }
#sxa .sxa-cell--wide .sxa-type b { font-size: 16px; }
#sxa .sxa-type em { display: block; font-style: normal; font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--sxa-cyan-text); margin: 5px 0 6px; }
#sxa .sxa-type span { display: block; font-size: 12.5px; color: #aeb5cb; line-height: 1.4; }
#sxa .sxa-type--star { border-color: rgba(63,220,255,.4); }

/* ------------------------------------------------- cartes qui s'inclinent
   La carte suit la souris sur ordinateur et l'inclinaison du telephone. Le
   mouvement est porte par la carte elle-meme, et l'apparition par sa
   cellule : deux transformations sur un meme element s'ecraseraient. */
#sxa .sxa-tilt {
  position: relative;
  transform: perspective(900px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
  transition: transform .5s cubic-bezier(.2,.8,.2,1), border-color .2s ease, background .2s ease, box-shadow .3s ease;
}
#sxa .sxa-tilt.sxa-tilting { transition: transform .1s linear, border-color .2s ease, background .2s ease, box-shadow .3s ease; }
#sxa .sxa-glare {
  position: absolute; inset: 0; border-radius: inherit; pointer-events: none; z-index: 2;
  background: radial-gradient(70% 90% at var(--gx, 50%) var(--gy, 0%), rgba(255,255,255,.2), transparent 60%);
  opacity: 0; transition: opacity .35s ease;
}
#sxa .sxa-plan .sxa-glare { background: radial-gradient(70% 90% at var(--gx, 50%) var(--gy, 0%), rgba(0,212,255,.13), transparent 62%); }
#sxa .sxa-tilt.sxa-tilting .sxa-glare, #sxa.sxa-gyro .sxa-tilt .sxa-glare { opacity: 1; }
#sxa .sxa-gyro-ask { display: none; }
#sxa.sxa-js .sxa-gyro-ask.sxa-gyro-ask--on {
  display: inline-flex; align-items: center; gap: 8px; margin-top: 16px; cursor: pointer;
  font-family: inherit; font-size: 13px; font-weight: 700; line-height: 1.2; padding: 10px 16px; border-radius: 999px;
  border: 1px solid rgba(63,220,255,.45); background: transparent; color: var(--sxa-cyan-text);
}
#sxa.sxa-js .sxa-sec--wash .sxa-gyro-ask.sxa-gyro-ask--on { color: var(--sxa-blue); border-color: rgba(16,1,239,.35); }
#sxa .sxa-gyro-ask svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }

/* ------------------------------------------------------------- les offres */
#sxa .sxa-plans { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 14px; margin-top: 30px; }
#sxa .sxa-plans .sxa-cell { display: flex; }
#sxa .sxa-plan {
  border: 1px solid var(--sxa-line); border-radius: 18px; padding: 24px 22px; background: #fff;
  display: flex; flex-direction: column; overflow: hidden;
}
#sxa .sxa-plan:hover { box-shadow: 0 16px 36px rgba(13,16,32,.08); }
#sxa .sxa-plan ul { margin-bottom: 18px; }
#sxa .sxa-plan-cta { display: grid; gap: 8px; margin-top: auto; position: relative; z-index: 3; }
#sxa .sxa-plan-cta .sxa-btn { justify-content: center; width: 100%; }
#sxa .sxa-plan--free { border-color: rgba(16,1,239,.35); box-shadow: 0 0 0 3px rgba(16,1,239,.07); }
#sxa .sxa-plan-name { font-size: 12px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; color: var(--sxa-blue); }
#sxa .sxa-price { white-space: nowrap; font-size: clamp(1.9rem, 3.8vw, 2.4rem); font-weight: 800; letter-spacing: -.04em; margin-top: 8px; line-height: 1; }
#sxa .sxa-period { font-size: 13px; font-weight: 600; color: var(--sxa-mid); margin-top: 4px; }
#sxa .sxa-plan-tag { font-size: 14px; color: var(--sxa-ink); font-weight: 600; margin: 14px 0 12px; }
#sxa .sxa-plan ul { display: grid; gap: 8px; }
#sxa .sxa-plan li { font-size: 13.5px; color: var(--sxa-mid); padding-left: 20px; position: relative; }
#sxa .sxa-plan li::before {
  content: ""; position: absolute; left: 2px; top: .5em; width: 9px; height: 5px;
  border-left: 2px solid var(--sxa-blue); border-bottom: 2px solid var(--sxa-blue); transform: rotate(-45deg);
}
#sxa .sxa-plan .sxa-inherit { font-weight: 700; color: var(--sxa-ink); padding-left: 0; }
#sxa .sxa-plan .sxa-inherit::before { display: none; }
#sxa .sxa-trust { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 12px; margin-top: 18px; }
#sxa .sxa-trust div { border-radius: 14px; padding: 16px; background: #fff; border: 1px dashed rgba(16,1,239,.25); }
#sxa .sxa-trust b { display: block; font-size: 13px; font-weight: 700; margin-bottom: 4px; }
#sxa .sxa-trust span { font-size: 12.5px; color: var(--sxa-mid); }

/* ===========================================================================
   DECLINAISONS DU LOGO
   =========================================================================== */
#sxa .sxa-logos { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 14px; margin-top: 28px; }
#sxa .sxa-logo {
  border: 1px solid var(--sxa-line); border-radius: 16px; background: #fff; overflow: hidden;
  transition: transform .2s ease, box-shadow .2s ease, border-color .2s ease;
}
#sxa .sxa-logo:hover { transform: translateY(-4px); border-color: rgba(16,1,239,.3); box-shadow: 0 16px 36px rgba(13,16,32,.09); }
#sxa .sxa-logo-art { aspect-ratio: 1; display: grid; place-items: center; padding: 22px; position: relative; overflow: hidden; }
#sxa .sxa-logo-art > svg { width: 78%; }
#sxa .sxa-logo-art--dark { background: var(--sxa-tile); }
#sxa .sxa-logo-art--photo { background: linear-gradient(180deg, #2a2160 0%, #a54f8f 52%, #ffb27f 70%, #3b2a66 71%, #1c1638 100%); padding: 0; }
#sxa .sxa-logo-lab { font-size: 11.5px; font-weight: 700; padding: 11px 14px; border-top: 1px solid var(--sxa-line); }
#sxa .sxa-logo-lab span { display: block; font-weight: 500; color: var(--sxa-mid); font-size: 11px; }
#sxa .sxa-wordmark {
  font-size: clamp(1.5rem, 3.2vw, 2.1rem); font-weight: 800; letter-spacing: .01em; color: #f2f4f9;
  text-shadow: -2px 0 rgba(255,45,85,.75), 2px 0 rgba(0,212,255,.75);
}
#sxa .sxa-slogan { font-size: clamp(1.25rem, 2.6vw, 1.7rem); font-weight: 800; letter-spacing: -.02em; text-align: center; line-height: 1.15; }
#sxa .sxa-logo-art--photo canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
#sxa .sxa-logo-art--photo .sxa-stamp { font-size: 15px; left: 12px; bottom: 11px; }
#sxa .sxa-favrow { display: flex; align-items: flex-end; gap: 16px; justify-content: center; }
#sxa .sxa-favrow figure { text-align: center; }
#sxa .sxa-favrow figcaption { font-size: 9px; color: var(--sxa-mid); margin-top: 6px; font-weight: 600; }

/* ---------------------------------------------------------------- services */
#sxa .sxa-serv { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 14px; margin-top: 30px; }
#sxa .sxa-serv .sxa-step { background: var(--sxa-wash); }

/* ---------------------------------------------------------------------- final */
#sxa .sxa-final { text-align: center; }
#sxa .sxa-final .sxa-lede { margin-inline: auto; }
#sxa .sxa-final .sxa-cta { justify-content: center; }
#sxa .sxa-more { font-size: 14px; color: var(--sxa-mid); margin-top: 22px; }
#sxa .sxa-more a { color: var(--sxa-blue); font-weight: 700; }
#sxa .sxa-legal { font-size: 12px; color: var(--sxa-mid); margin-top: 34px; max-width: 74ch; margin-inline: auto; }

/* ===========================================================================
   APPARITIONS
   L'etat cache n'existe QUE si le script a pu poser .sxa-js : un script bloque
   laisse l'article lisible, sans animation. L'animation est un supplement,
   jamais la condition d'affichage.
   =========================================================================== */
#sxa .sxa-rise, #sxa .sxa-left, #sxa .sxa-zoom {
  transition: opacity .65s ease, transform .65s cubic-bezier(.2,.75,.25,1);
  transition-delay: calc(var(--i, 0) * 70ms);
}
#sxa.sxa-js .sxa-rise { opacity: 0; transform: translateY(24px); }
#sxa.sxa-js .sxa-left { opacity: 0; transform: translateX(-26px); }
#sxa.sxa-js .sxa-zoom { opacity: 0; transform: scale(.93); }
#sxa.sxa-js .sxa-in.sxa-rise, #sxa.sxa-js .sxa-in .sxa-rise,
#sxa.sxa-js .sxa-in.sxa-left, #sxa.sxa-js .sxa-in .sxa-left,
#sxa.sxa-js .sxa-in.sxa-zoom, #sxa.sxa-js .sxa-in .sxa-zoom { opacity: 1; transform: none; }

/* ------------------------------------------------------------------ responsive */
@media (max-width: 900px) {
  #sxa .sxa-hero-grid { grid-template-columns: 1fr; }
  #sxa .sxa-mark { width: min(190px, 52%); margin-inline: 0; }
  #sxa .sxa-serv, #sxa .sxa-stats, #sxa .sxa-chain, #sxa .sxa-steps--3, #sxa .sxa-trust { grid-template-columns: repeat(2, minmax(0,1fr)); }
  #sxa .sxa-types { grid-template-columns: repeat(2, minmax(0,1fr)); }
  #sxa .sxa-plans { grid-template-columns: 1fr; max-width: 520px; }
  #sxa .sxa-rule { grid-template-columns: 1fr; }
  #sxa .sxa-sec { padding: 58px 0; }
  #sxa .sxa-hero { padding: 64px 0 50px; }
}
@media (max-width: 620px) {
  #sxa .sxa-steps, #sxa .sxa-steps--3, #sxa .sxa-serv, #sxa .sxa-chain, #sxa .sxa-trust { grid-template-columns: 1fr; }
  #sxa .sxa-types { gap: 10px; }
  #sxa .sxa-type-text { padding: 11px 11px 13px; }
  #sxa .sxa-logos { grid-template-columns: repeat(2, minmax(0,1fr)); }
  #sxa .sxa-demo { aspect-ratio: 4 / 3; }
  #sxa .sxa-stamp { font-size: max(11px, 3.38cqw); }
  #sxa .sxa-mode { font-size: 12.5px; padding: 8px 12px; }
}

@media (prefers-reduced-motion: reduce) {
  #sxa .sxa-rise, #sxa .sxa-left, #sxa .sxa-zoom,
  #sxa.sxa-js .sxa-rise, #sxa.sxa-js .sxa-left, #sxa.sxa-js .sxa-zoom {
    opacity: 1; transform: none; transition: none;
  }
  #sxa .sxa-relief::before, #sxa .sxa-relief::after { transition: none; }
  #sxa.sxa-js .sxa-relief::before { transform: translateX(-.05em); }
  #sxa.sxa-js .sxa-relief::after { transform: translateX(.05em); }
  #sxa.sxa-js .sxa-in .sxa-disc-r, #sxa.sxa-js .sxa-in .sxa-disc-c { animation: none; }
  #sxa .sxa-demo-legend i { animation: none; }
  #sxa .sxa-btn:hover, #sxa .sxa-step:hover, #sxa .sxa-logo:hover, #sxa .sxa-keys li:hover,
  /* L'inclinaison reste : elle repond a un geste du lecteur, elle n'est pas
     un mouvement autonome. Seul son retour au repos devient immediat. */
  #sxa .sxa-tilt { transition: border-color .2s ease, background .2s ease, box-shadow .3s ease; }
}
</style>

<div id="sxa">

  <!-- Les deux disques sont definis UNE fois : ils reviennent dans la puce de
       signature, la vignette du signe et les favicons. Deux copies d'un dessin
       sont deux occasions de les voir diverger. -->
  <svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
    <defs>
      <symbol id="sxa-discs" viewBox="0 0 64 44">
        <g style="isolation:isolate">
          <circle cx="24.5" cy="22" r="17" fill="#ff2d55"/>
          <circle cx="39.5" cy="22" r="17" fill="#00d4ff" style="mix-blend-mode:screen"/>
        </g>
      </symbol>
    </defs>
  </svg>

  <!-- ============================== EN-TETE ============================= -->
  <div class="sxa-banner">
    <img decoding="async" class="sxa-banner-fill" src="https://pcdlabs.presentcomposedesign.fr/tools/StereoptiX/assets/img/Header-stereoptix-wiggle-2026-10-07.gif" alt="" aria-hidden="true">
    <img fetchpriority="high" class="sxa-banner-img" src="https://pcdlabs.presentcomposedesign.fr/tools/StereoptiX/assets/img/Header-stereoptix-wiggle-2026-10-07.gif"
         alt="Des lunettes 3D rouge-cyan en carton, en wiggle : l'image balance d'un œil à l'autre et se voit en relief sans lunettes"
         width="1080" height="635" fetchpriority="high" decoding="async">
  </div>

  <!-- ============================== HERO ================================ -->
  <header class="sxa-hero" data-sxa-reveal>
    <div class="sxa-wrap">
      <div class="sxa-hero-grid">
        <div>
          <p class="sxa-kicker sxa-rise">Étude de cas &middot; Application web sur mesure</p>
          <h1 class="sxa-h1 sxa-rise" style="--i:1"><span class="sxa-relief" data-text="StereoptiX">StereoptiX</span></h1>
          <p class="sxa-lede sxa-rise" style="--i:2;margin-top:18px">
            Une application web qui transforme chaque <strong>photo en relief</strong> :
            anaglyphe rouge-cyan pour les lunettes 3D, côte à côte, ou wiggle, ce
            GIF animé qui se voit en relief sans rien sur le nez. La profondeur est
            calculée par une intelligence artificielle <strong>qui tourne dans le
            navigateur</strong> : aucune photo n'est envoyée. Imaginée, nommée,
            dessinée, développée et mise en ligne à Toulouse.
          </p>
          <div class="sxa-tags sxa-rise" style="--i:3">
            <span class="sxa-tag sxa-tag--on">Idéation</span>
            <span class="sxa-tag">Naming</span>
            <span class="sxa-tag">Identité graphique</span>
            <span class="sxa-tag">UX / UI</span>
            <span class="sxa-tag">IA embarquée</span>
            <span class="sxa-tag">WebGL</span>
            <span class="sxa-tag">Paiement en ligne</span>
            <span class="sxa-tag">Hébergement</span>
          </div>
          <div class="sxa-cta sxa-rise" style="--i:4">
            <a class="sxa-btn" href="https://pcdlabs.presentcomposedesign.fr/tools/StereoptiX/" target="_blank" rel="noopener">Voir l'application</a>
            <a class="sxa-btn sxa-btn--ghost" href="https://presentcomposedesign.fr/contact/">Demander un rendez-vous</a>
          </div>
        </div>

        <div class="sxa-mark sxa-zoom" aria-hidden="true">
          <!-- L'icone de l'application, redessinee a ses proportions exactes
               (icon-512.png) : tuile #0b0c10, disques de rayon 150. -->
          <svg viewBox="0 0 512 512">
            <rect width="512" height="512" rx="112" fill="#0b0c10"/>
            <g style="isolation:isolate">
              <circle class="sxa-disc-r" cx="206" cy="256" r="150" fill="#ff2d55"/>
              <circle class="sxa-disc-c" cx="306" cy="256" r="150" fill="#00d4ff" style="mix-blend-mode:screen"/>
            </g>
          </svg>
        </div>
      </div>
    </div>
  </header>

  <!-- ============================== DEMONSTRATION ======================= -->
  <section class="sxa-sec sxa-sec--night" data-sxa-reveal>
    <div class="sxa-wrap">
      <p class="sxa-kicker sxa-rise">Le cœur de l'application</p>
      <h2 class="sxa-h2 sxa-rise" style="--i:1">Une photo, et le relief apparaît</h2>
      <p class="sxa-p sxa-rise" style="--i:2">
        Un modèle d'intelligence artificielle estime, pour chaque pixel, ce qui
        est proche et ce qui est loin. L'application en tire deux points de vue,
        celui de l'œil gauche et celui de l'œil droit, puis les recompose selon le
        type de relief choisi. <strong>Essayez ci-dessous</strong> : la scène est
        calculée en direct dans cette page, avec les réglages de l'application.
      </p>

      <div class="sxa-demo sxa-zoom" style="--i:3" data-sxa-demo>
        <img class="sxa-demo-fond" src="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4gHYSUNDX1BST0ZJTEUAAQEAAAHIAAAAAAQwAABtbnRyUkdCIFhZWiAH4AABAAEAAAAAAABhY3NwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAA9tYAAQAAAADTLQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlkZXNjAAAA8AAAACRyWFlaAAABFAAAABRnWFlaAAABKAAAABRiWFlaAAABPAAAABR3dHB0AAABUAAAABRyVFJDAAABZAAAAChnVFJDAAABZAAAAChiVFJDAAABZAAAAChjcHJ0AAABjAAAADxtbHVjAAAAAAAAAAEAAAAMZW5VUwAAAAgAAAAcAHMAUgBHAEJYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9YWVogAAAAAAAA9tYAAQAAAADTLXBhcmEAAAAAAAQAAAACZmYAAPKnAAANWQAAE9AAAApbAAAAAAAAAABtbHVjAAAAAAAAAAEAAAAMZW5VUwAAACAAAAAcAEcAbwBvAGcAbABlACAASQBuAGMALgAgADIAMAAxADb/2wBDAAUEBAQEAwUEBAQGBQUGCA0ICAcHCBALDAkNExAUExIQEhIUFx0ZFBYcFhISGiMaHB4fISEhFBkkJyQgJh0gISD/2wBDAQUGBggHCA8ICA8gFRIVICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICD/wAARCAIaA74DASIAAhEBAxEB/8QAHAABAAIDAQEBAAAAAAAAAAAAAAIDAQQFBgcI/8QATBAAAQMDAgIFCAcFBgQGAgMAAAECEQMEEgVhEyEGMUFRcRQiMoGRobHBByMzQlJy0RU0YuHwQ1NjgpKiJXOywhYkJjVE8VTSNpPi/8QAGwEBAAMBAQEBAAAAAAAAAAAAAAECAwQFBgf/xAA2EQEAAgIBAwMCBAQFBAMBAAAAARECEgMTITEEQVEFMiJCYcFxgZHwIyQzodEUUrHhFSXxYv/aAAwDAQACEQMRAD8A/LcCCcF1ra17y7pWttT4laq7FjZRJX1nusWtAgtfTdSqOpvbi9iq1U7lQjAoQgQTgQKEIEE4EChCBBOBAoQgQTgQKEIEE4EChCBBOBAoQgQTgQKF9ewura0trqtTxpXKKtNZRZjr8Os1YLFVyta1XKrW9ST1GIFCECCcCBQhAgnAgULUu7pLBbBK9TyVanFWjPm5xGUd8GvBOBAoQgQTgQKB1KoxjHvpua2oksVUhHJMSnfzRU9RCCxZVERVmOSbGIFCECCcCBQhAgnAgUIQW1OAtOjwWVGvRn1qvciorsl5t5ckjHrnnJGBAoQgQTgQKEIEE4EChCBBOBAoQgQTgQKEIEE4EChCBBOBAoQgQbVa4dWt6FFaVNqUUVEcxsK6VnmvaUQBCBBOBAoQgQTgQKEIEE4EChCBBOBAoQgQTgQKEIEE4EChCBBuus22uo0re/fhTXB1R9u5tZUY5EdLYdCrivVKc+SxzNVUSVjmnZKQBCBBOBAoQgQW06fEcrUc1sIrpcsJySfaRgCECCcCBQuoVbenaXVKrZNrVqqNSlWV7kWiqOlVRE5LKcuZrQTgQKClRq1qraVGk+pUd1NYkqvqIQbVnd3Vhd07uzruoV6ay2oxYVOUfBSiJWRQhAgnAgUIQIJwIFCECCcCBQhAgnAgUD2MaymramauSXJCpis9W5CCcCBQhAgnAgUIQIJwIFCECDaurh13cLXdSpUlVrW40mIxvJETqTw57lEChCBBOBAoQgQTgQKEUlFRUVUVOpUMvc+pUdUqOc97llznLKqvepmBAoQgQTgQKEIEE4EChZUtK9K0o3T6cUa6uSm6U85WxPL1oUQTgQKEIEE4EChCDd1LVNQ1e4pXGpXLrirSost2OciJDGJi1vJOxEg1YEChCBBOBAoQgQTgQKEIEE4EChCBBOBAoQgQTgQKEIEE4EChCBBOBAoZgykoqKiqipzRUJQILJQgQTgQBCBBOBAEIEE4EAQgQTgQBCBBOBAEIEE4EAQgQTgQBCBBOBAEIEE4EAQgQTgQBCBBOBAEIEE4EAQgQTgQBCBBOBAEIEE4EAQgQbFS0uaNCjXq29WnSrorqT3MVG1ERYVWr2wqKnLtQqgCECCcCAIQIJwIAhAgnAgCECCcCAIQIJwIAhAgnAgCECCcCAIQIJwIAhAgnAgCECCcCAIQIJwIAhAgnAgCECCcCAIQIJwIAhAgnAgCECCcCAIwq9nUYg3bfULy1sbuyoVcLe8RrazMUXNGrknNUlOfcasAQgQTgQBCBBOBAEIN/UtIvtJW0S+psZ5ZbMu6ONRr5pv9FVhVheS8l5oacCBQhAgnAgCECCcCAIQIJwIAhAgnAgCECCcCAIQINm1qttryjcPoMrtpvRy0qiS18L1LsRuKiV7qrWbSZRbUerkpsSGslZhNkAogQTgQBCBBOBAEIMoiSmUonaqISgQBtarR02hq1xS0i7q3li10Uq1Wnw3PSE62845yaUE4EChCBBOBAEIEE4EAQgQTgQBCBBOBAEIEE4EAQgQTgQBCC+pUoOs6FJlqlOsxXK+tmqrUmISOpI2K4EChCBBOBAEIEE4EAQgQTgQBCBBOBAEIEE4EAQg6DdHvH6CuttSn5Ilz5L9oiOzxy9HriO00oEChKBBLEYl0owIJYjECMCCWJlKblarkaqo3rVE6gIQIJYjECMCCWIxAjAgliMQIwIJYjECMCCWIxAjAgliMQIwIJYm9ZajUsbO/tWWlpWS9pJSc+vRR76SI5HSxV9FeXWQOfAgliMSRGBBLEYgRgQSxGIEYEEsRiBGBBLEYgRgQSxGIGX1atSnTpPqvcykioxrlVUYirKwnZz5kIJYjEUIwIJYjECMCCWIxAjAgliMQIwIJYjECMCCWIxAjAgliMQIwIJYjECMCCWIxAjAgliMQIwIJYjECMCCWIxAjAgliMQIwIJYjECMCCWIxAjAgliMQIwIJYjECMCCWIxAjAgliMQIwIJYjECMCCWIxAjAgliMQIwIJYjECMCCWIxAjAgliMQIwIJYjECMCCWIxAjAgliMQIwIJYkqdJ9WqylTYr3vVGtaiSqqvUgFcCCb6b6dR1N7Va9qwrVSFRe4xiBGBBLEYgRgQSxGIEYEG5fXlW/q0qlSlRprTpNpIlJiNRUakIqonaauJAjAgliMSRGBBLEYgRgQSxGIEYEG+lndM0Nb5bRi2tWulJtwq+cj2tVVanPqVHIq8uxOZpYkCMCCWIxJEYEEsRiBGBBLEYgRgQSxGIEYEEsRiBGBBLEYgRgQSxEASgQTgQWpKECCcCBQhB17HpDqendHtU0K2qMSy1Th+UNViKq4OybC9aczlwIImLEIEE4EE0IQIJwIFCECCcCBQhAgnAgUIQIJwIFCECCcCBQhAgnAgUIQIJwIFCECCcCBQhAgnAgUIQIJwIFCECCcCBQhAgnAgUMOY9ioj2K1VRFRFSOS80UjBtXd5dX1Zta8rvr1GsbTRz1lcWpCJ6kQogCECCcCBQhAgnAgUIQIJwIFCECCcCBQhAgnAgUIQIJwIFCECCcCBQhAgnAgUIQIJwIFCECCcCBQhAgnAgUIQIJwWvpUW2tGq24a+q9XI+kjVRaaJEKqxCzz6u4DXgQTgQKEIEE4EChCBBOBAoQgQTgQKEIEE4EChCBBOBAoQgQTgQKEIEE4EChCBBOBAoQgQTgQKEIEE4EChCBBOBAoQgQTgQKEIEE4EChCBBOBAoQgQTgQKEIEE4EChCBBOBAoW0LVK9vc1VuaNJaLUcjHuh1SViG96mvBOBAoQgQTgQKEIEE4EChCBBOBAoQgQTgQKEIEE4EChCBBOBAoQgQTgQKEIEE4EChCBBOBAoQgQTgQKEIEE4EChCBBOBAoSgQZgQWGIEGYEAYgQZgQBiBBmBAGIEGYEAYgQZgQBiBBmBAGIEGYEAYgQZgQBiBBmBAGIEGYEAYgQZgQBiBBmBAGIEGYEAYgQZgQBiBBmBAGIEGYEAYgQZgQBiBBmBAGIEGYEAYgQZgQBiBBmBAGIEGYEAYgQZgQBiBBmBAGIEGYEAYgQZgQBiBBmBAGIEGYEAYgQZgQBiBBmBAGIEGYEAYgQZgQBiBBmBAGIEGYEAYgQTarmPa9vW1ZSUksubird3VS5rY8So7J2LUansQCiBBmBAGIEGYEAYgQZgQBiBBmBAGIEGYEAYgQZgQBiBBmBAGIEGYEAYgQZgQBiBBmBAGIEGYEAYgQZgQBiBBmBAGIEGYEAYgQZgQBiBisTHJOUmYJo+olJ1JHuSm5UVzUXkqpMKqbSvtArgQZgQBiBBmBAGIEGYEAYgQZgQBiBBmBAGIEGYEAYgQZgQBiBBmBAE4EGYEFksQIMwIAxAgzAgCdCrUt7mlcUlRKlJyPaqoipKLKcl6yV3cVb29r3ldWrVrvdUerWo1JVZXknV1lUCBQxAgzAgDECDMCAMQIMwIAxAgzAgDECDMCAMQIMwIAxAgzB0tI1e60W6q3NpTovfVovoOSszNEa7rjcDmQIMwIAxAgzAgDECDMCAMQIMwIAxAgzAgDECDMCAMQIMwdjTq/R+loOrUdS0+4r6nVbT8hr06kMoqi+fkkpMp4+rrE9hxoEGYEAYgQZgQBiBBmBAGIEGYEAYgQZgQBiBBmBAGIEGYEAYgQZgQBiBBmBAGIEGYEAYgQZgQBiBBmBAGIEGYEAYgQZgQBiBBmBAGIEGYEAYgQZgQBiBBmBAGIEGYEAYgQZgQBiBBmBAGIEGYNt1OxTS2VG1qi3q1IdSVvmoyOue+QNOBBmBAGIEGYEAYgQZgQBiBBmBAGIEGYEAYgQZgQBiBBmBAGIEGYEAYgQZgQBiBBmBAGIEGYEAYgQZgQBiBBmBAGIEGYEAYgQdLSqOkVql0mr3da2a23e6gtJmWdVPRavci8/1Q50AYgQZgQBiBBmBAGIEGYEAYgQZgQBiBBmDMAZgQTgQXoQgQTgQKEIEE4EChCBBOBAoQgQTgQKEIEE4EChCBBOBAoQgQTgQKEIEE4EChCBBOBAoQgQTgQKEIEE4EChCBBOBAoQgQTgQKEIEE4EChCBBOBAoQgQTgQKEIEE4EChCBBOBAoQgQTgQKEIEE4EChCBBfbvp0rqlVq0Ur02PRzqarCPRF5pKdUluoV6F1qVxc2tm2zoVaivZQa5XJTRV5NRV64IGnAgnAgmhCBBOBAoQgQTgQKEIEE4EChCBBOBAoQgQTgQKEIEE4EChCBBOBAoQgQTgQKEIEE4EChCBBOBAoQgQTgQKEIEE4EChCBBOBAoQgzg5Go7FcVWEXsVf6VCUHRra1qVx0ftdBq10dp9pVfWpU8GorXO61mJX29pA0a1tXt+HxqSs4rEqMn7zV6lKYLFVzoyVVhISexDEChCBBOBBNCECCcCBQhAgnAgUIQIJwIFCECCcCBQhAgnAgUIQIJwIFCECCcCBQhAgnAgUIQIJwIFCECCcCBQhAgnAgUJOp0kt6b21cqjlVHU8VTFOULPbPP2FUE4EChCBBOBAoQgQTgQKEIEE4EChCBBOBAoQgQTgQKEIEE4EChCBBOBAoQgQTgQKEIEE4ECkswIJwILJQgQTgQBCBBOBAEIEE4EAQgQTgQBCBBOBAEIEE4EAQgQTgQBCBBOBAEIEE4EAQgQTgQBCBBOBAEIEE4EAQgQTgQBCBBOBAEIEE4EAQgQTgQBCBBOBAEIEE4EAQgQTgQBCBBOBAEIEE4EAQgQTgtRLfyVUVtTyjNIWUwxhZ5dczAGvAgnAgCECCcCAIQIJwIAhAgnAgCECCcCAIQIJwIAhAgnAgCECCcCAIQIJwIAhAgnAgCECCcCAIQIJwZVqp1oqdvMCuBBOBAEIEE4EAQgQTgQBCBBOBAEIEHY0jU7XTaGpU7nR7XUFvLV1Cm+uizbOXqqM/iT+t+VBAhAgnAgkQgQTgQBCBBOBAEIEE4EAQgQTgQBCBBOBAEIEE4EAQgQTgQBCBBOBAEIEE4EAQgQTgQBCBBOBAEIEE4EAQgQTgQBCBBOBAEIEE4EAQgQTgQBNKDVsnXC16aOR6M4XPJUicuqIKYJwIFCECCcCAIQI2JwWcFfJ+NkyMsccky6pmOuNwIwIJQILUIwIJQIFCMCCUCBQjAglAgUIwIJQIFCMCCUCBQjAglAgUIwIJQIFCMCCUCBQjAglAgUIwIJQIFCMCCUCBQjAglAgUIwIJQIFCMCCUCBQjAglAgUIwIJQIFCMCCUCBQjAglAgUIwIJQIFCMCCUCBQjAglAgUIwIJQIFCMCCUCBQjAglBuLZ2yaMl7+0KS3K11peR4uzRmM8SYiJ5RMgaMCCUCBQjAglAgUMNXF7XQjoWYXmimXuzqOfi1uSqsNSETZBAgUOjomk09YvatvV1O105KdB9ZKly7FrlanopuvyU5kEoEChGBBKBAoRgQSgQKEYEEoEChGBBKBAoRgQSgQKEYN/VNWvtZr0a9/Ua+pRotoNVrEb5rerq8VNKBAoRgQSgQKEYEFio1WtRGwqJzWesjAoRgQSgQKEYEEoEChGBBKBAoRgQSgQKEYEEoEChGBBKBAoRgQSgQKEYEEoEChGBBKBAoRgQSgQKEYEEoEChGBBKBAoRgQSgQKEYEEoEChGBBKBAoZp8JEqcVj3KrYZi5Eh0pzXkspE8uXiQglAgUIwIJQIFCMCCUCBQjAglAgUIwIJQIFCMCCUCBQjAglAgUIwIJQIFCUCCcCC1JQgQTgQKEIEE4EChCBBOBAoQgQTgQKEIEE4EChCBBOBAoQgQTgQKEIEE4EChCBBOBAoQgQTgQKEIEE4EChCBBOBAoQgQTglTpLUcqI5rYarvOcidST29uwoVQIJwIFCECCcCBQhAgnAgUIQIJwIFCECCcCBQhAgnAgUIQIJwIFCECCcCBQhAgnAgUIQIJwIFCECCcCBQhAgnAgUIQIJwIFCECCcCBQhAgnAgUIQIJwIFCECCcCBQhAgnAgUIQILqVRaNTNGtcsKkPajk5pBCBQhAgnAgUIQIJwIFCECDpaVomq63Vr0tKsat2+3ouuKqU09Cm3rcvtQ0IHbwIQIJ4qZRiihXAgtwGGwFUCC3h7Dh7AVQILcDGCgVwIJ4qMV7hQhAgnBs2V35G+s5bW3uOLSdSiuzJGSnpN7nJ2KKGnAgnAgUIQIJwIFCECCcCBQhAgnAgUIQIJwIFCECCcCBQhAgnAgUIQIJwIFCECCcCBQhAgnAgUIQIJwIFCECCcCBQhBt6dYrqOoU7Nt1bWqvRy8W6qpTpthqu5uXqmITvVUQ14EChCBBOBAoQgQTgQKEIEE4EChCBBOBAoQgQTgQKE8RiXYjElfVfqF87UFtVdaW1v5PQbQTyenhmjZ853e5Z5qaWJdiMRHY1U4jEuxGINVOIxLsRiDVTiMS7EYg1U4jEuxGINVOIxLsRiDVTiMS7EYg1U4jEuxGINVOIxLsRiDVTiMS7EYg1U4jEuxGINW/peqU9OsdTtqmlWl6t9Q4LatdmTrdZ9Ji9i/ohysS7EYkfqaqcRiXYjEk1U4jEuxGINVOIxLsRiDVTiMS7EYg1U4jE62i6Nc67rNvpVpUoU69ecXV6mDEhqrzXwQ0FYqOVFjly5EX3o1UYjEuxGJJqpxGJdiMQaqcRiXYjEGqnEYl2IxBqpxGJdiMQaqcRiXYjEGqnEYl2IxBqpxGJdiMQaqcRiXYjEGqnEYl2IxBqpxGJdiMQaqcRiXYjEGqnEYl2IxBqpxGJdiMQaqcRiXYjEGqnEYl2CkkpKvYLTq18F7jKU1NpKRNKWxFmrFjeX+nVKlTT7yvaPq03UXuo1FYr2O62rHWi9xQlLY20pbE0pbFbNWklLYklLY3UpbGUpbEbGrSSlsZ4WxvcHYzwSNjVocLYcLY3+COCNjVz+FsY4Wx0ODsY4Ww2NXP4SdxFaWx0FpbEVpbE7GrnrS2IrTU6C0diC0ti2xq0MF7hibi0tiC0tibNWtiMS/BTGJNo1U4jEuxGINVOIxLsRiDVTiMS7EYg1U4jEuxGINVOIxLsRiDVTiMS7EYg1U4jEuxGINVOIxLsRiDVTiMTtaXo9PUrTUa79RtrRbKgtZtOs6HVv4W96/qhy8SL9jVTiMS7EYkmqnEYl2IxBqpxGJdiMQaqcRiXYjEGqnEYl2IxBqpxGJdiMQaqcRiXYjEGq7HYY7F2IxKujVTjsMdi7EYg1U47DHYuxGINVOOwx2LsRiDVTjsMdi7EYg1U47DHYuxGINVOOwx2LsRiDVTjsMdi7EYg1U47DHYuxGINVOOwx2LsRiDVTjsMdi7EYg1U47DHYuxGINVOOwx2LsRiDVTjsMdi7EYg1U47DHYuxGINVOOwx2LsRiDVTjsMdi7EYg1U47E6Ksp16dSpRbWY1yK6m5VRHpPUqpC89lJ4jEg1Uq1FVVRsT2J2DHYuxGJJqpx2GOxdiMQaqcdhjsXYjEGqnHYY7Gw2i5zHvRWojIlFciKvgnb6iOIs1bFrp9tcaZfXVW/pUKtujVp0HJ51eVhY8PWaOOxdiMSDVTjsMdi7EYkmqnHYY7F2IxBqpx2GOxdiMQaqcdhjsXYjEGqnHYY7F2IxBqpx2GOxdiMQaqcdhjsXYjEGqnHYY7F2IxBqpx2GOxdiEZINVOGxlKc9hsNp7FraWxEyatZtLYsbS2NlKWxY2lsUnI1ayUtiaUtjaSlsTSlsVnJOrVSlsSSlsbaUtiaUtiuxq00pbEkpbG4lLYklLYjY1aXC2M8LY3UpHU0PR7XVdS8lu9UoaZT4bn8ev6Mp2dac1KzlUWavPcHYcLY3uFsOFsTsnVo8LYjwtjf4WxjhDZGrQ4WxFaWx0OFsQWlsTsauetLYitLY6C0tiC0ti1mrnrS2K1pbHRWlsVrS2LbGrnLS2K1pbHRWlsVupbFoyRq0MNhjsbS0iCsVC1mqjHYY7F2IxJNVOOwx2LsRiDVTjsMdi7EYg1U47DHYuxGINVOOwx2LsRiDVTjsMdi7EYg1U47DHYuxGINVOOwx2LsRiDVTjsMdi7EYg1U47DHYuxGINVOOwx2LsRiDVTjsMdi7EYg1U47DHYuxGINVOOwx2LsRiDVTjsMdi7EYg1XYjEuxGJS2+qnEYl2IxFmqnEYl2IxFmqnEYl2IxFmqnEYl2IxFmqnEYl2IxFmqnEYl2IxFmqnEYl2IxFmqnEYl2IxFmqnEYl2IxFmqnEYl2IxFmqnEYl2IxFmqnEYl2IxFmqnEYl2IxFmqnEYl2IxFmqnEYl2IxFmqnEYl2IxFmqnEYl2IxFmqnEYl2IxFmqnEYl2IxFmqnEYl2IxFmqnEYl2IxFmqnEYl2IxFmqnEYl2IxFmqnEYl2IxFmqnEYl2IxFmqnEYl2IxFmqnEYl2IxFmqnEYl2IxFmqnEYl2IxFmqnEYl2JlGT2CzVSjJLG0ti5tPYtbS2ImTVQ2lsWtpF7aexa2nsUmTVQ2lsWNpbGw2lsWJS2KTKdWulLYmlLY2UpbFiUtilmrVSlsTSlsbSUtiSUtiuxTVSlsSSlsbaUtjKUtiNjVqJS2M8LY3EpbGeFsRsatLhDhbG7wthwthZq0eFsY4WxvcLYxwthsatFaWxFaWxvLS2IrS2J2NWgtLYgtLY31pbEFpbFtjVoLS2K1pbHQWlsVrT2LRkauetLYqWlsdFaWxUtLYvEmrnrS2KnUtjoup7FTqexeJRq5y04MYm66lsVLT2L2atfEYl2IxFmqnEYl2IxFmqnEYl2IxFmqnEYl2IxFmqnEYl2IxFmqnEYl2IxFmqnEYl2IxFmqnEYl2IxFmqnEYl2IxFmqnEYl2IxFmqnEYl2IxFmqnEYl2IxFmqnEYl2IxFmqnEYl2IxFmq7EYl2Owx2K231U4jEux2GOws1U4jEux2GOws1U4jEux2GOws1U4jEux2GOws1U4jEux2GOws1U4jEux2GOws1U4jEux2GOws1U4jEux2GOws1U4jEux2GOws1U4jEux2GOws1U4jEux2GOws1U4jEux2GOws1U4jEux2GOws1U4jEux2GOws1U4jEux2GOws1U4jEux2GOws1U4jEux2GOws1U4jEux2GOws1U4jEux2GOws1U4jEux2GOws1U4jEux2GOws1U4jEux2GOws1U4jEux2GOws1U4jEux2GOws1U4jEux2GOws1U4jEux2GOws1U4jEux2GOws1U4jEux2GOws1U4jEux2CM2FmqpGSWNp7Fraexc2nsRMmqptPYtbT2LW09i5tPYzmTVS2nsWtp7Fzaexa2nsUmSlDaexa2nsXtp7FjaexSZKUNp7FiUti9KexYlPYpMlNdKZNKexspT2JpT2K2U1UpbEkpbG0lPYklPYrsU1EpbGeFsbaU9jPD2I2KanC2McLY3eHsOHsNimlwtiPC2N7h7EeHsTsU0lp7EFp7G+tLYgtPYnYporT2ILSN9aexWtPYmymgtLYrWmb609it1LYvElNBaZUtLY6DqexU6nsXiTVz3U9ip1PY6DqexU6nsXiTVz3U9il1PY6LqexS6nsXiTVz1pqYxNx1PYqVkdhpZqoxGJdjsMdhZqpxGJdjsMdhZqpxGJdjsMdhZqpxGJdjsMdhZqpxGJdjsMdhZqpxGJdjsMdhZqpxGJdjsMdhZqpxGJdjsMdhZqpxGJdjsMdhZqpxGJdjsMdhZqpxGJdjsMdhZqpxGJdjsMdhZqpxGJdjsMdhZquxGJdiMSro1U4jEuxGINVOIxLsRiDVTiMS7EYg1U4jEuxGINVOIxLsRiDVTiMS7EYg1U4jEuxGINVOIxLsRiDVTiMS7EYg1U4jEuxGINVOIxLsRiDVTiMS7EYg1U4jEuxGINVOIxLsRiDVTiMS7EYg1U4jEuxGINVOIxLsRiDVTiMS7EYg1U4jEuxGINVOIxLsRiDVTiMS7EYg1U4jEuxGINVOIxLsRiDVTiMS7EYg1U4jEuxGINVOIxLsRiDVTiMS7EYg1U4jEuxGINVOJNtPYsRnMtbT2ImTVW2n1ci5tPYsbT2Lm09ikyilbaexa2n1ci1tPYtbT2M5kpW2nsWNp7Fraexc2nsZzKKUtp7Fraexc2nsWtp7FJkpQ2nsWJT2L209ixtPYpMlKEpbE0p7GwlPYmlPYrZTWSnsTSnsbCU9iaU9iuyKaqU9jPD2NtKexnhkWU1OHsY4exucMcMjYpp8PYjw9jd4exHh7E2U0lp7EVp7G6tMitPYnYporT2K1p7G+tPYrWmWiU00Vp7FS09jfWmVup7F4kpoOp7FTqexvup7FTqexeJKaDqexU6nsb7qexU6nsXiU057qexU6nsb7qexU6nsaRJq57qexS6nsdB1MpdT2NIk1aOIxNhzNiOJe06qcRiXYjEGqnEYl2IxBqpxGJdiMQaqcRiXYjEGqnEYl2IxBqpxGJdiMQaqcRiXYjEGqnEYl2IxBqpxGJdiMQaqcRiXYjEGqnEYl2IxBqpxGJdiMQar8RiW4jEq31VYjEtxGINVWIxLcRiDVViMS3EYg1VYjEtxGINVWIxLcRiDVViMS3EYg1VYjEtxGINVWIxLcRiDVViMS3EYg1VYjEtxGINVWIxLcRiDVViMS3EYg1VYjEtxGINVWIxLcRiDVViMS3EYg1VYjEtxGINVWIxLcRiDVViMS3EYg1VYjEtxGINVWIxLcRiDVViMS3EYg1VYjEtxGINVWIxLcRiDVViMS3EYg1VYjEtxGINVWIxLcRiDVViMS3EYg1VYjEtxMozmLNUGs2Lm09iTaexe1mxSZKQbT2LWs2Jtp7FzWbGcyjVBtPYubT2JtZsXNYZzKKVtp7FraexY1mxc1mxnMopW2nsWNp7FrWbFraZSZRSptPYsbT2LW09i1tPYpMlKUp7E209i9tPYmlPYpMopQlPYmlPYvSmTSnsVsprJT2JcPY2Up7GeHsVtFNXhjh7G1w9hw9hZTU4exHh7G5w9jHD2JtNNJaexBaexurT2ILTJsppLT2K1p7G8rNitaexeJKaLqexWtPY3lp7FTmFolNNFaexU6nsby09ipzNjSJTTRdT2KXU9jfczYpczY0iSmi5mxS6nsb7mbFLmbF4lNNB1PYpczY33M2KXM2NIlOrRczYqwN11PYpcw0iU6qMRiW4jEsaqsRiW4jEGqrEYluIxBqqxGJbiMQaqsRiW4jEGqrEYluIxBqqxGJbiMQaqsRiW4jEGqrEYluIxBqqxGJbiMQaqsRiW4jEGqrEYluIxBqux2GOxdgvcMF7ilujVTjsMdi7Be4YL3CzVTjsMdi7Be4YL3CzVTjsMdi7Be4YL3CzVTjsMdi7Be4YL3CzVTjsMdi7Be4YL3CzVTjsMdi7Be4YL3CzVTjsMdi7Be4YL3CzVTjsMdi7Be4YL3CzVTjsMdi7Be4YL3CzVTjsMdi7Be4YL3CzVTjsMdi7Be4YL3CzVTjsMdi7Be4YL3CzVTjsMdi7Be4YL3CzVTjsMdi7Be4YL3CzVTjsMdi7Be4YL3CzVTjsMdi7Be4YL3CzVTjsMdi7Be4YL3CzVTjsMdi7Be4YL3CzVTjsMdi7Be4YL3CzVTjsMdi7Be4YL3CzVTjsMdi7Be4YL3CzVTjsMdi7Be4YL3CzVTjsMdi7Be4YL3CzVTjsMdi7Be4YL3CzVTjsMdi7Be4YL3CzVTjsMdi7Be4YL3CzVTjsMdi7Be4YL3CzVTjsTYzYsRiz1FraewmUaoNZsXtZsSbT2Lm09jOZRqg1mxc1mxNrNi1tPYzmUaotZsXNZ1ciTaexc2nsZTKKQazYtazYm1mxc1mxSZVpBrNi1rNibWbFraexnMopBrNixtPYsbT2LW09jOZRSptPYsSnsWtp7FiU9ikyilKU9iaU9i9KexNKexXZFNdKexLh7GwlPYzwyuxTW4ew4extcPYcPYWU1OHsRWnsbi09iC09idimmtPYgtM3Vp7Fa09i2yaaSsK1ZsbrqexU9rWpLlRElElV7V5IWiSmm5mxU5mxuup7FbqexeJTTRczYqczY3XU9ip1PY0iU00XM2KnM2N5zNil1PY0iVqaLmbFTmbG86nsUup7GkSmmi5mxS5mxvOp7FLqexpEp1aLmbFLmbG86nsUup7GkStq0sNhjsbDmc+ojgvcXtOqnHYY7F2C9wwXuFmqnHYY7F2C9wwXuFmqnHYY7Erh6W9rWuHJ5tJivX1JJKkqVqLKrPRe1HJ4KLRUXSvHYY7F2C9wwXuFp1U47DHYuwXuGC9ws1U47DHYuwXuGC9ws1U47DHYuwXuGC9ws1U47DHYuwXuGC9ws1U47DHYuwXuGC9ws1U47DHYuwXuGC9ws1U47DHYuwXuM4bCzVs8PYcPY2+HsOHscXUdGrU4ew4ext8PYcPYdQ1anD2HD2Nvh7Dh7DqGrU4ew4ext8PYcPYdQ1anD2HD2Nvh7Dh7DqGrU4ew4ext8PYcPYdQ1anD2HD2Nvh7Dh7DqGrU4ew4ext8PYcPYdQ1anD2HD2Nvh7Dh7DqGrU4ew4ext8PYcPYdQ1anD2HD2Nvh7Dh7DqGrU4ew4ext8PYcPYdQ1anD2HD2Nvh7Dh7DqGrU4ew4ext8PYcPYdQ1anD2HD2Nvh7Dh7DqGrU4ew4ext8PYcPYdQ1anD2HD2Nvh7Dh7DqGrU4ew4ext8PYcPYdQ1anD2HD2Nvh7Dh7DqGrU4ew4ext8PYcPYdQ1anD2HD2Nvh7Dh7DqGrU4ew4ext8PYcPYdQ1anD2HD2Nvh7Dh7DqGrU4ew4ext8PYcPYdQ1anD2HD2Nvh7Dh7DqGrU4ew4ext8PYcPYdQ1anD2HD2Nvh7Dh7DqGrU4ew4ext8PYcPYdQ1azaXPqLm0ti5tLYtbS2KzyGqltLYubS2Lm09i1tPYpPIrqpbS2Lm09i5tPYtbT2M55FdVTaexa2nsXNp7FraexSeRXVU2nsWtp7Fraexc2nsZzyK6qm09i1tPYtbT2LW09ik5q0pbT2LW09i5tPYtbT2KTmilLaexY2nsXNp7FqU9ik5q0oSnsTSnsXpT2LEp7Fd0U10pbGUp7HoNN6NanqaI+jQ4dFf7Wr5qL4dq+o9PbdArRqIt5e1Ki9qUmo1PfJllz44+ZY5cuGPaZfOeEY4Wx9VToVoaJC06y7rUNev0E0t6fUXFek7dUcnw+ZT/qcWceowfMVpbEVp7HsNQ6F6naIr7fG8pp+Dk7/AE/pJ5t9FzHKx7Va5OSoqQqGuPLE+Jb45Y5d8ZaC09itaexvrT2K3U9jSM16aC09jx/0gat+w+jVK6asPdd0Ub/lej19zFPdLTPjH02XsVNI0xq9j6709jW/9x0cE75xDH1GWnFMvqeLXMa9qorVSUVO1Ct1PY0Oh13+0+hGkXirk51u1jl73N81fe1Tsup7FZyqadGP4oifloOp7FTqexvup7FTqexaM19XPdT2KnU9jfViL1QpU6nsaRyLRi4mq10sdIvL1eXAovqetEk19Kr+X6JZXvWtaix6+Kpz95ofSNc+R9C7hqLDrh7KKe3Jfc1TX+ju58r6G0qarLraq+kvtyT/AKjeJ/Bsw3/x+n+jvupbFLqWx0XU9ip1PYRyOqMXOdS2KXUtjoup7FTqWxeORbVzXUtiHD2N91LYr4exeORbVqcPYcPY2+HsOHsT1DVqcPYcPY2+HsOHsOoavM9K6vkvRm6VOTqkU09a8/dJPoxV8q6NWb+tWN4a/wCVY+CIcr6Q6/DtLGzRfTe6oqeCQn/UpZ9Htfi6bd2irK0qqPTwcn/+S+34bed1P85p+lfu9Tw9hw9jb4ew4exTqPR1anD2HD2Nvh7GHNYxEV6oiKqIniqwg6hq1eHsOHsbfD2HD2HUNWpw9hw9jb4ew4ew6hq1OHsOHsbfD2HD2HUNWpw9hw9jb4ew4ew6hq1OHsOHsbfD2HD2HUNWpw9hw9jb4ew4ew6hq2+HsOHsbfD2HD2PM6jq1anD2HD2Nvh7Dh7DqGrU4ew4ext8PYcPYdQ1anD2HD2Nvh7Dh7DqGrU4ew4ext8PYcPYdQ1anD2HD2Nvh7Dh7DqGrU4ew4ext8PYcPYdQ1anD2HD2Nvh7Dh7DqGrU4ew4ext8PYcPYdQ1anD2HD2Nvh7Dh7DqGrU4ew4ext8PYcPYdQ1anD2HD2Nvh7Dh7DqGrU4ew4ext8PYcPYdQ1anD2HD2Nvh7Dh7DqGrU4ew4ext8PYcPYdQ1anD2HD2Nvh7Dh7DqGrU4ew4ext8PYcPYdQ1anD2HD2Nvh7Dh7DqGrU4ew4ext8PYcPYdQ1anD2HD2Nvh7Dh7DqGrU4ew4ext8PYcPYdQ1anD2HD2Nvh7Dh7DqGrU4ew4ext8PYcPYdQ1anD2HD2Nvh7Dh7DqGrU4ew4ext8PYcPYdQ1anD2HD2Nvh7Dh7DqGrU4ew4ext8PYcPYdQ1anD2HD2Nvh7Dh7DqGqhtPYtbT2Lm045kVrUmduS9yE45ZZ9sYtnlWPlltPYtRiIkrCGo66evoIjfeUue5yy5yr4nTj6fOfumnNlzY+zoLWoM63ovhzILfMT0KSr4rBoA3x9PhHnuxnlmW5+0Kv3WMT1D9o3PZj7DTBp0cPhTaflvJqd0n4F/yl1PWarfTosd4ShywRPBxz7G0vRUdatHQlVj6a98Sh1LavbXKTQqsfsi8/YeJMtc5rkc1VaqdSoc+fo8Z+2aTvL6A1mxa2nseOtNevrZUR7krs7n9ftPSWOt6fd4tdU4FRfu1OXsXqPO5fT8vH3q4/Ra4l0m09ixrNixrNi1rNjh3JhWykrnI1rVVy8kRE6z32gdE6Nuxl3qdNKldebaS82s8e9SrojorUb+1bhiKvVRaqdXe79D2RhnyT4h5vqOab0xERESE5IADBwgAAHH1jQLPV6Suc1KVyiebWanPwXvQ7AJiZjvC2OU4zcPjt9p1xp92+1uqeNRvsVO9NjSczY+sa9pFPVrBWtaiXNNJpO+Xgp8xfSVrla5qoqLCovYdWPJb1uHkjkx/VouZsfmf6WL/wAt+kW7pNWWWlOnbt9SZL73Kfp+phTY6pUc1jGpKucsIiH421u/XVOkGoakqz5VcVKqeCuVU9x630+Ns5y+HL6/KsIx+X2v6INTpP6E3FtXqtZ5HcuRMl6muRHJ78j1t1r1Fsttaa1F/E7kns6z4h9G91w9WvLNVhK1JHom7V/Ryn009D/pcJznPL3bel5L4oblXUr2tM1lanczkarnOcsucrl3UiDoxxxx8Q6LZ6uosZcV6a+bVcmyrKFQJmInyPCfShqlSvS02wdHJXVnR29SJ/3Fn0S3c1dT05y9bWVmp4Sjvi08v07uvKellamiy23Y2knsyX3uUx0FvHWfS+2hytSs11JVTdJT3ohnnxxOE4R2eTHNXq9p+a/Z96WnsVOp7FdC/RYbcJ/mT5m7i1zUc2FRepUPJzjPjmsn0uExl4aLqexS6nsdB1PYqdT2KxyNYxcLWK/kOi3t51LRoucnjHL3lelVvLtHs7zrWtRa9fFU5+8530i3HknRCpTRYdc1WUk/6l/6Sv6PrjyvohSpqsut6j6S+3JPc46In/D3/Vyxy/5npfp+70PD2HD2Nvh7Dh7GXUdurU4ew4ext8PYcPYdQ1fG/pAueN0o4CLyt6TWRuvnfNCz6PLnh9I6lsq8riiqIn8SKi/CTz+u3XlvSG/ukWWvrOx/KiwnuRC3o3deRdKNOuFWGpWa1y9yO81fcqnq6/4dfo+Qjm/zfU/X/b/8fcOHsOHsbfD2HD2PK6j6/VqcPY8t001H9mWmnOasKt2x67tbzX5HtOHsfKfpMuc9btLNF5UKOa7K5f0ahtwZb5xDh9fn0uCco8vpaMRURU5oo4exq9HK/l3RnTrqZV1FqOXvcnJfeinV4exlOdTTswrLGMo92pw9hw9jb4ew4exHUW1anD2GEdZt8PY830s1NNJo6Y7KOLe00f8AkTm75Fsc5ymoZ8kxx4znl7Ozw9hw9jb4ew4exXqNNWpw9hw9jb4ew4ew6hq1OHsOHsbfD2HD2HUNW3w9hw9ja4ew4ex5nUdOrV4ew4extcPYcPYdQ1avD2HD2Nrh7Dh7DqGrV4ew4extcPYcPYdQ1avD2HD2Nrh7Dh7DqGrV4ew4extcPYcPYdQ1avD2HD2Nrh7Dh7DqGrV4ew4extcPYcPYdQ1avD2HD2Nrh7Dh7DqGrV4ew4extcPYcPYdQ1avD2HD2Nrh7Dh7DqGrV4ew4extcPYcPYdQ1avD2HD2Nrh7Dh7DqGrV4ew4extcPYcPYdQ1avD2HD2Nrh7GFajUycqIidqk9Q1a3D2HD2MVLy3ZybL12NddQd92kieKydeHp+fPvGLny5uLHzLZ4ew4exq+X1P7tpY3UG/fpKngpafS88eyI9RxT7ruHsOHsTp3FvU5I9Gr3O5F/DOTOcsJrKKb465ReM21eHsOHsbXD2HD2KdRbVq8PYcPY2uHsOHsOoatXh7Dh7G1w9hw9h1DVq8PYcPY2uHsOHsOoatXh7Dh7G1w9hw9h1DVq8PYcPY2uHsOHsOoatXh7Dh7G1w9hw9h1DVq8PYcPY2uHsOHsOoatXh7Dh7F1V9Kik1HIm3aaFW+c6UpNxTvXrOni4uXl+2O3yx5OTDj+6V70ZTSXqiJuaz7lqcqbZ3U1nOc5Zcqqu5E9bj9Hjj3zm3Bn6nKft7JuqPf6Tp2IAHbEREVDkmZnvIACUAAAAAAAAAAAAADqafrt/p8NZU4tJP7OpzT1dx7bRNe03VruhaVKqWdWq9GxVVEbzXsd1HzUHHzej4+XvVT8puX68o0qdGgyjSajabGo1qJ2IhM/M+g9O+knR9GUrW9Wvat6re489iJ3J2t9SofUND+lzRL7GjrFF+mVl5Z86lJfWiSnrT1nhc30/m4+8d4/T/h5efBnj38vpANWy1Gw1GjxrC9oXVP8VGoj09xtHnTEx2lzgAIAEXvZSpuqVHtYxqSrnLCIh4/WvpJ6LaQ1zWXn7QuE6qVr56Tu70ff6jTDiz5JrCLWxxnLtEPZHyDp1rmkaVrlZttVZdV3pk+lSdOD+1HL2d/fzPK9IvpK6Q67nQoVf2bZu5cK3cuTk/if1r6oTY8Se16f6ZMfi5p/k7+Diy452lDph0hva3R7ULmvVwY2k5GU28moq8k8eaofnw+r/SFdcHo2y3RedxWa1U2Tn8UQ+UHt4444RrjFQ4vW5XyRHw7nRK68k6WWD1WGvfwl3yTH4qh9qPz5RquoV6dZiw+m5HJ4osn6AoVW17enXZ6FRqPTwVJLNvQ5fhnFMAB6IAc7XbryLo9f3MwrKLsV/iVIT3qgRM1Fviup3Xlur3d3MpWquengq8vcRsLlbLU7W7T+xqtqexZNYB83c3b9DIqKiKiyi80U2Le6qW7uXnN7WqcPo9deW9GtPuJlVota5e9U81feinUK5YxlFZQ+lwz7RlDvUa1K5bNNeadbV60JOp7HBY91N6PY5WuTqVDrW2oU6sMrwx/4uxf0PG9R6TLj/Fx94ejxc8Zdsu0vl/0s3UV9M09qxDX1nJ4wifBxH6KrqampWDl60ZWantRfi04X0j3iXfTq7Y1ZZbNZRb6klfeqmPo5vPJenFqxVhtyx9FfWkp72oden+Ur9L/AHeFHN/9jt+tfs+3cPYcPY2uHsOHseF1H1urV4exoaxcfs/Qr69mFo0HvTxROXvg7PD2PG/SVdeR9CqtJFh11VZRT25L7mmvFlvyY4/LD1GXT4ss/iJfCzKKrXI5FhU5opgH1D8/fpHTa6X+k2l62Ir0WVPaiKbfD2POfR1deW9B7VFWXW7n0XepZT3Kh63h7Hy3LlpnOPxL9B4J6nFjn8xDV4ex8B6Y3flnTLUqiLLWVeEn+VMfkfoO6qMtLKvdVPQo03VHeCJKn5hq1X169StUWX1HK5y96qsqej9P/FOWTxfrWVYYYfPf+/6vsX0ZXPlPRV9sq+dbV3NRP4Vhye9VPb8PY+V/RNd46vqGnqvKtRbVRN2rH/d7j69w9jl9XOnNMPQ+nZdT02M/Hb+jV4ew4extcPYcPY5Oo9DVq8PY+TfSndZavYWKLyo0VqKm7lj/ALfefY+Hsfn3p1eeWdN9Sciy2k9KKbYojV96Keh6Cd+W/h4/1fLT0+vzP/t9p0W4/aGg2F7MrWoMe7xhJ98m/wAPY8t9Gl15X0KpUlWXWtV9JfCck/6j2fD2OTly05MsfiXo+ny6nFjn8xDV4ew4extcPYcPYy6jfVq8PYcPY2uHsOHsOoatvh7BWIiSvJDgP1K9f11lRP4URDWfUqVFmo9z1/iWTrw+lck/flEf7/8ADly9dhH2w79S6tKXpVmqvc3n8DVfqtFPs6TnePI46qjUVVVERO1Tm3OtWdCWsctZ3czq9p38f0zij7rly8nrs/0h6F2qV19FjG+qSlb66d/ax4IiHkauv3Tl+qp06ab81NZ2r6g7ruFTwaifI7sfR8WPjGHDl63KfzS9r5Zc/wB84m3ULpv9ojvFqHhm6tqLeq5VfFqL8jYp69eMX6xtOom6QvuLT6XjnzjH9ER6ufmXuqeq9lWii7tN+hdW1wqIyoiOX7ruSnhaOv2z1RK1J9Je9POQ6lG4oXDcqNVr02XqOHm+m8WX2/hl28Xrsvm3r+HsOHscO21O4oQ1y8Vnc7r9pvVNatfLdMtqb4fd1nU1avWiJTcvxRvtPC5/R8/DPi4+Yepx+p4s481Le4ew4ext8PYcPY87qOzVqcPYcPY2+HsaWrXCafot7fLy4FF9RPFE5e8nHOcpjGPdXKIxicp9kaLqdxb069JcmVGo5q7KWcPY4H0f3PlvQu1yXJ9Bz6Ll8FlPcqHquHsac18XJlxz7TTPhy6vHjyR7w1OHsOHsbfD2IUlp1qTatJyOY5JRU7TLqNdWvw9hw9jb4ew4ew6idWpw9hw9jb4ew4ew6hq1OHsOHsXU3U6tStTYsuouRj07lxR3wVC3h7DqUiMbanD2HD2L6rqdCktWq5GMTrVTz17qdS4VadKadL3u8Ts9L6fk9TP4fHy5+bmw4Y/F5bN1qFKiqsoolR/f2IcqrXq13TUeq7diFQPqeD0vHwx+GO/y8Xl58+Tz4AAdTnAAAL6N1Xoeg/l+FeaFAK5YY5xWUWtjlOM3Eu1b6lQqQ2snDd39h0EYjkRWwqL2oeVL6F3Xtlmk9UT8K80U8jn+mRl34Zr9HocXrZjtyRb0nD2HD2NW11a3qw2unBd3/dX9Dpta17Uc1Uci9SpzPA5sOThmuSKepx54ckXhNtbh7Dh7GwzCo1XMVHIiq2U70WF96E+HsYdRpq1OHsOHsbfD2HD2HUTq1OHsOHsbfD2HD2HUNWpw9hw9jb4ew4ew6hq1OHsOHsYub60tpR1RHPT7rOanGuNWr1pbSRKLduv2nfwek5+fvEVHzLl5fUcfH2mbl069e3t0+teiL+FOanKr6lUqS2i3ht7+00VVVWVWVMHvcH0/j4++X4p/v2eVy+rzz7R2hlVVVlVVVXtUwAek4wAAAAAAAAAAAAAAAAAAAAAAAAAAWUq1a3qpVoVX0qjepzHK1U9aHorPp90wsWIyjrtd7U7KyNq+96Kp5kFMuPDP7otE4xPmHumfSt0wa2Furd697qDZ9xrV/pM6Z12q39rJSavZTo0098SeOBlHpuGPyR/RTp4fDevtX1TVH5ajqNzdr1/W1VcieCL1GiAbxERFQ0iKAASPAdP7TUr+6s6dpZV61GhTc9z2MVWoqr3+DfefOD7T0uv/wBn9Frt7XRUrJwWeLuS+6VPiweL6zGI5L95X2tpc3tdKFpQfXqqk4MSVg+09GvKW9GrGneUalGvSZw3MqNhUxWE9yIfINDv/wBma9Z3sw2nUTP8q8ne5VPuvWkoG/ocY75e4AA9MPKdPatROjaWtFjnvuKrWq1qSuKecq+1EPVlVzXp2tpWuaqxTpMV7l2RJUKcmO2M43T8/ua5jla9qtcnWipCoGtc9yNY1XOXkiIkqpbdXFS7vK11VWalZ6vd4qskbetUtrmlcUlipSej2r3KiygfO9rfVegFWqvR6pa1mOY+hVWGuSPNXmnvyPWlNpc07yyoXdL0KzEe3wVJLg+i48dcIxuwKqNarlWETmqg5fSK68i6NahcTCpRVrV7ld5qe9QtlOsTL4tf3K3mpXV2vXWquqe1ZJ6XeLp+s2V8i/u9ZlX2ORTTBExcVL5yMpjLb3frBGI5qObCovNFTtM8PY8l0V6QV6/RnTqtVErItFrVXqWW+avPxQ9PR1Sxqxk9aa9z0+Z8jzej9RxT9tx+nd+hcPquLlxiYmv4ruHseV6ZdEK3Sq3tKLNRbZ07dznuRaWeSqiInakRz9p7Ji0qqTTqNen8Kyee6c6l+xuhOo3TXY1X0+DS78n+bKeCKq+o5uDk5MeXGMO2S/qcOPLhy6nfGrn+Xd+a67KdK5q06VXi02PVrakRmiLyWOySdnRp3F9Qt61bgU6tRrHVInBFWJjtgoB9vXan5zcXdP0V0O6JVuitldWtS/S8ZWqJUbFLDFYhe1euE9h6fh7HC6K9ILTUuiOnXle4TjrSRlVIVVzb5q+1Un1nRqazat5U6b379SHyGfD6nl5JmcZmf4U/QuHP0/HxYxhNRXb3cP6QLryDoJqT0WHVWJQbvkqIvuk/Ox9a+lXW3XOk2GnNppTSpWWssLKrikf93uPkp9B6Dgz4eKuSO8y+U+rc+PLz/h8RD0/QG88i6d6a5VhtZ60Hb5IqJ71Q/RXD2PynbV6lrd0bqksVKL21Gr3Kiyh+iKWp3Naiysy5crHtRyLspl630WfPnGWE07PpPq8eLjy48o97ek4ew4exwG6net/tp8WoXs1m5b6bKb08IU83L6Z6iPExP83ux63inzbqV3MtrarcVeTKTFe5dkSVPyvc133V3Wuanp1nuqO8VWVPvvTHpEyj0J1NOEtOrVpcFqos+kuK+5VPz6ej9O9PycUZTyRUvnvrPPjyZYY4T2jv/f8AR9V+h67m41XTnL6TWV2p4KrXfFp9c4ex8A+jK98j6f2TFWGXLX0HetJT/ciH6K4ex5X1T8HPfzF/s9f6Nnv6aI+JmP3/AHanD2HD2Nvh7Dh7Hl9R7WrU4ew4ext8PYcPYdQ1eJOde6vb2ssYqVav4UXknipyr/WalxNK2mnS7Xfed+hyT9DjH5fF583ti2bq+ubx01qnm9jE5IhrAGjmmZnvIAAgAIq5Ec1qrzd1ASMtc5jkcxytcnUqLCmAB1rXXLilDa7UrN7+pxzdd1ti67pdxbPc1LVUqr2Ki5c/chWedv38S+qr2IuPsI1g5OXKMat9+0/W2uxpXsIvUlVOr1noEpo5Ec1UVF5oqdp8O0vW7ulaUVc7jMxRIf1+pT2eidMaduraT3RTXrp1FiPBew+Z9d9H2vP0/afj/j4fR+l+pxUY8j3/AAtjx30k3PkXQiuxFh1zUZRT25L7mqe0sbu11Ggla1qo9O1O1vifLvpku8V0rTWr+Ou9PY1v/ceJ9P4cp9Zhx5RUxN/07uz1/PGPpc8sZ8xX9eyH0R3WdDVNPVfRcys1PFFRfg0+o8LY+HfRbeeTdOqVBVht3RfR9cZJ/wBPvPv3C2NvrPHp6qZ+Yif2/Zj9J5tvTRHxcfu4+pV00/Sby+dEW9F9Xnsir8jzn0dXbtQ6E2yvdlUoVH0XKvjknucht/SVdeQ9ArxEWH3LmUG+tZX3Ip5b6G7vOnqumuX0VZXYnjLXfBpXi9Pt9Pz5f/6j/bt+63J6qvXYcf6T/v8A/j6Zwthwtjd4Ww4Wx5FvU3aXC2HC2N3hbGpqdxT03Sbu/qqjW29J1TmsTCKsE43lMRCJ5IiLl4Pohq37Q6Z9KLZXZJxkfT3Rn1ar7EaevvruhYUs6yy5fRYnW4+E9CtdXRelPltRckrU303q6eteaKsboh7G66SW1es6tVqVKz17Ub+p9dyfR9/UbT9lR/Oor9nz3p/qdcFfmuf95v8Ad2by+rXtXKosNT0WJ1Iapxl6Q288qFRfGCDuklu17Grbvl6wnNO6T3cOKOPGMcIqIceXPGU7ZT3dwHHb0gtl9KjVTwhfmWt1ywd1q9ni39C+so6mPy6YNWnqNjVhGXNOV7FWPibSKipKLKELRMT4AUXlwlpYV7lY+qpufC9sIXNcjmo5qyipKKE33pkAi2oxz3MR0uZEp3SQJAAAW07ytZo6rTrOptakr3ewqOZr9x5N0evKkwq08E/zcvmRljGca5RcG84RtHs6XQrpT5Roj0vKbnubcVFV7V5+c7Pq/wAx7OjqenVoRtw1i9z/ADfifF+g1xFa8tVXra2oieHJfih7Y8v1H0ng5Mpyi4mfh0+k+ocsceMT3e/YjHpLHtcneiyS4Wx8/M8R/wCN3tPOn6H8cn+3/t3x9S+cf9//AE965GU0l7mtTvVYNOrqOn0fSumOXuYuXwPGmDXD6HhH35zP8O3/ACpl9Sy/Li9FX6QUklLe3V38T1j3HJuNSvLmUfVVrV+6zkhpg9Th9B6fh7449/me7j5PU8vJ2ykAB2uYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABVRElVhO9TVqajY0pzuWSnY1ZX3EomYjy2gcip0gtWylOlUqePJDRq6/dOX6qnTpp7VJ1lnPLjHu9KQqVqNJJq1WM/M5EPH1dQva3p3L47kWE9xrKqqsqsqW0Zzz/ABDn/SFq1K6r2lhb1Ueymi1XqnVK8k9yL7TwptajceValXrostV0N8E5IapR4/LnOec5SH1HRek11W0S28ym57GJTc50yqpylefrPlx6Toxcc69oq/4jfgvyJx891+DOccu3u96nSC8nnSo+xf1LG9IaqenbMXwcqHEBrrDv6mfy9JT6QWy/aUajPCFOL0z1y2/8MVKFtWmpcvSnEKio3rX4R6zVPIdJLji6i2gi8qLefivP4QUyxiIU5efKMJj5cQAGby31roDf+V9G/JnOl9o9Wf5V5p809R6w+T9AdRbZ9IH21V6MpXVNWyqwmSc090p6z6wioqSiyge76XPbjj9A5PSHSaut6Q6wpXKW+T2uc5W5Sic49sHWNLV75NN0a7vlXnRpq5s9rupE9sBvnETjO3h8Pv7ZtnqNxaMrJWSi9WZokI5UWFFhbNvdSt7N9ZKKVnpTzVJRqryQ13KrnK5yqqqsqq9oa5zXI5qqjkWUVOwPnLi7fb+j2k1dF0dun1bhLjB7nNcjcYRece2TrGlpN6mpaPaXyf21NHOjsd2p7ZN0Po8IiMY18B89+knUnL5FpTaiqiTXe2fU3/uPoR8R6TX/AO0ukt7co6aaP4bPyt5J7Yn1inL6zPXjqPdxwAHivo30b3+VG80x7ubVSsxNl5O/7fafQD4n0W1FNM6S2tw92NJy8Kov8LuXuWF9R9SuNfot822pLUX8TuSExEy9j0vLHTqZ8J9IqlpaaHd39e3pVKlKkqMV7EVUcvJvXuqHxA9p0x1m6ubejaVKvmvdmrG8khOr4+48WJinF6rkjPPt7PQ9EL61s9bwvLdlejWYrMXNR0O60Xn4KnrPpCa/QY1GU7RWsakIiKiIieB8ZpVXUa7KzPSY5HJ6j6JSqNq0WVWLLXtRyeCl8YiV/T8sxjrD0adIqc87V3+ovp69ZP5PbUp+KSnuPMAtrDrjmyY+kDU6FXRrS1t6zX8Wrm5E64anb63J7D5se91PyenYVbitRp1FptXHNqLCr1de8HgjPKKef6mZyz2ltaddvsNVtL6m5Wvt6rKqKmyop+j6GuXjGoquZWavVknZ6j4b0JqWH7dda6jb0K1O4pqjVrMRyNcnNOvq5T7j681rWtRrERrUSEROpEOfl4OPlj/ExiXo/T88+PGZwy8vR0+kNJftbVzd2uRTbp61pz+uo6n+Zq/I8kDzs/pHpsvETH8J/wCbe3j6/mjz3e1S/wBPVJS7pet0E/K7H/8AMof/ANiHhwc0/ROP2zlt/wDI5/8AbDwYAPqHy4AAAAAHOu62Go2rZ5J1+vkdE89qFRXag9UX0VRE9QZ5zUPQghTelSk16dTkRTLnNYxXvcjWtSVVVhEQNEjzNyzh3NRmeaovNYjmeh8ooutVuadRtSljkjmLKKmynmnOV71c7rVZUMeWfD0FgzCyppnkipKLHVJtHP0qrnarTVebF9y/0p0A0x8Q27HU9Q0yulawu6lB6fhXkvinUprdLtfq61ToOv7Wg++aiN8qbkj8EnzYnHrdPJEInn9Rq8W9fC8meahnPFhOcZzHePf3TnyZRhrfaXovo/1DQ9O6SpX1u3e9rWo+hVpq7KnURUVOSKkoqTPWfbaXTvopUidSWmvc+i/9D80Uai0q7KifdWT06KioipzRTzvV/S+H1We+czf6S6fSetz4cNMYh6z6WOkumarZaZYaXdpcNSo+tVxaqYqiIjetN3Hl/o71630DpalzeK9LatRfSfikr2OTl4tQ5GrvalOmyEycsqsc4Q1dMe1t5g5EXJOUp1Kb8foePj9P/wBN+Xv/AB7sOT1OeXqI5vfs+71/pO0ViL5PYXdVU6ska1F96nEuvpP1F6qllpttRb2cRVqL7oPAg5+P6R6TD8t/xl25ev58vzU9Dd9Nekt2q5ak+i1fu0WoyPWnP3nmtUuLy7tarqtapXqKnN1R6qsdvNSw09Sq8OyciLzf5qHo8fBx8X+njEfwhx8nJnnE7zMuLao9bqnw0lyLKJMTB6Y8vSqLSrMqJ91ZPToqOajkWUXmhq5+Lwyc2+rcO/tUnk1ZX18jpHntQfnf1O5sNQLZzUPQgrov4lCnU/E1FLA0CdOrVpOypVHMXvasEABjVdWvV0mpQqV1cypDeaJPXPX6jq6T0hRNMtmV6CuwYjMmrzWOXV6jyusP5Uqfi5S3SX5WrmdrXfEioRjy5Rn5e+p6xYVE+2wXueiocrRtRS56U6tSR6OY7FWQvLzPNWPacVzkYxzl6kSTg6fWczUWPmFcqoqpuRrDTLnm8bfXAeLZf3lP0Lmoidyuk2GazqDeS1kd+ZqFdJdMc+Pu9Yea6ZurfsanTp03uatRHPc1qqjURO3u5qhhuv3qdbKTvUv6nJ6Q65XudPSzcxjEqORVxnmifzgRjMSpy8uM4TDS6JOqs6Q03U6b3MVrm1Fa1VRqKnKe7miH0k+WaDqL9N1VtViI5KjVpuRV656veiHsF6Q3HZQpp4qpOWMzLL03JjjhUvRg82vSC77KNJPFF/Uj/wCILz+6o/6V/UrrLp62L0wPM/8AiC8/uqP+lf1JJ0guu2jSX2/qNZOti9IDgM6ROnz7VF8HR8i9nSC1d9pSqM8IUjWVo5cJ93YBpUtVsKvJtw1q9z/N+JuNc1zcmuRyL2opFLxMT4ZABCQAAAAAAAAAAAAAAAAAAAAABF9SnSblUe1je9ywc+trdlSlGOdVX+FOXtUmImUTlEeXSB5utr9y+Uo02Uk7185TnVb27r/a3D3J3TCewtGEsZ5sY8PX1Ly1ozxLim1U7Fck+w0auu2TOTM6v5WwnvPLgtpDKefL2dup0hqLKUbdrd3LJp1dXv6vLjYJ3MSDQBaoZzyZT7p1KtWqs1ajnr3uWSABKgAABparceS6XXqosOxxb4ryN0hUpUqzUbWpMqNRZhzUVJCJ8dnzgHoukS2tBtK2oW9Jj3ee5WsRFROz+tjzphMU8/LHWaDd0q48l1ShVVYbli7wXkaSLCye4saVheWNK4SzoS5POThpyXtJxi1uPHaezpAA2d6LnNYxz3LDWpKqfPLms64uqtd3XUcrvA+hvY2pTdTemTXJCovahxNZt9PstNe9lpSSq/zGeb1L3+wrlFsOXGZi3kgAYuNZQquoXNOu3rY5HJ6j6XaX9eixtS2ruRjkRyJ1oqeB5vRbfT73TmuqWtJ1WmuL1VvX3KdymxlKm2nTajWNSEROw1xh28MTjFxLv0OkD0hLmgjv4mcvccDp3rlCvo1CxtnrlWqZPRUiGt7PaqewmeJ1648o1eoiLLaScNPV1++SMoiGvNzZRhOPy5YAM3mvqH0dX/G0i409zvOt6mTU/hd/NF9p7Y+K9F9TraZrbHUnIiVmrSdKSnPmnvRD6A7W9QVISo1vg1C0YzL1/T88dOIn2dvWbmtaaJd17em+pXSmqU2saqqrl5JyTdZPiVXTtQoMWpXsbikxOtz6TkRPah9KfqV89IddVPUsfA8v0nvXNt2Wuaq+quTlVecJ/P4EzjUWy9TMZ/i+HkwAUeauba3Tmo9ltVVF5oqMU97aVX1rKjVqNVr3NTJFSFRe05PRu74tk61cvnUVlPyr/M613XS2s6tdfuNVUTvXsT2muMVFuzixiI2t47W7jyjV6qostp/Vp6uv3yc0k5HqquciyvNVVCJnLkmbmw9n0euONpSU1XzqLlZ6utP62PGIiqsIkqd3o3WdR1B9ByKjare3vT+lLYz3acU1k9aADV3PP9JrjC1pWyLzqOyXwT+a+48qdPXLjyjV6sLLaf1aerr98nMMcpuXByTeUrbes63uqVdvWxyOPqNjq1xbsYrH8WiqIqNd1Rt3Hyk9toNxx9Ipoqy6kvDX1dXugnH4a+nznGah9Es9StrxERjsKnax3X/M3DwaKqKiosKh17LXK1GKdyi1Wfi+8n6kzh8PVw5vbJ6UFVC5oXNPiUKiPTtjrTxLSjo8vBgA3eYAAADUttQtru8urWirlqWrkbUlISV6o7+o2xE34RE34DzV2jW3lVGKqplzVe/tPRVaiUqL6i9TUk8uqq5yuVZVeahlyz7PTW6NbbU0Yqq3FIVTXva9mqs024q41L1r6bGwq5JHPw5KR0urnZ4KvNix6jl6x/8Ay7QPGr/0oVzmoWnL8NtTojncdHbjT6lThvt66tXthOv4yWOREeqNWUReS95o2lVdN6Ua5YoseURUanYkrPwcbhXi+2vhz32iPh2NKoojVrtqzKYuZHUp1DiaVXwuFoqvJ/V4nbNHTh9rB5bXsdOtK1SlUV1VGK6Xdir1HqXORrVc5YRElTw3SCqtzRaxeu5rsZG0z8iuc1jMqcs9nQsGcbgMuHQ57UyVO+P1PT0mcKk2nkrsUiVPLoqospyVD0ttWSvbMq9qpz8SyOL4eI6V13Vtf06kx6ojakcl3T+Z1Kb1p1W1E62qinM6R8JemOnUKTERWMzdHasqvyOtauptuWLVajmKsKioZYfdlLL80vSNcjmo5vNFSUJEWtaxqNakInUhI1dgczVaVR9NKmTUp006lXmqqdM5GrV5cygi9XnO+QUz+1zGNze1sokrEr1IektmVKVsynVVFc1IlDzJ6Oyr8e0Y5V85PNd4hnxeWyebvEY29qoxVVMuar39p6Gq9KVJ9RepqKp5dzlc5XL1qsqE8s+z0lojG2dJGKqtxlJ6y85+lVc7VaarzYvuX+lOgGmM3AAAs4+rqziU0RPPjmu3Z8y3SFYtGpCQ9F5r3p2fM0L6otW8e77qLCeCFmmVFZdxzxemPr7A54n8duzcNR1tUa52KK1ZXY8/aNR15SRXK3zuS79h2NTq8OyVqLzeuJwWqrXI5OSosoE8k94erBCk9KtJlROpyIpMNw4mqMqrX4jmxTSGtWU5nbOHqlbiXKUkXzaae8M+Tw06NOpUqo2kkv605wemaqqxquTFypzTuPMUqjqVVtRvW1ZPTse2pTa9qyjklArxJAANgAAAAAJ06tWk7KlUcxe9qwQAHTo65e0oR6tqp/EnP2odOhr1rUhKzXUV7+tDzIInGJaxy5Q9zSrUqzcqVRr072rJM8Ix76bkcxytcnaiwp0aGtXtGEe5Kze56c/aUnD4bY88e71QOTQ161qQlZrqK+1DpUrihXSaNVr/AAUpMTDaMonxKwAELAAAAAACL3sptyqPaxO9ywaNbWbGjKJUWq5OxiT7yatE5RHl0DDnNY1XOcjWp1qqxB5yvr9w+UoU20k7185TmVrmvcLNas5/ivItGEscuaI8PTV9asqMox61ndzOr2nIr67d1ZSkjaLdua+1TlgvGMQwy5cpSqValV2VWo57u9yyRALMgAAAAAAAAAAAAAMKqNarnLCIkqpk5Gv3fk2mrTasPr+Ynh2/p6xM0jKai3lb+6W8v6twvU5fNTuTsNYAwebM33D0XRm7ipVsnryd57PHt/rY86XWtd1rd0rhnWxyL47ExNSthlrlb6ICFOo2rSZVYsteiORdiZs9EPHdIbvj6jwGrLKCR/m7f09R6m9uW2llVuHfcbyTvXsT2nz5znPe57llzllV71KZz7Ofny7asAAycjr9H7vyfUkouXzK/m+vs/T1nsz5s1ytcjmrCosovcfQLG5S8saVwnW5Oady9prhPs6uDLtq2FmFjr7JPK3fR91GjWu618i4orl8zrX2nqzzvSa7xpU7Jq83ee/w7P62Jyqu7TliNbl5cAGLhd+06PurUaN3RvkTJEe3zOpfaeqSYSes890Zu8qVSzcvNnns8O33/E9EbY1XZ3cURrcMdSSp4LU7vyzUatZFlk4s8E6j1WuXfkulvRqxUq+Y35+48SVzn2Zc+X5QAGbmb2lXfkepUqqrDF8x/gv9Se8Pmp7fR71LnSWPqO86kmL1Xbt9hphPs6eDL8rn9JruKdKyYvN3nv8ADs/rY8wbN9creX1W4Xqcvmp3J2GsVmbljnltladKq+jXZWprDmORyH0K3rMuLanXZ6L2o5D50ep6M3edCpZuXmzz2+C9fv8AiThPemnDlU09CVXNXgWtWtEqxqqid69iFoNXY+dVKVfzqlSk9JWVcrVKj03Sa7htKyavX57/AJfM8yYzFS8/PGMZqGWtc5yNa1XKvYiSeh6NurUbqrQqU3tbUbKKrVTmn8pODRqvoXFOtTWHMcjkPoNCsy4t6demvmvajkJxhpw43NrQAauxOlWq0KiVKNRWOTtQ7Vt0gVGY3VLJU+8zt9RwgRMRK+Oc4+AA0tUvV07Sbm9bT4jqTMkbMSTM1Fs5mu7U0TV6mquvkqUm0/J66024r1pvudg8d0GrLXpanVVqNzrI+O6ZPUX1fybTbm4mOFSc+fBFUz48rwuVMMrxuXD6JpxaWpX69dzdvVPBOr4qelOH0To8DotZoqIivRz13lyqnug7hPH9sJw+2EKqsbSc6oiKxElUU8w92dRz4RJWYTsO7qKVn26UqLFdkvnR3HBXksF2fJPd3tNeypaJDUR7fNdCdfccjWVjpb0fX+KqnuQr0OrX/b96yXOotp00VJlGquSzHqLNbT/1V0fX+Or8EMs5vH+f7l3h/fy52u022/TaxuHuxp3FJWOVE61SU/8A1Nk1um9Rraun1067erLl8YX/ALTZJw7ZZQyy+6Yb9hbMrqr0rKypTVFiDuHm7WutvctqdnU5O9D0iKjmo5FlF5opo346pzqlxUva97YUWIxbdWI57l5OyTKPh7Ty+qUVZrun2jnNcrFdUdj2QnI72m1m02ateuTzn3b2pviiMT4KebrV2r0jqXFeojWUbeXOXsVXGUzeMX8suSbiHWY3J6NlGysSvUh27WjVsaNVaz2rTRMuSr1nCRUVEVOpTu2dRl5ZcKsmSt5OSevuNVuPy8PcVFuOm1N7uatYqr7F/U3dN1BNQbWelLBtN+KLM5IcmtXpr0q1S4opFOjTejY/hRG/E6HRpiUtKpvezJr3q5U706vkc3HlM5VH6ueJ7vbWFfj2jVVfOb5qm0UW9KhTZnbthr0ReteZedLvjx3Dh6jbcJy1n1sn1Hcm4x8zt9SSp529uPKLlz0XzU5N8Az5KprnesLbgMV7a3EZURFRMY+ZwDraZdtbTdRqvRqN5tVVj1BnxzF93TqYcJy1URWIkqinmKjkfUc5Go1FWUROw6uoX1J9BaNF+SuXmqdxyAnkm5qHd0x1N9rLWNa9vmuVEie43jy7KtSlPDqOZPXCwYc97vSe53ioTHJUU9MtWk30qrE8XIQW7tk/+RT/ANSHmgDqy27+ule6VWrLG8kLNNuW0azmVHI1j0617FNABntN27l9cWrrVyo6nVf1N6lVJ7TiscjXtcrUciLML2kQDLK5t6anUt0pN4b2NbEoiKiQT4tL+8b7TywDTq/o9O6vRRqxWpz2S5Dz1xTVj8nVqdRzlVVwdJSAplnsyiIqoiqjd1OpaX1K3t+FVer4Xlii9XrOUAiMpjw7S6vR+7SevjCFTtYd9ygieLjlALdTJ0V1e47KdNPFF/Uiuq3XdTT1GgAjfL5byapdIvWxf8pJNWuU62U19S/qc8BG2Xy6jdYf9+gi+CwbDNWt3cntez1ShwwFo5Mnpad3bVfQrNVe5VhfeXHlC2ncV6P2dVzdp5BaOX5enBxaWrVm8qrEenenJTdpala1OSuWmv8AEgaRnEt0IqososKRa5r0lrkcneiySC7ap6jfUuTLqpCdirPxNpmu3zfS4dT8zf0OWCKhaM8o8S7SdIa8c7emvgqhekNeOVuz2qcUDWFupn8us7X71fRZSb4Iv6mtU1XUKnXcuRP4URPgaQFQic8p92XvfUdlUe5697lkwASoAAAAQdUps9J7W+KwBMFK3VsnXcU/9SGPK7X/APIp/wCoIuF4KUurZf8A5FP/AFITbVpO9Go13goLhMABIAAAAAAAAeJ1278q1R7WrNOj5jfn7z2rklqoiqkpEp2HEXozYqsrWuFX8yfoVyiZ8MuXHLKKh5EG5qVC2tr59vave9rOTleqLz7epDTMnFMVNAB3NI0ux1G2c6pVqtrMWHNaqRHYvUIi044zlNQ6XRy741i62cvnUV5flU7hzLLRrewuePRrVldCtVHKkKnsOmbR47u7CJjGpeZ6TXfOlZNXq89/y+Z5s9nc6Ba3dy+4q1q+b1lYVIT3HB1iws9PfTpUKlR9VyZOyVFRE9SGeUT5c3Jjlc5S5QAKMA9H0Zu4qVbJ68neezx7f62NDR7Gz1CrUo3FSoyoiZNxVERU7etDv2+gWltc07ilXr5sWUlU5+4vjE+W/HjlcZQ6yqjWq5ywiJKqfP7+6W8v6twvU5fNTuTsPVdILvyfTVpNWH1/MTw7f09Z4wnOfZbny76gAM3M2rC6Wzv6VwnU1fOTvTtPfoqOajmrKLzRT5sez0C78p0xKbl8+j5i+HZ/WxphPs6eDLvq4fSC78o1JaTVllBME8e39PUcg9q7QNNc5XOpPVVWVXNTzus0bK2vEt7RiorE89Vcq8+4jKJ8ypyYZR+KXMAHbBRiGzb3lW3t7igxfNrtxXbn+kp6zb0WhZXV062u2KrnJLFRyp4oYv8ATqrb6oyzsK/BauKLg5Z71LV7rxjNbQ5gLKtvXoKiV6L6Sr1ZtVJ9pWQoG1p90tlqFKv91Fh3gvWapspYXzmo5tnXVF5oqU15iExd3D6AioqIqLKL2mHOaxqucsNRJVe40NGfXdpjKdzSqU6lLzPPaqSnYvP+uRR0hu/J9O4LVh9dcfV2/p6za+1u+cqx2eVvrl15fVbhfvu5J3J2GuAYvPmb7h6ro1d521SzcvnU1yb4L1+/4nlTb027Wz1GlXnzUWHeC9ZOM1K/Hlrlb34MIqKkosoZNnoAAAHN11nE6O6g2J+oevsSTTdql2vU5rfBppXtzc1rKux1Zyo6m5FSYReRGXeJY5ckVTU+j9USzv1VYTNvwU7PSa8o0ujV8jazVe5mCIjufNUT5nj+i/7vc/mT4G3r65afTopM1azWHPhNcLHHkrjp6vT7y1tNIs7dXq51Oixqo1vajULV1eh92nUXxhDx2u3teytaPkzkY5746p5J2HXSYSes3xmL1+Fo5J8OpX1XiUXMp0larkjJV6jlgFkTlM+VekXvkuraovDzRy029cRDf5jVtQZV6RaHVWm5qU31FXt60aaljzvdQd/ionsahRqX/vOlfmf8EMZ+z+f7q7TrX9+W50lp2tfRLiq2tUdVa5Hojm9fPn7lUvtFp1LCyqMflxKTVd/CvahVfU+Lp1xT/FTciew1NBqZ6LRRV5sVzfeT45P5Iv8AE9WmkUlSUruVPBCro8q09JqUKj1cttXq01V3ZD1Ocyo+msse5q7LBzqV/cWlvrLEqckc+oqKnXkxFLZTUxLWM4ibp0dOpXVfQrPCguL0dWWO1XOV3zPN3tOpUvryk5i/XXNK3VO5GpK/I9NR1etp2g004Dank9uiIkqkw3t9h4x9avqTa1VirTq5VrzzViMlakT4IphyTURiyzmKh7Slb1qzVdSpq5E5cjNR9zplGrcOYrEwd1+Bno9q1F+ksqVlXN/pQnanJSvpPeWlTQ7h1NznVnIlNjeaJzVEX3SdE5fh2hp212iXgLd7mW99UVZc+mjZX+JyKp7HTKfC0q1ZEfVovrXn8zyNy2mx97b0lVYuG02R1K1Mk+SHv7byWlQRlam96t5Jiscjm9PHeWGEd3S0q4lq27l5pzb4dp1DzFC+t3xXtKCtxcqIrnKvNFhS2pf3VTrqq1O5vI6/Lqx5IiKdy4WlwXMq1kpo5ImYU4lVtgzlTdWqL6kT4Gqqq5ZVVVe9TAVyzv2ZMABmAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJMe+muTHq1e9Fg3Kep3VPk5UqJ/EhogJiZjw7DNYYv2lFybtWTYZqNo7+0xXdFPPgLxyZPTJc27uqvT/wBSE+LS/vG+08sAt1f0eoWtRTrqsT/MhBbu1b112epZPNAHVl6B2pWjf7TLwRSh+r0k9Ck53isHGAVnkl0n6vWX0KbG+PMofqF2/wDtYTZEQ1AFZymfdY6tVf6dV7vFZKwAqAAAAAJtqVGehUc3wWC5l7ds6q7l8efxNYBNzDoM1W5b6SMd4pBe3WE+/QVPBxyAFt8ndbqts70ke3xQtbf2juquieKKh50BbqS9Olzbu6q9Nf8AMhJKlNep7V9Z5YBPV/R6rJv4k9pp6nfNsrB9VHItRfNYm5wTlXlbiVsUXzWcvWRlNQrly9muqq5VVVlV5qqmADByhvaVerY6gyqq/Vu816bGiCUxNTcPoqXFu5JSvTXwchJtSm70XtXwU8ZZVuJSwVfOZ8DaN4m3XHL+j1FasyhQfWqLDGJKnz+6uX3d3UuKnpPWY7k7ENu/uX4pbo9YXm5J9hzjLKfZjyZ7dgAFGK22uH2t1TuKa+cxZ8dj6BQrMuLenXprLHpKHzo9J0avoV1hUXr86n80+ftL4z7N+HKppu6no1TUblKq3SU2tbi1uEx7zhanpLdNpsV10lR715NRkcu/rPaqqIiqqwic1VTweqXq31++si/Vp5rE2LZRC/LGMRfu0gAZOV1NM0lupUnubdJTexYVqsnl39Z3tM0epp1y6ql0lRrmw5uET7zzWl3q2OoMqqv1a+a9Nj3iKioiosovUqGuMQ6uKMZi/dr3122ysqlw7mrU81O9exDwFR76tR1R7snOVVVV7VO10ivuPeJasX6uj17u/l+pwyuU3LPlyuaDUqfValSf2VWqxfFOaG2at81fJuI30qTkenqMsvFsG7SqPo1mVaaw9ioqLue+tLundWLLpqojXNl38K9p89a5HMRydSpJN2q3dnQSypq3gXLsXz1p4ePaaRlr3bcfJo29TvVvr99aVwTzWJ3IaQMKqNRXOVEROaqvYQzmbm5ZPU6HqtJLJba4fDqXo8plv8jyrXI5qOaqKi80VCyjUWlVa9Ozr3JxmpWwynGbh7d2rWydTai+pDzmrPuNQvlqtpKlNqYsRVQua5HNRyLKLzQyqoiKqrCJzNZi2uWU5RUuJVpVKKee2FVJRJKKNTi0GVIjJqLBsV6q1qznr1dngadnyt8PwOc32KphPlzy2C9LS4VEVKcovPrQoOlY1sqa0nLzb1eBbGImUxFu1YapUo2NKjXoqr2JjOXWnYSvOkVOzs6ly62VyM7MutepOw55zNTivdWNj18SrxHJ/C1Jj1mmU1j2b75RHZ6f9svc1FS3Rsp1K7qILq9x2U6aepf1OcCyd8vkMOTJqt70g59S7rM6QUrOU4NSkrojnPP9DokRMTal2890X+xuU/ib8zb1Xz9Q0yj31s/ZBqdGuTr1vc5vzNi/qsp9ILR9V0U6FJ9R23JU+RzY/wClH9+7OPtUa/8AWahp1BO1/V4qiHoDzl5UZc9JNOdTlWKxr0X1qp6M14++WUrR5kABqs0dO5vvnd9y74Ia+pp/xfSl/jd8jW0utU/b95SzXhuzfjPKc4k2tT/920v/AJjvghz3eH8/3U9nV60hTidHVVlC6tl/sqq/p8jtnE0z6vX9TpfiXP3/AMy+f3YymfMO2cO/WK2o0v75tGPWuJ3Diakn/HbNkcquKf6XyTy+DLw6t0iOsq7V6lpuT3HB6MU8m3FRySiIjE8Oar8jt6g7DS7p0xFJ3wOR0Xci21wzuei+1P5FMv8AVxRP3Qt0ZVtb2801y+g7Nk9y/wBIX6r9Zcafbdj66PVO9G9fxKL7/wAp0itLvqZWThO8f6VPYbFX63pLbs/uaLn+1YIjtjp+p7U8lXqube1KyQq8ZzoXqmT6BKYz2dZ85Waj3uTq5uPf1n42FSp3U1X3Gfp5+5XD3U6Tz0qg78SK72qq/M3TXsW4adbN7qTfgbB1Y/bDSPAACyQAAAAAAAAAADJgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFF1W4NBVRfOdyQ5B0bm3r16sorUanJEVSnyCv3s9pnlcyzm5lqA2/IK/ez2mtUYtOorHRKdxSYmFaRBdRtqldqqxU5cualvkFfvZ7SalNSooVVo1mvTq7fA676jWUlqKstRJ8Tn+QV+9ntLVtrh1BtFzmojVnr6y2NwtFw0XuV71e7rVZIm5+z6v42e8fs+r+NnvK6yrUtMFteg6g9GuVFlJ5CjRdXerWqiKiTzIr2RSonSqvoVmVqaw9iyimz+z6v42e8LYVERVWoxETmvWTrKal07/XH19N4LWI19ZIVzV6k7UOAARM2nLKcvIACFQ9HY60lHQ6jajpr0fNpov3p6vZ8jzgJiaWxynHvDKqrnK5yyqrKqvaYNpLGsqIqK1UXn1jyCv8Aw+0mpRUtUw9qPY5i9Tkgtq0nUX4PiYnkKVF9Z6tZEok8yK9kU0rFyrZtavWxVYvqI6g1Vs3PT0mKjkL6NrWp6lc2yNRVVEqpC9/WbNSxuH0nsWnycip1oUjGZxoqVDHI9jXp1OSUIXP7nW/I74DTaNetp9NWsnGWrzTlCmbxrqdtXa9IVGLKeonzjYhafuVH8iF5ixt61WwouYyUxROvY2FtLhOunHrQnGJqCpbVhWlq0XLzTmngZv62LEpNXm7mvgajadai9KkImPP0kK6tRatVz17S+3alr7Uga9vyq3DO6pPtRFNg12ebfVk/E1rk96Gc+YUbBOlUWlVa9Ow17mrwbapU7UTl49gt1qOtqbqqy9UleUE33pL0TXI5qOasoqShzaP/AJjpDc1OttvTbTbz7V5r+hK1u2UqSsqqqInNFNfSbqilCtcPcuVeq6p1dkwiF5yiZiGl3Tsg1vLrfvd7DR1LWHWtJj7emlSVhUci8i85xEXKdoRv/M6R6dU/Ejm/17TsnG1jzb/S6vdWj2qh2SuH3ZQiPMvP9H/NvdSZ3PT4uNXWM6+vpbU151GMpeCKsmxoa/8AGNRb3uVf9yk7emlfpfc1utKLffCJ+pzxF4Rj+qntEIYN/wDGNKmxsMpMhE7kw5fE9EeftfrOmN078LF+CIegNuL3/ivj7gANlnk+j7nLq6ucqqrqbua/mk62prGq6X/zHfI5GgctTor+Jj0Ovqn/ALppa/4i/I5OP/T/AJ/8M4+11jiN+q6Yv/xqPyT/APU7ZwdTqMtekNjdVFxYjFRVjqTnPxNuTtET+q2TvHMvWI7XNMVf8T3NOmioqIqdSnIv6mOv6Yndl70gtyeP6f8AlM+F2tvw0W4VO1ET2qhyOjquo6lWt3LGdJr435L81Oj0hVf2WlNOupVa35/I0Ui26Yta3k1zUb/s/VDDk/1In+H7qT9zoa/RWrpS1G8nUXI9F93zNWzv6Lr26v69RKaOZTpt8VbKp7Tt16SV7apRXqe1W+1DxiU0Xo5Uf99tyk7JjHzHLeOW0f3Rl2m2va086N25fuUZ/wBzT2N67HQqzl7aCp7Ug8vp7J0zU391Nqe1f5HpL1f+Aok+k2m32qiFOHtjP8P+UY+HQptwpMZ+FEQkAdrUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAORd/vdTx+R1zkXf73U8fkUz8K5eG1p32b/E3TS077N/ibpbHwnHwAAlIAAObqH2zPyjT/t3fl+aDUPtmflGn/bu/L80MvzM/zOkV16bqtJWI/GetYksBq0aH7O/xv9v8x+zv8b/b/M3XVKbPTe1vipS69t29Tld4IVrFWoc+4ocBzW55Sk9UC3ocdzkzxhJ6pJXVdtd7Va1UREjmV0qz6KqrFRFXYz7Wp2tt/s7/ABv9v8x+zv8AG/2/zNZ1zXd11XerkVK5zvScq+Kk3Hwm4dWk6nQpJTfXa6OrsMreW6f2k+CKcgDc2X3VVlasjmTERzMW9daD1cjcpSOspKn12069Ok5FmpML2ciszXdW/dOteVGaxQuGtaivpupL4dZtrfXC9TkTwQ5d95tKnWT+zejvUbfZJWJm5guWvY161Krd0GvVqJUzhNyd45XWldzlVVVi818Chvmas9OypTRfWiwXXf7lW/IvwKx9swhm0cqWVFEVYwT4GalXhvpoqea9cVXuXsI2n7lR/InwM16fFoOYnX1ovcvYTF69hYqo1quVYRElSFKtTr00qU3S1TFGole3a9U9JIVN+00tNVaVavauX0XSn9ewTl3j9R0jXXlqLf4qap7FT9TYNS6qJRr29RUVUlW8t4GXaLENQl/BtkX7V/PwQ2K1ZLdKcslrnIzl2Gt9rrO1FnvX/wCy3UGK6xqKnW2HJ6ivzMCd2/h2dV0x5qonr5ErdnDtaTO5qGtdP41rbtTnxnNnw6zeLR3mwIVKbajcXpKTJMFkrtf82haVfwV2/P8AQ7Bx+kaTpOX4ajV+JtW186vqNxaPYjeG1rmqn3kVP5oXiYjOYn3ppfdyNEVE1++SfxL/ALiuy1JLXyu/Wi57Li4RqrMYpzX5mrb1/J9R1OrMLhURPFXIie86Gj2bbro9cUHpzqVFxVexURI95zYTM1GPnupH6M6Q5tTpFqFVFRU85EVO1Mv5HojwNnUrWlwl4xFii9Eeidyzy9ynvGPbUptqMXJrklFTtQ24MrilsJ7JHL1q8urG2pV7fHHOHyk8jl6te3GF7Y3CpKPa+nCRLJ6vgddKCXvR2nReqqr6DVRd4RU95ac97xxTd9ocPQ/3+1XvSonuOxqn/uWl/wDNX5HD0Ooi6jZs7UdU/wCk7eqLGpaX/wA1fkY8f+n/ADj9lI+11jg9IqbX1bBXckWorVXZYO8ed6TVFTyVretsvXbqRDfm+yV8vD0Rw9R/97oP7Ka0k9rlO21Uc1HJ1KknEvvt76qnXTfb/H+Y5PBl4XawqOuNOoqk5XDV9Sf/AGc7V14PSa0qzCeYq/6lQ6V6rX9INNpKk4o9/u5fA5nSZFbe21ROvBY9SmXL4yn9YVy95emqPSnSe9epqKp4JK76NnXtnJPHwf4Rzk9nqFWNHuKidtJY9aHkbxaflNOnEcO3a1d3YT8VK+onx/fkzbenM/8AT2pP74T2f/Z3LjztLs2dj30U96KcvT0joner3q74IdOoqLZaU3vqU/c1V+ROH2/y/cjw6gAOtoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGpVsuLVdU4sT2QbYExZMWpt6HAa5M8pWeqC4AeABhzmtSXORqbqa776gzk1VeuwmYgtsg5r7+q70Goz3qaz6tWp6b1XaSk5wrtDfuW21R6OqVoVEiG8zXp1qFu9XUke9VSPO5GqCkz7qW23X9Z3oo1vqkodWqv9Ko5fWVgi5LkABCAAAAAAAAA1b6mr7VXM9OmubfUbQ60hSJi4oatVyXOmvcnU5koa94/PR2Pn0kbJbZeZxrR39m7l4KadZY0ZrF62VMfiZZT2v8ARVt1PMubKp3orF9nI2Lv9yrfkX4GtqNNFs6c/dcns6jKVFfpD8vSaxWO8U5E3UzCWxa8rOj+RPgXFVt+50fyN+BaaR4S1qH1dzWo9irxG+C9fvNWr9RrVN/U2qkL8P0Nq48yvQrJ+LBfBf5wa+qtVKVKs30mO/r4GeXaP4IdE0b1yLd2lL+PJTda5HMa5OpUk5V49fLKlVP7BrfjJOc9iWxYefUua/4qkJ4J/wDZuVG50ns/Eioa+nswsKfevnG0Wwj8I5No7i1LNi/2aOVfgh1jmWNLHULpexqwnrWfkdMrx+CAg2o11R9NF85kT6yZrWfnU31l66r1d6upC8z3pLp6+k6JWXuVq/7kNaiqU+kNq/subVE9cT8kN3WknRblP4UX3oc6s7H9hXOzWKviifzJ5O2V/wAP/K8+XCrS7VK9NP7SqrV/1Hqej7cdGpr+Jzl98fI84xqO1ytl1NdVd7EVfkem0n6vo/RVexjne9VMeCPxTKuHl5GlVVFrIrcm10VsTHPrT3wel6OXfFsXWzll9FeX5V6vmcSjbLU0CrcsTz6NZFnaE/kWaTWS01unHKlXTGJ7F6vfyKcczhlEz7ox7S6PSe2RaNK7b1tXB26LzT+tzq2C/wDBrdU7KKfA1ukCf8Fq7Ob8SvR7ptXQVYrkzpNc1U7Y/wDpTp7Ryz+sL+MnB0Ncdbtl78v+lT0Oqp/xDS1/xv0PNUXeTMsrxFhUquRY7khfmel1RUW90tU6lrfoY8f2TH8FcfDrHndbZxVvakfYU6bfa5VX5Hojh3TeLpur1e+pH+lGp8lOnl740vl4dWzdnYW75nKm1fccq6TK11ionP6xqf6WtN/SXZ6Par/Aiezkch161/7VsUpKqrxaivReSQkfIrnMaxf99kTPZuuVKnSunCzw7efev6mn0pbytHonPzk+Bdp71q9I67+xtu1Pc1R0lT/y9q7uqR7imXfjyn9UT3iUr6tPRNjphX06afCTgPtlfpL9QXrfXxjaF+Z0LytPRGzRF9J+K+rL9DcvLdKHQ9Kapza1jvWrkVfiZ5Rv3+IRPdr6aqO6KXqdyv8Agh0W86ejM74d7KanK0pZ6O6kzuRV/wBv8jqUOa6Mn+Eq/wCxP1NMPEfy/wDKYdYAHU0AAAANe4vra15VaiZfhTmomYjyNgHDra49ZShRRv8AE/mvsNCrqF5V9Ku5E7m8vgZTy4x4UnOHqnOa1Jc5GpupU68tG9dzS/1IeRVVcsqqqu4Kdb9Feo9X+0LL/wDJZ7TKXtm5eVzT9bkQ8mCOtPwby9k2rSf6FRrvBZJHiy+ne3VFUwrvRE7FWU9haOb5hPUetBwaGt1W8q9NHp3t5Kda3vbe6T6qp534V5KaY545eFoyiWwAC6wAAAAAAAAAAABz7Wo+hqVeyquVzX/XUlXuXrT1KRM0OgACQAAAA176s+3sK9enGTGKqT3iZqLGwDV8r4elNvaqTFJKionasdRZa1nXFpSruZgtRqOxmYkiMonsWuBpNv41Z9hVp4S1HU3z6ff8/YboiYnwAAJAAAAAAAAAAAAAAAAAAAAAAAAAAAACupXpUvTek9ydYFgVUakuVETvU51TUHLypNxTvXrNR9R9RZe5XeJSc4VnKHTqX1FnJsvXbqNSpfVn8mwxNjVBScplScpZc5zllzlcu6mACqAAAAAAAAAAAAAAAAAAAAABpVPqtUpVPu1WqxfE0rzzWV6X+Ojvaim9qDV8lSqnpUnI9DQv1TypI6qmDk96HPydrhWXSv25WFVO5J95qK7Fl03sqUkqp605nQrtytqre9qp7jlVlixt63fTWmvs5fAtn2m0y6lt+50fyN+BaVW37nR/I34ELypVo2y1aUS1UVZTrQ0usbE7li1Lao1OuJTxTmhRcRcaW56drEf8zaY9r6baiL5rkk09PfxrWoyUxRytbshE95r5Ftg/OxpL3JHsNCs6bW9qfjqoxPUpOwe9ljcNbyfTVVSfD+Rr1p/ZlBO2o9z19RlM3jH8P/SHapNwosZ+FqITMNWWNXvSTKrCKq9h0LOXaV0TVa7F/tFVE9R1DgIvDq2twvLJVVf9SnUW7m+pUKeL2Paqq5Fnv/Qx48u1SiFl2/h2dVydcQnivIWlSlUtmpRWWs83qjqNbVn42zWfid8C7T6fDsac9bvOX1lr/HR7u5qqTpF0n+GqnHvF/wDTNhXTrpOY74odu+8/Srnei74Keb8pc7o4tpXajVVqPou7HojuaeKcy/LPef4NMnOqtY/U7lHdU1V9aIqoestk4fRti91urvdJ43ObipU/Fl70U9o9OH0bcn4bWP8AYZcH5pVw92joNFK2g16Luqo9yc/yoh516K22pu6qlF6sXbtT35ew6Gh6otnV8nrO+oqL1r9xe/wGoUMNR1Cgiea9vGaid6c/gris1lhFInvDrarWS56M8dOWaMdHrQ5Wipwq1wlWnD6ls5zFXu/n8jVdfVamiNsWosU3K56/wyke9fcbVeqyla6bcsqNV6UnUntReaIqLE+1SZyjLKMviC7m2lV/9mtf+bU+DTtur8ehodVearURqrukIvwOItWn+yqFKfObUqKqeLUgutrxrbexY5HKtC4V3q5L+pTHKp/p+yIl7Y5NFvE6O3L+tazar/GVWPkVVdehjuHb9nWrv5GlR1SrT01ls2kzFKeMrPPkdeXJjMtJyhsW1/5H0TZVRU4nnMZ45L8E5mNItUboV1cOWaldr0VV60REVPjJ59lSpWbRt3LNOmquRPHrOjQ1C7bY8JtWGLlyxTtVVXs3OfHO5i/aFNvlu9HncW9uqs/cYn9ew2ekqTpbF7qqL7lOBptzXtkq8GorMomE64n9S6/vLqvaqyrVVzZRYVEJjOOlRtGtCxXttOsUX+3eip3Jkn8zv68qN0Os2OtWp/uQ8pbVajL9lVrvOYqqiqkwb2o6hdXFnwq1RHNVyL6KIRjnEYZEZRSeju/4Tqrf8Kfc469qquqaTtbOX3NQ81ZXVS3oXdNiNVKtNWulNl/U6Npqj6dW1V1JHJSoKxERYnq5+4cecRERP99yMoerByWa7RX06D2+Copc3WLJ3W5zPFv6HZvj8tNodAqr3FG2p8Ss9Gp2d6mtV1W0p0VeyolR3Y1OUnnri4q3NValV0r2J2JshXPkiPCMsq8N271evWVWUZpU9vSX1nNAOaZmfLGZmfIACoAAAAAAAABFVFlFhUAA69jq7mRSulVzex/WqePedxrkc1HNVFReaKnaeMOnpeoJbu4Fd0Ul5oq/dU6OPk9paY5e0vQg5z9Zs2eir3/lb+prP11Oqnb+tzjWeTGPdfaHaB5t+sXrvRcxn5W/qaz727qelcVPBHQUnmhG8PWOexiS96NTdYNZ+oWVP0rhi/l5/A8qqqqyqyu4KzzT7Qr1Ho36zZN6le/wb+pS7XaSehbvXxVEOECnVyRvLsO11/3bZE8XT8jRvNUr1H0bhKbGvoOySJ5p2oaphURUVF6lKznlMeVdpdL9t3bklEpwv8K/qF1m9XtYn+U5FBVwVi9bFguIjPKfc2l0P2xe/iZ/pM/tq8/w/wDSc4E75fJtLpprd2n3KS+pf1Kb3V69exq0nU2IjmwqpJzX1Up1Gtcnmr94V/sH+BE8mUxMWbS27nUn19NoWPDRrVxRVReaon9IdRuuNaiNS0hqJCQ/+R5xnnXCdzGIbJGOeXkjKW7qeoMuG0rijSdTr0HZNdPWnah0KXSC0qNRVp1EWOaJCx7zzdergmCIuTuojbpw6jqTvS6x1coy7J2l6xNas1/vE/ykk1ixX77k/wAqnmga9XJO8vTJq1gvXWVPFqmf2rYf3/8Atd+h5gDrZG8vT/tWw/v/APa79B+1bD+//wBrv0PMAdbI3l6f9q2H9/8A7XfoY/a9j/er/pU8yB1sjeXpF1iyT7z1/wApFdbs0+7VX/Kn6nnQOrkby9AuuW3ZSqr4on6kF12j2UH+1DhAjq5I3l2115vZbKv+f+RFded2WqJ4v/kcYEdTL5N5dhddqdlu1P8AMRXXa0cqDPapyQOpl8m0ur+3Lj+5p+8wuuXPZSpJ6l/U5YG+XybS6f7cu/7uj7F/UymuXPbSpL6l/U5YI3y+UbS6v7cuP7mn7zKa5X7aNP3nJBPUy+U7S6VTWLmpyxaxP4TX8tXtp+81QRvKLlt+W/4fvM+WN/u19ppgbSi255YztYpnyyn+FxpAbSm295XS7newz5VR719hoAbyW6HlNH8fuUz5RR/GhzgN5LdLj0f7xDPFpf3jfacwE7yW6nEYv32+0yjmr1ORfWcoDct1gckzm9Opy+0nct1QcxKtVP7R3tMpcVk++o3hNukDnpdVk7UX1EkvKna1qk7wW3gaiXvfT95JLxnaxyE7QWvqOc2k5zW5ORFVE7yizu0uqSrGL28nISS6or2qnqOdWcy1v23NFZpv9JE95TLKu/sW6tVnEoPp/iaqHEqLmyyd2p5i+pTuNqU3Iite1UXuU4lRMbpKPY2vKeCx+hXlJd1UlIU5CMy0NzV66bl+P8zrnMb5tlfM/C9xbP8A5Jb1t+50fyN+BN7EqU3Md1OSFKbF+djSXubHs5GwXjvCXLpVlpaVXpuWH0lVntLLBi0HpTX79NHxv2/FDUvmObfupJybWVq/I3W1eJdUqzWxTydTa5PvJH6oYRPfv7KtVruDcX7OqWqqf16ymivFWjRVPs2Pn3ktRmnfVI/tGJ/XuNihRRNVrtRPRpx61RCtd6HQoLNtSXvYnwMXDsLWq7uYvwI2izZUfyIamoV3w+ixfNayX8u9YRDeZrG0te9ZjYWi9zfiiKYtGqmq007IVyeCpPzNrU2RYU0/C5E9xp21drLujUcvJKcL6kX9DCYrP+iPdbqrlfdUqLeaonvVTrsajGNYnU1IODbudc6ox7+tXZeEczuq9idb2p4qacc3M5Jhrv1e6fZupqlNWup49XZBpJdPqaK20c1isZKtWOaLKqQbzt0/L8ilixYu9aFJymfPwi5anYduvqV26wdRWoiMVmKojU6oOIdCt+7ujuM8JmImkW1+Gr7Vr062ynikmOPUqVWOqVHOVEwlVnl/Smxb/u7fX8TUr0+HVVE6l5oJiouBhi/V1E70T4mw9JsWr3czWeiNcqJ1Kbjedl/lUjH4Q1v/AI7fFfgYpL57U/iQz/8AHb+ZfgRpfbM8UI9xv1Vii9dgq4UpX7qGK32Lk74T3ld06KSN/EprM13ShaImL3dvUXUf3ds9xVb+ayqi9aFreVsn5fkRj4Fdp6DvEnc/u7vURtPsl8Sdx+7vEfaNa1511XYuu/sk/MVWn2jl2LLv0G+JWPtQqppDqqfwKXUvSZ/y/mVdVZ6d7PkW0fSb/wAtPiTiNgAGiQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUp5tyvc9PehcU1uSsf8AhcXEQABCo9GU1cvYSIvWnUVaK9cT4FCvVlJ9F/WnUveW27FhajvSfzMXTEWnn2tKTcxYWySj396wX9SSVW8eTtgxcvxpYp1u5Ex2xQqRrrh76iLEeiZc+Vp1V5OauLk7jYpsSnTRqdhRcNhyqnU9IXxKzFRaW0Cui7Ki1doLDSAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIVGJUpq1e0mANe2fLFpr1tKq/m3KL3wpN31V4juxxi7TzmL4mc/b/AAG6lWonVUd7TV4rsbpEdKO5ruTrVMKKqnWvJDXa1WMqtXrxRS2Ujas7iqyhg10Ii9xteV1f4fYaFvyc5Nmr7jYLYzNFte8uOPXp80RW8pQ2aldW0KbWtaiUnNVPUc2tHGfh1Sbjlzt1VO1slImZmRG9qLVuaT3NTu5eJOlcvTUa1RESXJHsKLh3m0n+sg1ytq5NTJyuVIInL8Vjfo3bqduxstSE5Saz6lR9BXOXnWeir8itGfVSvN71x8OZdVREWk1OrJCbmY7iy7q1Kls5HOlOXYczrg6NdJoP8DSpNRyvnsaqoVz7yLLdEW5dHUiLBs1nYsRdyi0Tm9fAldr9U1NyY7Y2hZS527fA15ixjvdBsUOdu3wNRV/8s1P4lE+Eq0SVRNzacqpavYvWzl7zWRPrUTc2LnzVVex6e9FKx4mULqH2DCm6lzkRPupKl9D7BngVomb667YoXnxEDTVZjbkbjf3L/KppG6n7l/lUph7jWn6mO53yDE+speKfEgi+aqblickou3+ZEDcq+i1O9yfEpq+fdU2dxdV66afxIU0/OvHu7v8A6NJ80kZydcev5lq8rVfyfIp6qlx4KW1OVqv5RHhDFr9j6ydf7B/gQtvsfWpK4WLdxMfalRaem7wJXnUxPEhafaOTYlefc9ZT8iEKnK4X8vyLqHpJ+RCivyr+pC+361/KnzEeRsAA1SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACusk0HptJJi5Ma7vQyqSip3ldBZoN25Ee4tNev8AWVWUU6utTYNal59xUqd3JCMvgbBVcLFu4uNe6WKKbqTl4EbeadRaS9qShl31l4idjOYreatKqnZyUW3nLUqL95Skf9o2SFVudJze3sJg0GtaOljm9ymya1JMLt7OxUk2SuPhAACyQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGvdNmll2tUquHZ06Tu9FNt7cqbm96HOVZptTuVTPPsNhV4lWizsREVTNblVqb0zFr51Rzu5EQzccqk97FQe1oSp8qzU76aFzlxYru5JKU5VaK97YJ1+dPH8SohaPA1qTcmVZ/CTtXyi0l8UJ26Iq1e5XGu3zMKncsKU8VIzWWaNJO5FJUmoiMqdqvgrqLLlb2Nn4l7U/8pTX+JF95Ed5GWc6zW/hVy+8nU516SeKkaKfXVV3gk796Zs1S8eBOr9i/wAFNKiscRf4FNuusUHew06aw2p+X5oRl5GxaJ9W5dzF4vmsTxJWi/VKm5C862J4j8iVtv8Au7fX8TTXnTa3+Jfkblt9gnrNNPSb+YjLxCGU/eP83zNm6SaM9ymt/wDJ/wA/zNq5+wXxQR4lKdH7BngRt+dJXfiVVM0/3Zv5Rb/u7fX8S8eyGgqQqp3Kbbf3H1Kar/tHeKmyz9xXwUzx8ylqdxa793pr3KpX3Fjv3VnipWENt/N9Lxn3FVrzV7u9SdT+z8F+BG0+yd4mv5koO5Vq6fw/oXV+Vs7wQpq/b1vy/oXXH2DvUR8jFr9j6xdL9R6zFr9j6xd/Yp+Yn8qFNp9sv5SV36TPAja/beold+m3wK/lSjc/bp4IXW/b+VpTdfb+out+38rRH3IbAANUgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFNDkj29z1Liql6dX8xE+RY5cWqvcklNqkUZ71knW+xf4GLf8Ad2ke4tNa7XzGpubJqXn3PWMvAtrIi2yz3ILZESg3eTFf91X1C2+wTxUfmF4ALDXfyvKa96QbBr1/tqPj+hsER5lAACUgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABzHpjUc3uVTpnPrfvDzPNDYtE+qVe9SNz6bfBSdr9h6yF11s8FE/alJeu3X+uonU51aTd1Ui70aHinwMu/eW/lUlCNr9m5e9xUqTRrJ+F0l1r9h6yl3oXH5vmR7QKmJKP/L8zYT9xRe79Sin6NT8vzQvT9w/rvK4iyhz4i971HXd+DPmYtvQd+ZTKfvbvymkeIEbnmxre9xqNWGu8Pmblf7Sj+Y0u8zz8jatPRengRu/tGpsStfSf4IYuPtk/KT+Uf//Z"
             alt="Démonstration de photo en relief : un paysage au crépuscule dessiné en plans successifs, montagnes, collines, sapins et herbes au premier plan"
             width="960" height="540" decoding="async">
        <canvas aria-hidden="true"></canvas>
        <p class="sxa-demo-legend"><i></i> <span data-sxa-legend>Calculé en direct</span></p>
        <span class="sxa-stamp" aria-hidden="true"><svg viewBox="0 0 64 44"><use href="#sxa-discs"/></svg>StereoptiX</span>
      </div>
      <div class="sxa-modes" role="group" aria-label="Type de relief de la démonstration">
        <button class="sxa-mode" type="button" data-sxa-mode="wiggle" aria-pressed="true">Wiggle</button>
        <button class="sxa-mode" type="button" data-sxa-mode="dubois" aria-pressed="false">Rouge-cyan Dubois</button>
        <button class="sxa-mode" type="button" data-sxa-mode="cross" aria-pressed="false">Côte à côte croisé</button>
        <button class="sxa-mode" type="button" data-sxa-mode="depth" aria-pressed="false">Carte de profondeur</button>
      </div>
      <p class="sxa-hint" data-sxa-hint aria-live="polite">Sans lunettes. Glissez sur l'image pour tourner autour de la scène.</p>
      <p class="sxa-cap">
        Pour la démonstration, la scène est dessinée en plans dont la profondeur
        est connue. Dans l'application, cette profondeur est déduite de votre
        photo : le relief d'une seule image est une reconstruction, pas une mesure.
      </p>

      <div class="sxa-chain">
        <div class="sxa-rise" style="--i:1"><b>1. Capter</b><span>Une photo, deux photos décalées d'un pas, ou une vidéo de cinq secondes. Prises à la caméra ou importées.</span></div>
        <div class="sxa-rise" style="--i:2"><b>2. Estimer la profondeur</b><span>Le modèle <a href="https://github.com/DepthAnything/Depth-Anything-V2" target="_blank" rel="noopener">Depth Anything V2</a>, exécuté sur l'appareil, par sa carte graphique quand elle le permet. Aucun serveur.</span></div>
        <div class="sxa-rise" style="--i:3"><b>3. Recomposer</b><span>Deux vues calculées en WebGL2, puis l'un des dix types de relief, à enregistrer ou partager.</span></div>
      </div>
    </div>
  </section>

  <!-- ============================== POINTS CLES ========================= -->
  <section class="sxa-sec" data-sxa-reveal>
    <div class="sxa-wrap">
      <p class="sxa-kicker sxa-rise">Points clés</p>
      <h2 class="sxa-h2 sxa-rise" style="--i:1">Ce que j'ai réalisé</h2>
      <ul class="sxa-keys">
        <li class="sxa-left" style="--i:1">Idéation, positionnement et naming de l'application</li>
        <li class="sxa-left" style="--i:2">Création du logo, de l'icône, de la signature des images et de l'identité graphique, en thème clair et sombre</li>
        <li class="sxa-left" style="--i:3">Conception de l'atelier et des parcours, pensés d'abord pour le téléphone</li>
        <li class="sxa-left" style="--i:4">Intelligence artificielle de profondeur exécutée dans le navigateur, et rendu de dix types de relief</li>
        <li class="sxa-left" style="--i:5">Prise de vue guidée à la caméra : une photo, deux photos ou une vidéo</li>
        <li class="sxa-left" style="--i:6">Modèle économique, paiement en ligne et licences signées, sans compte client</li>
        <li class="sxa-left" style="--i:7">Bibliothèque en ligne, sécurisation, mise en ligne et documentation</li>
      </ul>
    </div>
  </section>

  <!-- ============================== INSTALLABLE ========================= -->
  <section class="sxa-sec sxa-sec--wash" data-sxa-reveal>
    <div class="sxa-wrap">
      <p class="sxa-kicker sxa-rise">Sans magasin d'applications</p>
      <h2 class="sxa-h2 sxa-rise" style="--i:1">Elle s'installe sur tous les téléphones, gratuitement</h2>
      <p class="sxa-p sxa-rise" style="--i:2">
        StereoptiX est une application web. Elle s'ouvre dans un navigateur, puis
        s'installe d'un geste sur l'écran d'accueil ou le bureau, avec son icône.
        <strong>Aucun magasin d'applications, aucun compte, aucun abonnement.</strong>
        Une seule adresse, et le même atelier sur Android, sur iPhone, sur ordinateur.
      </p>
      <ul class="sxa-os sxa-rise" style="--i:3">
        <li class="sxa-os--free">
          <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4v16M4 12h16"/></svg>
          Gratuit
        </li>
        <li>
          <svg viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="2" width="12" height="20" rx="2.5"/><path d="M10.5 18.5h3"/></svg>
          Android
        </li>
        <li>
          <svg viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="2" width="12" height="20" rx="3"/><path d="M10 5h4"/></svg>
          iPhone et iPad
        </li>
        <li>
          <svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="5" width="18" height="12" rx="1.5"/><path d="M2 20h20"/></svg>
          Windows et macOS
        </li>
      </ul>
      <div class="sxa-cta sxa-rise" style="--i:4">
        <a class="sxa-btn" href="https://pcdlabs.presentcomposedesign.fr/tools/StereoptiX/" target="_blank" rel="noopener">Ouvrir et installer</a>
      </div>
      <div class="sxa-stats">
        <div class="sxa-stat sxa-rise" style="--i:0"><b data-sxa-count="10">10</b><span>types de relief</span></div>
        <div class="sxa-stat sxa-rise" style="--i:1"><b data-sxa-count="1">1</b><span>photo suffit</span></div>
        <div class="sxa-stat sxa-rise" style="--i:2"><b data-sxa-count="0">0</b><span>photo envoyée</span></div>
        <div class="sxa-stat sxa-rise" style="--i:3"><b data-sxa-count="559">559</b><span>contrôles auto</span></div>
      </div>
    </div>
  </section>

  <!-- ============================== PRISE DE VUE ======================== -->
  <section class="sxa-sec" data-sxa-reveal>
    <div class="sxa-wrap">
      <p class="sxa-kicker sxa-rise">Prise de vue</p>
      <h2 class="sxa-h2 sxa-rise" style="--i:1">Une photo suffit. Deux font mieux.</h2>
      <p class="sxa-p sxa-rise" style="--i:2">
        L'atelier a un seul départ, « Prendre 2 photos », et la caméra s'ouvre avec
        ses commandes posées sur l'image, comme dans un appareil photo. On y choisit
        entre trois prises, ou l'on importe une photo existante.
      </p>
      <div class="sxa-steps sxa-steps--3">
        <article class="sxa-step sxa-rise" style="--i:1">
          <svg class="sxa-ico" viewBox="0 0 32 32" aria-hidden="true"><rect x="3" y="8" width="26" height="18" rx="3"/><circle cx="16" cy="17" r="5"/><path d="M11 8l2-3h6l2 3"/></svg>
          <h3 class="sxa-h3">Une photo</h3>
          <p class="sxa-p">Le modèle de profondeur devine ce qui est proche et ce qui est loin. Prise à la caméra ou importée : JPEG, PNG, WebP, AVIF, GIF, et HEIC sur Safari.</p>
        </article>
        <article class="sxa-step sxa-rise" style="--i:2">
          <svg class="sxa-ico" viewBox="0 0 32 32" aria-hidden="true"><rect x="2" y="10" width="12" height="9" rx="2"/><rect x="18" y="10" width="12" height="9" rx="2"/><path d="M8 24h16M20 22l4 2-4 2"/></svg>
          <h3 class="sxa-h3">Deux photos</h3>
          <p class="sxa-p">Un pas de côté entre les deux prises, et c'est le vrai relief de la scène. L'atelier guide le geste avec une image fantôme à superposer au sujet.</p>
        </article>
        <article class="sxa-step sxa-rise" style="--i:3">
          <svg class="sxa-ico" viewBox="0 0 32 32" aria-hidden="true"><rect x="3" y="8" width="19" height="16" rx="3"/><path d="M22 14l7-4v12l-7-4z"/></svg>
          <h3 class="sxa-h3">Une vidéo</h3>
          <p class="sxa-p">Jusqu'à cinq secondes. Chaque image reçoit son relief, calculé sur l'appareil, et le tout s'enregistre en MP4, jusqu'en 1080p.</p>
        </article>
      </div>

      <figure class="sxa-rule sxa-zoom" style="--i:4">
        <!-- Schema de la prise en deux temps : vue de dessus, deux positions de
             l'appareil, le sujet devant, le fond derriere. -->
        <!-- Cadre serre sur le dessin : sur un telephone, le schema fait 300 px
             de large, et ses legendes doivent y rester au-dessus de 12 px. -->
        <svg viewBox="0 0 340 250" role="img" aria-label="Vue de dessus : deux positions de l'appareil séparées de 6 à 7 centimètres, face à un sujet à deux mètres">
          <defs>
            <marker id="sxa-arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" fill="#5d6479"/></marker>
          </defs>
          <path d="M8 34H332" stroke="#c9cede" stroke-width="2" stroke-dasharray="4 6"/>
          <text x="170" y="22" text-anchor="middle" font-size="14" font-weight="600" fill="#5d6479">le fond, plus lointain</text>
          <circle cx="170" cy="96" r="20" fill="#1001ef" opacity=".12"/>
          <circle cx="170" cy="96" r="9" fill="#1001ef"/>
          <text x="198" y="101" font-size="15" font-weight="700" fill="#0d1020">le sujet</text>
          <path d="M136 204L168 108" stroke="#ff2d55" stroke-width="1.8" stroke-dasharray="3 4"/>
          <path d="M204 204L172 108" stroke="#00d4ff" stroke-width="1.8" stroke-dasharray="3 4"/>
          <rect x="114" y="202" width="44" height="24" rx="5" fill="#ff2d55"/>
          <rect x="182" y="202" width="44" height="24" rx="5" fill="#00d4ff"/>
          <text x="136" y="219" text-anchor="middle" font-size="13" font-weight="800" fill="#fff">1</text>
          <text x="204" y="219" text-anchor="middle" font-size="13" font-weight="800" fill="#0b0c10">2</text>
          <path d="M140 240H200" stroke="#5d6479" stroke-width="1.6" marker-start="url(#sxa-arr)" marker-end="url(#sxa-arr)"/>
          <text x="236" y="219" font-size="15" font-weight="700" fill="#0d1020">6 à 7 cm</text>
          <path d="M78 96V202" stroke="#5d6479" stroke-width="1.6" marker-start="url(#sxa-arr)" marker-end="url(#sxa-arr)"/>
          <text x="66" y="154" text-anchor="end" font-size="15" font-weight="700" fill="#0d1020">2 m</text>
        </svg>
        <figcaption>
          <p class="sxa-kicker" style="margin-bottom:6px">La règle du trentième</p>
          <b class="sxa-formula">écart = distance du premier plan&nbsp;÷&nbsp;30</b>
          <p class="sxa-p">Pour un sujet à deux mètres, l'écart vaut 6&nbsp;à&nbsp;7&nbsp;cm, celui de vos yeux.
          Pour un paysage lointain, il monte à trente centimètres et plus. Entre les
          deux prises, l'appareil se décale sans tourner, et rien ne doit bouger.</p>
        </figcaption>
      </figure>
    </div>
  </section>

  <!-- ============================== LES DIX TYPES ======================= -->
  <section class="sxa-sec sxa-sec--night" data-sxa-reveal>
    <div class="sxa-wrap">
      <p class="sxa-kicker sxa-rise">Dix sorties</p>
      <h2 class="sxa-h2 sxa-rise" style="--i:1">Dix façons de voir en relief</h2>
      <p class="sxa-p sxa-rise" style="--i:2">
        Avec ou sans lunettes, pour un écran, une visionneuse ou un autre logiciel.
        L'anaglyphe conseillé applique les matrices calculées par
        <a href="http://www.site.uottawa.ca/~edubois/anaglyph/" target="_blank" rel="noopener">Eric Dubois</a>
        en lumière linéaire : les couleurs les plus justes, et le moins d'images
        fantômes à travers les filtres.
      </p>
      <!-- Les intitules et les conseils sont recopies de l'application
           (core/modes.js), mot pour mot. Les images sont celles du serveur de
           l'application : une meme prise de vue, une route d'Occitanie. -->
      <div class="sxa-types">
        <div class="sxa-cell sxa-cell--wide sxa-zoom" style="--i:1">
          <article class="sxa-type sxa-type--star sxa-tilt" data-sxa-tilt>
            <figure class="sxa-shot">
              <svg viewBox="0 0 120 54" aria-hidden="true"><path d="M8 20h104" stroke="#3a3f55" stroke-width="3"/><circle cx="38" cy="30" r="17" fill="#ff2d55"/><circle cx="82" cy="30" r="17" fill="#00d4ff"/><path d="M55 28q5-6 10 0" fill="none" stroke="#3a3f55" stroke-width="3"/></svg>
              <img loading="lazy" src="https://pcdlabs.presentcomposedesign.fr/tools/StereoptiX/assets/img/route-d%27occitanie-stereoptix-Dubois-2026-10-03.jpg" alt="Une route d'Occitanie en anaglyphe rouge-cyan, méthode de Dubois" width="1898" height="1060" loading="lazy" decoding="async">
            </figure>
            <div class="sxa-type-text"><b>Rouge-cyan Dubois</b><em>Lunettes rouge-cyan</em><span>Conseillé : les couleurs les plus justes et le moins d'images fantômes.</span></div>
            <i class="sxa-glare" aria-hidden="true"></i>
          </article>
        </div>
        <div class="sxa-cell sxa-cell--wide sxa-zoom" style="--i:2">
          <article class="sxa-type sxa-type--star sxa-tilt" data-sxa-tilt>
            <figure class="sxa-shot">
              <svg viewBox="0 0 120 54" aria-hidden="true"><rect x="34" y="7" width="52" height="40" rx="6" fill="#2a2160"/><path d="M34 37l14-12 10 8 12-14 16 18v4a6 6 0 01-6 6H40a6 6 0 01-6-6z" fill="#a54f8f"/><path d="M18 27h10M102 27H92M18 27l4-4M18 27l4 4M102 27l-4-4M102 27l-4 4" stroke="#8890a6" stroke-width="2" stroke-linecap="round"/></svg>
              <img loading="lazy" src="https://pcdlabs.presentcomposedesign.fr/tools/StereoptiX/assets/img/route-d%27occitanie-stereoptix-anim-2026-10-03%20watermark.gif" alt="La même route en wiggle : un GIF qui balance d'un œil à l'autre, avec la signature StereoptiX de la version gratuite" width="1080" height="603" loading="lazy" decoding="async">
            </figure>
            <div class="sxa-type-text"><b>Wiggle</b><em>Sans lunettes</em><span>Une animation qui balance entre les vues. S'exporte en GIF.</span></div>
            <i class="sxa-glare" aria-hidden="true"></i>
          </article>
        </div>
        <div class="sxa-cell sxa-zoom" style="--i:3">
          <article class="sxa-type sxa-tilt" data-sxa-tilt>
            <figure class="sxa-shot">
              <svg viewBox="0 0 120 54" aria-hidden="true"><path d="M8 20h104" stroke="#3a3f55" stroke-width="3"/><circle cx="38" cy="30" r="17" fill="#ff2d55"/><circle cx="82" cy="30" r="17" fill="#00d4ff"/><path d="M55 28q5-6 10 0" fill="none" stroke="#3a3f55" stroke-width="3"/></svg>
              <img loading="lazy" src="https://pcdlabs.presentcomposedesign.fr/tools/StereoptiX/assets/img/route-d%27occitanie-stereoptix-color-2026-10-03.jpg" alt="La route en anaglyphe rouge-cyan couleur" width="1817" height="1060" loading="lazy" decoding="async">
            </figure>
            <div class="sxa-type-text"><b>Rouge-cyan couleur</b><em>Lunettes rouge-cyan</em><span>Couleurs vives, mais les rouges francs scintillent d'un œil à l'autre.</span></div>
            <i class="sxa-glare" aria-hidden="true"></i>
          </article>
        </div>
        <div class="sxa-cell sxa-zoom" style="--i:4">
          <article class="sxa-type sxa-tilt" data-sxa-tilt>
            <figure class="sxa-shot">
              <svg viewBox="0 0 120 54" aria-hidden="true"><path d="M8 20h104" stroke="#3a3f55" stroke-width="3"/><circle cx="38" cy="30" r="17" fill="#ff2d55"/><circle cx="82" cy="30" r="17" fill="#00d4ff"/><path d="M55 28q5-6 10 0" fill="none" stroke="#3a3f55" stroke-width="3"/></svg>
              <img loading="lazy" src="https://pcdlabs.presentcomposedesign.fr/tools/StereoptiX/assets/img/route-d%27occitanie-stereoptix-Demi-couleur-2026-10-03.jpg" alt="La route en anaglyphe rouge-cyan demi-couleur" width="1898" height="1060" loading="lazy" decoding="async">
            </figure>
            <div class="sxa-type-text"><b>Rouge-cyan demi-couleur</b><em>Lunettes rouge-cyan</em><span>Un bon compromis quand l'image contient beaucoup de rouge.</span></div>
            <i class="sxa-glare" aria-hidden="true"></i>
          </article>
        </div>
        <div class="sxa-cell sxa-zoom" style="--i:5">
          <article class="sxa-type sxa-tilt" data-sxa-tilt>
            <figure class="sxa-shot">
              <svg viewBox="0 0 120 54" aria-hidden="true"><path d="M8 20h104" stroke="#3a3f55" stroke-width="3"/><circle cx="38" cy="30" r="17" fill="#ff2d55"/><circle cx="82" cy="30" r="17" fill="#00d4ff"/><path d="M55 28q5-6 10 0" fill="none" stroke="#3a3f55" stroke-width="3"/></svg>
              <img loading="lazy" src="https://pcdlabs.presentcomposedesign.fr/tools/StereoptiX/assets/img/route-d%27occitanie-stereoptix-B%26W-2026-10-03.jpg" alt="La route en anaglyphe rouge-cyan gris, avec la signature StereoptiX" width="1898" height="1060" loading="lazy" decoding="async">
            </figure>
            <div class="sxa-type-text"><b>Rouge-cyan gris</b><em>Lunettes rouge-cyan</em><span>Le plus reposant pour les yeux, sans couleur.</span></div>
            <i class="sxa-glare" aria-hidden="true"></i>
          </article>
        </div>
        <div class="sxa-cell sxa-zoom" style="--i:6">
          <article class="sxa-type sxa-tilt" data-sxa-tilt>
            <figure class="sxa-shot">
              <svg viewBox="0 0 120 54" aria-hidden="true"><path d="M8 20h104" stroke="#3a3f55" stroke-width="3"/><circle cx="38" cy="30" r="17" fill="#ff2d55"/><circle cx="82" cy="30" r="17" fill="#22c55e"/><path d="M55 28q5-6 10 0" fill="none" stroke="#3a3f55" stroke-width="3"/></svg>
              <img loading="lazy" src="https://pcdlabs.presentcomposedesign.fr/tools/StereoptiX/assets/img/route-d%27occitanie-stereoptix-Rouge%26vert-2026-10-03.jpg" alt="La route en anaglyphe rouge-vert" width="1898" height="1060" loading="lazy" decoding="async">
            </figure>
            <div class="sxa-type-text"><b>Rouge-vert</b><em>Lunettes rouge-vert</em><span>Pour les anciennes lunettes rouge-vert.</span></div>
            <i class="sxa-glare" aria-hidden="true"></i>
          </article>
        </div>
        <div class="sxa-cell sxa-zoom" style="--i:7">
          <article class="sxa-type sxa-tilt" data-sxa-tilt>
            <figure class="sxa-shot">
              <svg viewBox="0 0 120 54" aria-hidden="true"><path d="M8 20h104" stroke="#3a3f55" stroke-width="3"/><circle cx="38" cy="30" r="17" fill="#ff2d55"/><circle cx="82" cy="30" r="17" fill="#3b5bff"/><path d="M55 28q5-6 10 0" fill="none" stroke="#3a3f55" stroke-width="3"/></svg>
              <img loading="lazy" src="https://pcdlabs.presentcomposedesign.fr/tools/StereoptiX/assets/img/route-d%27occitanie-stereoptix-Rouge%26bleu-2026-10-03.jpg" alt="La route en anaglyphe rouge-bleu" width="1898" height="1060" loading="lazy" decoding="async">
            </figure>
            <div class="sxa-type-text"><b>Rouge-bleu</b><em>Lunettes rouge-bleu</em><span>Pour les lunettes rouge-bleu.</span></div>
            <i class="sxa-glare" aria-hidden="true"></i>
          </article>
        </div>
        <div class="sxa-cell sxa-zoom" style="--i:8">
          <article class="sxa-type sxa-tilt" data-sxa-tilt>
            <figure class="sxa-shot sxa-shot--dual">
              <svg viewBox="0 0 120 54" aria-hidden="true"><rect x="10" y="9" width="46" height="36" rx="5" fill="none" stroke="#ff6b88" stroke-width="2.5"/><rect x="64" y="9" width="46" height="36" rx="5" fill="none" stroke="#5fe3ff" stroke-width="2.5"/><text x="33" y="32" text-anchor="middle" font-size="14" font-weight="800" fill="#ff6b88">G</text><text x="87" y="32" text-anchor="middle" font-size="14" font-weight="800" fill="#5fe3ff">D</text></svg>
              <img loading="lazy" src="https://pcdlabs.presentcomposedesign.fr/tools/StereoptiX/assets/img/route-d%27occitanie-stereoptix-VRdual-2026-10-03.jpg" alt="La route en côte à côte parallèle : la vue de l'œil gauche à gauche, celle de l'œil droit à droite" width="3796" height="1060" loading="lazy" decoding="async">
            </figure>
            <div class="sxa-type-text"><b>Côte à côte parallèle</b><em>Casque VR ou visionneuse</em><span>Vue gauche à gauche, pour un casque ou une visionneuse.</span></div>
            <i class="sxa-glare" aria-hidden="true"></i>
          </article>
        </div>
        <div class="sxa-cell sxa-zoom" style="--i:9">
          <article class="sxa-type sxa-tilt" data-sxa-tilt>
            <figure class="sxa-shot sxa-shot--dual">
              <svg viewBox="0 0 120 54" aria-hidden="true"><rect x="10" y="9" width="46" height="36" rx="5" fill="none" stroke="#5fe3ff" stroke-width="2.5"/><rect x="64" y="9" width="46" height="36" rx="5" fill="none" stroke="#ff6b88" stroke-width="2.5"/><text x="33" y="32" text-anchor="middle" font-size="14" font-weight="800" fill="#5fe3ff">D</text><text x="87" y="32" text-anchor="middle" font-size="14" font-weight="800" fill="#ff6b88">G</text></svg>
              <img loading="lazy" src="https://pcdlabs.presentcomposedesign.fr/tools/StereoptiX/assets/img/route-d%27occitanie-stereoptix-VRcross-2026-10-03.jpg" alt="La route en côte à côte croisé : la vue de l'œil droit à gauche, à regarder en louchant" width="3796" height="1060" loading="lazy" decoding="async">
            </figure>
            <div class="sxa-type-text"><b>Côte à côte croisé</b><em>Sans lunettes, en louchant</em><span>Louchez jusqu'à voir une troisième image au milieu : elle est en relief.</span></div>
            <i class="sxa-glare" aria-hidden="true"></i>
          </article>
        </div>
        <div class="sxa-cell sxa-zoom" style="--i:10">
          <article class="sxa-type sxa-tilt" data-sxa-tilt>
            <figure class="sxa-shot">
              <svg viewBox="0 0 120 54" aria-hidden="true"><rect x="28" y="7" width="64" height="40" rx="6" fill="#333"/><path d="M28 36l16-12 12 9 14-15 22 22v1a6 6 0 01-6 6H34a6 6 0 01-6-6z" fill="#cfcfcf" opacity=".9"/></svg>
              <img loading="lazy" src="https://pcdlabs.presentcomposedesign.fr/tools/StereoptiX/assets/img/route-d%27occitanie-stereoptix-zdepth-2026-10-03.jpg" alt="La carte de profondeur de la route : le proche en blanc, le lointain en noir" width="1898" height="1060" loading="lazy" decoding="async">
            </figure>
            <div class="sxa-type-text"><b>Carte de profondeur</b><em>Pour d'autres logiciels</em><span>Proche en blanc, loin en noir.</span></div>
            <i class="sxa-glare" aria-hidden="true"></i>
          </article>
        </div>
      </div>
      <button class="sxa-gyro-ask" type="button" data-sxa-gyro><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="3" width="10" height="18" rx="2.5" transform="rotate(-14 12 12)"/><path d="M3.5 8.5a9 9 0 0 0 0 7M20.5 8.5a9 9 0 0 1 0 7"/></svg>Incliner le téléphone pour orienter les cartes</button>
      <p class="sxa-cap" style="margin-top:16px">
        Les dix sorties d'une même prise de vue, une route d'Occitanie. Chacune
        s'enregistre en PNG, JPEG ou WebP, le wiggle en GIF animé qui tourne en
        boucle, et la vidéo en MP4. Partagé, le GIF est allégé pour les
        messageries : 720&nbsp;pixels et 2&nbsp;Mo au plus, pour qu'il arrive animé.
      </p>
    </div>
  </section>

  <!-- ============================== LE MODELE =========================== -->
  <section class="sxa-sec sxa-sec--wash" data-sxa-reveal>
    <div class="sxa-wrap">
      <p class="sxa-kicker sxa-rise">Modèle économique</p>
      <h2 class="sxa-h2 sxa-rise" style="--i:1">Le gratuit est un troc, pas un essai</h2>
      <p class="sxa-p sxa-rise" style="--i:2">
        La formule gratuite n'expire pas et ne bride pas le relief : une photo,
        prise ou importée, les dix types de relief, l'enregistrement et le
        partage, sans compte.
      </p>
      <p class="sxa-p sxa-rise" style="--i:3">
        En échange, ce qui sort de l'atelier s'arrête au 720p, assez pour un écran
        ou une conversation, et porte une <strong>petite signature StereoptiX</strong>,
        celle que vous voyez en bas à gauche de la démonstration. Chaque image
        partagée fait ainsi connaître l'application : c'est elle qui paie la
        version gratuite.
      </p>
      <p class="sxa-p sxa-rise" style="--i:4">
        Les licences ouvrent le reste : deux photos pour le vrai relief d'une
        scène, la vidéo, la pleine définition sans signature, et avec Premium, une
        bibliothèque en ligne. Elles s'achètent <strong>une seule fois</strong>,
        sans abonnement et sans compte.
      </p>
      <div class="sxa-plans">
        <div class="sxa-cell sxa-rise" style="--i:1">
        <article class="sxa-plan sxa-plan--free sxa-tilt" data-sxa-tilt>
          <p class="sxa-plan-name">Découverte</p>
          <p class="sxa-price">0 €</p>
          <p class="sxa-period">pour toujours</p>
          <p class="sxa-plan-tag">Tout le relief, sans compte et sans payer.</p>
          <ul>
            <li>Une photo, prise ou importée</li>
            <li>Les dix types de relief</li>
            <li>Téléchargement et partage en 720p</li>
            <li>Petite signature StereoptiX sur l'image</li>
          </ul>
          <div class="sxa-plan-cta">
            <a class="sxa-btn" href="https://pcdlabs.presentcomposedesign.fr/tools/StereoptiX/index.html#prendre-1" target="_blank" rel="noopener">Prendre 1 photo</a>
          </div>
          <i class="sxa-glare" aria-hidden="true"></i>
        </article>
        </div>
        <div class="sxa-cell sxa-rise" style="--i:2">
        <article class="sxa-plan sxa-tilt" data-sxa-tilt>
          <p class="sxa-plan-name">Studio</p>
          <p class="sxa-price">4,99 €</p>
          <p class="sxa-period">une seule fois</p>
          <p class="sxa-plan-tag">Vos images, sans signature et en pleine définition.</p>
          <ul>
            <li>Deux photos : le vrai relief de la scène</li>
            <li>La vidéo en relief, en MP4 jusqu'en 1080p</li>
            <li>Pleine définition, selon l'appareil</li>
            <li>Signature retirée, ou gardée si vous le voulez</li>
          </ul>
          <div class="sxa-plan-cta">
            <a class="sxa-btn" href="https://pcdlabs.presentcomposedesign.fr/tools/StereoptiX/tarifs.html#offres" target="_blank" rel="noopener">Acheter</a>
            <a class="sxa-btn sxa-btn--ghost" href="https://pcdlabs.presentcomposedesign.fr/tools/StereoptiX/index.html#prendre" target="_blank" rel="noopener">Prendre 2 photos</a>
          </div>
          <i class="sxa-glare" aria-hidden="true"></i>
        </article>
        </div>
        <div class="sxa-cell sxa-rise" style="--i:3">
        <article class="sxa-plan sxa-tilt" data-sxa-tilt>
          <p class="sxa-plan-name">Premium</p>
          <p class="sxa-price">19,99 €</p>
          <p class="sxa-period">à vie</p>
          <p class="sxa-plan-tag">Tout Studio, et vos reliefs gardés en ligne.</p>
          <ul>
            <li class="sxa-inherit">Tout de Studio, plus :</li>
            <li>Mes clichés, une bibliothèque privée de 100&nbsp;Go</li>
            <li>Chaque cliché reste retouchable, sa source et ses réglages avec lui</li>
          </ul>
          <div class="sxa-plan-cta">
            <a class="sxa-btn" href="https://pcdlabs.presentcomposedesign.fr/tools/StereoptiX/tarifs.html#offres" target="_blank" rel="noopener">Acheter</a>
            <a class="sxa-btn sxa-btn--ghost" href="https://pcdlabs.presentcomposedesign.fr/tools/StereoptiX/index.html#prendre" target="_blank" rel="noopener">Prendre 2 photos</a>
          </div>
          <i class="sxa-glare" aria-hidden="true"></i>
        </article>
        </div>
      </div>
      <button class="sxa-gyro-ask" type="button" data-sxa-gyro><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="3" width="10" height="18" rx="2.5" transform="rotate(-14 12 12)"/><path d="M3.5 8.5a9 9 0 0 0 0 7M20.5 8.5a9 9 0 0 1 0 7"/></svg>Incliner le téléphone pour orienter les cartes</button>
      <div class="sxa-trust">
        <div class="sxa-rise" style="--i:1"><b>Sans compte client</b><span>Le serveur demande à Stripe si le paiement est fait, puis signe une licence rangée sur l'appareil.</span></div>
        <div class="sxa-rise" style="--i:2"><b>Vérifiée sur place</b><span>La page reconnaît la signature de la licence elle-même, y compris hors connexion.</span></div>
        <div class="sxa-rise" style="--i:3"><b>Un code à garder</b><span>Il passe la licence sur un autre appareil. Une licence vit sur un appareil à la fois.</span></div>
      </div>
      <div class="sxa-cta sxa-rise" style="--i:4">
        <a class="sxa-btn sxa-btn--ghost" href="https://pcdlabs.presentcomposedesign.fr/tools/StereoptiX/tarifs.html" target="_blank" rel="noopener">Voir les offres en détail</a>
      </div>
    </div>
  </section>

  <!-- ============================== IDENTITE ============================ -->
  <section class="sxa-sec" data-sxa-reveal>
    <div class="sxa-wrap">
      <p class="sxa-kicker sxa-rise">Identité graphique</p>
      <h2 class="sxa-h2 sxa-rise" style="--i:1">Deux disques, une addition de lumière</h2>
      <p class="sxa-p sxa-rise" style="--i:2">
        Le signe est un anaglyphe à lui seul : un disque rouge, un disque cyan, les
        deux couleurs des lunettes 3D, qui s'additionnent comme deux faisceaux de
        lumière. Là où ils se croisent, la lumière devient presque blanche. Le nom,
        le slogan et la signature des images reprennent le même geste : deux
        couches légèrement décalées, que l'œil réunit.
      </p>

      <div class="sxa-logos">
        <figure class="sxa-logo sxa-zoom" style="--i:1">
          <div class="sxa-logo-art sxa-logo-art--dark">
            <svg viewBox="0 0 64 44" style="width:72%"><use href="#sxa-discs"/></svg>
          </div>
          <figcaption class="sxa-logo-lab">Icône d'application<span>Disques en lumière sur #0b0c10</span></figcaption>
        </figure>
        <figure class="sxa-logo sxa-zoom" style="--i:2">
          <div class="sxa-logo-art">
            <svg viewBox="0 0 64 44" style="width:72%"><use href="#sxa-discs"/></svg>
          </div>
          <figcaption class="sxa-logo-lab">Le signe<span>Le même fichier, sur fond clair comme sombre</span></figcaption>
        </figure>
        <figure class="sxa-logo sxa-zoom" style="--i:3">
          <div class="sxa-logo-art sxa-logo-art--dark">
            <span class="sxa-wordmark">StereoptiX</span>
          </div>
          <figcaption class="sxa-logo-lab">Le nom<span>Ses deux couches, rouge à gauche, cyan à droite</span></figcaption>
        </figure>
        <figure class="sxa-logo sxa-zoom" style="--i:4">
          <div class="sxa-logo-art">
            <p class="sxa-slogan">Vos photos<br><span class="sxa-relief" data-text="en relief">en relief</span></p>
          </div>
          <figcaption class="sxa-logo-lab">Le slogan<span>« en relief » arrive superposé, puis se décompose</span></figcaption>
        </figure>
        <figure class="sxa-logo sxa-zoom" style="--i:5">
          <div class="sxa-logo-art sxa-logo-art--photo">
            <!-- La scene de la demonstration, recadree par le script ; sans
                 script, le degrade du fond la remplace. -->
            <canvas data-sxa-thumb aria-hidden="true"></canvas>
            <span class="sxa-stamp"><svg viewBox="0 0 64 44"><use href="#sxa-discs"/></svg>StereoptiX</span>
          </div>
          <figcaption class="sxa-logo-lab">La signature des images<span>4,5 % du petit côté, lisible une fois partagée</span></figcaption>
        </figure>
        <figure class="sxa-logo sxa-zoom" style="--i:6">
          <div class="sxa-logo-art">
            <div class="sxa-favrow">
              <figure><svg viewBox="0 0 64 44" style="width:48px;height:33px"><use href="#sxa-discs"/></svg><figcaption>48</figcaption></figure>
              <figure><svg viewBox="0 0 64 44" style="width:32px;height:22px"><use href="#sxa-discs"/></svg><figcaption>32</figcaption></figure>
              <figure><svg viewBox="0 0 64 44" style="width:16px;height:11px"><use href="#sxa-discs"/></svg><figcaption>16</figcaption></figure>
            </div>
          </div>
          <figcaption class="sxa-logo-lab">Lisibilité<span>Validé à sa plus petite taille</span></figcaption>
        </figure>
      </div>
    </div>
  </section>

  <!-- ============================== METHODE ============================= -->
  <section class="sxa-sec sxa-sec--wash" data-sxa-reveal>
    <div class="sxa-wrap">
      <p class="sxa-kicker sxa-rise">Méthode</p>
      <h2 class="sxa-h2 sxa-rise" style="--i:1">De l'idée à la mise en ligne</h2>
      <div class="sxa-steps">
        <article class="sxa-step sxa-rise" style="--i:1"><span class="sxa-num">01</span>
          <h3 class="sxa-h3">Idéation et positionnement</h3>
          <p class="sxa-p">Une photo de téléphone, et le relief en quelques secondes, sans matériel ni compétence. Ce que l'application refuse de faire est décidé aussi tôt que ce qu'elle fait.</p></article>
        <article class="sxa-step sxa-rise" style="--i:2"><span class="sxa-num">02</span>
          <h3 class="sxa-h3">Naming</h3>
          <p class="sxa-p">Un nom qui dit la stéréoscopie et l'optique, et se prononce en français comme en anglais. Le premier s'est révélé déjà pris : l'application a été renommée sans qu'une seule licence vendue cesse de fonctionner.</p></article>
        <article class="sxa-step sxa-rise" style="--i:3"><span class="sxa-num">03</span>
          <h3 class="sxa-h3">Logo et identité graphique</h3>
          <p class="sxa-p">Deux disques en addition de lumière, déclinés en icône d'application, favicon, nom en anaglyphe et signature des images, en thème clair et sombre.</p></article>
        <article class="sxa-step sxa-rise" style="--i:4"><span class="sxa-num">04</span>
          <h3 class="sxa-h3">Architecture et UX</h3>
          <p class="sxa-p">Un atelier pensé d'abord pour le téléphone, un seul départ, et une architecture sans étape de compilation, choisie pour tenir sur un hébergement mutualisé.</p></article>
        <article class="sxa-step sxa-rise" style="--i:5"><span class="sxa-num">05</span>
          <h3 class="sxa-h3">Développement</h3>
          <p class="sxa-p">Profondeur par intelligence artificielle, rendu WebGL2, caméra, vidéo MP4, GIF animé, paiement, licences signées, bibliothèque en ligne, et 559 contrôles automatiques.</p></article>
        <article class="sxa-step sxa-rise" style="--i:6"><span class="sxa-num">06</span>
          <h3 class="sxa-h3">Mise en ligne et suivi</h3>
          <p class="sxa-p">Sécurisation, conditions de vente, mentions légales, application installable, réglages de l'éditeur sans toucher au code, documentation et mises à jour.</p></article>
      </div>
    </div>
  </section>

  <!-- ============================== SERVICES ============================ -->
  <section class="sxa-sec" data-sxa-reveal>
    <div class="sxa-wrap">
      <p class="sxa-kicker sxa-rise">Ce que cela veut dire pour vous</p>
      <h2 class="sxa-h2 sxa-rise" style="--i:1">Un seul interlocuteur, du premier croquis à la mise en ligne</h2>
      <div class="sxa-serv">
        <article class="sxa-step sxa-rise" style="--i:1">
          <h3 class="sxa-h3">Application web sur mesure</h3>
          <p class="sxa-p">Outil métier, configurateur, application mobile installable, vente en ligne. Conçue, développée et hébergée.</p></article>
        <article class="sxa-step sxa-rise" style="--i:2">
          <h3 class="sxa-h3">Identité et design produit</h3>
          <p class="sxa-p">Naming, logo, système d'icônes, interface et déclinaisons, pensés pour tenir à toutes les tailles.</p></article>
        <article class="sxa-step sxa-rise" style="--i:3">
          <h3 class="sxa-h3">3D, relief et expériences immersives</h3>
          <p class="sxa-p">Images en relief, configurateurs 3D temps réel, visualiseurs, réalité augmentée, directement dans le navigateur.</p></article>
      </div>
    </div>
  </section>

  <!-- ============================== APPEL =============================== -->
  <section class="sxa-sec sxa-sec--wash sxa-final" data-sxa-reveal>
    <div class="sxa-wrap">
      <h2 class="sxa-h2 sxa-rise">Vous souhaitez faire appel à mes services ?</h2>
      <p class="sxa-lede sxa-rise" style="--i:1">
        Parlons de votre projet. J'interviens sur l'ensemble de la chaîne, ou sur la
        partie qui vous manque.
      </p>
      <div class="sxa-cta sxa-rise" style="--i:2">
        <a class="sxa-btn" href="https://presentcomposedesign.fr/contact/">Demander un rendez-vous</a>
        <a class="sxa-btn sxa-btn--ghost" href="https://presentcomposedesign.fr/realisations/">Voir les autres réalisations</a>
      </div>
      <p class="sxa-more sxa-rise" style="--i:3">
        Une autre étude de cas : <a href="https://presentcomposedesign.fr/birdsignal-infos/">BirdSignal, l'application qui identifie les oiseaux à leur chant</a>.
      </p>
      <p class="sxa-legal sxa-rise" style="--i:4">
        StereoptiX est une application de Present Composé Design, nom commercial de
        l'entreprise individuelle Alban DESBARAX, à Toulouse. Le relief tiré d'une
        seule photo est estimé par un modèle d'intelligence artificielle : c'est une
        reconstruction plausible, pas une mesure de la scène.
      </p>
    </div>
  </section>
</div>

<script>
(function () {
  var root = document.getElementById('sxa');
  if (!root) return;

  /* Marque la presence du script AVANT tout le reste : c'est cette classe, et
     elle seule, qui autorise la feuille de style a cacher ce qui va apparaitre. */
  root.classList.add('sxa-js');

  var calme = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  /* ----------------------------------------------------------- apparitions
     Seuil a zero, et non a un pourcentage : sur un ecran court, 12 % d'une
     section haute peut depasser la hauteur de la fenetre, et la section ne
     s'afficherait jamais. */
  var sections = root.querySelectorAll('[data-sxa-reveal]');
  if (calme || !('IntersectionObserver' in window)) {
    for (var k = 0; k < sections.length; k++) sections[k].classList.add('sxa-in');
  } else {
    var io = new IntersectionObserver(function (es) {
      es.forEach(function (e) {
        if (e.isIntersecting) { e.target.classList.add('sxa-in'); io.unobserve(e.target); }
      });
    }, { threshold: 0, rootMargin: '0px 0px -10% 0px' });
    for (var j = 0; j < sections.length; j++) io.observe(sections[j]);
  }

  /* -------------------------------------------------------------- compteurs
     Le HTML porte deja la valeur finale : sans script, les chiffres restent
     justes. C'est ici, et seulement ici, qu'on repart de zero. */
  function compte(el) {
    var cible = parseInt(el.getAttribute('data-sxa-count'), 10) || 0;
    if (calme || cible < 2) { el.textContent = String(cible); return; }
    el.textContent = '0';
    var t0 = null, duree = 1000;
    function pas(t) {
      if (t0 === null) t0 = t;
      var p = Math.min(1, (t - t0) / duree);
      el.textContent = String(Math.round(cible * (1 - Math.pow(1 - p, 3))));
      if (p < 1) requestAnimationFrame(pas);
    }
    requestAnimationFrame(pas);
  }
  var compteurs = root.querySelectorAll('[data-sxa-count]');
  if (!('IntersectionObserver' in window)) {
    for (var m = 0; m < compteurs.length; m++) compte(compteurs[m]);
  } else {
    var io2 = new IntersectionObserver(function (es) {
      es.forEach(function (e) { if (e.isIntersecting) { compte(e.target); io2.unobserve(e.target); } });
    }, { threshold: 0.5 });
    for (var q = 0; q < compteurs.length; q++) io2.observe(compteurs[q]);
  }

  /* --------------------------------------------- images des dix sorties
     Une image qui ne se charge pas laisse voir le schema pose dessous, au
     lieu d'un cadre vide marque d'une icone cassee. */
  var shots = root.querySelectorAll('.sxa-shot img');
  for (var si = 0; si < shots.length; si++) {
    shots[si].addEventListener('error', function () { this.hidden = true; });
  }

  /* =============================================== CARTES QUI S'INCLINENT
   *
   * Sur ordinateur, la carte suit la souris. Sur telephone, toutes les cartes
   * suivent l'inclinaison de l'appareil : c'est l'equivalent tactile du
   * survol. Le calcul est celui de l'application (core/orientation.js) :
   *   - le repos est l'horizontale vraie, jamais la premiere mesure, et il
   *     DERIVE vers la posture tenue. Il reste exp(-t/1600) de l'ecart au
   *     bout de t : une posture de lecture a perdu les deux tiers de son
   *     inclinaison en deux secondes, un geste de poignet (100 ms) ne deplace
   *     le repos que de 6 % ;
   *   - la derive suit le temps ecoule, pas le nombre de mesures (10 a 60 par
   *     seconde selon l'appareil), avec un ecart borne a 250 ms pour qu'un
   *     retour d'onglet ne fasse pas sauter le repos ;
   *   - l'ecart se mesure AVANT la derive, sinon le geste est mange ;
   *   - 26 degres valent une inclinaison complete ; en paysage, les axes de
   *     l'appareil sont ramenes a ceux de l'ecran.
   * Sur iPhone et iPad, le capteur ne s'ouvre que par un geste du lecteur :
   * un bouton le demande, et n'apparait que la.
   */
  var tilts = root.querySelectorAll('[data-sxa-tilt]');
  var TILT_X = 8, TILT_Y = 10;            // degres : avant-arriere, gauche-droite
  var gyroOn = false, gx = 0, gy = 0, gsx = 0, gsy = 0, gyroRaf = 0;
  var origin = { beta: 0, gamma: 0 }, lastAt = 0, hovered = null;

  function clamp1(v) { return v < -1 ? -1 : v > 1 ? 1 : v; }
  function tiltFrom(o, r) { return { x: clamp1((r.gamma - o.gamma) / 26), y: clamp1((r.beta - o.beta) / 26) }; }
  function settleOrigin(o, r, dtMs) {
    var dt = Math.min(250, Math.max(0, dtMs)), k = 1 - Math.exp(-dt / 1600);
    return { beta: o.beta + (r.beta - o.beta) * k, gamma: o.gamma + (r.gamma - o.gamma) * k };
  }
  function screenPosture(r, angle) {
    var a = ((Math.round(angle / 90) * 90) % 360 + 360) % 360;
    if (a === 90) return { beta: -r.gamma, gamma: r.beta };
    if (a === 180) return { beta: -r.beta, gamma: -r.gamma };
    if (a === 270) return { beta: r.gamma, gamma: -r.beta };
    return { beta: r.beta, gamma: r.gamma };
  }

  /* La carte penche vers le point vise : le bord sous la souris s'enfonce,
     comme sous le doigt, et le reflet glisse avec lui. */
  function lean(card, x, y) {
    card.style.setProperty('--ry', (x * TILT_Y).toFixed(2) + 'deg');
    card.style.setProperty('--rx', (-y * TILT_X).toFixed(2) + 'deg');
    card.style.setProperty('--gx', (50 + x * 45).toFixed(1) + '%');
    card.style.setProperty('--gy', (50 + y * 45).toFixed(1) + '%');
  }

  for (var ti = 0; ti < tilts.length; ti++) {
    (function (card) {
      card.addEventListener('pointermove', function (e) {
        // La souris et le stylet seulement : un doigt qui fait defiler la page
        // ne doit pas faire basculer la carte qu'il traverse.
        if (e.pointerType !== 'mouse' && e.pointerType !== 'pen') return;
        var r = card.getBoundingClientRect();
        hovered = card;
        card.classList.add('sxa-tilting');
        lean(card, clamp1(2 * (e.clientX - r.left) / r.width - 1), clamp1(2 * (e.clientY - r.top) / r.height - 1));
      });
      card.addEventListener('pointerleave', function () {
        if (hovered === card) hovered = null;
        card.classList.remove('sxa-tilting');
        lean(card, gyroOn ? gsx : 0, gyroOn ? gsy : 0);
      });
    })(tilts[ti]);
  }

  /* Le capteur, lisse dans la boucle d'affichage : il est bruite. */
  function gyroFrame() {
    gyroRaf = 0;
    gsx += (gx - gsx) * 0.09;
    gsy += (gy - gsy) * 0.09;
    for (var k3 = 0; k3 < tilts.length; k3++) if (tilts[k3] !== hovered) lean(tilts[k3], gsx, gsy);
    if (Math.abs(gx - gsx) > 0.002 || Math.abs(gy - gsy) > 0.002) gyroRaf = requestAnimationFrame(gyroFrame);
  }
  function onOrientation(e) {
    if (e.beta === null || e.gamma === null || e.beta === undefined) return;
    var angle = (screen.orientation && screen.orientation.angle) || window.orientation || 0;
    var reading = screenPosture({ beta: e.beta, gamma: e.gamma }, angle);
    var t = tiltFrom(origin, reading);
    gx = t.x; gy = t.y;
    var now = performance.now(), dt = lastAt ? now - lastAt : 16;
    lastAt = now;
    origin = settleOrigin(origin, reading, dt);
    if (!gyroRaf) gyroRaf = requestAnimationFrame(gyroFrame);
  }
  function startGyro() {
    if (gyroOn) return;
    gyroOn = true;
    origin = { beta: 0, gamma: 0 }; lastAt = 0;
    root.classList.add('sxa-gyro');
    window.addEventListener('deviceorientation', onOrientation);
  }

  /* Le capteur n'a de sens que la ou il n'y a pas de vrai pointeur. Le test
     combine deux indices : un iPad a clavier se declare en pointeur fin. */
  var coarse = window.matchMedia && window.matchMedia('(pointer: coarse)').matches;
  var touch = coarse || (navigator.maxTouchPoints || 0) > 0;
  if (touch && typeof DeviceOrientationEvent !== 'undefined') {
    var asks = root.querySelectorAll('[data-sxa-gyro]');
    var offer = function () {
      // iPhone et iPad : la demande DOIT partir d'un geste, sinon iOS la
      // rejette sans rien afficher. Le bouton fournit ce geste.
      for (var ai = 0; ai < asks.length; ai++) {
        asks[ai].classList.add('sxa-gyro-ask--on');
        asks[ai].addEventListener('click', function () {
          DeviceOrientationEvent.requestPermission().then(function (answer) {
            if (answer !== 'granted') return;
            startGyro();
            for (var aj = 0; aj < asks.length; aj++) asks[aj].classList.remove('sxa-gyro-ask--on');
          }).catch(function () {});
        });
      }
    };
    if (typeof DeviceOrientationEvent.requestPermission === 'function') {
      /* On demande d'abord SANS geste. Chrome expose aussi cette demande
         depuis peu (vu sur la version 154) et l'accorde aussitôt : le capteur
         s'ouvre alors seul, comme avant. iOS la refuse hors geste : le bouton
         apparaît. Supposer « demande = iOS » laissait Android sans capteur. */
      DeviceOrientationEvent.requestPermission().then(function (answer) {
        if (answer === 'granted') startGyro(); else offer();
      }).catch(offer);
    } else {
      startGyro();
    }
  }

  /* ======================================================= DEMONSTRATION
   *
   * Une scene en plans, chacun avec sa profondeur z (0 = le ciel, 1 = l'herbe
   * du premier plan). Chaque plan est dessine UNE fois dans son propre canvas,
   * un peu plus large que l'image ; une vue n'est ensuite qu'un empilement de
   * ces plans, chacun decale de  oeil x ecart x (z - convergence).
   * C'est exactement ce que fait l'application avec la profondeur qu'elle
   * deduit d'une photo, en continu au lieu de six plans.
   *
   * Les valeurs qui comptent sont celles de l'application :
   *   - le wiggle balance en cosinus sur 12 images de 70 ms (engine/gif.js) ;
   *   - l'anaglyphe Dubois applique ses deux matrices en lumiere LINEAIRE
   *     (core/modes.js) : appliquees aux valeurs d'ecran, elles assombrissent
   *     les tons moyens.
   */
  var demo = root.querySelector('[data-sxa-demo]');
  var cv = demo && demo.querySelector('canvas');
  if (!cv || !cv.getContext) return;
  var ctx = cv.getContext('2d');
  if (!ctx) return;

  var DUBOIS_L = [0.456, 0.500, 0.176, -0.040, -0.038, -0.016, -0.015, -0.021, -0.005];
  var DUBOIS_R = [-0.043, -0.088, -0.002, 0.378, 0.734, -0.018, -0.072, -0.113, 1.226];
  var CONV = 0.42;                       // plan de l'ecran : les collines
  var SWAY_MS = 12 * 70;                 // une periode du wiggle exporte
  var HINTS = {
    wiggle: "Sans lunettes. Glissez sur l'image pour tourner autour de la scène.",
    dubois: "Avec des lunettes rouge-cyan, filtre rouge sur l'œil gauche.",
    cross: "Sans lunettes : louchez jusqu'à voir une troisième image au milieu, elle est en relief.",
    depth: "Ce que l'intelligence artificielle calcule : proche en blanc, loin en noir."
  };

  /* Hasard reproductible : la meme scene a chaque visite. */
  function rng(seed) {
    return function () {
      seed = (seed + 0x6D2B79F5) | 0;
      var t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
      t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
      return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
    };
  }
  /* Une ligne de crete : une somme de sinus aux phases tirees au sort. */
  function ridge(seed, n) {
    var r = rng(seed), p = [];
    for (var i = 0; i < n; i++) p.push({ f: (i + 1) * (1.1 + r() * 0.9), ph: r() * 6.283, a: 1 / (i + 1.3) });
    return function (u) {
      var s = 0, t = 0;
      for (var i = 0; i < p.length; i++) { s += p[i].a * Math.sin(u * p[i].f * 6.283 + p[i].ph); t += p[i].a; }
      return s / t;
    };
  }
  function pine(g, x, base, h) {
    var w = h * 0.36;
    g.fillRect(x - h * 0.03, base - h * 0.16, h * 0.06, h * 0.18);
    for (var i = 0; i < 4; i++) {
      var y0 = base - h * 0.12 - i * h * 0.21, ww = w * (1 - i * 0.2);
      g.beginPath(); g.moveTo(x - ww / 2, y0); g.lineTo(x, y0 - h * 0.34); g.lineTo(x + ww / 2, y0); g.closePath(); g.fill();
    }
  }
  function range(g, W, H, M, seed, base, amp, n) {
    var f = ridge(seed, n);
    g.beginPath(); g.moveTo(0, H);
    for (var x = 0; x <= W + 2 * M; x += 3) g.lineTo(x, H * (base + amp * f((x - M) / W)));
    g.lineTo(W + 2 * M, H); g.closePath(); g.fill();
  }

  /* Les plans, du plus lointain au plus proche. `paint(g, W, H, M, prof)`
     dessine en couleur, ou en gris de profondeur quand `prof` est vrai. */
  var PLANS = [
    { z: 0, paint: function (g, W, H, M, prof) {
      if (prof) { g.fillStyle = '#000'; g.fillRect(0, 0, W + 2 * M, H); return; }
      var sky = g.createLinearGradient(0, 0, 0, H);
      sky.addColorStop(0, '#141638'); sky.addColorStop(0.3, '#35286c');
      sky.addColorStop(0.5, '#93488e'); sky.addColorStop(0.63, '#f2826e');
      sky.addColorStop(0.72, '#ffc996'); sky.addColorStop(1, '#ffd9ad');
      g.fillStyle = sky; g.fillRect(0, 0, W + 2 * M, H);
      var r = rng(3);
      for (var i = 0; i < 70; i++) {
        g.fillStyle = 'rgba(255,255,255,' + (0.25 + r() * 0.55).toFixed(2) + ')';
        var sx = r() * (W + 2 * M), sy = Math.pow(r(), 1.6) * H * 0.36, sr = 0.4 + r() * 1.1;
        g.beginPath(); g.arc(sx, sy, sr * W / 960, 0, 6.283); g.fill();
      }
      var cx = M + W * 0.64, cy = H * 0.6, sr2 = H * 0.075;
      var glow = g.createRadialGradient(cx, cy, sr2 * 0.6, cx, cy, sr2 * 4.2);
      glow.addColorStop(0, 'rgba(255,230,190,.55)'); glow.addColorStop(1, 'rgba(255,230,190,0)');
      g.fillStyle = glow; g.fillRect(0, 0, W + 2 * M, H);
      g.fillStyle = '#fff0d2'; g.beginPath(); g.arc(cx, cy, sr2, 0, 6.283); g.fill();
    } },
    { z: 0.16, paint: function (g, W, H, M, prof) {
      g.fillStyle = prof ? 'rgb(48,48,48)' : '#a0609c';
      range(g, W, H, M, 7, 0.6, 0.09, 4);
    } },
    { z: 0.3, paint: function (g, W, H, M, prof) {
      g.fillStyle = prof ? 'rgb(84,84,84)' : '#69417f';
      range(g, W, H, M, 11, 0.67, 0.07, 4);
    } },
    { z: 0.42, paint: function (g, W, H, M, prof) {
      g.fillStyle = prof ? 'rgb(118,118,118)' : '#432c63';
      range(g, W, H, M, 19, 0.75, 0.04, 3);
      var r = rng(5);
      for (var i = 0; i < 9; i++) pine(g, M + W * (0.05 + i * 0.035 + r() * 0.02), H * (0.765 + r() * 0.02), H * (0.11 + r() * 0.05));
    } },
    { z: 0.62, paint: function (g, W, H, M, prof) {
      g.fillStyle = prof ? 'rgb(166,166,166)' : '#2a1d47';
      g.beginPath(); g.moveTo(M + W * 0.58, H);
      g.quadraticCurveTo(M + W * 0.72, H * 0.78, M + W * 1.05, H * 0.8); g.lineTo(M + W * 1.05, H); g.closePath(); g.fill();
      var r = rng(9);
      var xs = [0.7, 0.76, 0.81, 0.86, 0.9, 0.95];
      for (var i = 0; i < xs.length; i++) pine(g, M + W * xs[i], H * (0.83 + r() * 0.02), H * (0.2 + r() * 0.12));
    } },
    { z: 0.82, paint: function (g, W, H, M, prof) {
      g.fillStyle = prof ? 'rgb(212,212,212)' : '#191230';
      range(g, W, H, M, 23, 0.91, 0.025, 3);
      // Un rocher, en ellipse entiere : sa moitie basse s'enfonce dans le sol.
      g.beginPath();
      g.ellipse(M + W * 0.25, H * 0.95, W * 0.07, H * 0.075, -0.08, 0, 2 * Math.PI); g.fill();
    } },
    { z: 1, paint: function (g, W, H, M, prof) {
      g.strokeStyle = prof ? '#fff' : '#0c0819';
      g.lineCap = 'round';
      var r = rng(31);
      function tuft(x0, x1, n, hmax) {
        for (var i = 0; i < n; i++) {
          var x = M + W * (x0 + r() * (x1 - x0)), h = H * (0.06 + r() * hmax), lean = (r() - 0.5) * W * 0.05;
          g.lineWidth = (1.4 + r() * 2.2) * W / 960;
          g.beginPath(); g.moveTo(x, H + 2); g.quadraticCurveTo(x + lean * 0.2, H - h * 0.6, x + lean, H - h); g.stroke();
        }
      }
      tuft(-0.02, 0.17, 46, 0.26);
      tuft(0.88, 1.03, 34, 0.2);
      tuft(0.4, 0.5, 8, 0.07);
    } }
  ];

  var mode = 'wiggle';
  var W = 0, H = 0, M = 0, D = 0;
  var layers = [];                       // { z, color: canvas, depth: canvas }
  var view = document.createElement('canvas'), vctx = view.getContext('2d');
  var view2 = document.createElement('canvas'), vctx2 = view2.getContext('2d');
  var eye = 0, target = null, raf = 0, visible = false, t0 = performance.now();

  function build() {
    var cssW = demo.clientWidth, cssH = demo.clientHeight;
    if (!cssW || !cssH) return false;
    var ratio = Math.min(window.devicePixelRatio || 1, 2);
    var nw = Math.min(1280, Math.round(cssW * ratio)), nh = Math.round(nw * cssH / cssW);
    if (nw === W && nh === H && layers.length) return true;
    W = nw; H = nh; cv.width = W; cv.height = H;
    view.width = view2.width = W; view.height = view2.height = H;
    D = W * 0.045;                     // ecart maximal entre les deux yeux
    M = Math.ceil(D) + 4;
    layers = PLANS.map(function (p) { return { plan: p, z: p.z, color: null, depth: null }; });
    return true;
  }

  /* Un plan n'est peint qu'au premier besoin : les calques de profondeur ne
     servent qu'a un bouton sur quatre, et quatorze grands canvas pesent
     plusieurs dizaines de megaoctets sur un telephone. */
  function plate(L, prof) {
    var key = prof ? 'depth' : 'color';
    if (!L[key]) {
      var c = document.createElement('canvas');
      c.width = W + 2 * M; c.height = H;
      L.plan.paint(c.getContext('2d'), W, H, M, prof);
      L[key] = c;
    }
    return L[key];
  }

  /* Une vue : les plans empiles, chacun decale selon sa profondeur. `e` va
     de -1 (oeil droit) a +1 (oeil gauche) ; `gap` regle l'ecart. */
  function compose(g, e, gap, prof) {
    for (var i = 0; i < layers.length; i++) {
      var L = layers[i];
      g.drawImage(plate(L, prof), -M + e * gap * (L.z - CONV), 0);
    }
  }

  var toLin = new Float32Array(256);
  for (var i = 0; i < 256; i++) { var c = i / 255; toLin[i] = c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }
  var toSrgb = new Uint8ClampedArray(4096);
  for (var s = 0; s < 4096; s++) { var l = s / 4095; toSrgb[s] = Math.round(255 * (l <= 0.0031308 ? l * 12.92 : 1.055 * Math.pow(l, 1 / 2.4) - 0.055)); }
  function enc(v) { return v <= 0 ? 0 : v >= 1 ? 255 : toSrgb[(v * 4095) | 0]; }

  function anaglyph() {
    var gap = D * 0.55;
    compose(vctx, 0.5, gap, false);
    compose(vctx2, -0.5, gap, false);
    var a = vctx.getImageData(0, 0, W, H), b = vctx2.getImageData(0, 0, W, H);
    var A = a.data, B = b.data, n = A.length, ML = DUBOIS_L, MR = DUBOIS_R;
    for (var p = 0; p < n; p += 4) {
      var lr = toLin[A[p]], lg = toLin[A[p + 1]], lb = toLin[A[p + 2]];
      var rr = toLin[B[p]], rg = toLin[B[p + 1]], rb = toLin[B[p + 2]];
      A[p]     = enc(ML[0] * lr + ML[1] * lg + ML[2] * lb + MR[0] * rr + MR[1] * rg + MR[2] * rb);
      A[p + 1] = enc(ML[3] * lr + ML[4] * lg + ML[5] * lb + MR[3] * rr + MR[4] * rg + MR[5] * rb);
      A[p + 2] = enc(ML[6] * lr + ML[7] * lg + ML[8] * lb + MR[6] * rr + MR[7] * rg + MR[8] * rb);
    }
    ctx.putImageData(a, 0, 0);
  }

  /* Cote a cote croise : la vue de l'oeil DROIT a gauche. Chaque moitie montre
     le centre de sa vue, sans la deformer. */
  function cross() {
    var gap = D * 0.55;
    compose(vctx, 0.5, gap, false);
    compose(vctx2, -0.5, gap, false);
    var half = Math.floor(W / 2), sx = Math.floor((W - half) / 2);
    ctx.fillStyle = '#0b0c10'; ctx.fillRect(0, 0, W, H);
    ctx.drawImage(view2, sx, 0, half, H, 0, 0, half, H);
    ctx.drawImage(view, sx, 0, half, H, W - half, 0, half, H);
    ctx.fillRect(half - Math.max(1, W / 480), 0, Math.max(2, W / 240), H);
  }

  function still() {
    if (!build()) return;
    if (mode === 'dubois') anaglyph();
    else if (mode === 'cross') cross();
    else if (mode === 'depth') compose(ctx, 0, 0, true);
    else compose(ctx, eye, D, false);
  }

  function frame(now) {
    raf = 0;
    if (!visible || document.hidden || mode !== 'wiggle') return;
    var goal = target !== null ? target : (calme ? 0 : Math.cos(2 * Math.PI * ((now - t0) % SWAY_MS) / SWAY_MS));
    eye += (goal - eye) * (target !== null ? 0.35 : 1);
    if (!build()) return;
    compose(ctx, eye, D, false);
    if (!calme || target !== null) raf = requestAnimationFrame(frame);
  }
  function wake() {
    if (mode === 'wiggle' && visible && !document.hidden && !raf) raf = requestAnimationFrame(frame);
  }

  /* Le doigt ou la souris prennent la place du balancement : la scene tourne
     sous le geste, comme l'apercu de l'atelier suit l'inclinaison. */
  function aim(ev) {
    if (mode !== 'wiggle') return;
    var r = demo.getBoundingClientRect();
    target = Math.max(-1, Math.min(1, 1 - 2 * (ev.clientX - r.left) / r.width));
    wake();
  }
  demo.addEventListener('pointermove', aim);
  demo.addEventListener('pointerdown', aim);
  demo.addEventListener('pointerleave', function () { target = null; t0 = performance.now() - SWAY_MS * Math.acos(Math.max(-1, Math.min(1, eye))) / (2 * Math.PI); wake(); });
  demo.addEventListener('pointercancel', function () { target = null; wake(); });

  var buttons = root.querySelectorAll('[data-sxa-mode]');
  var hint = root.querySelector('[data-sxa-hint]');
  var legend = root.querySelector('[data-sxa-legend]');
  for (var b = 0; b < buttons.length; b++) {
    buttons[b].addEventListener('click', function () {
      mode = this.getAttribute('data-sxa-mode');
      for (var k2 = 0; k2 < buttons.length; k2++) buttons[k2].setAttribute('aria-pressed', String(buttons[k2] === this));
      if (hint) hint.textContent = HINTS[mode];
      if (legend) legend.textContent = mode === 'depth' ? 'Profondeur' : 'Calculé en direct';
      demo.style.cursor = mode === 'wiggle' ? '' : 'default';
      if (mode === 'wiggle') wake(); else still();
    });
  }

  /* La vignette « signature des images » montre la meme scene, recadree au
     carre, la signature posee dessus comme sur une image exportee. */
  var thumbCv = root.querySelector('[data-sxa-thumb]');
  function thumb() {
    if (!thumbCv || !build()) return;
    var tw = thumbCv.clientWidth, th = thumbCv.clientHeight;
    if (!tw || !th) return;
    var ratio = Math.min(window.devicePixelRatio || 1, 2);
    thumbCv.width = Math.round(tw * ratio); thumbCv.height = Math.round(th * ratio);
    compose(vctx, 0, D, false);
    var side = Math.min(W, H), sx = Math.min(W - side, Math.round(W * 0.3));
    thumbCv.getContext('2d').drawImage(view, sx, 0, side, side, 0, 0, thumbCv.width, thumbCv.height);
  }

  var resizeTimer = 0;
  window.addEventListener('resize', function () {
    clearTimeout(resizeTimer);
    resizeTimer = setTimeout(function () { layers = []; W = 0; still(); thumb(); wake(); }, 150);
  });
  document.addEventListener('visibilitychange', wake);

  if ('IntersectionObserver' in window) {
    /* La DERNIERE entree fait foi : un saut de page (ancre, retour arriere)
       livre parfois « cache » puis « visible » dans le meme appel, et lire la
       premiere laissait la demonstration figee pour de bon. */
    new IntersectionObserver(function (es) {
      visible = es[es.length - 1].isIntersecting;
      if (visible) { still(); wake(); }
    }, { threshold: 0 }).observe(demo);
    if (thumbCv) {
      var io3 = new IntersectionObserver(function (es) {
        if (es[es.length - 1].isIntersecting) { thumb(); io3.disconnect(); }
      }, { threshold: 0 });
      io3.observe(thumbCv);
    }
  } else {
    visible = true; still(); thumb(); wake();
  }
})();
</script>
				</div>
				</div>
				</div>
		<div class="elementor-element elementor-element-bdab5e9 e-con-full e-flex wpr-particle-no wpr-jarallax-no wpr-parallax-no wpr-sticky-section-no wpr-column-slider-no wpr-equal-height-no e-con e-parent" data-id="bdab5e9" data-element_type="container" data-e-type="container">
				<div class="elementor-element elementor-element-87002b9 elementor-widget elementor-widget-html" data-id="87002b9" data-element_type="widget" data-e-type="widget" data-widget_type="html.default">
				<div class="elementor-widget-container">
					<!--
// StereoptiX, 3D & ANAGLYPH MODE for cameras, © All rights reserved.
// PCdlabs · Dev-Xprmnts · VR_Xprmnts · Present Compound Design     ║
// ║ Alban DESBARAX - Designer 360° / Creative Technologist         ║
// ║ Toulouse, FRANCE                                               ║
// ║                                                                ║
// ║   · Expertise & Support 2D, 3D, AR, VR, XR, AI                 ║
// ║   · Product Design & Industrial Design                         ║
// ║   · Digital communication                                      ║
// ║   · Immersive experiences  -ᯅ- ✔️                             ║
// ║                                                                ║                   
// · 🡺 https://presentcomposedesign.fr/      
//
//                                                       ++++++++++                                    
//                                                    =+++++++++===============                        
//                                                    +++++++++++++=====================+              
//                                                   ++++++++   *+++++=======================+*#       
//                                                    ==++++*          *++++==============*#%%%%       
//                                                     +==++=               *+======+*%%%%%%%%%%       
//       ==-===--=**+**                                                      @%%#%%%%%#-...=%%%%       
//      ==================+***                                          ##%%%%%%%%+:.......=%%%%       
//      =================------=+++***                           ##%#%%%%%%%%%%%%+::.......*%%%        
//      ====================-----------=+*++**             %%%%%%%%%%##%%%%%%%%%%=:::::::..#%%%        
//      -======     +=+==========-------------===+*   #%%%%%%%%#+==----=%%%%%%%%%=.......:#%%%@        
//       ----==            +======------------------=#%%%%%+=----------+%%%%%%%%%=..:+*%%%%%%          
//         ===+               ======----------------=#%%%%=------------#%%%%**%%%%%%%%%#+=             
//                              =======-------------=#%%%*=------------#%%%@  =*%%%***                 
//                                @+===---------=====#%%%*------------*%%%                             
//                                   +--------=======#%%%*------==*%%%%%%                              
//                                     =-----=======+#%%%#=-+#%%%%%%##%                                
//                                        ---=======+#%%%%%%%%###*#                                    
//                                               ===+##%%%%##@                                         
//                                                     +*                                              
//
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@//*
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%#*++==------===**%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#++-::::::::::::::::::::::-=*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#+-::::::::::::::::::::::::::::::::=*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@#+::::::::::::::::::::::::::::::::::::::::=%@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@%+:::::::::::::::::=++***+=-::::::::::::::::::::=%@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@#=::::::::::::::::+%@@@@@@@@@@%+-:::::::::::::::::::=#@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@#-::::::::::::::::#@@@@@@@@@@@@@@@@%-:::::::::::::::::::=#@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@%=::::::::::::::::*@@@@@@**==-=+##@@@@@*-:::::::::::::::::::=%@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@=:::::::::::::::::%@@@@@+::::::::::-*@@@@#-::::::::::::::::::::=@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@#::::::::::::::::::%@@@@=::::::::::::::-%@@@#-:::::::::::::::::::::#@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@=::::::::::::::::::*@@@@=::::::::::::::::-%@@@*::::::::::::::::::::::=@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@%-::::::::::::::::::-@@@@=::::::::::::::::::-%@@@-::::::::::::::::::::::-%@@@@@@@@@@@@@
// @@@@@@@@@@@@#::::::::::::::::::::*@@@#::::::::::::::::::::=@@@*::::::::::::::::::::::::*@@@@@@@@@@@@
// @@@@@@@@@@@#:::::::::::::::::::::%@@@+::::::::::::::::::::-@@@%:::::::::::::::::::::::::*@@@@@@@@@@@
// @@@@@@@@@@+::::::::::::::::::::::@@@@-:::::::::::::::::::::%@@@::::::::::::::::::::::::::*@@@@@@@@@@
// @@@@@@@@@*:::::::::::::::::::::::@@@@=:::::::::::::::::::::@@@@:::::::::::::::::::::::::::*@@@@@@@@@
// @@@@@@@@%::::::::::::::::::::::::@@@@*:::::::::::::::::::::@@@%::::::::::::::::::::::::::::*@@@@@@@@
// @@@@@@@%:::::::::::::::::::::::::@@@@%::::::::::::::::::::+@@@*:::::::::::::::::::::::::::::%@@@@@@@
// @@@@@@@::::::::::::::::::::::::::@@@@@*::::::::::::::::::-@@@@::::::::::::::::::::::::::::::-@@@@@@@
// @@@@@@=::::::::::::::::::::::::::@@@@@@*::::::::::::::::-@@@@+:::::::::::::::::::::::::::::::*@@@@@@
// @@@@@%:::::::::::::::::::::::::::@@@@@@@*::::::::::::::-%@@@#:::::::::::::::::::::::::::::::::*@@@@@
// @@@@@::::::::::::::::::::::::::::@@@@@@@@@*::::::::::+@@@@@*::::::::::::::::::::::::::::::::::-@@@@@
// @@@@*::::::::::::::::::::::::::::@@@#=@@@@@@%#*+=**%@@@@@@=::::::::::::::::::::::::::::::::::::*@@@@
// @@@@:::::::::::::::::::::::::::::@@@#::*@@@@@@@@@@@@@@@@*:::::::::::::::::::::::::::::::::::::::@@@@
// @@@*:::::::::::::::::::::::::::::@@@#::::=*@@@@@@@@@%#=:::::::::::::::::::::::::::::::::::::::::#@@@
// @@@=:::::::::::::::::::::::::::::@@@#::::::::-==+=-:::::::::::::::::::::::::::::::::::::::::::::=@@@
// @@@::::::::::::::::::::::::::::::@@@#:::::::::::::::::::::::::::::::::::::::::::::::::--:::::::::@@@
// @@*::::::::::::::::::::::::::::::@@@#:::::::::+######*:::::::::::::::::::::::::::::::+@=:::::::::#@@
// @@*::::::::::::::::::::::::::::::@@@#:::--::::===+*+==:::::=-:::::::=-::::::-::::::::+@=:::::::::+@@
// @@:::::::::::::::::::::::::::::::@@@#::+@#@@::-#@@%@%=::=@@@@+:::+@@@@@+-::@@@@%*-::#@@%#:::::::::@@
// @@:::::::::::::::::+*##%%#*=-::::@@@#::+@@=::-%@:::-*@=:%@-:=:::*@*:::-@*::@@-:=@%::=*@*=:::::::::@@
// @@::::::::::::::+%@@@@@@@@@@@%*-:@@@#::+@*:::*@@@@@@@@#:+@%*-:::@@@@@@@@@-:@%:::#@:::+@=::::::::::@@
// @%::::::::::::*@@@@@@@@@@@@@@@@@#+@@#::+@+:::+@=-------:::=%@*::@%-------::@%:::#@:::+@=::::::::::%@
// @%::::::::::-@@@@@@++-::::-=#@@@@@+*#::+@+:::-@%=-:+%%-:-=::#@::*@*-::+%=::@%:::#@:::+@=::::::::::#@
// @%:::::::::=@@@@%+::::::::::::#@@@@**::+@+::::-#@@@@%=::#@%%@+:::+@@@@@*:::@%:::#@:::+@=::::::::::%@
// @%::::::::-@@@@+:::::::::::::::-@@@@+:::-::::::::--:::::::=-:::::::-=-:::::--:::-=----=-::::::::::%@
// @@:::::::-@@@@*:::::::::::::::::-%%%*:::::::::::::::::::::::::::::::::::::::::::+%%%%%%#::::::::::@@
// @@:::::::*@@@#:::::::+#@%*-::::::=+*#*=+#*=-:::::+#%%#-::::::=#%%#=::::-#@@#-::::=##%#+-::::::::::@@
// @@-::::::@@@@-:::::-%@#+*%@*:::::%@#+#@@+*@*::::%@+==#@*::::%@#+=%@*-::%@-=*=:::*@*+=+@%-::::::::-@@
// @@+:::::-@@@@::::::#@=::::#@=::::%@::=@*::#@-::*@=::::-@+::*@*::::*@*::*@*-::::-@@####%@#::::::::*@@
// @@#:::::-@@@*::::::@@:::::=@*::::%@::=@*::#@-::%@::::::@*::%@-::::-@%:::=%@%-::*@#******+::::::::*@@
// @@@-::::-@@@%::::::#@+::::%@-::::%@::=@*::#@-::@@-::::=@-::*@*::::*@*:::-::@%::=@*::::-=-::::::::@@@
// @@@+:::::@@@@-::::::%@%*#@@+:::::%@::=@*::#@-::@@@#=+#@+::::*@%*+@@*:::#@*+@*:::*@%*+%@%::::::::-@@@
// @@@#:::::#@@@+:::::::=*##+-::::::+*::-*=::+*:::@%=*%#*-::::::=*%#*-:::::=*#+:::::-*##*=--=::::::*@@@
// @@@@-::::=@@@@-::::::::::::::::::=**+::::::::::@%:::::::::::::::::::::::-==++**##%%@@@@@@@-::::-@@@@
// @@@@*:::::*@@@@-::::::::::::::::+@@@#::::::::::::::::::::-==++**##=-#@@@@@@@@@@@@@@@@@@@@@=::::*@@@@
// @@@@@-:::::#@@@@+-:::::::::::::*@@@@::::::-==++--##%@@@@@@@@@#*#@@%%@@@@@@@@@@@#@@@@@*#@@@+:::-@@@@@
// @@@@@%::::::*@@@@@+-:::::::::+@@@@%:=@@@@@@@@@@==@@@**%@@@@*-#@*-@@*@@@*-+**@@@:%@@==@++@@*:::%@@@@@
// @@@@@@*::::::=@@@@@@@%%**##@@@@@@=::=@@@@#-:--=-=@@=+@+=@@@:%@@@=+@-*@*-@@@+:@@=#@:*@@%-@@#::+@@@@@@
// @@@@@@@-:::::::+%@@@@@@@@@@@@@@#-::::@@@+-%@@@*--@@:@@=*@@@%=*%@@@@*=@-*@@@*:+@=+=#@@@%:@@%:-@@@@@@@
// @@@@@@@@-::::::::=*%@@@@@@@##-:::::::@@@:#@@@@@*:@@:%==@@@@@@%+-%@@#-@*+@@@:+:@*:*@@@@@:%@@-@@@@@@@@
// @@@@@@@@#-:::::::::::::-:::::::::::::@@%:%@@@@@*-@@-#@@@#-@*%@@@-#@@:@@#=+:%@-#@:@@@@@@*=@@%@@@@@@@@
// @@@@@@@@@*:::::::::::::::::::::::::::#@@--@@@@%:*@@%*+*=*@@=+@@@=+@@-#@@@@@@@=#@#@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@*-:::::::::::::::::::::::::#@@@+-==-:#@@@@@@@@@@@@#++*#@@@@@@@#@@@%:@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@*-::::::::::::::::::::::::+@@@@@%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@-=-:+**++==--::%@@@@@@@@@@@
// @@@@@@@@@@@@#-:::::::::::::::::::::::=@@@@@@@@@@@@@@@@@@@@@%%##**++=---::::::::::::::::%@@@@@@@@@@@@
// @@@@@@@@@@@@@@-::::::::::::::::::::::-@@@@@@%###**++=--::::::::::::::::::::::::::::::-%@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@+::::::::::::::::::::::--::::::::::::::::::::::::::::::::::::::::::::+@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@%-::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::-%@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@+-:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::+@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@%=::::::::::::::::::::::::::::::::::::::::::::::::::::::::::+%@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@%=::::::::::::::::::::::::::::::::::::::::::::::::::::::=%@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@%=-:::::::::::::::::::::::::::::::::::::::::::::::::+%@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@*-:::::::::::::::::::::::::::::::::::::::::::::#@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@%+-:::::::::::::::::::::::::::::::::::::::+%@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%*=-:::::::::::::::::::::::::::::::+#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%**=-:::::::::::::::::::::=+#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%**++=====+++*##@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#****+++++****##%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@**+-::::::::::::::::::::==*#%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*+-::::::::::::::::::::::::::::::==#%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@#+-::::::::::::::::::::::::::::::::::::::=*%@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@*-::::::::::::::::::::::::::::::::::::::::::::=*%@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@#+:::::::::::::::++****%%@@@@@%%##**=--:::::::::::::=#@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@*=:::::::::::::+*%@@@@@@@@@@@@@@@@@@@@@@@#*=-:::::::::::=#@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@#=:::::::::::+*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#=-::::::::::=#@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@%=::::::::::+%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*=::::::::::=%@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@*::::::::::*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#+-:::::::::*%@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@%=::::::::=*@@@@@@@@@@@@@@%#*********#%%@@@@@@@@@@@@@@@@@@@%+-::::::::=%@@@@@@@@@@@@@@
// @@@@@@@@@@@@@#:::::::::#@@@@@@@@@@@**+-::::::::::::::::-+#%@%@@@@@@@@@@@@@@%+-::::::::*@@@@@@@@@@@@@
// @@@@@@@@@@@@+::::::::+@@@@@@@@@@*-::::::::::::::::::::::#@@@##%%@@@@@@@@@@@@@@+::::::::*%@@@@@@@@@@@
// @@@@@@@@@@@=:::::::=#@@@@@@@@@+::::::::::::::::::::::::-*@@@@@@#%%@@@@@@@@@@@@@*-:::::::=%@@@@@@@@@@
// @@@@@@@@@@=:::::::*@@@@@@@@@%=::::::::::::::::::::::::::*@@@@@@@@##%@@@@@@@@@@@@%+:::::::=%@@@@@@@@@
// @@@@@@@@@%=:::::::#@@@@@@@@@%:::::::::::::::::::::::::::+@@@@@@@@@@@%%@@@@@@@@@@@@@*-::::::-%@@@@@@@
// @@@@@@@@@=::::::-#@@@@@@@@@%-:::::::::::::::::::::::::==#@@@@@@@@@@@@%#@@@@@@@@@@@@@#-::::::=%@@@@@@
// @@@@@@@@=::::::-%@@@@@@@@@@=:::::::::::::::::::::::::*=#%@@@@@@@@@@@@@%#@@@@@@@@@@@@@#-::::::*@@@@@@
// @@@@@@@*::::::-#@@@@@@@@@@*::::::::::::::::::::::::*=#+%@@@%@@@@@@@@@@@%%@@@@@@@@@@@@@%-::::::*@@@@@
// @@@@@@%:::::::#@@@@@@@@@@%:::::::::::::::::::---:::-:-+*@%%@@@@@@@@@@@@@*%@@@@@@@@@@@@@%-::::::%@@@@
// @@@@@@-::::::#@@@@@@@@@@@-::::::::::::::::::::+=#-=#*#=+%%@@@@@@@@@@@@@@@%@@@@@@@@@@@@@@#-:::::-%@@@
// @@@@@*::::::*@@@@@@@@@@@#:::::::::::::::::::::-++=#==#+-%%%@@@@@@@@@@@@@@%@@@@@@@@@@@@@@@*::::::*@@@
// @@@@%::::::-@@@@@@@@@@@@=::::::::::::::::::::::::+=++#%=%*@@@@@@@@@@@@@@@@*@@@@@@@@@@@@@@@-::::::%@@
// @@@@*::::::#@@@@@@@@@@@%:::::::::::::::::::::::::+#:+@%+%@@@@@@@@@@@@@@@@@@#@@@@@@@@@@@@@@%-:::::*@@
// @@@%::::::=@@@@@@@@@@@@*::::::::::::::::::::::::-#*-#+=@@%@%@@@@@@@@@@@@@@@#@@@@@@@@@@@@@@@*::::::%@
// @@@*::::::%@@@@@@@@@@@@=:::::::::::::::::::::::+=#@++=+%@@@@@@@@@@@@@@#@@@+-%@@@@@@@@@@@@@@@-:::::*@
// @@::::::*@@@@@@@@@@@@@-:::::::::::::::::::::::-**+@+*=%@%@@@@@@@@@@@#@@%=::*@@@@@@@@@@@@@@@*::::::%@
// @%::::::#@@@@@@@@@@@@@::::::::::::::::::::::::::==#@%+@%+*%%@@@@@@@#==+::=-#@@@@@@@@@@@@@@@#::::::*@
// @*:::::-@@@@@@@@@@@@@@::::::::::::::::::::::::::::***%-@*@%%@@@@@@@%%=+*%%%@@@@@@@@@@@@@@@@@-:::::+@
// @=:::::*@@@@@@@@@@@@@@-::::::::::::::::::::::::::-+@+=+*%*%@@@@@@@%@@@%*=:*@@@@@@@@@@@@@@@@@+:::::=@
// %::::::*@@@@@@@@@@@@@@*::::::::::::::::::::::-=-=+#**#=%+@@*@@@@@@@@@@@+=**%@@@@@@@@@@@@@@@@#::::::%
// %::::::%@@@@@@@@@@@@@@#:::::::::::::::::::+#@++@%@@#*+*+#=@@@@@@@@@@@@@@*%@#%@@@@@@@@@@@@@@@#::::::#
// *::::::@@@@@@@@@@@@@@@@-:::::::::::::::::*@@@:::%@@@@*---**@@@@@@@@@@@@@@@@@@%@@@@@@@@@@@@@@%::::::*
// *::::::@@@@@@@@@@@@@@@@+:::::::::::::::::@@@@%::*@@@@@+-:=%+@@@@@@@@@@@@@@@@@@%@@@@@@@@@@@@@@::::::*
// *:::::-@@@@@@@@@@@@@@@@#:::::::::::::::::%=%@@-:+@@@@@*::%*@@@@@@@@@@@@@@@@@@@@%@@@@@@@@@@@@@-:::::*  
// *:::::=@@@@@@@@@@@@@@@@@+::::::::::::::::*:-@*::*-*@@@@-:-=%@%@@@@@@@@@@@@@@@@@#@@@@@@@@@@@@@::::::*
// *:::::-@@@@@@@@@@@@@@@@@%-:::::::::::::::*+:%@@:::-@@@@-:=+%%@%@@@@@@@@@@@*@@@@@@@@@@@@@@@@@@::::::*
// *::::::%@@@@@@@@@@@@@@@@@*::::::::::::::::*=-@*::::%@@@+:+*@@@%@@@@@@@@@@@%@@%#@@@@@@@@@@@@@%::::::*
// #::::::%@@@@@@@@@@@@@@@@@%-::::::::::::::::#+**:::::%@@*:-=%@*@@@@@@@@@@@#**%@@@@@@@@@@@@@@@%::::::#
// @::::::*@@@@@@@@@@@@@@@@@@*::::::::::::::::-%@@@@@**@@%=::=+@@@@@%@@@@@@@@@%@@@@@@@@@@@@@@@@*::::::@
// @-:::::*@@@@@@@@@@@@@@@@@@@=:::::::::::::::::#@@@@@@@@%:::+=%@@@@@@@@@@@@@@@%@@@@@@@@@@@@@@@*:::::-@
// @+:::::=@@@@@@@@@@@@@@@@@@@%-:::::::::::::::::*%@@@*%@-:::-*=*%@@@@@@@@@@@@@%@@@@@@@@@@@@@@@::::::*@
// @*::::::%@@@@@@@@@@@@@@@@@@@*:::::::::::::::::::=%%+#@=:::=*++@@@@@@@@@@@@@#@@@@@@@@@@@@@@@%::::::*@
// @@-:::::*@@@@@@@@@@@@@@@@@@@#::::::::::::::::----:=###::::=**%%@@@@@@@@@@@+%@@@@@@@@@@@@@@@@+:::::-@
// @@+:::::-%@@@@@@@@@@@@@@@@@@@-:::::::::::::=@@@@@+::::::::-*#%%%@@@@@@@@@%*%@@@@@@@@@@@@@@%::::::*@@
// @@#::::::*@@@@@@@@@@@@@@@@@@@+::::::::::::::%@@@@@-:::::::+%@%%%@@@@@@@@@%@@@@@@@@@@@@@@@@+::::::%@@
// @@@+::::::%@@@@@@@@@@@@@@@@@@*::::::::::::::*@@@@@-:::::::-+#@%%@%@@@@@@@%@@@@@@@@@@@@@@@%::::::=@@@
// @@@%-:::::*@@@@@@@@@@@@@@@@@@*::::::::::::::#@@@@@::::::::::*@%**%@@@@@@@#@@@@@@@@@@@@@@@=::::::%@@@
// @@@@*::::::%@@@@@@@@@@@@@@@@@*::::::::::::::*@@@@@#-::::::::===+-%%@@@@@@%%@@@@@@@@@@@@@*::::::*@@@@
// @@@@@-:::::-%@@@@@@@@@@@@@@@@+::::::::::::::=#%@@@@*--#=::::::--:=@@%%%@@%@@@@@@@@@@@@@#::::::-@@@@@
// @@@@@#-:::::-%@@@@@@@@@@@@@@@=::::::::::::::==@%@%%%#+%%*:::::::-=#%%*%@#*@@@@@@@@@@@@%:::::::#@@@@@
// @@@@@@*::::::*%@@@@@@@@@@**+-:::::::::::::::@#@@%@@@%#%@*::::-::::==::%%*@@@@@@@@@@@@%=::::::*@@@@@@
// @@@@@@@+::::::*@@@@@@@@*::::::::::::::::::-##%%@#@@@@%%%*-==*%:-::==+**@@@@@@@@@@@@@%=::::::=@@@@@@@
// @@@@@@@@-::::::*%@@@@*::::::::::::::::::::::%%%%%@@@@@@@#%@@%%@@@@@@@@@@@@@@@@@@@@@%=::::::=@@@@@@@@
// @@@@@@@@%-::::::=%@@%:::::::::::::::::::::#@%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#:::::::-@@@@@@@@@
// @@@@@@@@@@-::::::-%@#::::::::::::::::::::+@@@@@%@@@@@@@@@@@%%@@@@@@@@@@@@@@@@@@@@*:::::::=@@@@@@@@@@
// @@@@@@@@@@@-:::::::*@-:::::::::::::::::::-%@@@@@@@@@@@@@@@@*@@@@@@@@@@@@@@@@@@@@=:::::::=@@@@@@@@@@@
// @@@@@@@@@@@@+:::::::==:::::::::::::::::::-%@@@@@@@@@@@@@@@%@@@@@@@@@@@@@@@@@@@#-:::::::+@@@@@@@@@@@@
// @@@@@@@@@@@@@*-:::::::::::::::::::::::::::=@@@@@@@@@@@@@@%#@@@@@@@@@@@@@@@@@%=::::::::#@@@@@@@@@@@@@
// @@@@@@@@@@@@@@#-:::::::::::::::::::::::::::*@@@@@@@@@@@@#@@@@@@@@@@@@@@@@@@+::::::::=#@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@*-::::::::::::::::::::::::::*%@@@@@@@@@@*@@@@@@@@@@@@@@@@+:::::::::*@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@%=:::::::::::::::::::::::::::*%@@@@@@@@#@@@@@@@@@@@@@*=:::::::::=%@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@#-:::::::::::::::::::::::::::*%@@@@@@@%#@@@@@@@@*+-:::::::::=#@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@#-:::::::::::::::::::::::::::*%@@@@@@@%@@@@*+:::::::::::=#@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@#=-::::::::::::::::::::::::::+%@@@@%**--::::::::::::+#@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@%*-:::::::::::::::::::::::::::::::::::::::::::::*%@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@#*-:::::::::::::::::::::::::::::::::::::::+%@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@##=-:::::::::::::::::::::::::::::::+*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@##*=-::::::::::::::::::::-+**%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%##****+++++****%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
-->

<!--
  ============================================================================
  STEREOPTIX · BLOC SEO ET GEO
  Second bloc HTML personnalise Elementor, a placer JUSTE SOUS l'article,
  sur https://presentcomposedesign.fr/stereoptix-infos/

  CE QU'IL CONTIENT
    1. Une definition en une phrase, vraie hors de tout contexte.
    2. Une fiche de faits, visible, compacte, citable telle quelle.
    3. Une foire aux questions, visible, dix questions reellement posees.
    4. Un bloc JSON-LD qui decrit la page, l'application, ses trois offres,
       l'auteur et l'entreprise, et relie chaque entite a une adresse
       verifiable.

  CE QU'IL NE PEUT PAS CONTENIR, ET POURQUOI
    Un widget HTML d'Elementor s'insere dans le CORPS de la page. Un <title>
    ou un <meta name="description"> poses ici seraient decoratifs : Google ne
    lit la description que dans le <head>. Ces champs se reglent dans Yoast.
    Les valeurs a y coller sont dans stereoptix-infos-reglages-seo.md.

  UNE SEULE CHOSE A REMPLACER (chercher "A-REMPLACER")
    L'adresse de l'image de partage, une fois stereoptix-partage-1200x630.jpg
    televersee dans la mediatheque WordPress.

  A VERIFIER UNE FOIS EN LIGNE
    · Coller l'adresse de la page dans https://validator.schema.org/
    · Puis dans https://search.google.com/test/rich-results
    · Toutes les adresses de ce bloc repondaient le 7 octobre 2026.
  ============================================================================
-->

<style>
/* ===========================================================================
   Tout est enferme sous #sxa-faq, comme l'article sous #sxa : un bloc insere
   dans un theme ne doit jamais pouvoir repeindre ce qui l'entoure, ni en
   recevoir.
   =========================================================================== */
#sxa-faq {
  --f-blue: #1001ef;
  --f-ink: #0d1020;
  --f-mid: #5d6479;
  --f-line: #e4e6f0;
  --f-wash: #f7f8fc;

  box-sizing: border-box;
  margin: 0; padding: 72px 0;
  font-family: "Inter Tight", "Helvetica Neue", Arial, system-ui, sans-serif;
  color: var(--f-ink); background: #fff;
  line-height: 1.55;
  -webkit-font-smoothing: antialiased;
}
#sxa-faq *, #sxa-faq *::before, #sxa-faq *::after { box-sizing: border-box; }
#sxa-faq p, #sxa-faq h2, #sxa-faq h3, #sxa-faq dl, #sxa-faq dt, #sxa-faq dd { margin: 0; }
/* Un theme vise souvent les balises elles-memes : chaque propriete qu'il aime
   poser est reprise ici, et les classes du bloc l'emportent ensuite. */
#sxa-faq h2, #sxa-faq h3, #sxa-faq p, #sxa-faq dt, #sxa-faq dd, #sxa-faq strong {
  font-family: inherit; color: inherit; text-transform: none;
}
#sxa-faq p, #sxa-faq dd { font-size: inherit; line-height: 1.55; }
#sxa-faq a { color: var(--f-blue); text-decoration: none; font-weight: 600; }
#sxa-faq a:hover { text-decoration: underline; }

#sxa-faq .f-wrap { width: 100%; max-width: 1060px; margin: 0 auto; padding: 0 20px; }

#sxa-faq .f-kicker {
  display: inline-flex; align-items: center; gap: 8px;
  font-size: 11.5px; font-weight: 700; letter-spacing: .16em; text-transform: uppercase;
  color: var(--f-blue); margin-bottom: 14px;
}
#sxa-faq .f-kicker::before { content: ""; width: 24px; height: 2px; background: currentColor; }
#sxa-faq h2 { font-size: clamp(1.5rem, 3.2vw, 2.1rem); font-weight: 800; line-height: 1.12; letter-spacing: -.03em; }
#sxa-faq h3 { font-size: 1rem; font-weight: 700; letter-spacing: -.01em; margin-bottom: 6px; line-height: 1.35; }
#sxa-faq .f-lede { font-size: clamp(1.02rem, 1.9vw, 1.18rem); color: var(--f-mid); max-width: 62ch; margin-top: 12px; }
#sxa-faq .f-lede strong { color: var(--f-ink); }

/* ------------------------------------------------------------ fiche de faits
   Un tableau court, sans phrase, ou chaque ligne se lit seule. C'est la forme
   que les moteurs generatifs reprennent le plus volontiers, parce qu'elle ne
   demande aucun contexte pour etre exacte. */
#sxa-faq .f-facts {
  display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0;
  margin-top: 30px; border: 1px solid var(--f-line); border-radius: 18px; overflow: hidden;
  background: var(--f-wash);
}
#sxa-faq .f-facts > div {
  padding: 15px 18px; border-bottom: 1px solid var(--f-line); background: #fff;
}
#sxa-faq .f-facts > div:nth-child(odd) { border-right: 1px solid var(--f-line); }
#sxa-faq .f-facts dt {
  font-size: 10.5px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase;
  color: var(--f-mid); margin-bottom: 4px;
}
#sxa-faq .f-facts dd { font-size: 14.5px; font-weight: 600; margin: 0; }

/* -------------------------------------------------------------------- foire */
#sxa-faq .f-qa { margin-top: 44px; display: grid; gap: 12px; }
#sxa-faq .f-qa article {
  border: 1px solid var(--f-line); border-radius: 16px; padding: 20px 22px; background: #fff;
  transition: border-color .2s ease, box-shadow .2s ease;
}
#sxa-faq .f-qa article:hover { border-color: rgba(16,1,239,.28); box-shadow: 0 14px 32px rgba(13,16,32,.07); }
#sxa-faq .f-qa p { color: var(--f-mid); font-size: 14.5px; max-width: 78ch; }
#sxa-faq .f-qa p + p { margin-top: 8px; }

/* ------------------------------------------------------------------- liens */
#sxa-faq .f-links {
  margin-top: 40px; padding-top: 26px; border-top: 1px solid var(--f-line);
  display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px;
}
#sxa-faq .f-links a {
  display: block; border: 1px solid var(--f-line); border-radius: 14px; padding: 16px 18px;
  font-weight: 700; font-size: 14.5px; text-decoration: none;
  transition: transform .18s ease, border-color .18s ease;
}
#sxa-faq .f-links a:hover { transform: translateY(-2px); border-color: rgba(16,1,239,.35); text-decoration: none; }
#sxa-faq .f-links span { display: block; font-weight: 500; font-size: 12.5px; color: var(--f-mid); margin-top: 3px; }

@media (max-width: 760px) {
  #sxa-faq { padding: 52px 0; }
  #sxa-faq .f-facts { grid-template-columns: 1fr; }
  #sxa-faq .f-facts > div:nth-child(odd) { border-right: 0; }
  #sxa-faq .f-links { grid-template-columns: 1fr; }
}
@media (prefers-reduced-motion: reduce) {
  #sxa-faq .f-links a:hover { transform: none; }
}
</style>

<div id="sxa-faq">
  <div class="f-wrap">

    <p class="f-kicker">Questions fréquentes</p>
    <h2>StereoptiX en clair</h2>

    <!--
      Une definition autoportante, en une phrase. Un moteur generatif cite une
      phrase, pas un paragraphe : celle-ci doit donc etre vraie hors de tout
      contexte, sans « elle » ni « cette application ».
    -->
    <p class="f-lede">
      <strong>StereoptiX est une application web qui transforme une photo en
      image en relief, anaglyphe rouge-cyan, côte à côte ou wiggle GIF, en
      calculant la profondeur dans le navigateur, sans compte et sans envoyer
      la photo sur un serveur.</strong>
      Elle a été conçue et développée par Alban Desbarax, designer et
      développeur à Toulouse, sous le nom Present Composé Design.
    </p>

    <dl class="f-facts">
      <div><dt>Nom</dt><dd>StereoptiX</dd></div>
      <div><dt>Type</dt><dd>Application web installable (PWA)</dd></div>
      <div><dt>Fonction</dt><dd>Transformer une photo en image en relief</dd></div>
      <div><dt>Types de relief</dt><dd>10 : anaglyphes, côte à côte, wiggle GIF, carte de profondeur</dd></div>
      <div><dt>Prix</dt><dd>Gratuit ; licences Studio 4,99 € et Premium 19,99 €, payées une fois</dd></div>
      <div><dt>Compte requis</dt><dd>Aucun</dd></div>
      <div><dt>Calcul de la profondeur</dt><dd>Sur l'appareil, modèle Depth Anything V2 Small</dd></div>
      <div><dt>Photos envoyées</dt><dd>Aucune, sauf Mes clichés, à la demande (Premium)</dd></div>
      <div><dt>Export gratuit</dt><dd>720p, avec une petite signature</dd></div>
      <div><dt>Appareils</dt><dd>Android, iPhone, iPad, Windows, macOS, Linux</dd></div>
      <div><dt>Langue</dt><dd>Français</dd></div>
      <div><dt>Hébergement</dt><dd>IONOS, en Europe</dd></div>
      <div><dt>Auteur</dt><dd>Alban Desbarax, Present Composé Design, Toulouse</dd></div>
      <div><dt>En ligne depuis</dt><dd>Le 1er octobre 2026 (version 2.0.2)</dd></div>
    </dl>

    <!-- Chaque reponse est recopiee MOT POUR MOT dans le JSON-LD plus bas :
         une reponse balisee qui differe de la reponse visible est un motif
         de refus des resultats enrichis. Modifier l'une, c'est modifier
         l'autre. -->
    <div class="f-qa">

      <article>
        <h3>Qu'est-ce que StereoptiX ?</h3>
        <p>StereoptiX est une application web qui transforme une photo en image
        en relief. Elle produit un anaglyphe rouge-cyan pour les lunettes 3D, une
        image côte à côte pour un casque ou une visionneuse, un wiggle, c'est-à-dire
        un GIF animé qui se voit en relief sans lunettes, ou une carte de
        profondeur. La profondeur est estimée par un modèle d'intelligence
        artificielle qui s'exécute dans le navigateur de l'utilisateur.</p>
      </article>

      <article>
        <h3>StereoptiX est-elle gratuite ?</h3>
        <p>Oui. La formule Découverte est gratuite, sans limite de durée et sans
        compte : une photo, les dix types de relief, l'enregistrement et le
        partage, en 720p avec une petite signature StereoptiX sur l'image.</p>
        <p>Deux licences s'achètent une seule fois, sans abonnement. Studio, à
        4,99 €, ouvre la prise en deux photos, la vidéo, la pleine définition, et
        retire la signature. Premium, à 19,99 € à vie, y ajoute Mes clichés, une
        bibliothèque en ligne privée de 100 Go.</p>
      </article>

      <article>
        <h3>Faut-il créer un compte pour utiliser StereoptiX ?</h3>
        <p>Non. L'application s'utilise sans inscription ni adresse électronique.
        Une licence achetée est vérifiée auprès de Stripe, signée par le serveur
        et rangée dans le navigateur de l'appareil, qui la reconnaît même hors
        connexion. Un code de licence permet de la passer sur un autre appareil ;
        elle vit sur un appareil à la fois.</p>
      </article>

      <article>
        <h3>Mes photos sont-elles envoyées sur un serveur ?</h3>
        <p>Non. Le calcul de la profondeur et du relief se fait entièrement sur
        l'appareil, avec ou sans licence. La seule exception est volontaire : avec
        la licence Premium, un cliché que l'utilisateur range dans Mes clichés est
        conservé sur l'hébergement de l'application, chez IONOS en Europe, jusqu'à
        ce qu'il le supprime.</p>
      </article>

      <article>
        <h3>Comment voir une photo en relief sans lunettes 3D ?</h3>
        <p>De deux façons. Le wiggle alterne rapidement les vues de l'œil gauche et
        de l'œil droit : le relief se voit sans rien sur le nez, et l'animation
        s'enregistre en GIF qui tourne en boucle. Le côté à côte croisé se regarde
        en louchant, jusqu'à voir une troisième image au milieu, qui est en
        relief.</p>
      </article>

      <article>
        <h3>Quelles lunettes faut-il pour regarder un anaglyphe ?</h3>
        <p>Des lunettes rouge-cyan, filtre rouge sur l'œil gauche. StereoptiX
        propose quatre anaglyphes rouge-cyan, dont la méthode d'Eric Dubois,
        conseillée pour ses couleurs justes et ses images fantômes réduites, ainsi
        que des versions rouge-vert et rouge-bleu pour les lunettes plus
        anciennes.</p>
      </article>

      <article>
        <h3>Une seule photo suffit-elle pour obtenir une image en relief ?</h3>
        <p>Oui. Le modèle Depth Anything V2 estime, pour chaque pixel, ce qui est
        proche et ce qui est loin, et l'application en déduit les deux points de
        vue. Ce relief est une reconstruction plausible, pas une mesure.</p>
        <p>Pour le vrai relief d'une scène, on prend deux photos décalées d'un pas
        de côté : environ un trentième de la distance du premier plan, soit 6 à 7
        cm pour un sujet à deux mètres.</p>
      </article>

      <article>
        <h3>Peut-on partager un wiggle en GIF animé dans une messagerie ?</h3>
        <p>Oui, sur toutes les formules, y compris la formule gratuite. Le wiggle
        partagé est un GIF animé qui tourne en boucle, allégé pour les messageries
        à 720 pixels et 2 Mo au plus. Le fichier enregistré sur l'appareil garde la
        définition de la formule.</p>
      </article>

      <article>
        <h3>Sur quels appareils StereoptiX fonctionne-t-elle, et comment l'installer ?</h3>
        <p>StereoptiX fonctionne dans un navigateur récent, Chrome, Edge, Firefox
        ou Safari, sur Android, iPhone, iPad, Windows, macOS et Linux. C'est une
        application web progressive : elle s'installe depuis le navigateur sur
        l'écran d'accueil ou le bureau, sans magasin d'applications.</p>
        <p>À la première photo, le navigateur télécharge le modèle de profondeur,
        de 40 à 73 Mo selon l'appareil, puis le garde : les photos suivantes sont
        immédiates.</p>
      </article>

      <article>
        <h3>Qui a développé StereoptiX, et comment soutenir le projet ?</h3>
        <p>StereoptiX a été imaginée, nommée, dessinée, développée et mise en
        ligne par une seule personne : Alban Desbarax, designer et creative
        technologist à Toulouse, sous le nom Present Composé Design.</p>
        <p>Le projet est autofinancé. Il vit des licences Studio et Premium, et
        d'un soutien libre sur Buy Me a Coffee, qui ne débloque rien dans
        l'application.</p>
      </article>

    </div>

    <div class="f-links">
      <a href="https://pcdlabs.presentcomposedesign.fr/tools/StereoptiX/" rel="noopener">
        Ouvrir StereoptiX
        <span>L'application, sans inscription</span>
      </a>
      <a href="https://buymeacoffee.com/presentcomposedesign" rel="noopener nofollow">
        Soutenir le projet
        <span>Un café pour le créateur</span>
      </a>
      <a href="https://presentcomposedesign.fr/contact/">
        Parler d'un projet
        <span>Present Composé Design, Toulouse</span>
      </a>
    </div>

  </div>
</div>

<!--
  ============================================================================
  DONNEES STRUCTUREES

  Un seul graphe, un seul bloc. Chaque entite porte un @id stable, et les
  autres la designent par cet @id plutot que de la recopier. Les @id de
  l'entreprise, de l'auteur et du site sont LES MEMES que sur la page
  BirdSignal : c'est ce qui dit a un moteur que les deux applications ont le
  meme createur.

  AUCUNE NOTE, AUCUN AVIS, AUCUN NOMBRE D'UTILISATEURS n'est declare ici. Un
  aggregateRating invente est la premiere cause de sanction manuelle sur ce
  type de balisage. Consequence assumee : Google n'affichera pas d'etoiles
  pour l'application, il les reserve aux pages qui portent de vrais avis.
  ============================================================================
-->
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@graph": [
    {
      "@type": "ProfessionalService",
      "@id": "https://presentcomposedesign.fr/#organisation",
      "name": "Present Composé Design",
      "alternateName": "PCD",
      "legalName": "Alban DESBARAX",
      "url": "https://presentcomposedesign.fr/",
      "email": "contact@presentcomposedesign.fr",
      "telephone": "+33683949667",
      "founder": { "@id": "https://presentcomposedesign.fr/#alban" },
      "foundingDate": "2012-10-01",
      "taxID": "789583242",
      "identifier": [
        { "@type": "PropertyValue", "name": "SIREN", "value": "789583242" },
        { "@type": "PropertyValue", "name": "SIRET", "value": "78958324200035" },
        { "@type": "PropertyValue", "name": "APE", "value": "74.10Z" }
      ],
      "address": {
        "@type": "PostalAddress",
        "streetAddress": "12B rue Louis Plana",
        "postalCode": "31500",
        "addressLocality": "Toulouse",
        "addressRegion": "Occitanie",
        "addressCountry": "FR"
      },
      "areaServed": [
        { "@type": "City", "name": "Toulouse" },
        { "@type": "AdministrativeArea", "name": "Occitanie" },
        { "@type": "Country", "name": "France" }
      ],
      "knowsLanguage": ["fr", "en"],
      "knowsAbout": [
        "Design produit",
        "Identité visuelle et naming",
        "Développement web",
        "Application web progressive",
        "Intelligence artificielle embarquée",
        "Stéréoscopie et images en relief",
        "3D temps réel et WebGL",
        "Réalité augmentée et WebXR",
        "Paiement en ligne"
      ],
      "sameAs": [
        "https://www.linkedin.com/in/alban-desbarax-present-compose-design/",
        "https://buymeacoffee.com/presentcomposedesign"
      ]
    },

    {
      "@type": "Person",
      "@id": "https://presentcomposedesign.fr/#alban",
      "name": "Alban Desbarax",
      "givenName": "Alban",
      "familyName": "Desbarax",
      "jobTitle": "Designer 360 et Creative Technologist",
      "url": "https://presentcomposedesign.fr/",
      "email": "contact@presentcomposedesign.fr",
      "worksFor": { "@id": "https://presentcomposedesign.fr/#organisation" },
      "homeLocation": {
        "@type": "Place",
        "address": {
          "@type": "PostalAddress",
          "addressLocality": "Toulouse",
          "addressRegion": "Occitanie",
          "addressCountry": "FR"
        }
      },
      "sameAs": [
        "https://www.linkedin.com/in/alban-desbarax-present-compose-design/"
      ]
    },

    {
      "@type": "WebSite",
      "@id": "https://presentcomposedesign.fr/#site",
      "url": "https://presentcomposedesign.fr/",
      "name": "Present Composé Design",
      "inLanguage": "fr-FR",
      "publisher": { "@id": "https://presentcomposedesign.fr/#organisation" }
    },

    {
      "@type": ["WebPage", "FAQPage"],
      "@id": "https://presentcomposedesign.fr/stereoptix-infos/#page",
      "url": "https://presentcomposedesign.fr/stereoptix-infos/",
      "name": "StereoptiX : photo en relief, anaglyphe et wiggle 3D",
      "description": "Étude de cas complète de StereoptiX, application web qui transforme une photo en image en relief : intelligence artificielle embarquée, dix types de relief, identité, modèle économique et mise en ligne.",
      "inLanguage": "fr-FR",
      "isPartOf": { "@id": "https://presentcomposedesign.fr/#site" },
      "about": { "@id": "https://pcdlabs.presentcomposedesign.fr/tools/StereoptiX/#application" },
      "primaryImageOfPage": { "@id": "https://presentcomposedesign.fr/stereoptix-infos/#image" },
      "breadcrumb": { "@id": "https://presentcomposedesign.fr/stereoptix-infos/#fil" },
      "mainEntity": [
        {
          "@type": "Question",
          "name": "Qu'est-ce que StereoptiX ?",
          "acceptedAnswer": {
            "@type": "Answer",
            "text": "StereoptiX est une application web qui transforme une photo en image en relief. Elle produit un anaglyphe rouge-cyan pour les lunettes 3D, une image côte à côte pour un casque ou une visionneuse, un wiggle, c'est-à-dire un GIF animé qui se voit en relief sans lunettes, ou une carte de profondeur. La profondeur est estimée par un modèle d'intelligence artificielle qui s'exécute dans le navigateur de l'utilisateur."
          }
        },
        {
          "@type": "Question",
          "name": "StereoptiX est-elle gratuite ?",
          "acceptedAnswer": {
            "@type": "Answer",
            "text": "Oui. La formule Découverte est gratuite, sans limite de durée et sans compte : une photo, les dix types de relief, l'enregistrement et le partage, en 720p avec une petite signature StereoptiX sur l'image. Deux licences s'achètent une seule fois, sans abonnement. Studio, à 4,99 €, ouvre la prise en deux photos, la vidéo, la pleine définition, et retire la signature. Premium, à 19,99 € à vie, y ajoute Mes clichés, une bibliothèque en ligne privée de 100 Go."
          }
        },
        {
          "@type": "Question",
          "name": "Faut-il créer un compte pour utiliser StereoptiX ?",
          "acceptedAnswer": {
            "@type": "Answer",
            "text": "Non. L'application s'utilise sans inscription ni adresse électronique. Une licence achetée est vérifiée auprès de Stripe, signée par le serveur et rangée dans le navigateur de l'appareil, qui la reconnaît même hors connexion. Un code de licence permet de la passer sur un autre appareil ; elle vit sur un appareil à la fois."
          }
        },
        {
          "@type": "Question",
          "name": "Mes photos sont-elles envoyées sur un serveur ?",
          "acceptedAnswer": {
            "@type": "Answer",
            "text": "Non. Le calcul de la profondeur et du relief se fait entièrement sur l'appareil, avec ou sans licence. La seule exception est volontaire : avec la licence Premium, un cliché que l'utilisateur range dans Mes clichés est conservé sur l'hébergement de l'application, chez IONOS en Europe, jusqu'à ce qu'il le supprime."
          }
        },
        {
          "@type": "Question",
          "name": "Comment voir une photo en relief sans lunettes 3D ?",
          "acceptedAnswer": {
            "@type": "Answer",
            "text": "De deux façons. Le wiggle alterne rapidement les vues de l'œil gauche et de l'œil droit : le relief se voit sans rien sur le nez, et l'animation s'enregistre en GIF qui tourne en boucle. Le côté à côte croisé se regarde en louchant, jusqu'à voir une troisième image au milieu, qui est en relief."
          }
        },
        {
          "@type": "Question",
          "name": "Quelles lunettes faut-il pour regarder un anaglyphe ?",
          "acceptedAnswer": {
            "@type": "Answer",
            "text": "Des lunettes rouge-cyan, filtre rouge sur l'œil gauche. StereoptiX propose quatre anaglyphes rouge-cyan, dont la méthode d'Eric Dubois, conseillée pour ses couleurs justes et ses images fantômes réduites, ainsi que des versions rouge-vert et rouge-bleu pour les lunettes plus anciennes."
          }
        },
        {
          "@type": "Question",
          "name": "Une seule photo suffit-elle pour obtenir une image en relief ?",
          "acceptedAnswer": {
            "@type": "Answer",
            "text": "Oui. Le modèle Depth Anything V2 estime, pour chaque pixel, ce qui est proche et ce qui est loin, et l'application en déduit les deux points de vue. Ce relief est une reconstruction plausible, pas une mesure. Pour le vrai relief d'une scène, on prend deux photos décalées d'un pas de côté : environ un trentième de la distance du premier plan, soit 6 à 7 cm pour un sujet à deux mètres."
          }
        },
        {
          "@type": "Question",
          "name": "Peut-on partager un wiggle en GIF animé dans une messagerie ?",
          "acceptedAnswer": {
            "@type": "Answer",
            "text": "Oui, sur toutes les formules, y compris la formule gratuite. Le wiggle partagé est un GIF animé qui tourne en boucle, allégé pour les messageries à 720 pixels et 2 Mo au plus. Le fichier enregistré sur l'appareil garde la définition de la formule."
          }
        },
        {
          "@type": "Question",
          "name": "Sur quels appareils StereoptiX fonctionne-t-elle, et comment l'installer ?",
          "acceptedAnswer": {
            "@type": "Answer",
            "text": "StereoptiX fonctionne dans un navigateur récent, Chrome, Edge, Firefox ou Safari, sur Android, iPhone, iPad, Windows, macOS et Linux. C'est une application web progressive : elle s'installe depuis le navigateur sur l'écran d'accueil ou le bureau, sans magasin d'applications. À la première photo, le navigateur télécharge le modèle de profondeur, de 40 à 73 Mo selon l'appareil, puis le garde : les photos suivantes sont immédiates."
          }
        },
        {
          "@type": "Question",
          "name": "Qui a développé StereoptiX, et comment soutenir le projet ?",
          "acceptedAnswer": {
            "@type": "Answer",
            "text": "StereoptiX a été imaginée, nommée, dessinée, développée et mise en ligne par une seule personne : Alban Desbarax, designer et creative technologist à Toulouse, sous le nom Present Composé Design. Le projet est autofinancé. Il vit des licences Studio et Premium, et d'un soutien libre sur Buy Me a Coffee, qui ne débloque rien dans l'application."
          }
        }
      ]
    },

    {
      "@type": "ImageObject",
      "@id": "https://presentcomposedesign.fr/stereoptix-infos/#image",
      "url": "A-REMPLACER-adresse-de-stereoptix-partage-1200x630.jpg",
      "width": 1200,
      "height": 630,
      "caption": "StereoptiX, application web qui met vos photos en relief"
    },

    {
      "@type": "BreadcrumbList",
      "@id": "https://presentcomposedesign.fr/stereoptix-infos/#fil",
      "itemListElement": [
        {
          "@type": "ListItem",
          "position": 1,
          "name": "Accueil",
          "item": "https://presentcomposedesign.fr/"
        },
        {
          "@type": "ListItem",
          "position": 2,
          "name": "StereoptiX"
        }
      ]
    },

    {
      "@type": "TechArticle",
      "@id": "https://presentcomposedesign.fr/stereoptix-infos/#article",
      "headline": "StereoptiX : une application web qui met vos photos en relief",
      "description": "Étude de cas : de l'idée au naming, du logo à l'atelier, de l'intelligence artificielle de profondeur aux dix types de relief, du modèle économique à la mise en ligne.",
      "inLanguage": "fr-FR",
      "datePublished": "2026-10-06",
      "dateModified": "2026-10-07",
      "author": { "@id": "https://presentcomposedesign.fr/#alban" },
      "publisher": { "@id": "https://presentcomposedesign.fr/#organisation" },
      "mainEntityOfPage": { "@id": "https://presentcomposedesign.fr/stereoptix-infos/#page" },
      "image": { "@id": "https://presentcomposedesign.fr/stereoptix-infos/#image" },
      "about": { "@id": "https://pcdlabs.presentcomposedesign.fr/tools/StereoptiX/#application" },
      "proficiencyLevel": "Beginner",
      "keywords": "photo en relief, anaglyphe, anaglyphe rouge-cyan, photo 3D, image stéréoscopique, wiggle GIF, carte de profondeur, lunettes 3D, intelligence artificielle embarquée, application web progressive, design produit, Toulouse",
      "articleSection": [
        "Le relief calculé dans le navigateur",
        "Prise de vue",
        "Dix types de relief",
        "Modèle économique",
        "Identité graphique",
        "Méthode, de l'idée à la mise en ligne"
      ]
    },

    {
      "@type": "WebApplication",
      "@id": "https://pcdlabs.presentcomposedesign.fr/tools/StereoptiX/#application",
      "name": "StereoptiX",
      "url": "https://pcdlabs.presentcomposedesign.fr/tools/StereoptiX/",
      "description": "Application web qui transforme une photo en image en relief : anaglyphe rouge-cyan, côte à côte, wiggle GIF ou carte de profondeur. La profondeur est calculée dans le navigateur, sans compte et sans envoyer la photo sur un serveur.",
      "applicationCategory": "MultimediaApplication",
      "applicationSubCategory": "Photographie en relief et stéréoscopie",
      "operatingSystem": "Android, iOS, iPadOS, Windows, macOS, Linux",
      "browserRequirements": "Navigateur récent avec JavaScript, WebGL2 et WebAssembly",
      "storageRequirements": "De 40 à 73 Mo téléchargés à la première photo, puis conservés en cache",
      "permissions": "Caméra, facultative : une photo peut aussi être importée.",
      "inLanguage": "fr-FR",
      "softwareVersion": "2.0.2",
      "isAccessibleForFree": true,
      "creator": { "@id": "https://presentcomposedesign.fr/#alban" },
      "publisher": { "@id": "https://presentcomposedesign.fr/#organisation" },
      "countryOfOrigin": { "@type": "Country", "name": "France" },
      "offers": [
        {
          "@type": "Offer",
          "name": "Découverte",
          "description": "Une photo, les dix types de relief, enregistrement et partage en 720p avec une petite signature. Sans compte.",
          "price": "0",
          "priceCurrency": "EUR",
          "availability": "https://schema.org/InStock",
          "category": "free"
        },
        {
          "@type": "Offer",
          "name": "Licence Studio",
          "description": "Deux photos, vidéo en relief, pleine définition, signature retirée. Payée une seule fois.",
          "price": "4.99",
          "priceCurrency": "EUR",
          "availability": "https://schema.org/InStock",
          "url": "https://pcdlabs.presentcomposedesign.fr/tools/StereoptiX/tarifs.html"
        },
        {
          "@type": "Offer",
          "name": "Licence Premium",
          "description": "Tout Studio, plus Mes clichés, une bibliothèque en ligne privée de 100 Go. À vie.",
          "price": "19.99",
          "priceCurrency": "EUR",
          "availability": "https://schema.org/InStock",
          "url": "https://pcdlabs.presentcomposedesign.fr/tools/StereoptiX/tarifs.html"
        }
      ],
      "featureList": [
        "Photo en relief à partir d'une seule photo, par intelligence artificielle",
        "Vrai relief à partir de deux photos décalées d'un pas",
        "Vidéo en relief de cinq secondes, en MP4 jusqu'en 1080p",
        "Dix types de relief : six anaglyphes, deux côte à côte, wiggle, carte de profondeur",
        "Anaglyphe rouge-cyan selon la méthode d'Eric Dubois, en lumière linéaire",
        "Wiggle en GIF animé qui tourne en boucle, allégé pour les messageries",
        "Calcul de la profondeur sur l'appareil, aucune photo transmise",
        "Prise de vue guidée à la caméra",
        "Installation sur l'écran d'accueil sans magasin d'applications",
        "Licences sans compte, vérifiées hors connexion",
        "Bibliothèque en ligne Mes clichés de 100 Go, clichés retouchables",
        "Thème clair et thème sombre"
      ],
      "isBasedOn": {
        "@type": "SoftwareApplication",
        "name": "Depth Anything V2 Small",
        "url": "https://huggingface.co/depth-anything/Depth-Anything-V2-Small",
        "license": "https://www.apache.org/licenses/LICENSE-2.0",
        "applicationCategory": "MultimediaApplication"
      },
      "citation": {
        "@type": "CreativeWork",
        "name": "Anaglyphes par la méthode des moindres carrés, Eric Dubois, Université d'Ottawa",
        "url": "http://www.site.uottawa.ca/~edubois/anaglyph/"
      },
      "mainEntityOfPage": { "@id": "https://presentcomposedesign.fr/stereoptix-infos/#page" }
    }
  ]
}
</script>
				</div>
				</div>
				</div>
				</div>
		<p>Cet article <a href="https://presentcomposedesign.fr/stereoptix-infos/">StereoptiX</a> est apparu en premier sur <a href="https://presentcomposedesign.fr">Présent Composé design</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Apps</title>
		<link>https://presentcomposedesign.fr/apps/</link>
		
		<dc:creator><![CDATA[admin]]></dc:creator>
		<pubDate>Mon, 28 Sep 2026 11:13:23 +0000</pubDate>
				<category><![CDATA[Design produit]]></category>
		<guid isPermaLink="false">https://presentcomposedesign.fr/?p=36928</guid>

					<description><![CDATA[<p>Cet article <a href="https://presentcomposedesign.fr/apps/">Apps</a> est apparu en premier sur <a href="https://presentcomposedesign.fr">Présent Composé design</a>.</p>
]]></description>
										<content:encoded><![CDATA[<p>Cet article <a href="https://presentcomposedesign.fr/apps/">Apps</a> est apparu en premier sur <a href="https://presentcomposedesign.fr">Présent Composé design</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Applications</title>
		<link>https://presentcomposedesign.fr/applications/</link>
		
		<dc:creator><![CDATA[admin]]></dc:creator>
		<pubDate>Sun, 27 Sep 2026 12:36:02 +0000</pubDate>
				<category><![CDATA[Design produit]]></category>
		<guid isPermaLink="false">https://presentcomposedesign.fr/?p=36874</guid>

					<description><![CDATA[<p>Brouillon non publié : visible sur cet appareil seulement Ouvrir .dev PCdlabs · Présent Composé design Applications &#38; expériences Carrousel Grille Liste .dev .dev Mode développeur Code d&#8217;accès Annuler Entrer Mode développeur Administration Cartes Affichage En-tête Pied de page Publication Cartes de la vitrine + Ajouter une carte Version en ligne Versions précédentes Sauvegarde du contenu Le fichier JSON contient toutes les cartes et tous les réglages. Avec un serveur, c’est la version publiée qui s’affiche : le contenu collé dans le bloc Elementor ne sert que sans serveur, ou comme réserve à reprendre ici. Télécharger le JSON Copier pour le bloc Elementor Importer un JSON Reprendre le contenu du bloc Session Quitter le mode développeur Annuler les modifications Publier</p>
<p>Cet article <a href="https://presentcomposedesign.fr/applications/">Applications</a> est apparu en premier sur <a href="https://presentcomposedesign.fr">Présent Composé design</a>.</p>
]]></description>
										<content:encoded><![CDATA[		<div data-elementor-type="wp-post" data-elementor-id="36874" class="elementor elementor-36874">
				<article class="elementor-element elementor-element-b57d3b9 e-con-full e-flex wpr-particle-no wpr-jarallax-no wpr-parallax-no wpr-sticky-section-no wpr-column-slider-no wpr-equal-height-no e-con e-parent" data-id="b57d3b9" data-element_type="container" data-e-type="container" data-settings="{&quot;background_background&quot;:&quot;classic&quot;}">
				<div class="elementor-element elementor-element-6e5d796 e-transform elementor-widget elementor-widget-html" data-id="6e5d796" data-element_type="widget" data-e-type="widget" data-settings="{&quot;_transform_scale_effect&quot;:{&quot;unit&quot;:&quot;px&quot;,&quot;size&quot;:&quot;&quot;,&quot;sizes&quot;:[]},&quot;_transform_scale_effect_tablet&quot;:{&quot;unit&quot;:&quot;px&quot;,&quot;size&quot;:&quot;&quot;,&quot;sizes&quot;:[]},&quot;_transform_scale_effect_mobile&quot;:{&quot;unit&quot;:&quot;px&quot;,&quot;size&quot;:&quot;&quot;,&quot;sizes&quot;:[]}}" data-widget_type="html.default">
				<div class="elementor-widget-container">
					<!--
// ║  Tourniquet PCdlabs, © All rights reserved                                     ║
// ║  2026 · PCdlabs ·  Present Composé Design                                ║
// ║  Alban DESBARAX                                                          ║
// ║  360° Design & Creative Technologist - Toulouse, FRANCE                  ║
// ║                                                                          ║
// ║  · Expertise & Support 2D, 3D, AR, VR, XR, AI                            ║
// ║  · Product Design & Industrial Design                                    ║
// ║  · Digital communication                                                 ║
// ║  · Immersive experiences  -ᯅ- ✔️                                        ║
//                                                                            
//  · 🡺 https://presentcomposedesign.fr/      
//  · 🡺 https://pcdlabs.presentcomposedesign.fr/showcase/
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%#*++==------===**%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#++-::::::::::::::::::::::-=*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@#+-::::::::::::::::::::::::::::::::=*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@#+::::::::::::::::::::::::::::::::::::::::=%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@%+:::::::::::::::::=++***+=-::::::::::::::::::::=%@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@#=::::::::::::::::+%@@@@@@@@@@%+-:::::::::::::::::::=#@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@#-::::::::::::::::#@@@@@@@@@@@@@@@@%-:::::::::::::::::::=#@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@%=::::::::::::::::*@@@@@@**==-=+##@@@@@*-:::::::::::::::::::=%@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@=:::::::::::::::::%@@@@@+::::::::::-*@@@@#-::::::::::::::::::::=@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@#::::::::::::::::::%@@@@=::::::::::::::-%@@@#-:::::::::::::::::::::#@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@=::::::::::::::::::*@@@@=::::::::::::::::-%@@@*::::::::::::::::::::::=@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@%-::::::::::::::::::-@@@@=::::::::::::::::::-%@@@-::::::::::::::::::::::-%@@@@@@@@@@@@@@
// @@@@@@@@@@@@#::::::::::::::::::::*@@@#::::::::::::::::::::=@@@*::::::::::::::::::::::::*@@@@@@@@@@@@@
// @@@@@@@@@@@#:::::::::::::::::::::%@@@+::::::::::::::::::::-@@@%:::::::::::::::::::::::::*@@@@@@@@@@@@
// @@@@@@@@@@+::::::::::::::::::::::@@@@-:::::::::::::::::::::%@@@::::::::::::::::::::::::::*@@@@@@@@@@@
// @@@@@@@@@*:::::::::::::::::::::::@@@@=:::::::::::::::::::::@@@@:::::::::::::::::::::::::::*@@@@@@@@@@
// @@@@@@@@@%::::::::::::::::::::::::@@@@*:::::::::::::::::::::@@@%::::::::::::::::::::::::::::*@@@@@@@@
// @@@@@@@%:::::::::::::::::::::::::@@@@%::::::::::::::::::::+@@@*:::::::::::::::::::::::::::::%@@@@@@@@
// @@@@@@@::::::::::::::::::::::::::@@@@@*::::::::::::::::::-@@@@::::::::::::::::::::::::::::::-@@@@@@@@
// @@@@@@=::::::::::::::::::::::::::@@@@@@*::::::::::::::::-@@@@+:::::::::::::::::::::::::::::::*@@@@@@@
// @@@@@%:::::::::::::::::::::::::::@@@@@@@*::::::::::::::-%@@@#:::::::::::::::::::::::::::::::::*@@@@@@
// @@@@@::::::::::::::::::::::::::::@@@@@@@@@*::::::::::+@@@@@*::::::::::::::::::::::::::::::::::-@@@@@@
// @@@@*::::::::::::::::::::::::::::@@@#=@@@@@@%#*+=**%@@@@@@=::::::::::::::::::::::::::::::::::::*@@@@@
// @@@@:::::::::::::::::::::::::::::@@@#::*@@@@@@@@@@@@@@@@*:::::::::::::::::::::::::::::::::::::::@@@@@
// @@@*:::::::::::::::::::::::::::::@@@#::::=*@@@@@@@@@%#=:::::::::::::::::::::::::::::::::::::::::#@@@@
// @@@=:::::::::::::::::::::::::::::@@@#::::::::-==+=-:::::::::::::::::::::::::::::::::::::::::::::=@@@@
// @@@::::::::::::::::::::::::::::::@@@#:::::::::::::::::::::::::::::::::::::::::::::::::--:::::::::@@@@
// @@*::::::::::::::::::::::::::::::@@@#:::::::::+######*:::::::::::::::::::::::::::::::+@=:::::::::#@@@
// @@*::::::::::::::::::::::::::::::@@@#:::--::::===+*+==:::::=-:::::::=-::::::-::::::::+@=:::::::::+@@@
// @@:::::::::::::::::::::::::::::::@@@#::+@#@@::-#@@%@%=::=@@@@+:::+@@@@@+-::@@@@%*-::#@@%#:::::::::@@@
// @@:::::::::::::::::+*##%%#*=-::::@@@#::+@@=::-%@:::-*@=:%@-:=:::*@*:::-@*::@@-:=@%::=*@*=:::::::::@@@
// @@::::::::::::::+%@@@@@@@@@@@%*-:@@@#::+@*:::*@@@@@@@@#:+@%*-:::@@@@@@@@@-:@%:::#@:::+@=::::::::::@@@
// @%::::::::::::*@@@@@@@@@@@@@@@@@#+@@#::+@+:::+@=-------:::=%@*::@%-------::@%:::#@:::+@=::::::::::%@@
// @%::::::::::-@@@@@@++-::::-=#@@@@@+*#::+@+:::-@%=-:+%%-:-=::#@::*@*-::+%=::@%:::#@:::+@=::::::::::#@@
// @%:::::::::=@@@@%+::::::::::::#@@@@**::+@+::::-#@@@@%=::#@%%@+:::+@@@@@*:::@%:::#@:::+@=::::::::::%@@
// @%::::::::-@@@@+:::::::::::::::-@@@@+:::-::::::::--:::::::=-:::::::-=-:::::--:::-=----=-::::::::::%@@
// @@:::::::-@@@@*:::::::::::::::::-%%%*:::::::::::::::::::::::::::::::::::::::::::+%%%%%%#::::::::::@@@
// @@:::::::*@@@#:::::::+#@%*-::::::=+*#*=+#*=-:::::+#%%#-::::::=#%%#=::::-#@@#-::::=##%#+-::::::::::@@@
// @@-::::::@@@@-:::::-%@#+*%@*:::::%@#+#@@+*@*::::%@+==#@*::::%@#+=%@*-::%@-=*=:::*@*+=+@%-::::::::-@@@
// @@+:::::-@@@@::::::#@=::::#@=::::%@::=@*::#@-::*@=::::-@+::*@*::::*@*::*@*-::::-@@####%@#::::::::*@@@
// @@#:::::-@@@*::::::@@:::::=@*::::%@::=@*::#@-::%@::::::@*::%@-::::-@%:::=%@%-::*@#******+::::::::*@@@
// @@@-::::-@@@%::::::#@+::::%@-::::%@::=@*::#@-::@@-::::=@-::*@*::::*@*:::-::@%::=@*::::-=-::::::::@@@@
// @@@+:::::@@@@-::::::%@%*#@@+:::::%@::=@*::#@-::@@@#=+#@+::::*@%*+@@*:::#@*+@*:::*@%*+%@%::::::::-@@@@
// @@@#:::::#@@@+:::::::=*##+-::::::+*::-*=::+*:::@%=*%#*-::::::=*%#*-:::::=*#+:::::-*##*=--=::::::*@@@@
// @@@@-::::=@@@@-::::::::::::::::::=**+::::::::::@%:::::::::::::::::::::::-==++**##%%@@@@@@@-::::-@@@@@
// @@@@*:::::*@@@@-::::::::::::::::+@@@#::::::::::::::::::::-==++**##=-#@@@@@@@@@@@@@@@@@@@@@=::::*@@@@@
// @@@@@-:::::#@@@@+-:::::::::::::*@@@@::::::-==++--##%@@@@@@@@@#*#@@%%@@@@@@@@@@@#@@@@@*#@@@+:::-@@@@@@
// @@@@@%::::::*@@@@@+-:::::::::+@@@@%:=@@@@@@@@@@==@@@**%@@@@*-#@*-@@*@@@*-+**@@@:%@@==@++@@*:::%@@@@@@
// @@@@@@*::::::=@@@@@@@%%**##@@@@@@=::=@@@@#-:--=-=@@=+@+=@@@:%@@@=+@-*@*-@@@+:@@=#@:*@@%-@@#::+@@@@@@@
// @@@@@@@-:::::::+%@@@@@@@@@@@@@@#-::::@@@+-%@@@*--@@:@@=*@@@%=*%@@@@*=@-*@@@*:+@=+=#@@@%:@@%:-@@@@@@@@
// @@@@@@@@-::::::::=*%@@@@@@@##-:::::::@@@:#@@@@@*:@@:%==@@@@@@%+-%@@#-@*+@@@:+:@*:*@@@@@:%@@-@@@@@@@@@
// @@@@@@@@#-:::::::::::::-:::::::::::::@@%:%@@@@@*-@@-#@@@#-@*%@@@-#@@:@@#=+:%@-#@:@@@@@@*=@@%@@@@@@@@@
// @@@@@@@@@*:::::::::::::::::::::::::::#@@--@@@@%:*@@%*+*=*@@=+@@@=+@@-#@@@@@@@=#@#@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@*-:::::::::::::::::::::::::#@@@+-==-:#@@@@@@@@@@@@#++*#@@@@@@@#@@@%:@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@*-::::::::::::::::::::::::+@@@@@%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@-=-:+**++==--::%@@@@@@@@@@@@
// @@@@@@@@@@@@#-:::::::::::::::::::::::=@@@@@@@@@@@@@@@@@@@@@%%##**++=---::::::::::::::::%@@@@@@@@@@@@@
// @@@@@@@@@@@@@@-::::::::::::::::::::::-@@@@@@%###**++=--::::::::::::::::::::::::::::::-%@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@+::::::::::::::::::::::--::::::::::::::::::::::::::::::::::::::::::::+@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@%-::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::-%@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@+-:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::+@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@%=::::::::::::::::::::::::::::::::::::::::::::::::::::::::::+%@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@%=::::::::::::::::::::::::::::::::::::::::::::::::::::::=%@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@%=-:::::::::::::::::::::::::::::::::::::::::::::::::+%@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@*-:::::::::::::::::::::::::::::::::::::::::::::#@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@%+-:::::::::::::::::::::::::::::::::::::::+%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%*=-:::::::::::::::::::::::::::::::+#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%**=-:::::::::::::::::::::=+#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%**++=====+++*##@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
-->
<!--
  TOURNIQUET PCdlabs: projects as a 3D carousel, a grid or a list, with an
  administration (developer mode) for cards, media, texts, links, sections,
  sizes, spacing, fonts, colours, footer logo, slogan and QR code.

  INSTALL
  1. Upload the "showcase" folder to https://pcdlabs.presentcomposedesign.fr/showcase/
     (api/, data/, media/). When updating later, NEVER re-upload data/ nor
     api/config.local.php: they hold the published content and the secrets.
  2. Paste this whole file into an Elementor "HTML" widget.
  3. Open ".dev" in the footer (or add #dev to the page address), enter the
     access code, edit, then "Publier".

  ATTRIBUTES of <section class="pcdc">
  - data-media-base : folder that relative media paths are resolved against.
  - data-api        : the administration endpoint. Empty = no server: the
                      block shows its own JSON below, and the developer mode
                      exports a JSON to paste back here.
  Everything else (cards, sections, sizes, footer) is in the JSON
  configuration, edited from the developer mode. A URL parameter can force
  the view: ?vue=carrousel | grille | liste
-->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@300;400;500;600;700;800;900&display=swap">

<section class="pcdc"
  data-media-base="https://pcdlabs.presentcomposedesign.fr/showcase/media/"
  data-api="https://pcdlabs.presentcomposedesign.fr/showcase/api/showcase.php"
  aria-label="Applications et expériences PCdlabs">

<style>
/* ------------------------------------------------------------------
   Scope: every rule starts with .pcdc so the block never leaks into the
   theme, and the few element resets below shield it from Elementor's
   global button / link / image styles.
   ------------------------------------------------------------------ */
/* Theme tokens. Dark is the default; light comes from data-theme="light",
   or from data-theme="auto" when the browser prefers a light scheme. */
.pcdc {
  --pcdc-blue: #1001ef;
  --pcdc-blue-2: #4a5bff;
  --pcdc-ink: #f4f6ff;
  --pcdc-title: #fff;
  --pcdc-muted: rgba(244, 246, 255, .62);
  --pcdc-line: rgba(255, 255, 255, .12);
  --pcdc-bg: #06070f;
  --pcdc-halo: rgba(16, 1, 239, .42);
  --pcdc-halo-2: rgba(124, 139, 255, .14);
  --pcdc-glass: rgba(255, 255, 255, .07);
  --pcdc-glass-line: rgba(255, 255, 255, .16);
  --pcdc-glass-hover: rgba(255, 255, 255, .16);
  --pcdc-dot: rgba(255, 255, 255, .28);
  --pcdc-card-bg-1: #151a36;
  --pcdc-card-bg-2: #06070f;
  --pcdc-shade: 5, 6, 14;
  --pcdc-card-ink: #fff;
  --pcdc-card-text: rgba(244, 246, 255, .84);
  --pcdc-chip: rgba(255, 255, 255, .09);
  --pcdc-chip-line: rgba(255, 255, 255, .14);
  --pcdc-pill: rgba(8, 10, 22, .5);
  --pcdc-dim-color: #04050c;
  --pcdc-tile-shadow: rgba(0, 0, 0, .6);
  --pcdc-row: rgba(255, 255, 255, .045);
  --pcdc-row-hover: rgba(255, 255, 255, .08);
  --pcdc-floor: rgba(16, 1, 239, .55);
  --pcdc-tile-w: .58;              /* glass square width, share of the card (data-tile) */
  --pcdc-tile-cy: .46;             /* its vertical centre, between the pills and the title */
  /* One fluid unit for every text and control outside the cards: it follows
     the width of the block AND the height of the screen, so a full-screen
     tablet, a 1080p monitor or a 4K TV in F11 all get proportionate sizes.
     --pcdc-ts is the global text size chosen in the developer mode. */
  --pcdc-u: calc(clamp(13.5px, min(6px + .7cqi, 2.15vh), 26px) * var(--pcdc-ts, 1));
  --pcdc-pad: calc(clamp(16px, 3.2vw, 44px) * var(--pcdc-kp, 1));
  --pcdc-size: 320px;              /* carousel card size, set by the script */
  --pcdc-persp: 760px;             /* carousel camera distance, set by the script */
  --pcdc-d: calc(var(--pcdc-size) * .028);   /* card thickness */
  --pcdc-view-i: 0;
  container: pcdc / inline-size;
  position: relative;
  display: flex; flex-direction: column;
  box-sizing: border-box;          /* "full screen" height includes the margins */
  isolation: isolate;
  overflow: hidden;
  padding: var(--pcdc-pad);
  border-radius: clamp(18px, 2.4vw, 32px);
  color: var(--pcdc-ink);
  font-family: var(--pcdc-font, "Inter Tight", system-ui, -apple-system, "Segoe UI", sans-serif);
  font-size: 16px;
  line-height: 1.4;
  text-align: left;
  -webkit-font-smoothing: antialiased;
  background:
    radial-gradient(1100px 520px at 50% -12%, var(--pcdc-halo), transparent 62%),
    radial-gradient(700px 380px at 105% 105%, var(--pcdc-halo-2), transparent 70%),
    var(--pcdc-bg);
  transition: background-color .4s, color .4s;
}
/* Transparent block (setting, or data-transparent on a banner block). */
.pcdc.is-transparent { background: transparent; }
.pcdc[data-theme="light"] {
  --pcdc-ink: #0b0e1c;
  --pcdc-title: #0b0e1c;
  --pcdc-muted: rgba(11, 14, 28, .6);
  --pcdc-line: rgba(11, 14, 28, .12);
  --pcdc-bg: #f3f5fc;
  --pcdc-halo: rgba(16, 1, 239, .16);
  --pcdc-halo-2: rgba(124, 139, 255, .16);
  --pcdc-glass: rgba(255, 255, 255, .75);
  --pcdc-glass-line: rgba(11, 14, 28, .1);
  --pcdc-glass-hover: #fff;
  --pcdc-dot: rgba(11, 14, 28, .2);
  --pcdc-card-bg-1: #ffffff;
  --pcdc-card-bg-2: #e6ebfa;
  --pcdc-shade: 246, 248, 255;
  --pcdc-card-ink: #0b0e1c;
  --pcdc-card-text: rgba(11, 14, 28, .78);
  --pcdc-chip: rgba(11, 14, 28, .06);
  --pcdc-chip-line: rgba(11, 14, 28, .12);
  --pcdc-pill: rgba(255, 255, 255, .72);
  --pcdc-dim-color: #eef1fa;
  --pcdc-tile-shadow: rgba(20, 24, 60, .32);
  --pcdc-row: rgba(255, 255, 255, .7);
  --pcdc-row-hover: #fff;
  --pcdc-floor: rgba(16, 1, 239, .22);
}
@media (prefers-color-scheme: light) {
  .pcdc[data-theme="auto"] {
    --pcdc-ink: #0b0e1c;
    --pcdc-title: #0b0e1c;
    --pcdc-muted: rgba(11, 14, 28, .6);
    --pcdc-line: rgba(11, 14, 28, .12);
    --pcdc-bg: #f3f5fc;
    --pcdc-halo: rgba(16, 1, 239, .16);
    --pcdc-halo-2: rgba(124, 139, 255, .16);
    --pcdc-glass: rgba(255, 255, 255, .75);
    --pcdc-glass-line: rgba(11, 14, 28, .1);
    --pcdc-glass-hover: #fff;
    --pcdc-dot: rgba(11, 14, 28, .2);
    --pcdc-card-bg-1: #ffffff;
    --pcdc-card-bg-2: #e6ebfa;
    --pcdc-shade: 246, 248, 255;
    --pcdc-card-ink: #0b0e1c;
    --pcdc-card-text: rgba(11, 14, 28, .78);
    --pcdc-chip: rgba(11, 14, 28, .06);
    --pcdc-chip-line: rgba(11, 14, 28, .12);
    --pcdc-pill: rgba(255, 255, 255, .72);
    --pcdc-dim-color: #eef1fa;
    --pcdc-tile-shadow: rgba(20, 24, 60, .32);
    --pcdc-row: rgba(255, 255, 255, .7);
    --pcdc-row-hover: #fff;
    --pcdc-floor: rgba(16, 1, 239, .22);
  }
}
/* Accent text: lightened accent on dark cards, darkened accent on light cards. */
.pcdc .pcdc-card { --pcdc-accent-ink: var(--pcdc-accent-light); }
.pcdc[data-theme="light"] .pcdc-card { --pcdc-accent-ink: var(--pcdc-accent-deep); }
@media (prefers-color-scheme: light) {
  .pcdc[data-theme="auto"] .pcdc-card { --pcdc-accent-ink: var(--pcdc-accent-deep); }
}
/* Doubled class: beats theme rules such as ".entry-content p" or ".elementor-kit-5 button". */
.pcdc *, .pcdc *::before, .pcdc *::after { box-sizing: border-box; }
.pcdc.pcdc :where(h2, h3, p, ul, li, figure) { margin: 0; padding: 0; font-family: inherit; }
.pcdc.pcdc :where(ul, li) { list-style: none; }
.pcdc.pcdc img, .pcdc.pcdc video {
  display: block; max-width: none; width: 100%; height: 100%;
  border: 0; border-radius: 0; box-shadow: none; margin: 0; padding: 0;
}
.pcdc.pcdc button {
  -webkit-appearance: none; appearance: none; margin: 0; padding: 0; border: 0; border-radius: 0; background: none;
  font: inherit; color: inherit; letter-spacing: inherit; text-transform: none;
  line-height: 1; min-height: 0; box-shadow: none; cursor: pointer;
}
.pcdc svg { display: block; flex: none; }
.pcdc .pcdc-sr {
  position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip: rect(0 0 0 0); white-space: nowrap;
}

/* ---------------- Header + view switch ---------------- */
.pcdc .pcdc-head {
  flex: none;
  display: flex; flex-wrap: wrap; align-items: flex-end;
  justify-content: space-between; gap: 18px 24px;
  margin-bottom: calc(var(--pcdc-u) * 1.7 * var(--pcdc-kh, 1));
}
/* Every text element reads four variables set from the developer mode:
   --pcdc-s-<name> (size factor), --pcdc-f-<name> (font), --pcdc-w-<name>
   (weight) and --pcdc-c-<name> (colour). Unset, the theme decides. */
.pcdc .pcdc-kicker {
  display: flex; align-items: center; gap: .8em;
  font-size: calc(var(--pcdc-u) * .78 * var(--pcdc-s-kicker, 1));
  font-family: var(--pcdc-f-kicker, inherit); font-weight: var(--pcdc-w-kicker, 700);
  letter-spacing: .16em; text-transform: uppercase; color: var(--pcdc-c-kicker, var(--pcdc-muted));
}
.pcdc .pcdc-kicker::before {
  content: ""; width: .62em; height: .62em; border-radius: 50%; flex: none;
  background: var(--pcdc-blue-2); box-shadow: 0 0 12px var(--pcdc-blue-2);
}
.pcdc .pcdc-h {
  margin-top: .22em;
  font-size: calc(var(--pcdc-u) * 2.6 * var(--pcdc-s-title, 1));
  font-family: var(--pcdc-f-title, inherit); font-weight: var(--pcdc-w-title, 800);
  line-height: 1.02; letter-spacing: -.025em; color: var(--pcdc-c-title, var(--pcdc-title));
}
.pcdc .pcdc-kicker[hidden] + .pcdc-h { margin-top: 0; }
/* Centred header: texts centred, the view switch below them. */
.pcdc[data-align="center"] .pcdc-head { flex-direction: column; align-items: center; text-align: center; }
.pcdc[data-align="center"] .pcdc-kicker { justify-content: center; }
.pcdc .pcdc-switch {
  --pcdc-sw: calc(var(--pcdc-u) * var(--pcdc-s-switch, 1));
  position: relative; display: grid; grid-template-columns: repeat(3, 1fr);
  padding: 4px; border-radius: 999px;
  background: var(--pcdc-glass);
  border: 1px solid var(--pcdc-glass-line);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, .12), 0 12px 30px -18px rgba(0, 0, 0, .6);
  -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px);
}
.pcdc .pcdc-switch-thumb {
  position: absolute; top: 4px; bottom: 4px; left: 4px;
  width: calc((100% - 8px) / 3); border-radius: 999px;
  background: linear-gradient(135deg, var(--pcdc-blue), var(--pcdc-blue-2));
  box-shadow: 0 6px 20px -6px rgba(16, 1, 239, .9), inset 0 1px 0 rgba(255, 255, 255, .35);
  transform: translateX(calc(100% * var(--pcdc-view-i)));
  transition: transform .45s cubic-bezier(.2, .8, .2, 1);
}
.pcdc button.pcdc-switch-btn {
  position: relative; z-index: 1; display: inline-flex; align-items: center;
  justify-content: center; gap: .5em; min-height: calc(var(--pcdc-sw) * 2.7); padding: 0 1.1em;
  border-radius: 999px; background: transparent; color: var(--pcdc-muted);
  font-size: calc(var(--pcdc-sw) * .92); font-weight: 600; transition: color .3s;
}
.pcdc button.pcdc-switch-btn svg { width: 1.25em; height: 1.25em; }
.pcdc button.pcdc-switch-btn:hover { background: transparent; color: var(--pcdc-title); }
.pcdc.pcdc button.pcdc-switch-btn[aria-pressed="true"]:hover { color: #fff; }
.pcdc button.pcdc-switch-btn[aria-pressed="true"] { color: #fff; }
.pcdc button.pcdc-switch-btn:focus-visible { outline: 2px solid var(--pcdc-title); outline-offset: 2px; }
@container pcdc (max-width: 560px) {
  .pcdc .pcdc-switch { width: 100%; }
  .pcdc .pcdc-switch-label { font-size: .92em; }
}

/* ---------------- Card anatomy (shared by every view) ---------------- */
.pcdc .pcdc-card { position: relative; }
.pcdc .pcdc-face {
  position: absolute; inset: 0; overflow: hidden; isolation: isolate;
  border-radius: 7.5%;
  -webkit-backface-visibility: hidden; backface-visibility: hidden;
  container: pcdc-face / inline-size;
  background: var(--pcdc-card-bg-2);
}
/* Everything inside a face is sized in em, and 1em follows the card width,
   so a 1024 px card and a 240 px card look the same, only smaller. */
.pcdc .pcdc-in { position: absolute; inset: 0; font-size: max(9px, calc(4.6cqi * var(--pcdc-ct, 1))); }

/* Iridescent rim, as on the BirdSignal cards. */
.pcdc .pcdc-face::before {
  content: ""; position: absolute; inset: 0; z-index: 8; pointer-events: none;
  border-radius: inherit; padding: 1.5px;
  background: linear-gradient(135deg, rgba(255,255,255,.55), rgba(180,196,255,.28) 50%, rgba(230,168,255,.4));
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor; mask-composite: exclude;
}
/* Depth dimming (carousel): cards further back get darker. */
.pcdc .pcdc-in::after {
  content: ""; position: absolute; inset: 0; z-index: 7; pointer-events: none;
  background: var(--pcdc-dim-color); opacity: var(--pcdc-dim, 0);
}
/* Moving sheen: follows the card angle in the carousel, sweeps on hover elsewhere. */
.pcdc .pcdc-in::before {
  content: ""; position: absolute; inset: 0; z-index: 6; pointer-events: none;
  background: linear-gradient(112deg, transparent 32%, rgba(255,255,255,.13) 46%, rgba(255,255,255,.02) 54%, transparent 64%);
  background-size: 260% 100%;
  background-position: calc(var(--pcdc-sheen, -40) * 1%) 0;
  transition: background-position 1.1s cubic-bezier(.2, .8, .2, 1);
}

/* Featured media ("image à la une"): image, GIF or video, cropped square. */
.pcdc .pcdc-bg {
  position: absolute; inset: 0; overflow: hidden;
  background:
    radial-gradient(110% 85% at 18% 0%, var(--pcdc-accent-soft), transparent 62%),
    radial-gradient(90% 70% at 100% 100%, rgba(16, 1, 239, .35), transparent 70%),
    linear-gradient(160deg, var(--pcdc-card-bg-1), var(--pcdc-card-bg-2));
}
.pcdc .pcdc-bg-media { position: absolute; inset: 0; object-fit: cover; }
.pcdc .pcdc-bg.is-blurred .pcdc-bg-media { filter: blur(1.2em) saturate(1.35); transform: scale(1.3); opacity: .8; }
.pcdc .pcdc-bg.is-icon .pcdc-bg-media {
  object-fit: contain; filter: blur(2.4em) saturate(1.5);
  transform: scale(1.35) translateY(-14%); opacity: .5;
}
.pcdc .pcdc-shade {
  position: absolute; inset: 0; pointer-events: none;
  background: linear-gradient(180deg, rgba(var(--pcdc-shade), .35) 0%, rgba(var(--pcdc-shade), 0) 26%, rgba(var(--pcdc-shade), .3) 60%, rgba(var(--pcdc-shade), .94) 100%);
}
.pcdc .pcdc-shade::after {
  content: ""; position: absolute; inset: 0; opacity: 0;
  background: linear-gradient(180deg, rgba(var(--pcdc-shade), .25), rgba(var(--pcdc-shade), .8) 55%, rgba(var(--pcdc-shade), .96));
  transition: opacity .45s;
}

/* Top row: index + kind of entry. */
.pcdc .pcdc-top {
  position: absolute; z-index: 3; top: 5.5%; left: 6%; right: 6%;
  display: flex; justify-content: space-between; align-items: center;
  pointer-events: none;
}
.pcdc .pcdc-pill {
  font-size: .54em; font-weight: 700; letter-spacing: .14em; text-transform: uppercase;
  padding: .55em .9em; border-radius: 99em; color: var(--pcdc-card-ink);
  background: var(--pcdc-pill); border: 1px solid var(--pcdc-chip-line);
}
.pcdc .pcdc-pill.is-app { color: #fff; background: rgba(16, 1, 239, .7); border-color: rgba(140, 150, 255, .5); }
/* Card elements switched off in the developer mode. */
.pcdc.no-card-num .pcdc-pill.is-num,
.pcdc.no-card-badge .pcdc-pill.is-badge,
.pcdc.no-card-kind .pcdc-kind,
.pcdc.no-card-desc .pcdc-desc,
.pcdc.no-card-tags .pcdc-tags { display: none; }
.pcdc.no-card-num .pcdc-top { justify-content: flex-end; }

/* The glass tile: app icon, or the demo GIF / video when there is no icon.
   No backdrop-filter here (unreliable inside 3D transforms): the frosted look
   comes from a blurred copy of the featured image, aligned under the tile. */
/* Position and size come from two numbers, so the square stays centred
   whatever its size: --pcdc-tile-w (share of the card width) and
   --pcdc-tile-cy (vertical centre, midway between the pills and the title). */
.pcdc .pcdc-tile {
  --pcdc-tl: calc((1 - var(--pcdc-tile-w)) / 2);
  --pcdc-tt: calc(var(--pcdc-tile-cy) - var(--pcdc-tile-w) / 2);
  position: absolute; z-index: 2; aspect-ratio: 1;
  left: calc(var(--pcdc-tl) * 100%); top: calc(var(--pcdc-tt) * 100%);
  width: calc(var(--pcdc-tile-w) * 100%);
  border-radius: 23%; overflow: hidden;
  transform-origin: 50% 0;
  box-shadow:
    0 1.1em 2.2em -.5em var(--pcdc-tile-shadow),
    0 .35em .7em -.2em rgba(0, 0, 0, .35),
    0 0 2.6em -.6em var(--pcdc-accent-glow);
  transition: transform .55s cubic-bezier(.2, .8, .2, 1);
}
.pcdc .pcdc-tile-blur {
  /* Blurred copy of the card background, sized and shifted to line up with it. */
  position: absolute;
  left: calc(-100% * var(--pcdc-tl) / var(--pcdc-tile-w));
  top: calc(-100% * var(--pcdc-tt) / var(--pcdc-tile-w));
  width: calc(100% / var(--pcdc-tile-w)); height: calc(100% / var(--pcdc-tile-w));
  background-image: var(--pcdc-still, none), radial-gradient(80% 70% at 30% 20%, var(--pcdc-accent-soft), transparent 70%);
  background-color: #1a1f3f;
  background-size: cover; background-position: center;
  filter: blur(.75em) saturate(1.6) brightness(1.08);
}
.pcdc .pcdc-tile::before {  /* frosted tint */
  content: ""; position: absolute; inset: 0; z-index: 1;
  background: linear-gradient(150deg, rgba(255,255,255,.38), rgba(255,255,255,.1) 42%, rgba(255,255,255,.06) 60%, rgba(255,255,255,.18));
}
.pcdc .pcdc-tile::after {   /* bevel, rim light and specular spot: the relief */
  content: ""; position: absolute; inset: 0; z-index: 3; pointer-events: none;
  border-radius: inherit;
  border: 1px solid rgba(255, 255, 255, .5);
  background: radial-gradient(60% 40% at 22% 8%, rgba(255,255,255,.55), transparent 70%);
  box-shadow:
    inset 0 .12em .1em rgba(255, 255, 255, .6),
    inset 0 -.25em .5em rgba(0, 0, 0, .28),
    inset .2em 0 .45em rgba(255, 255, 255, .12);
  mix-blend-mode: screen;
}
.pcdc .pcdc-tile-content { position: absolute; z-index: 2; }
.pcdc .pcdc-tile-content.is-icon { inset: 17%; filter: drop-shadow(0 .35em .45em rgba(0, 0, 0, .38)); }
.pcdc .pcdc-tile-content.is-icon img { object-fit: contain; border-radius: 18%; }
.pcdc .pcdc-tile-content.is-media {
  inset: 6.5%; border-radius: 18%; overflow: hidden;
  box-shadow: 0 .2em .6em rgba(0, 0, 0, .45);
}
.pcdc .pcdc-tile-content.is-media :is(img, video) { object-fit: cover; }
.pcdc .pcdc-tile-content.is-letter {
  inset: 0; display: grid; place-items: center;
  font-size: 3.2em; font-weight: 800; color: #fff;
}

/* Bottom stack: kind, title, then (on hover) description, tags and CTAs. */
.pcdc .pcdc-bottom {
  position: absolute; z-index: 4; left: 6.5%; right: 6.5%; bottom: 6%;
  display: flex; flex-direction: column; pointer-events: none;
}
.pcdc .pcdc-kind {
  font-size: .62em; font-weight: 700; letter-spacing: .15em; text-transform: uppercase;
  color: var(--pcdc-accent-ink);
}
.pcdc h3.pcdc-title {
  margin-top: .22em; font-family: inherit;
  font-size: 1.55em; font-weight: 800; line-height: 1.02; letter-spacing: -.02em;
  color: var(--pcdc-card-ink); text-wrap: balance;
}
.pcdc .pcdc-more, .pcdc .pcdc-actions-wrap {
  display: grid; grid-template-rows: 0fr; opacity: 0;
  transition: grid-template-rows .5s cubic-bezier(.2, .8, .2, 1), opacity .35s;
}
.pcdc .pcdc-more > div, .pcdc .pcdc-actions-wrap > div { min-height: 0; overflow: hidden; }
.pcdc .pcdc-desc {
  margin-top: .75em; font-size: max(11px, .76em); line-height: 1.42; color: var(--pcdc-card-text);
  display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;
}
.pcdc .pcdc-tags { display: flex; flex-wrap: wrap; gap: .3em; margin-top: .6em; }
.pcdc .pcdc-tags li {
  font-size: .56em; font-weight: 600; letter-spacing: .02em;
  padding: .4em .75em; border-radius: 99em; color: var(--pcdc-card-text);
  background: var(--pcdc-chip); border: 1px solid var(--pcdc-chip-line);
}
.pcdc .pcdc-actions { display: flex; flex-wrap: wrap; gap: .4em; padding-top: .8em; }
.pcdc a.pcdc-cta, .pcdc a.pcdc-cta:visited {
  position: relative; z-index: 5; pointer-events: auto;
  display: inline-flex; align-items: center; gap: .5em;
  font-size: .7em; font-weight: 700; line-height: 1; letter-spacing: 0;
  padding: .85em 1.2em; border-radius: 99em; text-decoration: none; white-space: nowrap;
  transition: transform .25s, box-shadow .25s, background .25s;
}
.pcdc a.pcdc-cta svg { width: 1.05em; height: 1.05em; }
.pcdc a.pcdc-cta.is-primary, .pcdc a.pcdc-cta.is-primary:visited {
  color: #fff; background: linear-gradient(135deg, var(--pcdc-blue), var(--pcdc-blue-2));
  box-shadow: 0 .6em 1.4em -.4em rgba(16, 1, 239, .95), inset 0 1px 0 rgba(255, 255, 255, .35);
}
.pcdc a.pcdc-cta.is-secondary, .pcdc a.pcdc-cta.is-secondary:visited {
  color: var(--pcdc-card-ink); background: var(--pcdc-chip);
  border: 1px solid var(--pcdc-chip-line);
}
.pcdc a.pcdc-cta:hover, .pcdc a.pcdc-cta:focus-visible { transform: translateY(-1px); text-decoration: none; }
.pcdc a.pcdc-cta.is-primary:is(:hover, :focus-visible) { color: #fff; }
.pcdc a.pcdc-cta.is-secondary:is(:hover, :focus-visible) { color: var(--pcdc-card-ink); }
.pcdc a.pcdc-cta.is-primary:hover { box-shadow: 0 .8em 1.8em -.4em rgba(16, 1, 239, 1), inset 0 1px 0 rgba(255, 255, 255, .45); }
.pcdc a.pcdc-cta:focus-visible { outline: 2px solid var(--pcdc-card-ink); outline-offset: 2px; }

/* The whole card is a link (the "hit" layer); the CTAs sit above it. */
.pcdc a.pcdc-hit { position: absolute; inset: 0; z-index: 3; border-radius: inherit; }

/* Revealed state: mouse hover, keyboard focus, or a first tap on touch screens. */
/* Revealed: the square shrinks to 32 % of the card and moves up to 8 % from the top. */
.pcdc .pcdc-card:is(.is-hover, .is-open, :focus-within) .pcdc-tile {
  transform: translateY(calc((.08 - var(--pcdc-tt)) / var(--pcdc-tile-w) * 100%)) scale(calc(.32 / var(--pcdc-tile-w)));
}
.pcdc .pcdc-card:is(.is-hover, .is-open, :focus-within) .pcdc-shade::after { opacity: 1; }
.pcdc .pcdc-card:is(.is-hover, .is-open, :focus-within) :is(.pcdc-more, .pcdc-actions-wrap) { grid-template-rows: 1fr; opacity: 1; }
.pcdc:not([data-view="carousel"]) .pcdc-card:is(.is-hover, .is-open) { --pcdc-sheen: 140; }

/* Small cards: keep the essentials. */
@container pcdc-face (max-width: 300px) {
  .pcdc .pcdc-tags { display: none; }
  .pcdc .pcdc-desc { -webkit-line-clamp: 2; }
}

/* Back face and edges: only visible in the 3D carousel. */
.pcdc .pcdc-back {
  background:
    radial-gradient(100% 80% at 80% 0%, var(--pcdc-accent-soft), transparent 65%),
    linear-gradient(200deg, var(--pcdc-card-bg-1), var(--pcdc-card-bg-2));
}
.pcdc img.pcdc-back-mark {
  position: absolute; left: 20%; top: 16%; width: 60%; height: 60%;
  object-fit: contain; opacity: .22; filter: saturate(1.6) blur(.02em);
}
.pcdc img.pcdc-back-mark.is-photo { left: 0; top: 0; width: 100%; height: 100%; object-fit: cover; opacity: .28; filter: blur(.5em) saturate(1.4); }
.pcdc .pcdc-back-text { position: absolute; left: 7%; right: 7%; bottom: 7%; }
.pcdc .pcdc-back-text strong { display: block; font-size: 1.3em; font-weight: 800; color: var(--pcdc-card-ink); letter-spacing: -.02em; }
.pcdc .pcdc-back-text span { display: block; margin-top: .5em; font-size: .5em; font-weight: 700; letter-spacing: .16em; text-transform: uppercase; color: var(--pcdc-muted); }
.pcdc .pcdc-edge {
  position: absolute; display: block; pointer-events: none;
  background: linear-gradient(180deg, #eef1ff, #9aa6ff 35%, #d8aaff 66%, #ffd9a3);
  opacity: calc(1 - var(--pcdc-dim, 0) * 1.1);
}
.pcdc .pcdc-edge-l { top: 7%; bottom: 7%; left: calc(var(--pcdc-d) / -2); width: var(--pcdc-d); transform: rotateY(-90deg); filter: brightness(.7); }
.pcdc .pcdc-edge-r { top: 7%; bottom: 7%; right: calc(var(--pcdc-d) / -2); width: var(--pcdc-d); transform: rotateY(90deg); }
.pcdc .pcdc-edge-t { left: 7%; right: 7%; top: calc(var(--pcdc-d) / -2); height: var(--pcdc-d); transform: rotateX(90deg); filter: brightness(1.1); }
.pcdc:not([data-view="carousel"]) :is(.pcdc-back, .pcdc-edge) { display: none; }

/* ---------------- View 1: the 3D carousel ("tourniquet") ---------------- */
.pcdc .pcdc-views { flex: 1 1 auto; display: flex; flex-direction: column; min-height: 0; }
.pcdc[data-view="carousel"] .pcdc-stage {
  position: relative; flex: none; height: calc(var(--pcdc-size) * 1.32);
  margin-inline: calc(var(--pcdc-pad) * -1);
  perspective: var(--pcdc-persp); perspective-origin: 50% 38%;
  overflow: hidden; touch-action: pan-y; cursor: grab;
  -webkit-user-select: none; user-select: none; -webkit-tap-highlight-color: transparent;
  outline: none;
}
/* Keyboard focus only: a thin rounded ring inside the stage, never a frame on click. */
.pcdc[data-view="carousel"] .pcdc-stage:focus { outline: none; }
.pcdc[data-view="carousel"] .pcdc-stage:focus-visible { outline: 2px solid rgba(74, 91, 255, .6); outline-offset: -10px; border-radius: 28px; }
.pcdc[data-view="carousel"] .pcdc-stage::after {   /* soft glow on the "floor" */
  content: ""; position: absolute; left: 50%; bottom: 2%; width: calc(var(--pcdc-size) * 2.6); height: calc(var(--pcdc-size) * .34);
  transform: translateX(-50%); border-radius: 50%; pointer-events: none;
  background: radial-gradient(closest-side, var(--pcdc-floor), transparent);
  filter: blur(18px);
}
.pcdc.is-dragging .pcdc-stage { cursor: grabbing; }
/* Full-screen mode: the block is as tall as the screen and the ring takes
   whatever height the header, the controls and the footer leave; the script
   then sizes the cards from that height (no fixed maximum any more). */
/* --pcdc-wrap: the space the page puts around the block (measured by the
   script), so that the whole section, margins included, fills the screen. */
.pcdc.is-fill {
  min-height: calc(100vh * var(--pcdc-hf, 1) - var(--pcdc-wrap, 0px));
  min-height: calc(100svh * var(--pcdc-hf, 1) - var(--pcdc-wrap, 0px));
}
/* In an Elementor container set to "wrap" (often on mobile), the widget that
   holds the block is first measured at its natural width; the block, being a
   size container, has none, so it was measured too narrow, hence too tall,
   and a gap was left under it. A full width removes the gap; Elementor's own
   width settings, more specific, still win. */
:where(.elementor-widget):has(> .elementor-widget-container > .pcdc, > .pcdc) { width: 100%; }
.pcdc.is-fill[data-view="carousel"] .pcdc-stage { flex: 1 1 0; height: auto; min-height: 220px; }
/* On a phone the card matters more than seeing the footer without scrolling. */
@container pcdc (max-width: 560px) { .pcdc.is-fill[data-view="carousel"] .pcdc-stage { min-height: 84cqi; } }
.pcdc[data-view="carousel"] .pcdc-track {
  /* Centre of the ring: the engine lifts the front card a little, so the
     centre sits a little lower, plus the offset chosen in the developer mode. */
  position: absolute; z-index: 1; left: 50%; width: 0; height: 0;
  top: calc(50% + var(--pcdc-size) * (.05 + var(--pcdc-off, 0)));
  transform-style: preserve-3d;
}
.pcdc[data-view="carousel"] .pcdc-card {
  position: absolute; width: var(--pcdc-size); height: var(--pcdc-size);
  left: calc(var(--pcdc-size) / -2); top: calc(var(--pcdc-size) / -2);
  transform-style: preserve-3d; will-change: transform; cursor: pointer;
}
.pcdc[data-view="carousel"] .pcdc-front { transform: translateZ(calc(var(--pcdc-d) / 2)); }
.pcdc[data-view="carousel"] .pcdc-back { transform: rotateY(180deg) translateZ(calc(var(--pcdc-d) / 2)); }
.pcdc[data-view="carousel"] .pcdc-card:not(.is-front) :is(.pcdc-cta) { pointer-events: none; }

.pcdc .pcdc-nav {
  flex: none;
  display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: calc(var(--pcdc-u) * .9);
  width: min(100%, calc(var(--pcdc-u) * 30)); margin: calc(var(--pcdc-u) * .7 * var(--pcdc-kn, 1)) auto 0;
}
.pcdc .pcdc-nav.no-arrows { grid-template-columns: minmax(0, 1fr); }
.pcdc .pcdc-nav.no-caption { grid-template-columns: auto auto; justify-content: center; gap: calc(var(--pcdc-u) * 2); }
.pcdc:not([data-view="carousel"]) :is(.pcdc-nav, .pcdc-dots, .pcdc-hint) { display: none; }
.pcdc button.pcdc-arrow {
  --pcdc-ar: calc(var(--pcdc-u) * 3 * var(--pcdc-s-arrows, 1));
  display: grid; place-items: center; width: var(--pcdc-ar); height: var(--pcdc-ar); border-radius: 50%;
  color: var(--pcdc-title); background: var(--pcdc-glass);
  border: 1px solid var(--pcdc-glass-line);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, .18), 0 10px 24px -14px rgba(0, 0, 0, .6);
  transition: background .25s, transform .25s;
}
.pcdc button.pcdc-arrow:hover { background: var(--pcdc-glass-hover); color: var(--pcdc-title); }
.pcdc button.pcdc-arrow:active { transform: scale(.94); }
.pcdc button.pcdc-arrow:focus-visible { outline: 2px solid var(--pcdc-title); outline-offset: 2px; }
.pcdc button.pcdc-arrow svg { width: 42%; height: 42%; }
.pcdc .pcdc-caption {
  --pcdc-cp: calc(var(--pcdc-u) * var(--pcdc-s-caption, 1));
  text-align: center; min-width: 0; font-family: var(--pcdc-f-caption, inherit);
}
.pcdc .pcdc-count { font-size: calc(var(--pcdc-cp) * .74); font-weight: 700; letter-spacing: .16em; color: var(--pcdc-muted); }
.pcdc .pcdc-cap-title {
  display: block; margin-top: .15em; font-size: calc(var(--pcdc-cp) * 1.4); line-height: 1.15;
  font-weight: var(--pcdc-w-caption, 800); color: var(--pcdc-c-caption, var(--pcdc-title)); letter-spacing: -.01em;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.pcdc .pcdc-cap-kind { display: block; margin-top: .1em; font-size: calc(var(--pcdc-cp) * .95); color: var(--pcdc-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pcdc .pcdc-dots {
  --pcdc-dt: calc(var(--pcdc-u) * .42 * var(--pcdc-s-dots, 1));
  flex: none; display: flex; justify-content: center; flex-wrap: wrap; gap: 0; margin-top: calc(var(--pcdc-u) * .5);
}
.pcdc button.pcdc-dot { width: calc(var(--pcdc-dt) * 3.4); height: calc(var(--pcdc-dt) * 3.4); display: grid; place-items: center; background: transparent; }
.pcdc button.pcdc-dot:hover { background: transparent; }
.pcdc button.pcdc-dot::before {
  content: ""; width: var(--pcdc-dt); height: var(--pcdc-dt); border-radius: 99px; background: var(--pcdc-dot);
  transition: width .35s, background .35s;
}
.pcdc button.pcdc-dot[aria-current="true"]::before { width: calc(var(--pcdc-dt) * 2.8); background: var(--pcdc-title); }
.pcdc button.pcdc-dot:focus-visible { outline: 2px solid var(--pcdc-title); outline-offset: 0; border-radius: 6px; }
.pcdc .pcdc-hint {
  flex: none; margin-top: calc(var(--pcdc-u) * .5); text-align: center;
  font-size: calc(var(--pcdc-u) * .82 * var(--pcdc-s-hint, 1));
  font-family: var(--pcdc-f-hint, inherit); font-weight: var(--pcdc-w-hint, 400); color: var(--pcdc-c-hint, var(--pcdc-muted));
  transition: opacity .6s;
}
.pcdc.has-interacted .pcdc-hint { opacity: 0; }

/* ---------------- View 2: grid ---------------- */
/* 3 columns on a desktop or a landscape tablet, 2 on a portrait tablet,
   1 on a phone held upright (widths of the block itself, not of the screen). */
.pcdc[data-view="grid"] .pcdc-track {
  display: grid; gap: clamp(12px, 1.8vw, 22px);
  grid-template-columns: repeat(3, minmax(0, 1fr));
}
@container pcdc (max-width: 900px) { .pcdc[data-view="grid"] .pcdc-track { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@container pcdc (max-width: 560px) { .pcdc[data-view="grid"] .pcdc-track { grid-template-columns: minmax(0, 1fr); } }
.pcdc[data-view="grid"] .pcdc-card {
  aspect-ratio: 1; transition: transform .45s cubic-bezier(.2, .8, .2, 1);
}
.pcdc[data-view="grid"] .pcdc-card.is-hover { transform: translateY(-4px); }
.pcdc[data-view="grid"] .pcdc-face { box-shadow: 0 24px 50px -30px #000; }

/* ---------------- View 3: list (fastest access on a stand) ---------------- */
.pcdc[data-view="list"] .pcdc-track { display: flex; flex-direction: column; gap: calc(var(--pcdc-u) * .6); }
.pcdc[data-view="list"] .pcdc-face {
  position: relative; inset: auto; border-radius: 18px; container-type: normal;
  background: var(--pcdc-row);
}
.pcdc[data-view="list"] .pcdc-face::before { padding: 1px; opacity: .6; }
.pcdc[data-view="list"] .pcdc-in {
  position: relative; inset: auto; font-size: var(--pcdc-u);
  display: grid; grid-template-columns: 4.3em 1fr; align-items: center; gap: 1em;
  padding: .75em 1em .75em .75em;
}
.pcdc[data-view="list"] :is(.pcdc-bg, .pcdc-shade, .pcdc-top) { display: none; }
.pcdc[data-view="list"] .pcdc-card.is-hover .pcdc-face { background: var(--pcdc-row-hover); }
.pcdc[data-view="list"] .pcdc-tile {
  position: relative; left: auto; top: auto; width: 4.3em; font-size: .44em;
  transform: none !important; border-radius: 1.1em;
}
.pcdc[data-view="list"] .pcdc-tile-blur { left: 0; top: 0; width: 100%; height: 100%; background-image: radial-gradient(80% 70% at 30% 20%, var(--pcdc-accent-soft), transparent 70%); }
.pcdc[data-view="list"] .pcdc-bottom {
  position: static; display: grid; grid-template-columns: 1fr; gap: 10px; align-items: center;
}
.pcdc[data-view="list"] .pcdc-kind { font-size: .72em; }
.pcdc[data-view="list"] h3.pcdc-title { font-size: 1.3em; margin-top: 2px; }
.pcdc[data-view="list"] :is(.pcdc-more, .pcdc-actions-wrap) { grid-template-rows: 1fr; opacity: 1; }
.pcdc[data-view="list"] .pcdc-desc { margin-top: 4px; font-size: .92em; -webkit-line-clamp: 2; }
.pcdc[data-view="list"] .pcdc-tags { display: none; }
.pcdc[data-view="list"] .pcdc-actions { padding-top: 0; }
.pcdc[data-view="list"] a.pcdc-cta { font-size: .86em; padding: .85em 1.1em; }
@container pcdc (min-width: 760px) {
  .pcdc[data-view="list"] .pcdc-bottom { grid-template-columns: 1fr auto; gap: 24px; }
  .pcdc[data-view="list"] .pcdc-tags { display: flex; }
  .pcdc[data-view="list"] .pcdc-tags li { font-size: .72em; }
}

/* View change: short fade. */
.pcdc .pcdc-views.is-entering { animation: pcdc-fade .5s cubic-bezier(.2, .8, .2, 1); }
@keyframes pcdc-fade { from { opacity: 0; } to { opacity: 1; } }

/* ---------------- Footer: logo, texts, slogan, QR code ---------------- */
.pcdc { --pcdc-logo: #fff; }
.pcdc[data-theme="light"] { --pcdc-logo: var(--pcdc-blue); }
@media (prefers-color-scheme: light) { .pcdc[data-theme="auto"] { --pcdc-logo: var(--pcdc-blue); } }

.pcdc .pcdc-foot {
  /* QR card width: large by default, it is meant to be scanned from a few steps away. */
  --pcdc-qr-w: calc(var(--pcdc-u) * 9.5 * var(--pcdc-s-qr, 1));
  flex: none; position: relative;
  /* Two equal side columns: the middle one is centred on the block, whatever
     the logo on the left or the QR code on the right weigh. */
  display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center;
  gap: calc(var(--pcdc-u) * .8) calc(var(--pcdc-u) * 1.4);
  margin-top: calc(var(--pcdc-u) * 1.6 * var(--pcdc-kf, 1));
  padding-top: calc(var(--pcdc-u) * .9);
  border-top: 1px solid var(--pcdc-line);
  font-size: var(--pcdc-u); line-height: 1.3; color: var(--pcdc-muted);
}
.pcdc.no-foot-line .pcdc-foot { border-top-color: transparent; }
.pcdc .pcdc-foot:first-child { margin-top: 0; }
/* Room above the footer line for the part of the QR card that sticks out
   (measured by the script: --pcdc-qr-band). */
.pcdc.has-qr .pcdc-foot { margin-top: max(calc(var(--pcdc-u) * 1.6 * var(--pcdc-kf, 1)), var(--pcdc-qr-band, 0px)); }
.pcdc .pcdc-foot a, .pcdc .pcdc-foot a:visited { color: inherit; text-decoration: none; }
.pcdc .pcdc-foot a:hover { text-decoration: underline; }

.pcdc .pcdc-foot-left { grid-column: 1; display: flex; flex-wrap: wrap; align-items: center; gap: .5em 1em; min-width: 0; }
.pcdc .pcdc-brand { --pcdc-lh: calc(var(--pcdc-u) * 3.2 * var(--pcdc-s-logo, 1)); }
.pcdc .pcdc-foot a.pcdc-brand, .pcdc .pcdc-foot a.pcdc-brand:hover { display: inline-flex; color: var(--pcdc-logo); text-decoration: none; }
.pcdc .pcdc-brand svg { display: block; width: auto; height: var(--pcdc-lh); }
.pcdc.pcdc .pcdc-brand img { display: block; width: auto; height: var(--pcdc-lh); max-width: calc(var(--pcdc-lh) * 5); object-fit: contain; }
.pcdc .pcdc-copy {
  font-size: calc(var(--pcdc-u) * .82 * var(--pcdc-s-copy, 1));
  font-family: var(--pcdc-f-copy, inherit); font-weight: var(--pcdc-w-copy, 500); color: var(--pcdc-c-copy, var(--pcdc-muted));
}

/* Slogan and subtitle. Centred on the block by the grid, then on the SCREEN
   by the script (--pcdc-mid-shift) when the block is not centred itself. */
.pcdc .pcdc-foot-mid {
  grid-column: 2; display: flex; flex-direction: column; align-items: center; text-align: center;
  max-width: 58cqi; transform: translateX(var(--pcdc-mid-shift, 0px));
}
.pcdc .pcdc-slogan {
  margin: 0; font-size: calc(var(--pcdc-u) * 1.55 * var(--pcdc-s-slogan, 1));
  font-family: var(--pcdc-f-slogan, inherit); font-weight: var(--pcdc-w-slogan, 700);
  line-height: 1.15; letter-spacing: -.012em; color: var(--pcdc-c-slogan, var(--pcdc-title)); text-wrap: balance;
}
.pcdc .pcdc-sub {
  margin: .35em 0 0; font-size: calc(var(--pcdc-u) * 1 * var(--pcdc-s-sub, 1));
  font-family: var(--pcdc-f-sub, inherit); font-weight: var(--pcdc-w-sub, 500);
  line-height: 1.3; color: var(--pcdc-c-sub, var(--pcdc-muted)); text-wrap: balance;
}
.pcdc .pcdc-slogan[hidden] + .pcdc-sub { margin-top: 0; }

.pcdc .pcdc-foot-right { grid-column: 3; justify-self: end; display: flex; align-items: center; gap: 1em; min-width: 0; }
.pcdc .pcdc-foot a.pcdc-site {
  font-size: calc(var(--pcdc-u) * .82 * var(--pcdc-s-site, 1));
  font-family: var(--pcdc-f-site, inherit); font-weight: var(--pcdc-w-site, 600); color: var(--pcdc-c-site, var(--pcdc-muted));
}
.pcdc .pcdc-qr-slot { display: none; width: var(--pcdc-qr-w); flex: none; }
.pcdc.has-qr .pcdc-qr-slot { display: block; }

/* The QR card sticks out of the footer on purpose: it crosses the footer
   line, tilted, and floats a little, to invite visitors to scan it. */
.pcdc .pcdc-foot a.pcdc-qr, .pcdc .pcdc-foot a.pcdc-qr:hover {
  position: absolute; right: 4px; bottom: 0; z-index: 2;
  display: flex; flex-direction: column; width: var(--pcdc-qr-w);
  padding: calc(var(--pcdc-qr-w) * .055) calc(var(--pcdc-qr-w) * .055) 0;
  border-radius: calc(var(--pcdc-qr-w) * .12); background: #fff; text-decoration: none;
  box-shadow: 0 22px 44px -18px rgba(0, 0, 0, .7), 0 0 0 1px rgba(255, 255, 255, .5), 0 0 40px -8px rgba(74, 91, 255, .55);
  transform: rotate(-4deg); transform-origin: 50% 100%;
  animation: pcdc-qr-float 4.8s ease-in-out infinite;
}
.pcdc a.pcdc-qr:hover, .pcdc a.pcdc-qr:focus-visible { animation-play-state: paused; }
.pcdc a.pcdc-qr:focus-visible { outline: 3px solid var(--pcdc-blue-2); outline-offset: 4px; }
.pcdc .pcdc-qr-code svg { display: block; width: 100%; height: auto; }
.pcdc .pcdc-qr-cap {
  display: block; margin: calc(var(--pcdc-qr-w) * .04) calc(var(--pcdc-qr-w) * -.055) 0;
  padding: .55em .4em .65em; border-radius: 0 0 calc(var(--pcdc-qr-w) * .12) calc(var(--pcdc-qr-w) * .12);
  font-size: calc(var(--pcdc-qr-w) * .085); font-weight: 800; letter-spacing: .02em; line-height: 1.15; text-align: center;
  color: #fff; background: linear-gradient(135deg, var(--pcdc-blue), var(--pcdc-blue-2));
}
@keyframes pcdc-qr-float {
  0%, 100% { transform: rotate(-4deg) translateY(0); }
  50% { transform: rotate(-2deg) translateY(-7px); }
}

/* Floating note with a hand-drawn arrow pointing at the QR card. */
.pcdc .pcdc-foot .pcdc-qr-hint {
  position: absolute; z-index: 2; margin: 0;
  right: calc(var(--pcdc-qr-w) + var(--pcdc-u) * .6); bottom: calc(var(--pcdc-qr-w) * .62);
  display: flex; align-items: flex-end; gap: .15em;
  max-width: calc(100% - var(--pcdc-qr-w) - var(--pcdc-u) * 2);
  font-size: calc(var(--pcdc-u) * 1.05 * var(--pcdc-s-qrHint, 1)); line-height: 1.15; text-align: right;
  font-family: var(--pcdc-f-qrHint, inherit); font-weight: var(--pcdc-w-qrHint, 700);
  color: var(--pcdc-c-qrHint, var(--pcdc-title)); pointer-events: none;
  animation: pcdc-hint-float 4.8s ease-in-out infinite;
}
.pcdc .pcdc-qr-hint-text { max-width: 10.5em; padding-bottom: 1.5em; text-wrap: balance; }
.pcdc .pcdc-qr-arrow { width: 3.4em; height: auto; flex: none; color: var(--pcdc-blue-2); transform: translateY(.3em); }
@keyframes pcdc-hint-float {
  0%, 100% { transform: translate(0, 0); }
  50% { transform: translate(-4px, -6px); }
}

/* Narrower blocks (portrait tablet, phone, side column): the footer stacks,
   centred. The QR card then straddles the footer line (half above it, so it
   still sticks out) and its note sits in the free band above that line;
   room is kept on both sides so the centred texts stay centred. */
@container pcdc (max-width: 900px) {
  .pcdc .pcdc-foot { grid-template-columns: minmax(0, 1fr); justify-items: center; text-align: center; }
  .pcdc .pcdc-foot-left, .pcdc .pcdc-foot-mid, .pcdc .pcdc-foot-right { grid-column: 1; justify-self: center; justify-content: center; }
  .pcdc .pcdc-foot-mid { transform: none; max-width: 100%; }
  .pcdc.has-qr .pcdc-qr-slot { display: none; }
  .pcdc.has-qr .pcdc-foot { padding-inline: calc(var(--pcdc-qr-w) + 16px); }
  .pcdc .pcdc-foot a.pcdc-qr, .pcdc .pcdc-foot a.pcdc-qr:hover { bottom: auto; top: calc(var(--pcdc-qr-w) * -.55); }
  .pcdc .pcdc-foot .pcdc-qr-hint { bottom: calc(100% - var(--pcdc-qr-w) * .02); max-width: calc(100% - var(--pcdc-qr-w) - var(--pcdc-u) * 1.5); }
}
/* Phone held upright: a smaller card, and the texts keep the left side. */
@container pcdc (max-width: 560px) {
  .pcdc .pcdc-foot { --pcdc-qr-w: calc(var(--pcdc-u) * 7.6 * var(--pcdc-s-qr, 1)); }
  .pcdc.has-qr .pcdc-foot { padding-inline: 0 calc(var(--pcdc-qr-w) + 14px); justify-items: start; text-align: left; }
  .pcdc.has-qr .pcdc-foot :is(.pcdc-foot-left, .pcdc-foot-mid, .pcdc-foot-right) { justify-self: start; justify-content: flex-start; align-items: flex-start; text-align: left; }
  .pcdc .pcdc-foot .pcdc-qr-hint { font-size: calc(var(--pcdc-u) * .95 * var(--pcdc-s-qrHint, 1)); }
}

/* Way into the developer mode when the footer is switched off. */
.pcdc button.pcdc-dev-corner {
  position: absolute; left: 6px; bottom: 4px; z-index: 5; padding: 6px;
  font: 600 12px/1 ui-monospace, "SF Mono", Consolas, monospace;
  color: var(--pcdc-muted); background: transparent; opacity: .22; transition: opacity .25s;
}
.pcdc button.pcdc-dev-corner:hover, .pcdc button.pcdc-dev-corner:focus-visible { opacity: 1; background: transparent; color: var(--pcdc-title); }

/* Draft banner: only in the developer's own browser, never for visitors. */
.pcdc .pcdc-draft {
  position: absolute; z-index: 6; top: 10px; left: 50%; transform: translateX(-50%);
  display: flex; align-items: center; gap: 10px; max-width: calc(100% - 24px);
  padding: 6px 6px 6px 14px; border-radius: 99px; white-space: nowrap;
  font-size: .78rem; font-weight: 700; color: #1d1400; background: #ffb020;
  box-shadow: 0 10px 30px -10px rgba(0, 0, 0, .6);
}
.pcdc .pcdc-draft span { overflow: hidden; text-overflow: ellipsis; }
.pcdc button.pcdc-draft-btn { padding: 6px 12px; border-radius: 99px; font-size: .74rem; font-weight: 700; color: #fff; background: #1d1400; }
.pcdc button.pcdc-draft-btn:hover { color: #fff; background: #3a2a00; }

/* While the published version is on its way (first visit only). */
.pcdc .pcdc-head, .pcdc .pcdc-views, .pcdc .pcdc-foot { transition: opacity .35s; }
.pcdc.is-loading :is(.pcdc-head, .pcdc-views, .pcdc-foot) { opacity: 0; }

/* No visible card. */
.pcdc .pcdc-empty {
  padding: 48px 20px; text-align: center; font-size: .95rem; color: var(--pcdc-muted);
  border: 1px dashed var(--pcdc-glass-line); border-radius: 22px;
}
.pcdc[data-view="carousel"] .pcdc-empty { position: absolute; left: 50%; top: 50%; width: min(420px, 80%); transform: translate(-50%, -50%); }

/* Settings: the view switch can be hidden from the developer panel. */
.pcdc[data-switch="false"] .pcdc-switch { display: none; }
.pcdc.pcdc [hidden] { display: none !important; }   /* [hidden] must win over any display rule */

/* ---------------- Developer mode ---------------- */
.pcdc button.pcdc-dev-link {
  font-family: ui-monospace, "SF Mono", Consolas, monospace; font-size: calc(var(--pcdc-u) * .72);
  color: var(--pcdc-muted); opacity: .45; padding: 4px 2px; background: transparent;
  transition: opacity .25s;
}
.pcdc button.pcdc-dev-link:hover, .pcdc button.pcdc-dev-link:focus-visible { opacity: 1; background: transparent; color: var(--pcdc-title); }
.pcdc button.pcdc-dev-link:focus-visible { outline: 1px dashed currentColor; outline-offset: 2px; }

.pcdc dialog.pcdc-dlg {
  margin: auto; padding: 0; overflow: hidden;
  border: 1px solid var(--pcdc-glass-line); border-radius: 22px;
  color: var(--pcdc-ink); background: var(--pcdc-bg);
  background-image: radial-gradient(420px 220px at 50% -20%, var(--pcdc-halo), transparent 70%);
  box-shadow: 0 40px 90px -30px rgba(0, 0, 0, .75);
  font: 400 15px/1.45 "Inter Tight", system-ui, sans-serif; text-align: left;
}
.pcdc dialog.pcdc-dlg.is-lock { width: min(420px, calc(100vw - 24px)); }
.pcdc dialog.pcdc-dlg::backdrop { background: rgba(4, 5, 12, .5); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); }
/* Administration: a drawer on the right, the showcase stays visible as a live preview. */
.pcdc dialog.pcdc-dlg.is-admin {
  width: min(560px, 100vw); height: 100dvh; max-height: 100dvh; max-width: 100vw;
  margin: 0 0 0 auto; border-radius: 22px 0 0 22px; border-right: 0;
}
.pcdc dialog.pcdc-dlg.is-admin::backdrop { background: rgba(4, 5, 12, .22); -webkit-backdrop-filter: none; backdrop-filter: none; }
@media (max-width: 600px) { .pcdc dialog.pcdc-dlg.is-admin { border-radius: 0; border-left: 0; } }

.pcdc .pcdc-dlg-body { padding: 22px 22px 20px; }
.pcdc .pcdc-dlg-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; }
.pcdc .pcdc-dlg-kicker { font-size: .7rem; font-weight: 700; letter-spacing: .16em; text-transform: uppercase; color: var(--pcdc-muted); }
.pcdc .pcdc-dlg-title { margin-top: 4px; font-size: 1.35rem; font-weight: 800; letter-spacing: -.02em; color: var(--pcdc-title); }
.pcdc .pcdc-dlg-note { font-size: .82rem; color: var(--pcdc-muted); margin-bottom: 14px; }
.pcdc button.pcdc-x {
  width: 36px; height: 36px; border-radius: 50%; display: grid; place-items: center; flex: none;
  color: var(--pcdc-title); background: var(--pcdc-glass); border: 1px solid var(--pcdc-glass-line);
}
.pcdc button.pcdc-x:hover { background: var(--pcdc-glass-hover); color: var(--pcdc-title); }
.pcdc button.pcdc-x svg, .pcdc button.pcdc-ico svg { width: 16px; height: 16px; }

/* Admin layout: head, tabs, scrolling body, sticky publish bar. */
.pcdc .pcdc-adm { display: flex; flex-direction: column; height: 100%; }
.pcdc .pcdc-adm-head { padding: 18px 20px 12px; display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.pcdc .pcdc-server { display: inline-flex; align-items: center; gap: 7px; margin-top: 6px; font-size: .76rem; color: var(--pcdc-muted); }
.pcdc .pcdc-server::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: #9aa0b4; }
.pcdc .pcdc-server[data-state="online"]::before { background: #2fd27a; box-shadow: 0 0 10px #2fd27a; }
.pcdc .pcdc-server[data-state="offline"]::before, .pcdc .pcdc-server[data-state="error"]::before { background: #ff5d6e; }
.pcdc .pcdc-server[data-state="local"]::before { background: #ffb020; }
.pcdc .pcdc-server[data-state="outdated"]::before { background: #ffb020; box-shadow: 0 0 10px #ffb020; }
.pcdc .pcdc-server[data-state="outdated"] { color: #ffb020; font-weight: 600; }
.pcdc .pcdc-tabs {
  display: grid; grid-auto-flow: column; grid-auto-columns: minmax(max-content, 1fr); gap: 2px; margin: 0 20px; padding: 3px;
  border-radius: 99px; background: var(--pcdc-glass); border: 1px solid var(--pcdc-glass-line);
  overflow-x: auto; scrollbar-width: none;
}
.pcdc .pcdc-tabs::-webkit-scrollbar { display: none; }
.pcdc button.pcdc-tab {
  padding: 9px 10px; border-radius: 99px; font-size: .8rem; font-weight: 700; white-space: nowrap;
  color: var(--pcdc-muted); background: transparent;
}
.pcdc button.pcdc-tab:hover { color: var(--pcdc-title); background: transparent; }
.pcdc button.pcdc-tab[aria-selected="true"], .pcdc button.pcdc-tab[aria-selected="true"]:hover { color: #fff; background: linear-gradient(135deg, var(--pcdc-blue), var(--pcdc-blue-2)); }
.pcdc button.pcdc-tab:focus-visible { outline: 2px solid var(--pcdc-blue-2); outline-offset: 1px; }
.pcdc .pcdc-adm-body { flex: 1; min-height: 0; overflow: auto; padding: 16px 20px 24px; overscroll-behavior: contain; }
.pcdc .pcdc-adm-foot {
  display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; padding: 12px 20px 16px;
  border-top: 1px solid var(--pcdc-line); background: var(--pcdc-bg);
}
.pcdc .pcdc-state { flex: 1 1 200px; min-width: 0; font-size: .8rem; color: var(--pcdc-muted); }
.pcdc .pcdc-state strong { color: var(--pcdc-title); }
.pcdc .pcdc-state.is-dirty strong { color: #ffb020; }
.pcdc .pcdc-state.is-error strong { color: #ff5d6e; }
.pcdc .pcdc-state.is-ok strong { color: #2fd27a; }

.pcdc .pcdc-sec-title { margin: 18px 0 8px; font-size: .72rem; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; color: var(--pcdc-muted); }
.pcdc .pcdc-sec-title:first-child { margin-top: 0; }
.pcdc .pcdc-row {
  display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 6px 16px;
  padding: 12px 0; border-top: 1px solid var(--pcdc-line);
}
.pcdc .pcdc-row > span { font-size: .9rem; font-weight: 600; color: var(--pcdc-title); }
.pcdc .pcdc-row small { display: block; font-size: .76rem; font-weight: 400; color: var(--pcdc-muted); }
.pcdc .pcdc-row.is-wide { grid-template-columns: 1fr; }
@media (max-width: 520px) {
  .pcdc .pcdc-row:has(.pcdc-seg) { grid-template-columns: 1fr; }
  .pcdc .pcdc-row .pcdc-seg { width: 100%; }
}

/* Form controls. */
.pcdc .pcdc-lbl { display: block; margin: 14px 0 6px; font-size: .8rem; font-weight: 700; color: var(--pcdc-title); }
.pcdc .pcdc-lbl small { font-weight: 400; color: var(--pcdc-muted); }
.pcdc .pcdc-help { margin-top: 5px; font-size: .74rem; color: var(--pcdc-muted); }
.pcdc .pcdc-warn { margin-top: 5px; font-size: .74rem; color: #ff8a3d; }
.pcdc.pcdc :is(input.pcdc-field, textarea.pcdc-field, select.pcdc-field) {
  width: 100%; margin: 0; padding: 11px 13px; border-radius: 12px; box-shadow: none; min-height: 0;
  font: 500 .95rem/1.35 "Inter Tight", system-ui, sans-serif; color: var(--pcdc-title);
  background: var(--pcdc-glass); border: 1px solid var(--pcdc-glass-line);
}
.pcdc.pcdc textarea.pcdc-field { resize: vertical; min-height: 84px; }
.pcdc.pcdc textarea.pcdc-field.is-code { font: 500 .74rem/1.5 ui-monospace, Consolas, monospace; min-height: 110px; }
.pcdc.pcdc select.pcdc-field { -webkit-appearance: none; appearance: none; background-image: linear-gradient(45deg, transparent 50%, currentColor 50%), linear-gradient(135deg, currentColor 50%, transparent 50%); background-position: calc(100% - 18px) 55%, calc(100% - 13px) 55%; background-size: 5px 5px; background-repeat: no-repeat; padding-right: 34px; }
.pcdc.pcdc select.pcdc-field option { color: #0b0e1c; }
.pcdc.pcdc .pcdc-field:focus { outline: 2px solid var(--pcdc-blue-2); outline-offset: 1px; }
.pcdc.pcdc input[type="color"].pcdc-color { width: 52px; height: 40px; padding: 3px; border-radius: 10px; border: 1px solid var(--pcdc-glass-line); background: var(--pcdc-glass); cursor: pointer; }
.pcdc .pcdc-err { min-height: 1.3em; margin-top: 8px; font-size: .82rem; color: #ff5d6e; }
.pcdc .pcdc-lock.is-wrong .pcdc-field { animation: pcdc-shake .35s; }
@keyframes pcdc-shake { 25% { transform: translateX(-6px); } 50% { transform: translateX(6px); } 75% { transform: translateX(-3px); } }
.pcdc .pcdc-inline { display: flex; align-items: center; gap: 10px; }
.pcdc .pcdc-inline > .pcdc-field { flex: 1; }

/* On / off switch built on a checkbox. */
.pcdc.pcdc input.pcdc-toggle {
  -webkit-appearance: none; appearance: none; position: relative; margin: 0; padding: 0; cursor: pointer;
  width: 46px; height: 28px; border-radius: 99px; flex: none;
  background: var(--pcdc-dot); border: 1px solid var(--pcdc-glass-line); transition: background .25s;
}
.pcdc.pcdc input.pcdc-toggle::after {
  content: ""; position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; border-radius: 50%;
  background: #fff; box-shadow: 0 2px 6px rgba(0, 0, 0, .35); transition: transform .25s cubic-bezier(.2, .8, .2, 1);
}
.pcdc.pcdc input.pcdc-toggle:checked { background: linear-gradient(135deg, var(--pcdc-blue), var(--pcdc-blue-2)); }
.pcdc.pcdc input.pcdc-toggle:checked::after { transform: translateX(18px); }
.pcdc.pcdc input.pcdc-toggle:focus-visible { outline: 2px solid var(--pcdc-blue-2); outline-offset: 2px; }

/* Segmented choice built on radio buttons. */
.pcdc .pcdc-seg {
  display: inline-grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: 2px; padding: 3px;
  border-radius: 99px; background: var(--pcdc-glass); border: 1px solid var(--pcdc-glass-line);
}
.pcdc .pcdc-seg label { position: relative; margin: 0; cursor: pointer; }
.pcdc .pcdc-seg input { position: absolute; opacity: 0; width: 1px; height: 1px; margin: 0; }
.pcdc .pcdc-seg span {
  display: block; padding: 7px 12px; border-radius: 99px; text-align: center; white-space: nowrap;
  font-size: .8rem; font-weight: 600; color: var(--pcdc-muted); transition: background .2s, color .2s;
}
.pcdc .pcdc-seg input:checked + span { color: #fff; background: linear-gradient(135deg, var(--pcdc-blue), var(--pcdc-blue-2)); }
.pcdc .pcdc-seg input:focus-visible + span { outline: 2px solid var(--pcdc-blue-2); outline-offset: 1px; }

/* Slider. */
.pcdc .pcdc-range { display: flex; align-items: center; gap: 12px; }
.pcdc.pcdc .pcdc-range input { flex: 1; margin: 0; accent-color: var(--pcdc-blue-2); }
.pcdc .pcdc-range output { min-width: 5.2em; text-align: right; font-size: .82rem; font-weight: 700; color: var(--pcdc-title); font-variant-numeric: tabular-nums; }

/* Buttons. */
.pcdc .pcdc-dlg-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-top: 16px; }
.pcdc button.pcdc-btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 7px;
  min-height: 42px; padding: 0 18px; border-radius: 99px; font-size: .86rem; font-weight: 700;
  color: var(--pcdc-title); background: var(--pcdc-glass); border: 1px solid var(--pcdc-glass-line);
}
.pcdc button.pcdc-btn:hover { background: var(--pcdc-glass-hover); color: var(--pcdc-title); }
.pcdc button.pcdc-btn.is-primary, .pcdc button.pcdc-btn.is-primary:hover {
  color: #fff; border-color: transparent; background: linear-gradient(135deg, var(--pcdc-blue), var(--pcdc-blue-2));
}
.pcdc button.pcdc-btn.is-danger, .pcdc button.pcdc-btn.is-danger:hover { color: #fff; border-color: transparent; background: #d92d43; }
.pcdc button.pcdc-btn.is-quiet { color: var(--pcdc-muted); background: transparent; border-color: transparent; }
.pcdc button.pcdc-btn.is-small { min-height: 34px; padding: 0 12px; font-size: .78rem; }
.pcdc button.pcdc-btn:disabled { opacity: .45; cursor: not-allowed; }
.pcdc button.pcdc-btn:focus-visible, .pcdc button.pcdc-ico:focus-visible { outline: 2px solid var(--pcdc-blue-2); outline-offset: 2px; }
.pcdc button.pcdc-ico {
  width: 34px; height: 34px; display: grid; place-items: center; flex: none; border-radius: 10px;
  color: var(--pcdc-title); background: var(--pcdc-glass); border: 1px solid var(--pcdc-glass-line);
}
.pcdc button.pcdc-ico:hover { background: var(--pcdc-glass-hover); color: var(--pcdc-title); }
.pcdc button.pcdc-ico:disabled { opacity: .3; cursor: default; }

/* Cards tab: list of cards. */
.pcdc .pcdc-adm-cards { display: grid; gap: 8px; }
.pcdc .pcdc-adm-card {
  display: grid; grid-template-columns: 48px 1fr auto; align-items: center; gap: 12px;
  padding: 8px 8px 8px 8px; border-radius: 14px; background: var(--pcdc-glass); border: 1px solid var(--pcdc-glass-line);
}
.pcdc .pcdc-adm-card.is-hidden { opacity: .55; }
.pcdc .pcdc-thumb {
  width: 48px; height: 48px; border-radius: 12px; overflow: hidden; display: grid; place-items: center;
  font-weight: 800; color: #fff; background: linear-gradient(135deg, var(--pcdc-accent, #1001ef), #0b0e1c);
}
.pcdc.pcdc .pcdc-thumb img { width: 100%; height: 100%; object-fit: cover; }
.pcdc.pcdc .pcdc-thumb.is-icon img { object-fit: contain; padding: 4px; }
.pcdc button.pcdc-adm-open { min-width: 0; text-align: left; background: transparent; padding: 4px 0; }
.pcdc button.pcdc-adm-open:hover { background: transparent; }
.pcdc .pcdc-adm-open strong { display: block; font-size: .92rem; font-weight: 700; color: var(--pcdc-title); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pcdc .pcdc-adm-open small { display: block; font-size: .74rem; color: var(--pcdc-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pcdc .pcdc-adm-open em { font-style: normal; font-size: .66rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; color: #ffb020; margin-left: 6px; }
.pcdc .pcdc-adm-tools { display: flex; gap: 4px; }

/* Card editor. */
.pcdc .pcdc-edit-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 4px; }
.pcdc .pcdc-media-box { display: grid; grid-template-columns: 72px 1fr; gap: 12px; align-items: start; }
.pcdc .pcdc-media-box .pcdc-thumb { width: 72px; height: 72px; border-radius: 14px; }
.pcdc .pcdc-media-btns { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.pcdc .pcdc-progress { height: 4px; margin-top: 8px; border-radius: 4px; background: var(--pcdc-dot); overflow: hidden; }
.pcdc .pcdc-progress i { display: block; height: 100%; width: 0; background: linear-gradient(90deg, var(--pcdc-blue), var(--pcdc-blue-2)); transition: width .2s; }
.pcdc .pcdc-danger-zone { margin-top: 22px; padding-top: 14px; border-top: 1px solid var(--pcdc-line); display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; }

/* Footer tab: QR preview. */
.pcdc .pcdc-qr-preview { display: grid; grid-template-columns: 96px 1fr; gap: 14px; align-items: center; margin-top: 10px; }
.pcdc .pcdc-qr-preview > div:first-child { padding: 6px; border-radius: 12px; background: #fff; }
.pcdc .pcdc-qr-preview svg { display: block; width: 100%; height: auto; }
.pcdc .pcdc-qr-preview p { font-size: .76rem; color: var(--pcdc-muted); word-break: break-all; }

/* Settings tabs: one box per element of the page, a switch to show it and
   the details (text, size, font, weight, colour) folded underneath. */
.pcdc .pcdc-set-intro { font-size: .8rem; color: var(--pcdc-muted); margin-bottom: 6px; }
.pcdc .pcdc-el { margin-top: 8px; border-radius: 14px; background: var(--pcdc-glass); border: 1px solid var(--pcdc-glass-line); }
.pcdc .pcdc-el-head { display: flex; align-items: center; gap: 10px; padding: 4px 12px 4px 4px; }
.pcdc button.pcdc-el-name {
  flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 10px 8px; text-align: left;
  font-size: .9rem; font-weight: 700; line-height: 1.2; color: var(--pcdc-title); background: transparent;
}
.pcdc button.pcdc-el-name:hover { background: transparent; color: var(--pcdc-title); }
.pcdc button.pcdc-el-name:focus-visible { outline: 2px solid var(--pcdc-blue-2); outline-offset: 1px; border-radius: 10px; }
.pcdc .pcdc-el-name small { display: block; font-size: .72rem; font-weight: 400; color: var(--pcdc-muted); }
.pcdc .pcdc-el-name svg { width: 14px; height: 14px; flex: none; transition: transform .2s; }
.pcdc .pcdc-el-name[aria-expanded="true"] svg { transform: rotate(90deg); }
.pcdc .pcdc-el.is-off > .pcdc-el-head .pcdc-el-name { opacity: .5; }
.pcdc .pcdc-el-body { padding: 2px 14px 14px; border-top: 1px solid var(--pcdc-line); }
.pcdc .pcdc-el-body > .pcdc-row:first-child { border-top: 0; }
.pcdc .pcdc-el-body .pcdc-lbl:first-child { margin-top: 10px; }
.pcdc .pcdc-duo { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.pcdc .pcdc-duo .pcdc-lbl { margin-top: 12px; }
.pcdc .pcdc-colorctl { display: flex; align-items: center; gap: 10px; min-height: 40px; }
.pcdc .pcdc-colorctl label { display: inline-flex; align-items: center; gap: 6px; font-size: .8rem; color: var(--pcdc-muted); cursor: pointer; }
.pcdc.pcdc .pcdc-colorctl input[type="checkbox"] { width: 16px; height: 16px; margin: 0; accent-color: var(--pcdc-blue-2); }
.pcdc .pcdc-colorctl input[type="color"]:disabled { opacity: .35; cursor: default; }

/* Banner export: choices on the full width, preview on a checkerboard
   (dark behind light texts, light behind dark texts). */
.pcdc .pcdc-row.is-choice .pcdc-seg { width: 100%; }
.pcdc .pcdc-row.is-choice .pcdc-seg span { padding-inline: 6px; }
.pcdc .pcdc-exp-prev {
  margin-top: 12px; border-radius: 12px; overflow: hidden; border: 1px solid var(--pcdc-glass-line);
  background: repeating-conic-gradient(#2c2f3b 0% 25%, #1c1e27 0% 50%) 50% / 18px 18px;
}
.pcdc .pcdc-exp-prev.is-light { background: repeating-conic-gradient(#dfe2ea 0% 25%, #f5f6fa 0% 50%) 50% / 18px 18px; }
.pcdc.pcdc .pcdc-exp-canvas { display: block; width: 100%; height: auto; }

/* Publication tab. */
.pcdc .pcdc-versions { display: grid; gap: 6px; margin-top: 8px; }
.pcdc .pcdc-version { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 10px; border-radius: 12px; background: var(--pcdc-glass); border: 1px solid var(--pcdc-glass-line); font-size: .8rem; }
.pcdc .pcdc-version small { color: var(--pcdc-muted); }

/* Dots and hint as wide as their content: the script measures them to let
   the QR card rise beside the controls. */
.pcdc .pcdc-dots, .pcdc .pcdc-hint { width: fit-content; max-width: 100%; margin-inline: auto; }

@media (prefers-reduced-motion: reduce) {
  .pcdc *, .pcdc *::before, .pcdc *::after { transition-duration: .01ms !important; animation-duration: .01ms !important; }
  .pcdc a.pcdc-qr, .pcdc .pcdc-qr-hint { animation: none; }
}
.pcdc .pcdc-dlg-actions.is-start { justify-content: flex-start; }
</style>

  <div class="pcdc-draft" hidden>
    <span>Brouillon non publié : visible sur cet appareil seulement</span>
    <button type="button" class="pcdc-draft-btn" data-open-dev>Ouvrir .dev</button>
  </div>

  <header class="pcdc-head">
    <div>
      <p class="pcdc-kicker">PCdlabs · Présent Composé design</p>
      <h2 class="pcdc-h">Applications &amp; expériences</h2>
    </div>
    <div class="pcdc-switch" role="group" aria-label="Mode d'affichage">
      <span class="pcdc-switch-thumb" aria-hidden="true"></span>
      <button type="button" class="pcdc-switch-btn" data-view="carousel" aria-pressed="true">
        <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" aria-hidden="true"><rect x="8" y="5" width="8" height="12" rx="1.6"/><path d="M5 7.5 3 8.5v7l2 1M19 7.5l2 1v7l-2 1"/><path d="M6 20.5c3.8 1 8.2 1 12 0" stroke-linecap="round"/></svg>
        <span class="pcdc-switch-label">Carrousel</span>
      </button>
      <button type="button" class="pcdc-switch-btn" data-view="grid" aria-pressed="false">
        <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="3.5" y="3.5" width="7" height="7" rx="1.6"/><rect x="13.5" y="3.5" width="7" height="7" rx="1.6"/><rect x="3.5" y="13.5" width="7" height="7" rx="1.6"/><rect x="13.5" y="13.5" width="7" height="7" rx="1.6"/></svg>
        <span class="pcdc-switch-label">Grille</span>
      </button>
      <button type="button" class="pcdc-switch-btn" data-view="list" aria-pressed="false">
        <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><rect x="3.5" y="4" width="5" height="5" rx="1.2"/><rect x="3.5" y="15" width="5" height="5" rx="1.2"/><path d="M12 6.5h8.5M12 17.5h8.5"/></svg>
        <span class="pcdc-switch-label">Liste</span>
      </button>
    </div>
  </header>

  <div class="pcdc-views">
    <div class="pcdc-stage" tabindex="0" role="region" aria-roledescription="carrousel" aria-label="Projets, utilisez les flèches gauche et droite">
      <div class="pcdc-track"></div>
    </div>
    <div class="pcdc-nav">
      <button type="button" class="pcdc-arrow" data-dir="-1" aria-label="Projet précédent">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 5l-7 7 7 7"/></svg>
      </button>
      <div class="pcdc-caption" aria-live="polite">
        <span class="pcdc-count"></span>
        <strong class="pcdc-cap-title"></strong>
        <span class="pcdc-cap-kind"></span>
      </div>
      <button type="button" class="pcdc-arrow" data-dir="1" aria-label="Projet suivant">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 5l7 7-7 7"/></svg>
      </button>
    </div>
    <div class="pcdc-dots"></div>
    <p class="pcdc-hint"></p>
  </div>

  <footer class="pcdc-foot">
    <div class="pcdc-foot-left">
      <a class="pcdc-brand" href="https://presentcomposedesign.fr/" aria-label="Présent Composé design">
        <svg class="pcdc-brand-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 140.31 140.32" fill="currentColor" aria-hidden="true" focusable="false"><path d="M67.46,46.96c8.95,0,16.24-7.28,16.24-16.24s-7.28-16.24-16.24-16.24-16.24,7.28-16.24,16.24,7.28,16.24,16.24,16.24h0Z"></path><path d="M72.99,78.96c-2.59,0-4.69,2.1-4.69,4.68s2.1,4.69,4.69,4.69,4.68-2.1,4.68-4.69-2.1-4.68-4.68-4.68Z"></path><path d="M119.78,79.07c-2.09,0-3.43.89-4.02,2.48h7.99c-.57-1.6-1.89-2.48-3.98-2.48Z"></path><path d="M108.58,113.12c.75-.32,1.36-.71,1.84-1.2.8-.8,1.12-2.24.95-4.32-.01-.15-.03-.32-.06-.51-.09-.82-.35-2.14-.78-3.98-.11-.46-.34-1.13-.68-2.03-.18-.4-.33-.74-.43-1-.07-.14-.14-.25-.19-.32-.05-.06-.09-.11-.13-.15-.04-.03-.08-.07-.13-.1-.14-.07-.29-.1-.45-.1-.07,0-.13.02-.17.03-.35-.63-.74-1.11-1.17-1.44-.59-.46-1.44-.68-2.54-.65-.06,0-.12.02-.18.02-.1,0-.2-.01-.3-.01-.82.03-1.55.18-2.17.45-.63.27-1.13.63-1.49,1.07-.37.45-.65.92-.84,1.43-.19.51-.3,1.05-.31,1.62-.03.64.04,1.25.2,1.84.12.57.32,1.11.62,1.63.6,1.08,1.45,1.77,2.53,2.08.65.19,1.24.25,1.81.19.67.01,1.29-.11,1.85-.43.69-.4,1.25-1.04,1.68-1.91.29-.59.54-1.31.73-2.16.05.16.09.3.11.41.34,1.48.58,2.61.7,3.41.02.16.04.31.05.43.19,1.85-.05,3.09-.71,3.74-.39.37-.88.68-1.45.93-.5.22-.99.33-1.47.37-1.06-.02-1.72-.5-1.94-1.46-.03-.13-.11-.22-.25-.29-.14-.06-.3-.09-.47-.06-.05,0-.09.02-.13.04-.11-.03-.23-.05-.35-.03-.17.03-.31.09-.4.19-.1.1-.13.2-.1.31.02.08.05.15.07.22,0,.02,0,.05,0,.07.18.77.58,1.33,1.2,1.69.29.17.61.29.94.37l-51.89,4.89-1.95-20.67,15.3-1.44.28,4.18c-1.29-1.09-2.99-1.7-4.8-1.58-3.72.25-6.54,3.47-6.29,7.19.25,3.72,3.47,6.54,7.19,6.29,3.72-.25,6.54-3.47,6.29-7.19l-.61-9.09,26.63-2.51s-.07.05-.1.08c-.11.02-.21.05-.3.09-.05.03-.1.07-.17.1-.14.04-.25.09-.34.16-.35.24-.42.54-.23.89,0,.12.04.25.13.39.2.27.54.45,1.02.52.25.04.47.03.68,0,.33.03.63,0,.9-.13.33-.15.53-.35.62-.61.07-.21.05-.41-.05-.6,0-.15-.06-.3-.16-.45-.17-.23-.47-.4-.89-.52-.02,0-.04,0-.06-.01l32.39-3.05,1.58,16.75c6.69-10.75,10.56-23.44,10.56-37.03C140.31,31.41,108.9,0,70.16,0S0,31.42,0,70.16s31.41,70.16,70.16,70.16c23.33,0,43.99-11.38,56.75-28.9l-18.44,1.74s.07-.03.11-.04h0ZM106.32,104.78c-.35.71-.77,1.21-1.26,1.49-.23.13-.48.2-.75.23-.22-.02-.45-.05-.69-.12-.77-.23-1.4-.78-1.91-1.64-.21-.38-.36-.78-.47-1.2-.09-.43-.13-.87-.11-1.34.04-1.08.36-1.96.98-2.64.56-.62,1.34-.96,2.31-1.03.61.01,1.1.11,1.41.35.33.27.65.68.95,1.23l.26.46c.1.21.18.37.26.5-.23,1.62-.56,2.85-.98,3.7h0ZM61.59,90.08h-2.53v-9.66c-.12-.74-1-1.11-2.1-1.11-.91,0-1.68.35-2.02,1.04v9.72h-2.5v-9.66h-.02c-.11-.74-.86-1.11-1.96-1.11-.76,0-1.61.24-2.05.72v10.04h-2.55v-12.19h1.75c.76-.44,1.71-.65,2.85-.65,1.32,0,2.41.28,3.26.85.84-.57,1.91-.85,3.23-.85s2.43.39,3.34,1.17c.82.72,1.24,1.56,1.28,2.53v9.14h0ZM61.03,61.68s-1.04-.27-2.11.92c-1.27,1.41-1.35,3.16-1.35,3.16v6.82h-2.42v-12.99h2.42v1.87s.76-1.21,1.94-1.56c1.07-.32,2.6-.02,2.6-.02l-1.08,1.79h0ZM63.49,66.1c0-1.84.67-3.41,2.01-4.75,1.34-1.33,2.97-1.97,4.9-1.97s3.6.75,4.95,2.1c1.17,1.17,1.75,2.8,1.75,4.83h-11.51c.07,1.2.68,2.21,1.5,3.03.88.88,2.11,1.32,3.31,1.32s2.24-.45,3.14-1.35c.12-.12.36-.4.72-.84l2.27.8c-.34.57-.73,1.07-1.17,1.51-1.35,1.36-3,2.04-4.95,2.04s-3.56-.66-4.9-2c-1.34-1.34-2.01-2.91-2.01-4.73h0ZM64.93,58.33v-1.75h10.73v1.75h-10.73ZM62.28,110.32c-2.58.17-4.81-1.78-4.99-4.36s1.78-4.81,4.36-4.99c2.58-.17,4.81,1.78,4.99,4.36.17,2.58-1.78,4.81-4.36,4.99h0ZM73.01,90.4c-1.82,0-3.5-.73-4.67-1.89v4.51h-2.32v-9.37c0-3.73,3.13-6.75,6.86-6.75s6.82,3.02,6.82,6.75-2.96,6.75-6.69,6.75h0ZM80.28,72.12c-1.19-.9-1.45-1.26-1.45-1.26l1.53-1.74c1.19,1.5,2.41,1.78,2.81,1.8.25.02,1.1.04,1.58-.39.35-.32.64-.74.63-1.38,0-.49-.26-.9-.7-1.22-.31-.23-1.12-.58-1.74-.85-1.19-.51-3.84-1.37-3.79-4.09.02-1,.4-1.96,1.24-2.64.88-.71,1.93-.97,3.07-.92,0,0,2.11.03,3.88,1.97l-1.52,1.47c-1.31-1.11-1.83-1.31-2.28-1.37-.26-.03-.93-.08-1.38.16-.51.27-.73.56-.78,1.09-.08.74.51,1.27.96,1.51.9.49,3.48,1.31,4.4,2.49.55.71.82,1.42.82,2.38s-.4,2.08-1.2,2.83c-.87.81-1.91,1.06-3.05,1.06s-2.12-.25-3.01-.92h0ZM95.37,88.39c-1.33,1.34-2.96,2.01-4.91,2.01s-3.56-.67-4.9-2.01c-1.34-1.34-2.01-2.92-2.01-4.74s.67-3.43,2.01-4.76c1.34-1.33,2.97-2,4.9-2s3.58.67,4.91,2c1.32,1.33,1.99,2.92,1.99,4.76s-.66,3.41-1.99,4.74h0ZM116.52,86.92c.88.88,2.05,1.32,3.25,1.32s2.24-.45,3.14-1.35c.12-.12.36-.4.72-.84l2.27.8c-.34.57-.73,1.07-1.17,1.51-1.35,1.36-3,2.04-4.95,2.04s-3.56-.67-4.9-2.01c-1.34-1.34-2.01-2.92-2.01-4.74s.67-3.42,2.01-4.76c1.34-1.33,2.97-1.99,4.9-1.99s3.6.71,4.95,2.06c1.17,1.17,1.75,2.58,1.75,4.76h-11.41c.02,1.26.59,2.33,1.45,3.19h0ZM114.3,75.9v-1.75h10.74v1.75h-10.74ZM119.6,60.34h2.27v-4.74h2.4v4.74h2.27v2.08h-2.27v10.11h-2.4v-10.11h-2.27v-2.08h0ZM106.51,60.34v-.72h2.46v.68c.42-.42,1.25-.63,2.49-.63,1.54,0,2.78.47,3.72,1.41.94.94,1.42,2.1,1.44,3.45v7.99h-2.4v-7.88c0-.83-.28-1.51-.84-2.06-.56-.55-1.2-.82-1.92-.82s-1.34.26-1.88.77c-.31.31-.53.61-.65.89v9.1h-2.42v-12.19h0ZM105.05,76.69s2.1.03,3.87,1.97l-1.52,1.48c-1.31-1.11-1.83-1.31-2.28-1.37-.25-.03-.92-.08-1.38.16-.51.27-.73.56-.78,1.09-.08.74.51,1.27.95,1.51.9.49,3.49,1.31,4.4,2.49.55.71.82,1.43.82,2.38,0,1-.4,2.09-1.2,2.83-.87.81-1.91,1.06-3.04,1.06s-2.12-.25-3.01-.92c-1.19-.9-1.45-1.26-1.45-1.26l1.52-1.74c1.19,1.5,2.41,1.78,2.81,1.8.25.02,1.1.04,1.58-.39.35-.32.64-.74.63-1.38,0-.49-.26-.89-.7-1.22-.31-.23-1.12-.58-1.75-.84-1.18-.51-3.84-1.37-3.79-4.09.02-1,.39-1.96,1.23-2.63.89-.71,1.93-.97,3.07-.92h0ZM92.29,61.37c1.34-1.33,2.97-1.97,4.9-1.97s3.6.74,4.95,2.09c1.16,1.17,1.75,2.79,1.75,4.82h-11.31c.06,1.2.57,2.22,1.4,3.06.88.88,2.01,1.32,3.21,1.32s2.24-.45,3.14-1.35c.12-.12.36-.4.72-.84l2.27.8c-.34.57-.73,1.07-1.17,1.51-1.35,1.36-3,2.04-4.95,2.04s-3.56-.66-4.9-2c-1.34-1.34-2.01-2.91-2.01-4.74s.67-3.41,2.01-4.75h0ZM45.56,30.72c0-11.94,9.84-21.65,21.78-21.65s21.6,9.71,21.6,21.65-9.76,21.65-21.7,21.65c-6.46,0-12.25-2.85-16.46-7.35v28.01s0,.02,0,.03h0s0-.03,0-.03c-.8-3.22-3.05-5.47-5.22-6.94V30.72h0ZM31.97,105.82c-11.94,0-21.65-9.71-21.65-21.65s9.71-21.65,21.65-21.65c8.15,0,17.47,5.64,19.19,13.39h-5.58c-2.8-5.46-7.49-7.98-13.61-7.98-8.95,0-16.24,7.28-16.24,16.24s7.28,16.24,16.24,16.24c6.43,0,11.41-2.48,14.25-8.32l5.52.16c-2.84,8.21-11.2,13.57-19.78,13.57h0ZM38.99,83.65c0,1.82-.66,3.41-1.99,4.74-1.32,1.34-2.96,2.01-4.91,2.01s-3.56-.67-4.9-2.01c-1.34-1.34-2.01-2.92-2.01-4.74s.67-3.43,2.01-4.76c1.34-1.33,2.97-2,4.9-2s3.59.67,4.91,2c1.32,1.33,1.99,2.92,1.99,4.76Z"></path><path d="M97.2,61.51c-2.09,0-3.43.89-4.02,2.48h7.99c-.57-1.6-1.89-2.48-3.97-2.48Z"></path><path d="M90.46,79.02c-1.2,0-2.24.44-3.12,1.33-.88.89-1.32,1.99-1.32,3.29s.44,2.39,1.32,3.27c.88.88,1.92,1.32,3.12,1.32s2.23-.44,3.12-1.32,1.33-1.97,1.33-3.27-.44-2.4-1.33-3.29c-.89-.89-1.92-1.33-3.12-1.33Z"></path><path d="M28.97,80.36c-.88.89-1.32,1.99-1.32,3.29s.44,2.39,1.32,3.27c.88.88,1.92,1.32,3.12,1.32s2.23-.44,3.12-1.32,1.33-1.97,1.33-3.27-.44-2.4-1.33-3.29c-.89-.89-1.92-1.33-3.12-1.33s-2.24.44-3.12,1.33h0Z"></path><path d="M82.09,105.9c0-.06,0-.12-.04-.19-.06-.11-.17-.18-.32-.22-.15-.04-.31-.04-.46.01,0,0,0,0,0,0,0,0,0,0,0,0-.15-.04-.31-.03-.46.01-.16.05-.27.13-.33.24-.28.46-.7.96-1.28,1.51-.58.55-1.1.93-1.57,1.13-.37.15-.77.24-1.17.28-.27,0-.55-.03-.84-.08-.88-.17-1.49-.51-1.84-1.02-.16-.34-.3-.82-.41-1.46-.16-.84-.24-1.72-.24-2.66.02-1.01.12-1.87.31-2.58.24-.71.52-1.14.86-1.3.02,0,.07-.01.14-.03.11-.02.27-.04.48-.05.18-.02.32-.02.47-.02.59.02,1.01.18,1.23.49.11.3.13.68.05,1.13-.11.59-.33,1.19-.66,1.8-.34.61-.67,1.08-.99,1.39l-.23.18s-.1.07-.18.12c-.08.04-.15.08-.2.11-.06.02-.16.07-.32.12-.16.06-.29.11-.4.16-.16.06-.26.14-.3.27-.04.12-.02.23.05.33.03.03.07.06.11.09,0,.08.02.15.06.21.07.1.19.16.37.18.16.02.29,0,.42-.04.02,0,.03.03.06.03.17.02.32,0,.46-.05.07-.04.22-.1.45-.18.24-.08.39-.15.47-.19.08-.04.19-.12.35-.22s.31-.22.46-.36c.39-.37.78-.89,1.15-1.58.38-.68.62-1.35.72-2.01.12-.72.04-1.3-.23-1.75-.07-.18-.15-.34-.26-.48-.47-.64-1.28-.96-2.42-.95-.13,0-.28,0-.45.01-.01,0-.02,0-.03,0-.25,0-.57.01-.96.05-.23.01-.42.03-.58.05-.25.04-.44.1-.57.17-.65.3-1.11.93-1.38,1.88-.24.76-.36,1.67-.38,2.75,0,.97.08,1.9.24,2.8.16.9.38,1.55.65,1.95,0,.01.02.02.03.03.05.09.1.19.15.26.52.75,1.39,1.24,2.63,1.47.62.12,1.22.15,1.79.1.74-.01,1.44-.13,2.11-.41.65-.27,1.3-.72,1.95-1.35.65-.63,1.13-1.21,1.42-1.75.06-.1.06-.2,0-.31-.03-.04-.06-.08-.1-.11h0Z"></path><path d="M97.61,105.16c-.03-.17-.1-.84-.2-2.01-.09-.88-.18-1.56-.27-2.03l-.25-.91c-.11-.39-.17-.64-.18-.75,0-.11,0-.38.03-.81.01-.09-.02-.17-.09-.25,0-.02,0-.04,0-.06.02-.11-.03-.21-.13-.31-.11-.1-.24-.15-.4-.16-.1,0-.2.02-.3.05-.05-.02-.11-.03-.18-.04-.16,0-.31.03-.46.11-.14.08-.22.17-.24.29-.02.51-.03.84-.02.99,0,.15.08.44.2.88.13.44.2.72.23.84.11.44.2,1.09.27,1.96.11,1.18.18,1.86.21,2.03.07.44.19,1.15.38,2.12.19,1.02.3,1.71.35,2.07,0,.06.03.12.07.17,0,.04.01.09.02.13,0,.11.07.2.2.27.13.07.28.11.45.1.08,0,.14-.02.2-.04.08.02.17.03.27.03.17,0,.31-.06.42-.15.11-.09.16-.2.15-.33-.05-.5-.16-1.21-.35-2.12-.21-.95-.33-1.64-.38-2.07h0Z"></path><path d="M70.41,61.51c-2.09,0-3.43.89-4.02,2.48h7.99c-.57-1.6-1.89-2.48-3.98-2.48h0Z"></path><path d="M92.73,107.14c-.02-.06-.03-.11-.04-.17-.11-.31-.29-.62-.53-.92-.25-.3-.46-.56-.66-.76-.19-.2-.52-.45-.96-.75l-.92-.59c-.17-.1-.53-.31-1.1-.61-.56-.3-.88-.47-.95-.5-1.15-.64-1.95-1.22-2.39-1.76-.07-.09-.12-.19-.17-.28-.12-.4-.1-.83.1-1.32.14-.34.42-.69.82-1.04.4-.35.83-.61,1.28-.78.43-.17.82-.26,1.18-.3.13.01.27.03.39.06.48.12.9.41,1.24.86.25.32.49.81.73,1.45.14.43.29.92.43,1.48,0,.04.03.07.05.1.02.07.04.13.05.2.03.13.11.22.25.28.14.06.3.08.47.06.05,0,.09-.02.13-.04.11.03.22.04.35.03.17-.02.31-.09.4-.19.1-.1.13-.2.1-.31-.16-.65-.34-1.23-.51-1.73-.31-.95-.64-1.65-.97-2.08-.52-.65-1.14-1.07-1.89-1.26-.36-.09-.76-.11-1.16-.09-.73-.04-1.54.09-2.42.43-.59.22-1.13.55-1.65,1-.52.45-.86.89-1.05,1.32-.14.36-.22.7-.24,1.02-.02.32.04.63.17.91.03.06.06.12.1.18.01.04.02.08.04.13.13.28.28.54.47.77.18.24.43.47.74.7.31.23.6.43.87.6.27.17.59.36.97.56.14.09.43.25.86.48.44.23.74.4.9.5l.74.44c.35.22.6.4.75.53l.57.5c.24.21.42.42.55.62.11.18.19.36.26.56.15.47.17.82.03,1.04-.21.42-.6.75-1.17,1-.53.22-1.12.35-1.78.4-.59,0-1.14-.07-1.64-.25-.56-.2-.95-.48-1.18-.84-.03-.08-.06-.15-.09-.23-.04-.18-.07-.34-.07-.47,0-.13-.01-.32-.01-.57,0-.24,0-.43-.01-.56,0-.08-.05-.15-.13-.22,0-.02,0-.06,0-.08,0-.11-.07-.21-.2-.29-.13-.08-.28-.12-.44-.11-.08,0-.15.02-.22.04-.08-.02-.17-.04-.26-.03-.16,0-.3.06-.42.15-.12.1-.18.2-.17.31,0,.08,0,.25,0,.51,0,.26,0,.48.01.64,0,.17.04.37.1.6.06.22.13.41.22.57.04.12.09.23.15.32.35.58.92,1.01,1.73,1.29.8.28,1.67.4,2.6.37.05,0,.1,0,.15-.01.11,0,.21,0,.33,0,.93-.03,1.79-.22,2.57-.57.78-.35,1.34-.82,1.67-1.42.2-.42.19-1.01-.06-1.76-.01-.04-.04-.08-.06-.12h0Z"></path><path d="M125.53,104.85c-.1-.27-.18-.61-.22-1.02-.04-.42-.08-.98-.1-1.69-.02-.71-.05-1.25-.07-1.62-.08-1.03-.22-1.83-.43-2.43-.27-.86-.71-1.45-1.32-1.76-.43-.22-.89-.29-1.37-.24-.74-.06-1.54.21-2.41.84-.84.61-1.76,1.56-2.74,2.85-.81,1.09-1.55,2.26-2.24,3.47-.03-.21-.06-.4-.09-.58-.24-1.21-.42-2.54-.55-3.98-.04-.46-.07-.94-.09-1.42,0,0,0-.01,0-.02,0-.1-.01-.19-.02-.29,0-.11-.07-.21-.21-.29-.13-.08-.27-.12-.43-.11-.08,0-.15.02-.22.04-.08-.02-.17-.04-.26-.03-.16,0-.3.06-.42.16-.12.09-.18.19-.17.3.06,1.11.14,2.15.26,3.14.12,1.06.26,2.05.44,2.97.03.08.11.76.26,2.06.15,1.3.37,2.28.68,2.94.05.11.15.19.3.23.15.05.3.05.46.01.01,0,.02-.01.03-.02.14.04.29.05.45.01.16-.04.27-.11.35-.22.07-.11.08-.21.03-.32,0-.02-.01-.04-.02-.06.03-.08.04-.16,0-.24-.2-.42-.33-.9-.44-1.43.15-.06.25-.15.29-.28.14-.4.52-1.15,1.15-2.24.61-1.07,1.3-2.12,2.07-3.14.92-1.22,1.75-2.1,2.5-2.64.37-.27.68-.43.96-.5.06.01.13.02.18.05.4.2.71.63.92,1.28.19.56.32,1.31.39,2.26.02.31.03.84.06,1.58.02.73.06,1.33.11,1.8.04.47.12.85.23,1.14.16.4.55.98,1.18,1.72.1.11.22.17.39.2.13.02.26,0,.38-.04.03,0,.06.02.09.03.16.02.31,0,.46-.06.14-.06.24-.15.28-.26.03-.09.02-.17-.02-.25.01-.02.03-.03.04-.06.04-.11.02-.22-.05-.31-.58-.7-.93-1.21-1.04-1.54h0Z"></path></svg>
        <img class="pcdc-brand-img" alt="" hidden>
      </a>
      <span class="pcdc-copy"></span>
      <button type="button" class="pcdc-dev-link" aria-haspopup="dialog" title="Mode développeur" data-open-dev>.dev</button>
    </div>
    <!-- Centred on the screen (or on the block), whatever sits left and right. -->
    <div class="pcdc-foot-mid">
      <p class="pcdc-slogan" hidden></p>
      <p class="pcdc-sub" hidden></p>
    </div>
    <div class="pcdc-foot-right">
      <a class="pcdc-site"></a>
      <span class="pcdc-qr-slot" aria-hidden="true"></span>
    </div>
    <a class="pcdc-qr" hidden>
      <span class="pcdc-qr-code"></span>
      <span class="pcdc-qr-cap"></span>
    </a>
    <p class="pcdc-qr-hint" aria-hidden="true" hidden>
      <span class="pcdc-qr-hint-text"></span>
      <svg class="pcdc-qr-arrow" viewBox="0 0 120 80" fill="none" stroke="currentColor" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 10C40 4 88 14 104 58"/><path d="M93.3 49 104 58l2.4-13.8"/></svg>
    </p>
  </footer>
  <!-- Way into the developer mode when the footer is switched off. -->
  <button type="button" class="pcdc-dev-corner" aria-haspopup="dialog" title="Mode développeur" data-open-dev hidden>.dev</button>

  <!-- Developer mode: access code, then the administration drawer. -->
  <dialog class="pcdc-dlg is-lock" aria-label="Mode développeur">
    <form class="pcdc-lock pcdc-dlg-body" autocomplete="off">
      <div class="pcdc-dlg-head">
        <div>
          <p class="pcdc-dlg-kicker">Mode développeur</p>
          <p class="pcdc-dlg-title">Code d'accès</p>
        </div>
        <button type="button" class="pcdc-x" data-close aria-label="Fermer">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg>
        </button>
      </div>
      <p class="pcdc-dlg-note pcdc-lock-note" hidden></p>
      <input class="pcdc-field" type="password" name="code" aria-label="Code d'accès" autocomplete="off" autocapitalize="off" spellcheck="false" required>
      <p class="pcdc-err" aria-live="assertive"></p>
      <div class="pcdc-dlg-actions">
        <button type="button" class="pcdc-btn" data-close>Annuler</button>
        <button type="submit" class="pcdc-btn is-primary">Entrer</button>
      </div>
    </form>
    <div class="pcdc-adm" hidden></div>
  </dialog>

  <!-- Administration drawer, cloned on the first unlock. -->
  <template class="pcdc-adm-tpl">
    <div class="pcdc-adm-head">
      <div>
        <p class="pcdc-dlg-kicker">Mode développeur</p>
        <p class="pcdc-dlg-title">Administration</p>
        <span class="pcdc-server" data-state="unknown"></span>
      </div>
      <button type="button" class="pcdc-x" data-close aria-label="Fermer">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg>
      </button>
    </div>
    <div class="pcdc-tabs" role="tablist" aria-label="Sections">
      <button type="button" class="pcdc-tab" role="tab" data-tab="cards" aria-selected="true">Cartes</button>
      <button type="button" class="pcdc-tab" role="tab" data-tab="display" aria-selected="false">Affichage</button>
      <button type="button" class="pcdc-tab" role="tab" data-tab="header" aria-selected="false">En-tête</button>
      <button type="button" class="pcdc-tab" role="tab" data-tab="footer" aria-selected="false">Pied de page</button>
      <button type="button" class="pcdc-tab" role="tab" data-tab="publish" aria-selected="false">Publication</button>
    </div>

    <div class="pcdc-adm-body">
      <!-- Cards: list, then the editor of one card. -->
      <section class="pcdc-panel" data-panel="cards" role="tabpanel">
        <div class="pcdc-cards-view">
          <p class="pcdc-sec-title">Cartes de la vitrine</p>
          <div class="pcdc-adm-cards"></div>
          <div class="pcdc-dlg-actions">
            <button type="button" class="pcdc-btn is-primary" data-add>+ Ajouter une carte</button>
          </div>
        </div>
        <div class="pcdc-editor" hidden></div>
      </section>

      <!-- Settings: built by the script, one control per setting. -->
      <div class="pcdc-panel pcdc-set" data-panel="display" role="tabpanel" hidden></div>
      <div class="pcdc-panel pcdc-set" data-panel="header" role="tabpanel" hidden></div>
      <div class="pcdc-panel pcdc-set" data-panel="footer" role="tabpanel" hidden></div>

      <!-- Publication, versions, import / export, exit. -->
      <section class="pcdc-panel" data-panel="publish" role="tabpanel" hidden>
        <p class="pcdc-sec-title">Version en ligne</p>
        <p class="pcdc-dlg-note pcdc-pub-info"></p>
        <div class="pcdc-dlg-actions is-start">
          <button type="button" class="pcdc-btn is-small" data-versions>Versions précédentes</button>
        </div>
        <div class="pcdc-versions" hidden></div>

        <p class="pcdc-sec-title">Sauvegarde du contenu</p>
        <p class="pcdc-dlg-note">Le fichier JSON contient toutes les cartes et tous les réglages. Avec un serveur, c’est la version publiée qui s’affiche : le contenu collé dans le bloc Elementor ne sert que sans serveur, ou comme réserve à reprendre ici.</p>
        <div class="pcdc-dlg-actions is-start">
          <button type="button" class="pcdc-btn is-small" data-export>Télécharger le JSON</button>
          <button type="button" class="pcdc-btn is-small" data-copy-block>Copier pour le bloc Elementor</button>
          <button type="button" class="pcdc-btn is-small" data-import>Importer un JSON</button>
          <button type="button" class="pcdc-btn is-small" data-from-block>Reprendre le contenu du bloc</button>
        </div>
        <p class="pcdc-help pcdc-io-state" aria-live="polite"></p>

        <p class="pcdc-sec-title">Session</p>
        <div class="pcdc-dlg-actions is-start">
          <button type="button" class="pcdc-btn is-small" data-logout>Quitter le mode développeur</button>
        </div>
      </section>
    </div>

    <div class="pcdc-adm-foot">
      <p class="pcdc-state" aria-live="polite"></p>
      <button type="button" class="pcdc-btn is-small is-quiet" data-discard>Annuler les modifications</button>
      <button type="button" class="pcdc-btn is-primary" data-publish>Publier</button>
    </div>
  </template>

  <!-- Content shown until something is published from the developer mode. -->
  <script type="application/json" class="pcdc-data">
{
  "schema": 2,
  "rev": 0,
  "updatedAt": null,
  "header": {
    "kicker": "PCdlabs · Présent Composé design",
    "title": "Applications & expériences",
    "hint": "Faites glisser pour tourner · Touchez la carte de face pour l'ouvrir",
    "align": "left"
  },
  "settings": {
    "view": "carousel",
    "theme": "light",
    "switch": true,
    "wheel": true,
    "tile": 63,
    "attract": 20,
    "cardLink": "app",
    "newTab": true,
    "font": "inter-tight",
    "bg": ""
  },
  "layout": {
    "header": true,
    "showcase": true,
    "controls": true,
    "footer": true,
    "height": 100,
    "offset": 0,
    "pad": 100,
    "gapHead": 100,
    "gapNav": 100,
    "gapFoot": 100,
    "text": 100,
    "cardSize": 100,
    "cardText": 100,
    "cardNum": true,
    "cardBadge": true,
    "cardKind": true,
    "cardDesc": true,
    "cardTags": true,
    "footLine": true,
    "sloganAnchor": "screen"
  },
  "els": {
    "kicker": {
      "show": false,
      "size": 100,
      "font": "",
      "weight": "",
      "color": ""
    },
    "title": {
      "show": true,
      "size": 100,
      "font": "",
      "weight": "",
      "color": ""
    },
    "switch": {
      "size": 100
    },
    "arrows": {
      "show": true,
      "size": 100
    },
    "caption": {
      "show": true,
      "size": 100,
      "font": "",
      "weight": "",
      "color": ""
    },
    "dots": {
      "show": true,
      "size": 100
    },
    "hint": {
      "show": false,
      "size": 100,
      "font": "",
      "weight": "",
      "color": ""
    },
    "logo": {
      "size": 200
    },
    "copy": {
      "show": true,
      "size": 100,
      "font": "",
      "weight": "",
      "color": ""
    },
    "site": {
      "show": true,
      "size": 100,
      "font": "",
      "weight": "",
      "color": ""
    },
    "slogan": {
      "size": 85,
      "font": "",
      "weight": "",
      "color": ""
    },
    "sub": {
      "show": true,
      "size": 90,
      "font": "",
      "weight": "",
      "color": ""
    },
    "qr": {
      "size": 110
    },
    "qrHint": {
      "show": true,
      "size": 100,
      "font": "",
      "weight": "",
      "color": ""
    }
  },
  "footer": {
    "logo": true,
    "logoUrl": "",
    "copyText": "© PCdlabs · Présent Composé design",
    "siteText": "presentcomposedesign.fr",
    "siteUrl": "https://presentcomposedesign.fr/",
    "slogan": true,
    "sloganText": "Design 360° & Technologies créatives · Toulouse",
    "subText": "Applications web, expériences immersives & réalité augmentée",
    "qr": true,
    "qrUrl": "https://www.linkedin.com/in/alban-desbarax-present-compose-design/",
    "qrCaption": "Contactez moi sur Linkedin",
    "qrHintText": "Retrouvez toutes les apps sur votre téléphone"
  },
  "projects": [
    {
      "id": "enginr",
      "title": "enginR",
      "kind": "Visualiseur 3D · AR · VR",
      "badge": "",
      "description": "Un fichier 3D, dix minutes, une page web immersive. Modèles, gaussian splatting, 360 et données, visibles en AR et en VR sans écrire une ligne de code.",
      "tags": [
        "3D",
        "Gaussian splatting",
        "WebXR",
        "QR code"
      ],
      "icon": "icons/enginr.webp",
      "media": {
        "type": "image",
        "src": "featured/enginr.webp",
        "poster": ""
      },
      "appUrl": "https://pcdlabs.presentcomposedesign.fr/tools/enginR",
      "articleUrl": "",
      "cta": "",
      "accent": "#1001ef",
      "hidden": false
    },
    {
      "id": "birdsignal",
      "title": "BirdSignal",
      "kind": "Bioacoustique",
      "badge": "",
      "description": "Écouter, identifier, collectionner et signaler les oiseaux qui vous entourent, depuis le micro ou un fichier audio. Cartes à collectionner et statut UICN.",
      "tags": [
        "BirdNET",
        "Audio",
        "Collection",
        "PWA"
      ],
      "icon": "icons/birdsignal.webp",
      "media": {
        "type": "video",
        "src": "featured/birdsignal.mp4",
        "poster": "featured/birdsignal.jpg"
      },
      "appUrl": "https://pcdlabs.presentcomposedesign.fr/tools/birdsignal/",
      "articleUrl": "https://presentcomposedesign.fr/birdsignal-infos/",
      "cta": "",
      "accent": "#3a3cf0",
      "hidden": false
    },
    {
      "id": "pcd-1210",
      "title": "PCd-1210",
      "kind": "Platine vinyle virtuelle",
      "badge": "",
      "description": "Une platine direct drive dans le navigateur : bibliothèque de vinyles, scratch et lecteur média, installable sur mobile et ordinateur.",
      "tags": [
        "Audio",
        "Scratch",
        "3D",
        "PWA"
      ],
      "icon": "icons/platine.webp",
      "media": {
        "type": "image",
        "src": "featured/platine.webp",
        "poster": ""
      },
      "appUrl": "https://pcdlabs.presentcomposedesign.fr/experiences/platine-virtuelle/",
      "articleUrl": "https://presentcomposedesign.fr/pcd-1210_infos/",
      "cta": "",
      "accent": "#ff6f91",
      "hidden": false
    },
    {
      "id": "convrt",
      "title": "CONVRT",
      "kind": "Convertisseur de fichiers",
      "badge": "",
      "description": "Convertir images, vidéos, sons et modèles 3D sans rien téléverser : tout se passe dans le navigateur.",
      "tags": [
        "Images",
        "Vidéo",
        "Audio",
        "3D"
      ],
      "icon": "icons/convrt.webp",
      "media": null,
      "appUrl": "https://pcdlabs.presentcomposedesign.fr/tools/CONVRT/",
      "articleUrl": "",
      "cta": "",
      "accent": "#c8fe44",
      "hidden": false
    },
    {
      "id": "le-prompteur",
      "title": "Le Prompteur",
      "kind": "Générateur de prompts",
      "badge": "",
      "description": "Des prompts optimisés pour Mistral, Claude, ChatGPT, Gemini, Suno et Rodin, construits en quelques choix.",
      "tags": [
        "IA",
        "Texte",
        "Image",
        "Musique"
      ],
      "icon": "icons/leprompteur.webp",
      "media": {
        "type": "image",
        "src": "featured/leprompteur.webp",
        "poster": ""
      },
      "appUrl": "https://pcdlabs.presentcomposedesign.fr/tools/leprompteur",
      "articleUrl": "",
      "cta": "",
      "accent": "#ff8a1e",
      "hidden": false
    },
    {
      "id": "spotbaignade",
      "title": "Spotbaignade",
      "kind": "Carte des baignades",
      "badge": "",
      "description": "Lacs, rivières et bases de loisirs pour se baigner autour de vous, en Occitanie. Géolocalisé, gratuit et open source.",
      "tags": [
        "Carte",
        "Géolocalisation",
        "Open source"
      ],
      "icon": "icons/spotbaignade.webp",
      "media": null,
      "appUrl": "https://pcdlabs.presentcomposedesign.fr/tools/Spotbaignade/",
      "articleUrl": "https://presentcomposedesign.fr/spotbaignade/",
      "cta": "",
      "accent": "#1d7fe0",
      "hidden": false
    },
    {
      "id": "splatgate",
      "title": "SplatGate",
      "kind": "Hub VR",
      "badge": "Expérience immersive",
      "description": "Un portail de voyage virtuel pour explorer des paysages réalistes depuis un ordinateur, un mobile ou un casque XR.",
      "tags": [
        "VR",
        "Gaussian splatting",
        "Voyage"
      ],
      "icon": "",
      "media": {
        "type": "video",
        "src": "featured/splatgate.mp4",
        "poster": "featured/splatgate.jpg"
      },
      "appUrl": "",
      "articleUrl": "https://presentcomposedesign.fr/splatgate/",
      "cta": "",
      "accent": "#52b2f9",
      "hidden": false
    },
    {
      "id": "paper-plane",
      "title": "Paper Plane",
      "kind": "Avatar WebXR",
      "badge": "Expérience immersive",
      "description": "Un avatar-avion en papier, simple et universel, pensé pour traverser les mondes immersifs avec légèreté.",
      "tags": [
        "WebXR",
        "Avatar",
        "Arrival.space"
      ],
      "icon": "",
      "media": {
        "type": "video",
        "src": "featured/paperplane.mp4",
        "poster": "featured/paperplane.jpg"
      },
      "appUrl": "",
      "articleUrl": "https://presentcomposedesign.fr/paper-plane-mode_for_arrival-space/",
      "cta": "",
      "accent": "#e79bb0",
      "hidden": false
    },
    {
      "id": "curiosity",
      "title": "Curiosity",
      "kind": "IA culturelle",
      "badge": "",
      "description": "Un oracle culturel français qui invite le public à poser des questions et à explorer un savoir vivant.",
      "tags": [
        "IA",
        "Culture",
        "Médiation"
      ],
      "icon": "",
      "media": {
        "type": "image",
        "src": "featured/curiosity.webp",
        "poster": ""
      },
      "appUrl": "",
      "articleUrl": "https://presentcomposedesign.fr/curiosity/",
      "cta": "",
      "accent": "#7ecf73",
      "hidden": false
    },
    {
      "id": "rizom",
      "title": "RIZOM",
      "kind": "WebXR · hologrammes",
      "badge": "",
      "description": "Une exploration WebXR autour du passage de la vidéo vers des présences 3D et des hologrammes navigables.",
      "tags": [
        "WebXR",
        "Hologrammes",
        "Vidéo volumétrique"
      ],
      "icon": "",
      "media": {
        "type": "video",
        "src": "featured/rizom.mp4",
        "poster": "featured/rizom.jpg"
      },
      "appUrl": "",
      "articleUrl": "https://presentcomposedesign.fr/rizom/",
      "cta": "",
      "accent": "#4b63ff",
      "hidden": false
    },
    {
      "id": "les-moliaires",
      "title": "Les MoliAires",
      "kind": "Réalité augmentée · IA",
      "badge": "Exposition augmentée",
      "description": "Neuf expressions françaises transformées par l’IA en tableaux absurdes et poétiques, visitables en réalité augmentée.",
      "tags": [
        "AR",
        "IA",
        "Exposition"
      ],
      "icon": "",
      "media": {
        "type": "video",
        "src": "featured/moliaires.mp4",
        "poster": "featured/moliaires.jpg"
      },
      "appUrl": "https://pcdlabs.presentcomposedesign.fr/experiences/les-moliaires/",
      "articleUrl": "https://presentcomposedesign.fr/les-moliaires-exposition-augmentee/",
      "cta": "",
      "accent": "#e2564f",
      "hidden": false
    }
  ]
}
  </script>

<script>
(() => {
  'use strict';

  /* ==================================================================
     TOURNIQUET PCdlabs
     Projects shown as a 3D carousel, a grid or a list, with a developer
     mode (".dev" in the footer) to administer everything.

     The content is ONE JSON configuration (schema 2):
       header   texts of the header and of the carousel hint, alignment
       settings view, theme, font, background, behaviour
       layout   sections on / off, height, spacing, sizes, card elements
       els      one entry per element of the page: show, size, font, weight, colour
       footer   logo, texts, slogan, subtitle, QR code and its note
       projects [ { id, title, kind, badge, description, tags[], icon,
                    media: { type: "image" | "gif" | "video", src, poster } | null,
                    appUrl, articleUrl, cta, accent, hidden } ]
     Every field of header / settings / layout / els / footer is described
     once, in SPEC (type, bounds, default): the same table cleans the
     configuration here and on the server (showcase.php).
     Media paths are relative to data-media-base, or full https URLs.

     Where it comes from, highest priority first:
       1. the developer's working copy (only in the developer's own browser,
          while the developer mode is open, until it is published);
       2. the version published on the server (data-api);
       3. the JSON embedded in this block (<script class="pcdc-data">).
     ================================================================== */

  const UI = {
    openApp: 'Ouvrir l’application',
    discover: 'Découvrir le projet',
    readArticle: 'Lire l’article',
    badgeApp: 'Application',
    badgeProject: 'Projet',
    goTo: 'Afficher',
    empty: 'Aucune carte à afficher pour le moment.',
  };

  const ARROW_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 17 17 7M9 7h8v8"/></svg>';
  const ICONS = {
    up: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 15l6-6 6 6"/></svg>',
    down: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg>',
    eye: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg>',
    eyeOff: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 3l18 18M10.6 5.1A10.8 10.8 0 0 1 12 5c6.4 0 10 7 10 7a17.7 17.7 0 0 1-3.2 4.2M6.6 6.6C3.8 8.4 2 12 2 12s3.6 7 10 7a9.7 9.7 0 0 0 5.4-1.6M9.9 9.9a3 3 0 0 0 4.2 4.2"/></svg>',
    edit: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 20h4L19 9l-4-4L4 16v4z"/><path d="M13.5 6.5l4 4"/></svg>',
  };
  const CHEVRON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 5l7 7-7 7"/></svg>';
  const TAU = Math.PI * 2;

  /* Every setting: ["bool", d] ["int", min, max, d] ["enum", list, d]
     ["str", max, d] ["link", d] ["media", d] ["color", d]. Generated from
     spec.py, identical to the table of showcase.php. */
  const SPEC = {"header":{"kicker":["str",120,"PCdlabs · Présent Composé design"],"title":["str",120,"Applications & expériences"],"hint":["str",160,"Faites glisser pour tourner · Touchez la carte de face pour l'ouvrir"],"align":["enum",["left","center"],"left"]},"settings":{"view":["enum",["carousel","grid","list"],"carousel"],"theme":["enum",["auto","dark","light"],"dark"],"switch":["bool",true],"wheel":["bool",false],"tile":["int",40,70,58],"attract":["int",0,600,20],"cardLink":["enum",["app","article"],"app"],"newTab":["bool",true],"font":["enum",["inter-tight","inter","manrope","dm-sans","space-grotesk","syne","unbounded","montserrat","poppins","bricolage","fraunces","playfair","dm-serif","instrument-serif","bebas","caveat","jetbrains","system"],"inter-tight"],"bg":["color",""]},"layout":{"header":["bool",true],"showcase":["bool",true],"controls":["bool",true],"footer":["bool",true],"height":["int",0,100,100],"offset":["int",-30,30,0],"pad":["int",0,250,100],"gapHead":["int",0,300,100],"gapNav":["int",0,300,100],"gapFoot":["int",0,300,100],"text":["int",70,170,100],"cardSize":["int",50,150,100],"cardText":["int",70,160,100],"cardNum":["bool",true],"cardBadge":["bool",true],"cardKind":["bool",true],"cardDesc":["bool",true],"cardTags":["bool",true],"footLine":["bool",true],"sloganAnchor":["enum",["screen","block"],"screen"],"bgNone":["bool",false]},"els":{"kicker":{"show":["bool",true],"size":["int",40,300,100],"font":["enum",["","inter-tight","inter","manrope","dm-sans","space-grotesk","syne","unbounded","montserrat","poppins","bricolage","fraunces","playfair","dm-serif","instrument-serif","bebas","caveat","jetbrains","system"],""],"weight":["enum",["","300","400","500","600","700","800","900"],""],"color":["color",""]},"title":{"show":["bool",true],"size":["int",40,300,100],"font":["enum",["","inter-tight","inter","manrope","dm-sans","space-grotesk","syne","unbounded","montserrat","poppins","bricolage","fraunces","playfair","dm-serif","instrument-serif","bebas","caveat","jetbrains","system"],""],"weight":["enum",["","300","400","500","600","700","800","900"],""],"color":["color",""]},"switch":{"size":["int",40,300,100]},"arrows":{"show":["bool",true],"size":["int",40,300,100]},"caption":{"show":["bool",true],"size":["int",40,300,100],"font":["enum",["","inter-tight","inter","manrope","dm-sans","space-grotesk","syne","unbounded","montserrat","poppins","bricolage","fraunces","playfair","dm-serif","instrument-serif","bebas","caveat","jetbrains","system"],""],"weight":["enum",["","300","400","500","600","700","800","900"],""],"color":["color",""]},"dots":{"show":["bool",true],"size":["int",40,300,100]},"hint":{"show":["bool",true],"size":["int",40,300,100],"font":["enum",["","inter-tight","inter","manrope","dm-sans","space-grotesk","syne","unbounded","montserrat","poppins","bricolage","fraunces","playfair","dm-serif","instrument-serif","bebas","caveat","jetbrains","system"],""],"weight":["enum",["","300","400","500","600","700","800","900"],""],"color":["color",""]},"logo":{"size":["int",40,300,100]},"copy":{"show":["bool",true],"size":["int",40,300,100],"font":["enum",["","inter-tight","inter","manrope","dm-sans","space-grotesk","syne","unbounded","montserrat","poppins","bricolage","fraunces","playfair","dm-serif","instrument-serif","bebas","caveat","jetbrains","system"],""],"weight":["enum",["","300","400","500","600","700","800","900"],""],"color":["color",""]},"site":{"show":["bool",true],"size":["int",40,300,100],"font":["enum",["","inter-tight","inter","manrope","dm-sans","space-grotesk","syne","unbounded","montserrat","poppins","bricolage","fraunces","playfair","dm-serif","instrument-serif","bebas","caveat","jetbrains","system"],""],"weight":["enum",["","300","400","500","600","700","800","900"],""],"color":["color",""]},"slogan":{"size":["int",40,300,100],"font":["enum",["","inter-tight","inter","manrope","dm-sans","space-grotesk","syne","unbounded","montserrat","poppins","bricolage","fraunces","playfair","dm-serif","instrument-serif","bebas","caveat","jetbrains","system"],""],"weight":["enum",["","300","400","500","600","700","800","900"],""],"color":["color",""]},"sub":{"show":["bool",true],"size":["int",40,300,100],"font":["enum",["","inter-tight","inter","manrope","dm-sans","space-grotesk","syne","unbounded","montserrat","poppins","bricolage","fraunces","playfair","dm-serif","instrument-serif","bebas","caveat","jetbrains","system"],""],"weight":["enum",["","300","400","500","600","700","800","900"],""],"color":["color",""]},"qr":{"size":["int",40,300,100]},"qrHint":{"show":["bool",true],"size":["int",40,300,100],"font":["enum",["","inter-tight","inter","manrope","dm-sans","space-grotesk","syne","unbounded","montserrat","poppins","bricolage","fraunces","playfair","dm-serif","instrument-serif","bebas","caveat","jetbrains","system"],""],"weight":["enum",["","300","400","500","600","700","800","900"],""],"color":["color",""]}},"footer":{"logo":["bool",true],"logoUrl":["media",""],"copyText":["str",120,"© PCdlabs · Présent Composé design"],"siteText":["str",80,"presentcomposedesign.fr"],"siteUrl":["link","https://presentcomposedesign.fr/"],"slogan":["bool",true],"sloganText":["str",140,"Design 360° & technologies créatives · Toulouse"],"subText":["str",160,"Applications web, expériences immersives & réalité augmentée"],"qr":["bool",false],"qrUrl":["link",""],"qrCaption":["str",40,"Scannez-moi"],"qrHintText":["str",120,"Retrouvez toutes les apps sur votre téléphone"]}};

  /* Fonts offered in the developer mode: [family, Google Fonts query, fallback].
     A font is downloaded only once something uses it. */
  const FONTS = {
    'inter-tight': ['Inter Tight', 'Inter+Tight:wght@300;400;500;600;700;800;900', 'system-ui, sans-serif'],
    inter: ['Inter', 'Inter:wght@300;400;500;600;700;800;900', 'system-ui, sans-serif'],
    manrope: ['Manrope', 'Manrope:wght@300;400;500;600;700;800', 'system-ui, sans-serif'],
    'dm-sans': ['DM Sans', 'DM+Sans:wght@300;400;500;600;700;800;900', 'system-ui, sans-serif'],
    'space-grotesk': ['Space Grotesk', 'Space+Grotesk:wght@300;400;500;600;700', 'system-ui, sans-serif'],
    syne: ['Syne', 'Syne:wght@400;500;600;700;800', 'system-ui, sans-serif'],
    unbounded: ['Unbounded', 'Unbounded:wght@300;400;500;600;700;800;900', 'system-ui, sans-serif'],
    montserrat: ['Montserrat', 'Montserrat:wght@300;400;500;600;700;800;900', 'system-ui, sans-serif'],
    poppins: ['Poppins', 'Poppins:wght@300;400;500;600;700;800;900', 'system-ui, sans-serif'],
    bricolage: ['Bricolage Grotesque', 'Bricolage+Grotesque:wght@300;400;500;600;700;800', 'system-ui, sans-serif'],
    fraunces: ['Fraunces', 'Fraunces:wght@300;400;500;600;700;800;900', 'Georgia, serif'],
    playfair: ['Playfair Display', 'Playfair+Display:wght@400;500;600;700;800;900', 'Georgia, serif'],
    'dm-serif': ['DM Serif Display', 'DM+Serif+Display', 'Georgia, serif'],
    'instrument-serif': ['Instrument Serif', 'Instrument+Serif', 'Georgia, serif'],
    bebas: ['Bebas Neue', 'Bebas+Neue', 'Impact, sans-serif'],
    caveat: ['Caveat', 'Caveat:wght@400;500;600;700', 'cursive'],
    jetbrains: ['JetBrains Mono', 'JetBrains+Mono:wght@300;400;500;600;700;800', 'ui-monospace, monospace'],
    system: ['', '', 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif'],
  };
  const fontStack = (k) => { const f = FONTS[k]; return !f ? '' : f[0] ? `"${f[0]}", ${f[2]}` : f[2]; };
  const fontLabel = (k) => (FONTS[k] && FONTS[k][0]) || 'Police du système';
  function loadFont(k) {
    const f = FONTS[k];
    if (!f || !f[1]) return;
    // The block itself links Inter Tight (400 to 800).
    if (document.querySelector(`link[data-pcdc-font="${k}"]`)) return;
    const l = document.createElement('link');
    l.rel = 'stylesheet';
    l.href = `https://fonts.googleapis.com/css2?family=${f[1]}&display=swap`;
    l.dataset.pcdcFont = k;
    document.head.append(l);
  }

  /* Schema 1 had no badge. Corrections asked for on 2026-09-27 for three
     cards already published, applied when their badge has never been set. */
  const LEGACY_BADGES = { splatgate: 'Expérience immersive', 'paper-plane': 'Expérience immersive', 'les-moliaires': 'Exposition augmentée' };
  const VIEWS = ['carousel', 'grid', 'list'];
  const VIEW_PARAM = { carrousel: 'carousel', grille: 'grid', liste: 'list', carousel: 'carousel', grid: 'grid', list: 'list' };
  const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
  const noHover = window.matchMedia('(hover: none)');

  /* Offline unlock only (no server reachable): a slow PBKDF2 fingerprint of
     the access code. When a server answers, the server alone checks the code. */
  const DEV_KDF = { salt: '9ef7933f6a148b5e13f2d200c057a50c', iter: 600000, hash: '1c09e8aefde1cfbaa2c42f12e73a789ae9db0bfb2dd3ae936f57b5a29e82dd90' };

  /* Browser storage: conveniences only (last view, cache, draft of the
     developer). Every access may fail (private mode): wrapped. */
  const store = {
    get(k) { try { return window.localStorage.getItem(k); } catch (e) { return null; } },
    set(k, v) { try { window.localStorage.setItem(k, v); return true; } catch (e) { return false; } },
    del(k) { try { window.localStorage.removeItem(k); } catch (e) { /* ignore */ } },
  };
  const sess = {
    get(k) { try { return window.sessionStorage.getItem(k); } catch (e) { return null; } },
    set(k, v) { try { window.sessionStorage.setItem(k, v); } catch (e) { /* ignore */ } },
    del(k) { try { window.sessionStorage.removeItem(k); } catch (e) { /* ignore */ } },
  };

  /* Tiny DOM helper: h('a', { class: 'x', href }, child, 'text'). */
  function h(tag, attrs, ...children) {
    const el = document.createElement(tag);
    for (const [k, v] of Object.entries(attrs || {})) {
      if (v === null || v === undefined || v === false) continue;
      el.setAttribute(k, v === true ? '' : String(v));
    }
    for (const c of children) if (c !== null && c !== undefined && c !== false) el.append(c);
    return el;
  }

  function hexRgb(hex) {
    const m = /^#?([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(hex || '');
    return m ? m.slice(1).map((x) => parseInt(x, 16)) : [16, 1, 239];
  }
  const rgba = (c, a) => `rgba(${c[0]}, ${c[1]}, ${c[2]}, ${a})`;
  const tint = (c, t) => `rgb(${c.map((v) => Math.round(v + (255 - v) * t)).join(', ')})`;
  const deepen = (c, t) => `rgb(${c.map((v) => Math.round(v * (1 - t))).join(', ')})`;
  const clone = (o) => JSON.parse(JSON.stringify(o));
  const slugify = (s) => String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '')
    .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32);

    /* ------------------------------------------------------------------
     QR code encoder (ISO/IEC 18004), byte mode, error correction level M.
     Written from the standard and checked against three independent
     decoders (zxing-cpp, ZBar, OpenCV). Returns a square boolean matrix
     (true = dark module), without the quiet zone, or null if the text is
     too long for a version 40 symbol.
     ------------------------------------------------------------------ */
  const QR = (() => {
    // Level M, indexed by version (1 to 40).
    const ECC_PER_BLOCK = [0, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28];
    const NUM_BLOCKS = [0, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49];
    const ECL_BITS_M = 0;                          // format bits of level M

    // Modules available for data and error correction in a given version.
    function rawDataModules(ver) {
      let r = (16 * ver + 128) * ver + 64;
      if (ver >= 2) {
        const na = Math.floor(ver / 7) + 2;
        r -= (25 * na - 10) * na - 55;
        if (ver >= 7) r -= 36;
      }
      return r;
    }
    const dataCodewords = (ver) => Math.floor(rawDataModules(ver) / 8) - ECC_PER_BLOCK[ver] * NUM_BLOCKS[ver];

    // Arithmetic in GF(256), reducing polynomial x^8 + x^4 + x^3 + x^2 + 1.
    function gfMul(x, y) {
      let z = 0;
      for (let i = 7; i >= 0; i--) {
        z = (z << 1) ^ ((z >>> 7) * 0x11d);
        z ^= ((y >>> i) & 1) * x;
      }
      return z & 0xff;
    }
    function rsDivisor(degree) {
      const res = new Array(degree).fill(0);
      res[degree - 1] = 1;
      let root = 1;
      for (let i = 0; i < degree; i++) {
        for (let j = 0; j < degree; j++) {
          res[j] = gfMul(res[j], root);
          if (j + 1 < degree) res[j] ^= res[j + 1];
        }
        root = gfMul(root, 0x02);
      }
      return res;
    }
    function rsRemainder(data, div) {
      const res = div.map(() => 0);
      for (const b of data) {
        const factor = b ^ res.shift();
        res.push(0);
        div.forEach((coef, i) => { res[i] ^= gfMul(coef, factor); });
      }
      return res;
    }

    function utf8(text) {
      return Array.from(new TextEncoder().encode(text));
    }

    function encode(text) {
      const bytes = utf8(text);
      const eci = bytes.some((b) => b > 0x7f);     // non-ASCII: declare UTF-8 (ECI 26)
      // Smallest version that holds the segment.
      let ver = 1;
      let bitLen = 0;
      for (; ver <= 40; ver++) {
        const ccBits = ver <= 9 ? 8 : 16;
        bitLen = (eci ? 12 : 0) + 4 + ccBits + bytes.length * 8;
        if (bitLen <= dataCodewords(ver) * 8) break;
      }
      if (ver > 40) return null;

      // Bit stream: [ECI] mode, count, data, terminator, padding.
      const bits = [];
      const put = (val, len) => { for (let i = len - 1; i >= 0; i--) bits.push((val >>> i) & 1); };
      if (eci) { put(0x7, 4); put(26, 8); }
      put(0x4, 4);
      put(bytes.length, ver <= 9 ? 8 : 16);
      bytes.forEach((b) => put(b, 8));
      const capacity = dataCodewords(ver) * 8;
      put(0, Math.min(4, capacity - bits.length));
      put(0, (8 - (bits.length % 8)) % 8);
      for (let pad = 0xec; bits.length < capacity; pad ^= 0xec ^ 0x11) put(pad, 8);
      const data = [];
      for (let i = 0; i < bits.length; i += 8) {
        let b = 0;
        for (let j = 0; j < 8; j++) b = (b << 1) | bits[i + j];
        data.push(b);
      }

      // Split into blocks, add error correction, interleave.
      const numBlocks = NUM_BLOCKS[ver];
      const eccLen = ECC_PER_BLOCK[ver];
      const raw = Math.floor(rawDataModules(ver) / 8);
      const numShort = numBlocks - (raw % numBlocks);
      const shortLen = Math.floor(raw / numBlocks);
      const div = rsDivisor(eccLen);
      const blocks = [];
      for (let i = 0, k = 0; i < numBlocks; i++) {
        const dat = data.slice(k, k + shortLen - eccLen + (i < numShort ? 0 : 1));
        k += dat.length;
        const ecc = rsRemainder(dat, div);
        if (i < numShort) dat.push(0);
        blocks.push(dat.concat(ecc));
      }
      const codewords = [];
      for (let i = 0; i < blocks[0].length; i++) {
        blocks.forEach((blk, j) => {
          if (i !== shortLen - eccLen || j >= numShort) codewords.push(blk[i]);
        });
      }

      // Matrix with function patterns.
      const size = ver * 4 + 17;
      const mods = Array.from({ length: size }, () => new Array(size).fill(false));
      const isFn = Array.from({ length: size }, () => new Array(size).fill(false));
      const setFn = (x, y, dark) => { mods[y][x] = dark; isFn[y][x] = true; };

      for (let i = 0; i < size; i++) { setFn(6, i, i % 2 === 0); setFn(i, 6, i % 2 === 0); }
      const finder = (cx, cy) => {
        for (let dy = -4; dy <= 4; dy++) {
          for (let dx = -4; dx <= 4; dx++) {
            const x = cx + dx;
            const y = cy + dy;
            if (x < 0 || y < 0 || x >= size || y >= size) continue;
            const d = Math.max(Math.abs(dx), Math.abs(dy));
            setFn(x, y, d !== 2 && d !== 4);
          }
        }
      };
      finder(3, 3); finder(size - 4, 3); finder(3, size - 4);

      if (ver > 1) {
        const na = Math.floor(ver / 7) + 2;
        const stepA = ver === 32 ? 26 : Math.ceil((ver * 4 + 4) / (na * 2 - 2)) * 2;
        const pos = [6];
        for (let p = size - 7; pos.length < na; p -= stepA) pos.splice(1, 0, p);
        pos.forEach((ay, i) => pos.forEach((ax, j) => {
          if ((i === 0 && j === 0) || (i === 0 && j === na - 1) || (i === na - 1 && j === 0)) return;
          for (let dy = -2; dy <= 2; dy++) {
            for (let dx = -2; dx <= 2; dx++) setFn(ax + dx, ay + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
          }
        }));
      }

      const drawFormat = (mask) => {
        const dat = (ECL_BITS_M << 3) | mask;
        let rem = dat;
        for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
        const f = ((dat << 10) | rem) ^ 0x5412;
        const bit = (i) => ((f >>> i) & 1) === 1;
        for (let i = 0; i <= 5; i++) setFn(8, i, bit(i));
        setFn(8, 7, bit(6));
        setFn(8, 8, bit(7));
        setFn(7, 8, bit(8));
        for (let i = 9; i < 15; i++) setFn(14 - i, 8, bit(i));
        for (let i = 0; i < 8; i++) setFn(size - 1 - i, 8, bit(i));
        for (let i = 8; i < 15; i++) setFn(8, size - 15 + i, bit(i));
        setFn(8, size - 8, true);                  // the dark module
      };
      drawFormat(0);                               // reserves the format area

      if (ver >= 7) {
        let rem = ver;
        for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25);
        const v = (ver << 12) | rem;
        for (let i = 0; i < 18; i++) {
          const dark = ((v >>> i) & 1) === 1;
          const a = size - 11 + (i % 3);
          const b = Math.floor(i / 3);
          setFn(a, b, dark);
          setFn(b, a, dark);
        }
      }

      // Data, in the two-column zigzag from the bottom-right corner.
      let bi = 0;
      for (let right = size - 1; right >= 1; right -= 2) {
        if (right === 6) right = 5;
        for (let vert = 0; vert < size; vert++) {
          for (let j = 0; j < 2; j++) {
            const x = right - j;
            const upward = ((right + 1) & 2) === 0;
            const y = upward ? size - 1 - vert : vert;
            if (!isFn[y][x] && bi < codewords.length * 8) {
              mods[y][x] = ((codewords[bi >>> 3] >>> (7 - (bi & 7))) & 1) === 1;
              bi++;
            }
          }
        }
      }

      const MASKS = [
        (x, y) => (x + y) % 2 === 0,
        (x, y) => y % 2 === 0,
        (x) => x % 3 === 0,
        (x, y) => (x + y) % 3 === 0,
        (x, y) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0,
        (x, y) => ((x * y) % 2) + ((x * y) % 3) === 0,
        (x, y) => (((x * y) % 2) + ((x * y) % 3)) % 2 === 0,
        (x, y) => (((x + y) % 2) + ((x * y) % 3)) % 2 === 0,
      ];
      const applyMask = (m) => {
        for (let y = 0; y < size; y++) {
          for (let x = 0; x < size; x++) if (!isFn[y][x] && MASKS[m](x, y)) mods[y][x] = !mods[y][x];
        }
      };

      // Penalty rules of the standard: runs, 2x2 blocks, finder look-alikes, balance.
      const penalty = () => {
        let p = 0;
        const line = (get) => {
          let s = 0;
          for (let a = 0; a < size; a++) {
            let run = 1;
            for (let b = 1; b < size; b++) {
              if (get(a, b) === get(a, b - 1)) { run++; if (run === 5) s += 3; else if (run > 5) s++; } else run = 1;
            }
            for (let b = 0; b + 11 <= size; b++) {
              const w = [];
              for (let k = 0; k < 11; k++) w.push(get(a, b + k) ? 1 : 0);
              const t = w.join('');
              if (t === '10111010000' || t === '00001011101') s += 40;
            }
          }
          return s;
        };
        p += line((a, b) => mods[a][b]);
        p += line((a, b) => mods[b][a]);
        let dark = 0;
        for (let y = 0; y < size; y++) {
          for (let x = 0; x < size; x++) {
            if (mods[y][x]) dark++;
            if (x < size - 1 && y < size - 1) {
              const c = mods[y][x];
              if (c === mods[y][x + 1] && c === mods[y + 1][x] && c === mods[y + 1][x + 1]) p += 3;
            }
          }
        }
        p += Math.floor(Math.abs(dark * 20 - size * size * 10) / (size * size)) * 10;
        return p;
      };

      let best = 0;
      let bestScore = Infinity;
      for (let m = 0; m < 8; m++) {
        applyMask(m);
        drawFormat(m);
        const sc = penalty();
        if (sc < bestScore) { bestScore = sc; best = m; }
        applyMask(m);                              // XOR again: back to unmasked
      }
      applyMask(best);
      drawFormat(best);
      return mods;
    }

    // SVG with the mandatory 4-module quiet zone, one path for every dark module.
    function svg(text) {
      const m = encode(text);
      if (!m) return '';
      const n = m.length;
      const q = 4;
      let d = '';
      for (let y = 0; y < n; y++) {
        for (let x = 0; x < n; x++) if (m[y][x]) d += `M${x + q} ${y + q}h1v1h-1z`;
      }
      return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${n + 2 * q} ${n + 2 * q}" shape-rendering="crispEdges" aria-hidden="true"><rect width="100%" height="100%" fill="#fff"/><path d="${d}" fill="#0b0e1c"/></svg>`;
    }

    return { encode, svg };
  })();

  /* ------------------------------------------------------------------
     Configuration clean-up. Mirrors the server whitelist (showcase.php):
     every field is typed, bounded, and links can only be http(s), so a
     configuration can never inject a script or a style.
     ------------------------------------------------------------------ */
  // Same rules as the PHP side: control characters become a space, only
  // spaces are trimmed (as PHP's trim), and the length counts characters.
  const str = (v, max) => Array.from((typeof v === 'string' || typeof v === 'number' ? String(v) : '')
    .replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/^ +| +$/g, '')).slice(0, max).join('');
  const oneOf = (v, list, dflt) => (list.includes(v) ? v : dflt);
  const bool = (v, dflt) => (typeof v === 'boolean' ? v : dflt);
  // A number, or a numeric string as PHP's is_numeric reads it ("0x10" is
  // not one); halves round away from zero, as PHP's round. Else: the default.
  const NUMERIC = /^[ \t\n\r\v\f]*[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?[ \t\n\r\v\f]*$/;
  const int = (v, lo, hi, dflt) => {
    if (typeof v === 'string' && NUMERIC.test(v)) v = Number(v);
    if (typeof v !== 'number' || !Number.isFinite(v)) return dflt;
    return Math.min(hi, Math.max(lo, Math.sign(v) * Math.round(Math.abs(v))));
  };
  const link = (v) => { const s = str(v, 500); return /^https?:\/\/[^ \t\n\r\v\f"'<>\\]+$/i.test(s) ? s : ''; };
  const mediaPath = (v) => {
    const s = str(v, 500);
    if (/^https?:\/\/[^ \t\n\r\v\f"'<>\\()]+$/i.test(s)) return s;
    return /^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(s) && !s.includes('..') ? s : '';
  };
  const color = (v) => (/^#[0-9a-f]{6}$/i.test(v || '') ? v.toLowerCase() : '#1001ef');
  const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});

  // One value by its rule, or an object by its spec (only the keys of the spec).
  function cleanBy(rule, v) {
    if (!Array.isArray(rule)) {
      const o = obj(v);
      const out = {};
      for (const k of Object.keys(rule)) out[k] = cleanBy(rule[k], o[k]);
      return out;
    }
    switch (rule[0]) {
      case 'bool': return bool(v, rule[1]);
      case 'int': return int(v, rule[1], rule[2], rule[3]);
      case 'enum': return oneOf(v, rule[1], rule[2]);
      case 'str': return typeof v === 'string' ? str(v, rule[1]) : rule[2];
      case 'link': return typeof v === 'string' ? link(v) : rule[1];
      case 'media': return typeof v === 'string' ? mediaPath(v) : rule[1];
      case 'color': return typeof v === 'string' && /^#[0-9a-f]{6}$/i.test(v) ? v.toLowerCase() : rule[1];
      default: return null;
    }
  }

  function cleanConfig(raw) {
    const r = obj(raw);
    const seen = new Set();
    const projects = [];
    for (const item of (Array.isArray(r.projects) ? r.projects : []).slice(0, 60)) {
      const p = obj(item);
      const title = str(p.title, 80);
      if (!title) continue;
      let id = slugify(str(p.id, 40)) || slugify(title) || 'carte';
      for (let k = 2, base = id; seen.has(id); k++) id = `${base}-${k}`;
      seen.add(id);
      const m = obj(p.media);
      const type = oneOf(m.type, ['image', 'gif', 'video'], '');
      const src = mediaPath(m.src);
      const legacy = typeof p.badge !== 'string';
      let kind = str(p.kind, 80);
      // Same correction: the badge now says "Exposition augmentée", the line
      // above the title no longer repeats it.
      if (legacy && id === 'les-moliaires' && kind === 'Exposition augmentée') kind = 'Réalité augmentée · IA';
      projects.push({
        id,
        title,
        kind,
        badge: legacy ? (LEGACY_BADGES[id] || '') : str(p.badge, 40),
        description: str(p.description, 600),
        tags: (Array.isArray(p.tags) ? p.tags : []).slice(0, 10).map((t) => str(t, 40)).filter(Boolean),
        icon: mediaPath(p.icon),
        media: type && src ? { type, src, poster: mediaPath(m.poster) } : null,
        appUrl: link(p.appUrl),
        articleUrl: link(p.articleUrl),
        cta: str(p.cta, 40),
        accent: color(p.accent),
        hidden: bool(p.hidden, false),
      });
    }
    return {
      schema: 2,
      rev: Number.isInteger(r.rev) && r.rev >= 0 ? r.rev : 0,
      updatedAt: str(r.updatedAt, 40) || null,
      ...cleanBy(SPEC, r),
      projects,
    };
  }
  // Content only (without revision and date), to compare two versions.
  const contentKey = (c) => JSON.stringify({ header: c.header, settings: c.settings, layout: c.layout, els: c.els, footer: c.footer, projects: c.projects });

  // Elementor may run this script again when the widget is edited: only
  // blocks that are not yet initialised get wired.
  document.querySelectorAll('.pcdc:not([data-pcdc-ready])').forEach(init);

  function init(root) {
    root.setAttribute('data-pcdc-ready', '');

    const mediaBase = root.dataset.mediaBase || '';
    /* One block can show only a part of the showcase, whatever the settings:
       data-part="footer"   the footer banner alone (a second widget, a video overlay)
       data-part="showcase" header + cards + controls, without the footer
       data-transparent     no background of its own (the page shows through) */
    const part = oneOf((root.dataset.part || '').toLowerCase(), ['footer', 'showcase'], '');
    const forcedTransparent = root.hasAttribute('data-transparent');
    const apiUrl = root.dataset.api || '';
    const KEY = {
      view: 'pcdc-view',
      cache: 'pcdc-published:' + (apiUrl || 'local'),
      draft: 'pcdc-draft:' + (apiUrl || 'local'),
      token: 'pcdc-token:' + (apiUrl || 'local'),
      local: 'pcdc-local-unlock',
    };
    /* Relative media paths are resolved against data-media-base. */
    const resolve = (p) => (!p ? '' : /^https?:\/\//i.test(p) ? p : mediaBase + p);

    const $ = (sel) => root.querySelector(sel);
    const stage = $('.pcdc-stage');
    const track = $('.pcdc-track');
    const views = $('.pcdc-views');
    const switchBtns = [...root.querySelectorAll('.pcdc-switch-btn')];
    const dotsBox = $('.pcdc-dots');
    const capCount = $('.pcdc-count');
    const capTitle = $('.pcdc-cap-title');
    const capKind = $('.pcdc-cap-kind');
    const head = $('.pcdc-head');
    const kickerEl = $('.pcdc-kicker');
    const titleEl = $('.pcdc-h');
    const nav = $('.pcdc-nav');
    const arrowBtns = [...root.querySelectorAll('.pcdc-arrow')];
    const caption = $('.pcdc-caption');
    const hintEl = $('.pcdc-hint');
    const foot = $('.pcdc-foot');
    const footLeft = $('.pcdc-foot-left');
    const footMid = $('.pcdc-foot-mid');
    const footRight = $('.pcdc-foot-right');
    const brand = $('.pcdc-brand');
    const brandSvg = brand.querySelector('svg');
    const brandImg = $('.pcdc-brand-img');
    const copyEl = $('.pcdc-copy');
    const siteEl = $('.pcdc-site');
    const sloganEl = $('.pcdc-slogan');
    const subEl = $('.pcdc-sub');
    const qrLink = $('.pcdc-qr');
    const qrCode = $('.pcdc-qr-code');
    const qrCap = $('.pcdc-qr-cap');
    const qrHint = $('.pcdc-qr-hint');
    const qrHintText = $('.pcdc-qr-hint-text');
    const devCorner = $('.pcdc-dev-corner');
    const draftBadge = $('.pcdc-draft');
    const dlg = $('.pcdc-dlg');

    /* ------------------------- content state ------------------------- */
    let embedded;
    try { embedded = cleanConfig(JSON.parse($('script.pcdc-data').textContent)); } catch (e) { embedded = cleanConfig({}); }
    let pub = embedded;                  // what visitors see
    let serverState = apiUrl ? 'unknown' : 'none';
    /* Version of showcase.php. Version 1 (the v3 file) accepts a publication
       but silently drops every setting it does not know: publishing to it is
       refused, so nothing is ever lost again. */
    const API_NEEDED = 2;
    let serverApi = 0;
    const serverOutdated = () => serverState === 'online' && serverApi > 0 && serverApi < API_NEEDED;
    let work = null;                     // developer working copy
    let workBase = 0;                    // revision the working copy started from
    let conf = pub;                      // what is on screen

    try {
      const cached = JSON.parse(store.get(KEY.cache) || 'null');
      if (apiUrl && cached) pub = cleanConfig(cached);
    } catch (e) { /* no cache */ }

    let token = sess.get(KEY.token) || '';
    let localMode = sess.get(KEY.local) === '1';
    const unlocked = () => Boolean(token) || localMode;

    /* --------------------------- card building --------------------------- */
    let cards = [];
    let dots = [];
    let n = 0;
    let step = TAU;
    let cardsKey = '';
    let cardIO = null;

    function mediaEl(m, cls, role) {
      if (m.type === 'video') {
        // No src yet: a video is only downloaded the first time it has to play.
        const v = h('video', {
          class: cls, muted: true, loop: true, playsinline: true, preload: 'none',
          poster: resolve(m.poster) || null, 'aria-hidden': 'true', disablepictureinpicture: true,
        });
        v.muted = true;
        v.dataset.src = resolve(m.src);
        v.dataset.role = role;
        return v;
      }
      return h('img', { class: cls, src: resolve(m.src), alt: '', decoding: 'async', draggable: 'false' });
    }

    function buildCard(p, i) {
      const rgb = hexRgb(p.accent);
      const primaryUrl = p.appUrl || p.articleUrl || '#';
      const still = p.media ? resolve(p.media.type === 'image' ? p.media.src : (p.media.poster || p.media.src)) : '';
      const num = String(i + 1).padStart(2, '0');

      const el = h('article', { class: 'pcdc-card', 'data-index': i });
      el.style.setProperty('--pcdc-accent', p.accent);
      el.style.setProperty('--pcdc-accent-soft', rgba(rgb, .55));
      el.style.setProperty('--pcdc-accent-glow', rgba(rgb, .65));
      // Accent text: lightened on dark cards, darkened on light cards (see the theme rules).
      el.style.setProperty('--pcdc-accent-light', tint(rgb, .5));
      el.style.setProperty('--pcdc-accent-deep', deepen(rgb, .45));

      // Featured media behind everything.
      const bg = h('div', { class: 'pcdc-bg' });
      if (p.icon && p.media) {
        bg.append(mediaEl(p.media, 'pcdc-bg-media', 'bg'));
      } else if (!p.icon && still) {
        bg.classList.add('is-blurred');
        bg.append(h('img', { class: 'pcdc-bg-media', src: still, alt: '', decoding: 'async', draggable: 'false' }));
      } else if (p.icon) {
        bg.classList.add('is-icon');
        bg.append(h('img', { class: 'pcdc-bg-media', src: resolve(p.icon), alt: '', decoding: 'async', draggable: 'false' }));
      }

      // Glass tile: the icon, or the media itself when there is no icon.
      const tileBlur = h('div', { class: 'pcdc-tile-blur' });
      if (still) tileBlur.style.setProperty('--pcdc-still', `url("${still}")`);
      let content;
      if (p.icon) {
        content = h('div', { class: 'pcdc-tile-content is-icon' },
          h('img', { src: resolve(p.icon), alt: '', decoding: 'async', draggable: 'false' }));
      } else if (p.media) {
        content = h('div', { class: 'pcdc-tile-content is-media' }, mediaEl(p.media, 'pcdc-tile-media', 'tile'));
      } else {
        content = h('div', { class: 'pcdc-tile-content is-letter' }, p.title.charAt(0));
      }
      const tile = h('div', { class: 'pcdc-tile' }, tileBlur, content);

      // Badge: its own text ("Expérience immersive"...), else Application / Projet.
      const top = h('div', { class: 'pcdc-top' },
        h('span', { class: 'pcdc-pill is-num' }, num),
        h('span', { class: 'pcdc-pill is-badge' + (p.appUrl || p.badge ? ' is-app' : '') }, p.badge || (p.appUrl ? UI.badgeApp : UI.badgeProject)));

      // CTAs: the app first, the article as a second choice.
      const ctaText = p.cta || (p.appUrl ? UI.openApp : UI.discover);
      const actions = h('div', { class: 'pcdc-actions' });
      const primary = h('a', { class: 'pcdc-cta is-primary', href: primaryUrl, 'aria-label': `${ctaText} : ${p.title}` });
      primary.append(h('span', null, ctaText));
      primary.insertAdjacentHTML('beforeend', ARROW_SVG);
      actions.append(primary);
      if (p.appUrl && p.articleUrl) {
        actions.append(h('a', { class: 'pcdc-cta is-secondary', href: p.articleUrl,
          'aria-label': `${UI.readArticle} : ${p.title}` }, UI.readArticle));
      }

      const tags = h('ul', { class: 'pcdc-tags' }, ...p.tags.map((t) => h('li', null, t)));
      const bottom = h('div', { class: 'pcdc-bottom' },
        h('div', { class: 'pcdc-info' },
          h('span', { class: 'pcdc-kind' }, p.kind),
          h('h3', { class: 'pcdc-title' }, p.title),
          h('div', { class: 'pcdc-more' }, h('div', null, h('p', { class: 'pcdc-desc' }, p.description), tags))),
        h('div', { class: 'pcdc-actions-wrap' }, h('div', null, actions)));

      // The card body itself is a link (kept out of the tab order: the CTA is
      // the focus target). Its href and target are set by applyLinks().
      const hit = h('a', { class: 'pcdc-hit', href: primaryUrl, tabindex: '-1', 'aria-hidden': 'true' });

      const front = h('div', { class: 'pcdc-face pcdc-front' },
        h('div', { class: 'pcdc-in' }, bg, h('div', { class: 'pcdc-shade' }), top, tile, hit, bottom));

      // Back face: in a tourniquet some cards are always seen from behind.
      const backMark = p.icon
        ? h('img', { class: 'pcdc-back-mark', src: resolve(p.icon), alt: '', decoding: 'async', draggable: 'false' })
        : still ? h('img', { class: 'pcdc-back-mark is-photo', src: still, alt: '', decoding: 'async', draggable: 'false' }) : null;
      const back = h('div', { class: 'pcdc-face pcdc-back', 'aria-hidden': 'true' },
        h('div', { class: 'pcdc-in' }, backMark,
          h('div', { class: 'pcdc-back-text' }, h('strong', null, p.title), h('span', null, 'PCdlabs · ' + num))));

      el.append(front, back,
        h('i', { class: 'pcdc-edge pcdc-edge-l' }), h('i', { class: 'pcdc-edge pcdc-edge-r' }), h('i', { class: 'pcdc-edge pcdc-edge-t' }));

      return { el, p, hit, primaryUrl, videos: [...el.querySelectorAll('video')], links: [...el.querySelectorAll('a.pcdc-cta')], visible: false };
    }

    function buildCards(list) {
      const keepId = cards[frontIdx] ? cards[frontIdx].p.id : null;
      if (cardIO) cardIO.disconnect();
      cards.forEach((c) => c.videos.forEach((v) => { v.pause(); v.removeAttribute('src'); v.load(); }));
      track.textContent = '';
      dotsBox.textContent = '';
      cards = list.map(buildCard);
      n = cards.length;
      step = TAU / Math.max(1, n);
      if (!n) {
        track.append(h('p', { class: 'pcdc-empty' }, UI.empty));
        track.style.transform = '';
        capCount.textContent = capTitle.textContent = capKind.textContent = '';
      }
      cards.forEach((c) => track.append(c.el));
      dots = cards.map((c, i) => {
        const b = h('button', { type: 'button', class: 'pcdc-dot', 'aria-label': `${UI.goTo} ${c.p.title}` });
        b.addEventListener('click', () => { userActed(); goTo(i); });
        dotsBox.append(b);
        return b;
      });
      if (cardIO) cards.forEach((c) => cardIO.observe(c.el));
      else cards.forEach((c) => { c.visible = true; });
      // Keep the same card in front after a rebuild (an edit, a new version).
      const keep = keepId ? cards.findIndex((c) => c.p.id === keepId) : -1;
      const idx = keep >= 0 ? keep : Math.min(Math.max(0, frontIdx), Math.max(0, n - 1));
      target = current = -idx * step;
      frontIdx = -1;
      hovered = -1;
      if (view === 'carousel') { layout(); kick(); }
      syncTabindex();
      syncMedia();
    }

    // Card link and tab behaviour, from the settings.
    function applyLinks() {
      const s = conf.settings;
      cards.forEach((c) => {
        c.hit.href = s.cardLink === 'article' && c.p.articleUrl ? c.p.articleUrl : c.primaryUrl;
        c.el.querySelectorAll('a').forEach((a) => {
          if (s.newTab) { a.target = '_blank'; a.rel = 'noopener'; } else { a.removeAttribute('target'); a.removeAttribute('rel'); }
        });
      });
      [qrLink, brand, siteEl].forEach((a) => {
        if (s.newTab) { a.target = '_blank'; a.rel = 'noopener'; } else { a.removeAttribute('target'); a.removeAttribute('rel'); }
      });
    }

    /* ------------------------ carousel engine ------------------------
       Same logic as the BirdSignal favourites tourniquet: cards stand on a
       ring, EDGE-ON, and only turn to face you once they reach the front
       (turn = cos^6, so exactly one card faces at a time). The front card
       grows and rises, the others dim with depth. Drawn with CSS 3D, so
       GIFs, videos, links and hover panels stay native HTML. */
    let S = 320;           // card size in px
    let R = 880;           // ring radius in px
    let target = 0;        // ring angle we are heading to
    let current = 0;       // ring angle on screen
    let hovered = -1;
    let frontIdx = -1;
    let tiltX = 0, tiltZ = 0, tiltTX = 0, tiltTZ = 0;
    let raf = 0;
    let view = 'carousel';

    function layout() {
      const W = stage.clientWidth || root.clientWidth || 800;
      const fill = root.classList.contains('is-fill');
      const H = fill ? stage.clientHeight : 0;
      let s;
      // Wide screens show the front card plus neighbours, phones one card. A
      // tall stage (portrait tablet in full-screen mode) gets one big card.
      if (fill && H > W * 0.62) s = W * (W < 600 ? 0.72 : 0.6);
      else s = W >= 900 ? W / 3.1 : W >= 600 ? W / 2.45 : W * 0.66;
      // The front card must fit the height: the free height of the stage in
      // full-screen mode, part of the window otherwise. No fixed maximum: a
      // 4K screen in F11 gets 4K-sized cards.
      s = Math.min(s, fill && H > 0 ? H / 1.14 : (window.innerHeight || 800) * 0.56);
      s *= (conf.layout ? conf.layout.cardSize : 100) / 100;
      S = Math.round(Math.max(140, Math.min(s, 1600)));
      // The arc between two cards must exceed a card width, or they interpenetrate.
      R = Math.max(1.6 * S, (1.55 * S) / (2 * Math.sin(Math.PI / Math.max(3, n))));
      root.style.setProperty('--pcdc-size', S + 'px');
      root.style.setProperty('--pcdc-persp', Math.round(S * 2.5) + 'px');
    }

    const frontOf = (angle) => (n ? ((Math.round(-angle / step) % n) + n) % n : -1);

    function frame() {
      raf = 0;
      if (view !== 'carousel' || !n) return;
      const k = reduceMotion.matches ? 0.4 : 0.12;
      current += (target - current) * k;
      if (Math.abs(target - current) < 1e-4) current = target;
      tiltX += (tiltTX - tiltX) * 0.08;
      tiltZ += (tiltTZ - tiltZ) * 0.08;

      // Slight high-angle view: gives the ring its depth without drawing a stand.
      // Tilting the ring about its centre would push the front card down by
      // R.sin(pitch); the leading translateY puts it back in the middle.
      const pitch = ((-6 + tiltX) * Math.PI) / 180;
      track.style.transform =
        `translateY(${(R * Math.sin(pitch)).toFixed(1)}px) translateZ(${(-R).toFixed(1)}px) rotateX(${pitch.toFixed(4)}rad) rotateZ(${tiltZ.toFixed(2)}deg) rotateY(${current.toFixed(4)}rad)`;

      for (let i = 0; i < n; i++) {
        const a = i * step + current;
        const front = Math.cos(a);                 // 1 = right in front, -1 = behind
        const turn = Math.pow(Math.max(0, front), 6);
        // Neighbours stand edge-on on BOTH sides, like cards in an index box:
        // the quarter turn follows the side of the ring (smoothly, so a card
        // passing behind does not flip).
        const side = Math.max(-1, Math.min(1, Math.sin(a) * 4));
        const edge = (Math.PI / 2) * (1 - turn) * side;
        const hot = i === hovered ? 1 : 0;
        const scale = (0.82 + 0.32 * turn + 0.09 * hot) / 1.14;
        const lift = -(0.07 * turn + 0.04 * hot) * S;
        const c = cards[i].el;
        c.style.transform =
          `rotateY(${(i * step).toFixed(4)}rad) translateZ(${R.toFixed(1)}px) rotateY(${edge.toFixed(4)}rad) translateY(${lift.toFixed(1)}px) scale(${scale.toFixed(4)})`;
        c.style.setProperty('--pcdc-dim', ((1 - (front * 0.5 + 0.5)) * 0.62).toFixed(3));
        c.style.setProperty('--pcdc-sheen', (50 + Math.sin(a) * 90).toFixed(1));
      }

      const f = frontOf(target);
      if (f !== frontIdx) setFront(f);

      const settling = current !== target || Math.abs(tiltTX - tiltX) > 0.01 || Math.abs(tiltTZ - tiltZ) > 0.01;
      if (settling) kick();                        // the loop sleeps once everything is still
    }
    const kick = () => { if (!raf && view === 'carousel' && n && !views.hidden) raf = requestAnimationFrame(frame); };

    function setFront(f) {
      frontIdx = f;
      const c = cards[f];
      if (!c) return;
      capCount.textContent = `${String(f + 1).padStart(2, '0')} / ${String(n).padStart(2, '0')}`;
      capTitle.textContent = c.p.title;
      capKind.textContent = c.p.kind;
      dots.forEach((d, i) => d.setAttribute('aria-current', String(i === f)));
      cards.forEach((cc, i) => {
        cc.el.classList.toggle('is-front', i === f);
        // No hover on a tablet: the front card shows its details straight away,
        // so one tap on it opens the app.
        cc.el.classList.toggle('is-open', i === f && noHover.matches);
        if (i !== f) cc.el.classList.remove('is-hover');
        else if (i === hovered) cc.el.classList.add('is-hover');
      });
      syncTabindex();
      syncMedia();
    }

    function goTo(i) {                             // shortest way round
      if (!n) return;
      const aim = -i * step;
      target = aim + Math.round((target - aim) / TAU) * TAU;
      kick();
    }
    const nudge = (d) => { if (!n) return; target = Math.round(target / step) * step - d * step; kick(); };
    const snap = () => { if (!n) return; target = Math.round(target / step) * step; kick(); };

    /* ------------------------------ views ------------------------------ */
    function setView(v, persist) {
      if (!VIEWS.includes(v)) v = 'carousel';
      view = v;
      root.dataset.view = v;
      root.style.setProperty('--pcdc-view-i', VIEWS.indexOf(v));
      switchBtns.forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.view === v)));
      cards.forEach((c) => c.el.classList.remove('is-open', 'is-hover'));
      if (v === 'carousel') {
        layout();
        frontIdx = -1;                             // force caption / classes refresh
        kick();
      } else {
        cancelAnimationFrame(raf); raf = 0;
        track.style.transform = '';
        cards.forEach((c) => {
          c.el.style.transform = '';
          c.el.style.removeProperty('--pcdc-dim');
          c.el.style.removeProperty('--pcdc-sheen');
          c.el.classList.remove('is-front');
        });
      }
      syncTabindex();
      syncMedia();
      if (persist) store.set(KEY.view, v);
      views.classList.remove('is-entering');
      void views.offsetWidth;
      views.classList.add('is-entering');
    }
    switchBtns.forEach((b) => b.addEventListener('click', () => { userActed(); setView(b.dataset.view, true); }));

    // Only the front card's buttons are reachable with Tab in the carousel.
    function syncTabindex() {
      cards.forEach((c, i) => {
        const reachable = view !== 'carousel' || i === frontIdx;
        c.links.forEach((a) => (reachable ? a.removeAttribute('tabindex') : a.setAttribute('tabindex', '-1')));
      });
    }

    /* ------------------------------ media ------------------------------
       Videos play only where they are seen: the front card of the carousel,
       or cards on screen in grid / list. Everything pauses when the block is
       scrolled away or the tab is hidden: a tablet on a stand runs all day. */
    let rootVisible = true;
    function syncMedia() {
      const on = rootVisible && !document.hidden && !reduceMotion.matches && !views.hidden;
      cards.forEach((c, i) => {
        const seen = view === 'carousel' ? i === frontIdx : c.visible;
        c.videos.forEach((v) => {
          const want = on && seen && !(view === 'list' && v.dataset.role === 'bg');
          if (want) {
            if (!v.getAttribute('src')) v.src = v.dataset.src;
            if (v.paused) { const pr = v.play(); if (pr && pr.catch) pr.catch(() => { /* poster stays */ }); }
          } else if (!v.paused) {
            v.pause();
          }
        });
      });
    }
    if ('IntersectionObserver' in window) {
      new IntersectionObserver((entries) => {
        rootVisible = entries[0].isIntersecting;
        syncMedia();
      }).observe(root);
      cardIO = new IntersectionObserver((entries) => {
        entries.forEach((e) => {
          const c = cards.find((cc) => cc.el === e.target);
          if (c) c.visible = e.isIntersecting;
        });
        syncMedia();
      }, { threshold: 0.2 });
    }
    document.addEventListener('visibilitychange', syncMedia);

    /* --------------------------- interaction --------------------------- */
    let lastAct = performance.now();
    let lastAttract = 0;
    function userActed() {
      lastAct = performance.now();
      root.classList.add('has-interacted');
    }

    // Drag / swipe. The pointer is only captured once the gesture is clearly
    // horizontal, so a plain tap still reaches the links, and a vertical swipe
    // still scrolls the page on a tablet.
    let drag = null;
    let suppressClick = false;
    stage.addEventListener('pointerdown', (e) => {
      if (view !== 'carousel' || e.button > 0) return;
      suppressClick = false;
      drag = { id: e.pointerId, x0: e.clientX, y0: e.clientY, x: e.clientX, active: false };
      userActed();
    });
    stage.addEventListener('pointermove', (e) => {
      if (view !== 'carousel') return;
      if (e.pointerType === 'mouse' && !drag) {    // subtle parallax, as the gyroscope did on phones
        const r = stage.getBoundingClientRect();
        tiltTZ = ((e.clientX - r.left) / r.width - 0.5) * 3;
        tiltTX = ((e.clientY - r.top) / r.height - 0.5) * -4;
        userActed();
        kick();
      }
      if (!drag || e.pointerId !== drag.id) return;
      const dx = e.clientX - drag.x0;
      const dy = e.clientY - drag.y0;
      if (!drag.active) {
        if (Math.hypot(dx, dy) < 8) return;
        if (Math.abs(dy) > Math.abs(dx)) { drag = null; return; }
        drag.active = true;
        try { stage.setPointerCapture(e.pointerId); } catch (err) { /* already released */ }
        root.classList.add('is-dragging');
      }
      target += (e.clientX - drag.x) / (R * 0.8);  // the front card follows the finger
      drag.x = e.clientX;
      kick();
    });
    const endDrag = (e) => {
      if (!drag || e.pointerId !== drag.id) return;
      if (drag.active) {
        suppressClick = true;                      // the release of a drag is not a click
        setTimeout(() => { suppressClick = false; }, 350);
        snap();
      }
      drag = null;
      root.classList.remove('is-dragging');
    };
    stage.addEventListener('pointerup', endDrag);
    stage.addEventListener('pointercancel', endDrag);
    // Links and images are natively draggable: that would cancel the swipe.
    stage.addEventListener('dragstart', (e) => e.preventDefault());
    stage.addEventListener('pointerleave', () => { tiltTX = 0; tiltTZ = 0; kick(); });

    // Horizontal wheel / trackpad (or Shift + wheel) always turns the ring. The
    // vertical wheel is left to the page, unless "lateral scrolling" is on in
    // the settings: then it turns the ring too while the pointer is over it.
    let wheelTimer = 0;
    stage.addEventListener('wheel', (e) => {
      if (view !== 'carousel' || !n) return;
      const unit = e.deltaMode === 1 ? 16 : 1;
      const d = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : (e.shiftKey || conf.settings.wheel) ? e.deltaY : 0;
      if (!d) return;
      e.preventDefault();
      userActed();
      target -= d * unit * 0.0022;
      clearTimeout(wheelTimer);
      wheelTimer = setTimeout(snap, 140);
      kick();
    }, { passive: false });

    stage.addEventListener('keydown', (e) => {
      if (view !== 'carousel' || !n) return;
      if (e.key === 'ArrowRight') nudge(1);
      else if (e.key === 'ArrowLeft') nudge(-1);
      else if (e.key === 'Home') goTo(0);
      else if (e.key === 'End') goTo(n - 1);
      else if ((e.key === 'Enter' || e.key === ' ') && e.target === stage && cards[frontIdx]) cards[frontIdx].hit.click();
      else return;
      e.preventDefault();
      userActed();
    });
    root.querySelectorAll('.pcdc-arrow').forEach((b) =>
      b.addEventListener('click', () => { userActed(); nudge(Number(b.dataset.dir)); }));

    // Hover (mouse only): lifts the card and shows its details.
    track.addEventListener('pointerover', (e) => {
      if (e.pointerType !== 'mouse') return;
      const card = e.target.closest('.pcdc-card');
      setHover(card ? cards.findIndex((c) => c.el === card) : -1);
    });
    track.addEventListener('pointerleave', () => setHover(-1));
    function setHover(i) {
      if (i === hovered) return;
      hovered = i;
      cards.forEach((c, j) => c.el.classList.toggle('is-hover', j === i && (view !== 'carousel' || j === frontIdx)));
      kick();
    }

    // Clicks are decided here, in the capture phase, before any link reacts.
    root.addEventListener('click', (e) => {
      if (suppressClick) { e.preventDefault(); e.stopPropagation(); suppressClick = false; return; }
      if (e.target.closest('.pcdc-dlg')) return;
      const card = e.target.closest('.pcdc-card');
      if (!card) {
        if (!e.target.closest('button')) cards.forEach((c) => view !== 'carousel' && c.el.classList.remove('is-open'));
        return;
      }
      const i = cards.findIndex((c) => c.el === card);
      userActed();
      if (view === 'carousel') {
        // A card on the side first comes round to the front; the front card opens.
        if (i !== frontIdx) { e.preventDefault(); goTo(i); }
        return;
      }
      // Grid on a touch screen: first tap shows the details, second tap opens.
      if (view === 'grid' && noHover.matches && !card.classList.contains('is-open') && !e.target.closest('.pcdc-cta')) {
        e.preventDefault();
        cards.forEach((c) => c.el.classList.toggle('is-open', c.el === card));
      }
    }, true);

    // Attract mode: after a while without anyone touching the tablet, the
    // carousel turns on its own; the first touch stops it.
    setInterval(() => {
      const wait = conf.settings.attract;
      if (!wait || view !== 'carousel' || n < 2 || drag || dlg.open || document.hidden || !rootVisible || reduceMotion.matches || views.hidden) return;
      const now = performance.now();
      if (now - lastAct > wait * 1000 && now - lastAttract > 4500) { lastAttract = now; nudge(1); }
    }, 500);

    // Any change of size (window, F11, rotation, a section switched on or
    // off, a font arriving) re-sizes the ring and re-fits the footer, once per frame.
    let resizeRaf = 0;
    function onResize() {
      resizeRaf = 0;
      fitWrap();
      if (view === 'carousel' && !views.hidden) { layout(); kick(); }
      fitFooter();
      centerMid();
    }
    const queueResize = () => { if (!resizeRaf) resizeRaf = requestAnimationFrame(onResize); };
    if ('ResizeObserver' in window) {
      const ro = new ResizeObserver(queueResize);
      [root, stage, foot].forEach((el) => ro.observe(el));
    }
    window.addEventListener('resize', queueResize);
    if (document.fonts && document.fonts.ready) {
      document.fonts.ready.then(queueResize);
      if (document.fonts.addEventListener) document.fonts.addEventListener('loadingdone', queueResize);
    }

    /* ------------------------------ render ------------------------------ */
    const pageUrl = () => {
      try { const u = new URL(window.location.href); u.hash = ''; u.searchParams.delete('vue'); return u.href; } catch (e) { return window.location.href; }
    };
    let qrText = null;

    const setVar = (k, v) => (v === null || v === undefined || v === '' ? root.style.removeProperty(k) : root.style.setProperty(k, String(v)));

    // Theme, fonts, sizes and spacing: CSS variables on the block.
    function applyLook(c) {
      const s = c.settings;
      const L = c.layout;
      root.dataset.theme = s.theme;
      root.dataset.switch = String(s.switch);
      root.dataset.align = c.header.align;
      setVar('--pcdc-tile-w', s.tile / 100);
      setVar('--pcdc-font', s.font === 'inter-tight' ? null : fontStack(s.font));
      if (s.font !== 'inter-tight') loadFont(s.font);
      setVar('--pcdc-bg', s.bg || null);
      const factor = (v) => (v === 100 ? null : v / 100);
      setVar('--pcdc-ts', factor(L.text));
      setVar('--pcdc-kp', factor(L.pad));
      setVar('--pcdc-kh', factor(L.gapHead));
      setVar('--pcdc-kn', factor(L.gapNav));
      setVar('--pcdc-kf', factor(L.gapFoot));
      setVar('--pcdc-ct', factor(L.cardText));
      setVar('--pcdc-off', L.offset ? L.offset / 100 : null);
      setVar('--pcdc-hf', L.height / 100);
      root.classList.toggle('is-fill', L.height > 0 && sectionsOf(c).showcase);
      root.classList.toggle('is-transparent', L.bgNone || forcedTransparent);
      root.classList.toggle('no-foot-line', !L.footLine);
      [['num', L.cardNum], ['badge', L.cardBadge], ['kind', L.cardKind], ['desc', L.cardDesc], ['tags', L.cardTags]]
        .forEach(([k, on]) => root.classList.toggle('no-card-' + k, !on));
      // One set of variables per element: --pcdc-s-title, --pcdc-f-title...
      for (const [k, e] of Object.entries(c.els)) {
        setVar(`--pcdc-s-${k}`, factor(e.size));
        if ('font' in e) { setVar(`--pcdc-f-${k}`, e.font ? fontStack(e.font) : null); if (e.font) loadFont(e.font); }
        if ('weight' in e) setVar(`--pcdc-w-${k}`, e.weight || null);
        if ('color' in e) setVar(`--pcdc-c-${k}`, e.color || null);
      }
    }

    // Sections really shown by THIS block: the settings, then data-part.
    function sectionsOf(c) {
      const L = c.layout;
      return {
        header: part === 'footer' ? false : L.header,
        showcase: part === 'footer' ? false : L.showcase,
        footer: part === 'showcase' ? false : part === 'footer' ? true : L.footer,
      };
    }

    // Sections and elements on / off.
    function applySections(c) {
      const L = c.layout;
      const E = c.els;
      const sec = sectionsOf(c);
      kickerEl.hidden = !E.kicker.show || !c.header.kicker;
      titleEl.hidden = !E.title.show || !c.header.title;
      head.hidden = !sec.header || (kickerEl.hidden && titleEl.hidden && !c.settings.switch);
      views.hidden = !sec.showcase;
      const ctl = L.controls;
      arrowBtns.forEach((b) => { b.hidden = !ctl || !E.arrows.show; });
      caption.hidden = !ctl || !E.caption.show;
      nav.hidden = !ctl || (!E.arrows.show && !E.caption.show);
      nav.classList.toggle('no-arrows', !E.arrows.show);
      nav.classList.toggle('no-caption', !E.caption.show);
      dotsBox.hidden = !ctl || !E.dots.show;
      hintEl.textContent = c.header.hint;
      hintEl.hidden = !ctl || !E.hint.show || !c.header.hint;
      foot.hidden = !sec.footer;
      devCorner.hidden = sec.footer;               // ".dev" must stay reachable
    }

    function renderFooter(c) {
      const f = c.footer;
      const E = c.els;
      brand.hidden = !f.logo;
      brand.href = f.siteUrl || 'https://presentcomposedesign.fr/';
      if (f.logo && f.logoUrl) {
        brandImg.src = resolve(f.logoUrl);
        brandImg.hidden = false;
        if (brandSvg) brandSvg.style.display = 'none';
      } else {
        brandImg.hidden = true;
        brandImg.removeAttribute('src');
        if (brandSvg) brandSvg.style.display = '';
      }
      copyEl.textContent = f.copyText;
      copyEl.hidden = !E.copy.show || !f.copyText;
      siteEl.textContent = f.siteText || f.siteUrl.replace(/^https?:\/\//i, '').replace(/\/$/, '');
      siteEl.hidden = !E.site.show || !siteEl.textContent;
      if (f.siteUrl) siteEl.href = f.siteUrl; else siteEl.removeAttribute('href');
      sloganEl.textContent = f.sloganText;
      sloganEl.hidden = !(f.slogan && f.sloganText);
      subEl.textContent = f.subText;
      subEl.hidden = !(E.sub.show && f.subText);
      footMid.hidden = sloganEl.hidden && subEl.hidden;

      const text = f.qrUrl || pageUrl();
      const svg = f.qr ? QR.svg(text) : '';
      const showQr = Boolean(svg);
      root.classList.toggle('has-qr', showQr);
      qrLink.hidden = !showQr;
      if (showQr && text !== qrText) {
        qrText = text;
        qrCode.innerHTML = svg;                    // generated here, never from user HTML
        qrLink.href = text;
        qrLink.setAttribute('aria-label', `${f.qrCaption || 'QR code'} : ${text}`);
      }
      qrCap.textContent = f.qrCaption || 'Scannez-moi';
      qrHintText.textContent = f.qrHintText;
      qrHint.hidden = !(showQr && E.qrHint.show && f.qrHintText);
    }

    /* Room above the footer line for the QR card, which sticks out of the
       footer on purpose (and for its note). On a wide screen, when the
       centred controls leave enough room on their right, the card rises
       beside them instead of pushing the carousel up. */
    function fitFooter() {
      if (!root.classList.contains('has-qr') || foot.hidden) { root.style.removeProperty('--pcdc-qr-band'); return; }
      const hintOn = !qrHint.hidden;
      let band = -Math.min(qrLink.offsetTop, hintOn ? qrHint.offsetTop : 0) + 14;   // + tilt and float
      if (!views.hidden && view === 'carousel') {
        const parts = [nav, dotsBox, hintEl].filter((e) => !e.hidden && e.offsetHeight);
        if (parts.length) {
          const rects = parts.map((e) => e.getBoundingClientRect());
          const left = Math.min(qrLink.getBoundingClientRect().left, hintOn ? qrHint.getBoundingClientRect().left : Infinity);
          if (rects.every((r) => r.right + 24 <= left)) {
            band -= Math.max(...rects.map((r) => r.bottom)) - Math.min(...rects.map((r) => r.top));
          }
        }
      }
      root.style.setProperty('--pcdc-qr-band', Math.max(0, Math.round(band)) + 'px');
    }

    /* The slogan is centred on the SCREEN (setting "Centré sur"): when the
       block is not centred in the window, the middle column moves, within
       the room the logo on the left and the links on the right leave. */
    function centerMid() {
      let shift = 0;
      const wide = getComputedStyle(foot).gridTemplateColumns.split(' ').length === 3;
      if (conf.layout.sloganAnchor === 'screen' && wide && !foot.hidden && !footMid.hidden) {
        const current = parseFloat(root.style.getPropertyValue('--pcdc-mid-shift')) || 0;
        const fr = foot.getBoundingClientRect();
        const mr = footMid.getBoundingClientRect();
        const midL = mr.left - current;
        const midR = mr.right - current;
        const vw = document.documentElement.clientWidth || window.innerWidth;
        const want = vw / 2 - (midL + midR) / 2;
        const rects = (box) => [...box.children].filter((e) => !e.hidden && e.offsetWidth).map((e) => e.getBoundingClientRect());
        const leftEdge = rects(footLeft).reduce((m, r) => Math.max(m, r.right), fr.left);
        const rightEdge = rects(footRight).reduce((m, r) => Math.min(m, r.left), fr.right);
        const lo = leftEdge + 16 - midL;
        const hi = rightEdge - 16 - midR;
        shift = lo > hi ? 0 : Math.max(lo, Math.min(hi, want));
        if (Math.abs(shift) < 0.5) shift = 0;
      }
      setVar('--pcdc-mid-shift', shift ? shift.toFixed(1) + 'px' : null);
    }

    /* Full-screen mode: "100 %" means that the whole section fills the
       screen, not the block alone with the page's margins on top of it.
       The space the page puts around the block is measured, never guessed:
       padding and borders of the wrappers that hold nothing but the block
       (an Elementor container...), their margins, and whatever sits just
       above it near the top of the page (a header, the admin bar). */
    function fitWrap() {
      if (!root.classList.contains('is-fill')) { setVar('--pcdc-wrap', null); return; }
      const sum = (el, keys) => { const cs = getComputedStyle(el); return keys.reduce((s, k) => s + (parseFloat(cs[k]) || 0), 0); };
      const holdsOnly = (el, me) => ![...el.children].some((c) => {
        if (c === me) return false;
        const cs = getComputedStyle(c);
        return cs.display !== 'none' && cs.position !== 'absolute' && cs.position !== 'fixed' && c.getBoundingClientRect().height > 1;
      });
      let extra = 0;
      let outer = root;
      for (let el = root.parentElement; el && el !== document.body && el !== document.documentElement; el = el.parentElement) {
        if (!holdsOnly(el, outer)) break;
        extra += sum(outer, ['marginTop', 'marginBottom']) + sum(el, ['paddingTop', 'paddingBottom', 'borderTopWidth', 'borderBottomWidth']);
        outer = el;
      }
      const top = outer.getBoundingClientRect().top + window.scrollY;
      if (top > 0 && top < (window.innerHeight || 800) * 0.3) extra += top;
      setVar('--pcdc-wrap', extra > 0.5 ? Math.round(extra) + 'px' : null);
    }

    function render(c) {
      conf = c;
      applyLook(c);
      applySections(c);
      kickerEl.textContent = c.header.kicker;
      titleEl.textContent = c.header.title;
      renderFooter(c);
      const visible = c.projects.filter((p) => !p.hidden);
      const key = mediaBase + JSON.stringify(visible);
      if (key !== cardsKey) { cardsKey = key; buildCards(visible); }
      applyLinks();
      draftBadge.hidden = !(work && isDirty());
      fitWrap();
      if (view === 'carousel' && !views.hidden) { layout(); kick(); }
      syncMedia();
      fitFooter();
      centerMid();
    }
    const shown = () => (work && unlocked() ? work : pub);
    const isDirty = () => Boolean(work) && contentKey(work) !== contentKey(pub);

    /* ------------------------- server connection ------------------------- */
    const endpoint = (action, extra) => {
      const u = new URL(apiUrl, window.location.href);
      u.searchParams.set('action', action);
      Object.entries(extra || {}).forEach(([k, v]) => u.searchParams.set(k, v));
      return u.href;
    };
    async function api(action, { method = 'GET', body, extra } = {}) {
      if (!apiUrl) return { status: 0, data: null };
      const headers = {};
      if (token) headers['X-PCDC-Token'] = token;
      if (body) headers['Content-Type'] = 'application/json';
      try {
        const r = await fetch(endpoint(action, extra), {
          method, headers, body: body ? JSON.stringify(body) : undefined,
          cache: action === 'config' ? 'no-cache' : 'no-store', credentials: 'omit',
        });
        let data = null;
        try { data = await r.json(); } catch (e) { /* not JSON */ }
        return { status: r.status, data };
      } catch (e) {
        return { status: 0, data: null };        // network error, CORS refusal, no server
      }
    }
    async function loadPublished() {
      const res = await api('config');
      if (res.status !== 200 || !res.data || !res.data.ok) {
        serverState = res.status === 0 ? 'offline' : 'error';
        return false;
      }
      serverState = 'online';
      serverApi = Number(res.data.api) || 1;
      if (!res.data.config) return true;           // nothing published yet: the block's JSON stays
      const fresh = cleanConfig(res.data.config);
      const differs = contentKey(fresh) !== contentKey(pub) || fresh.rev !== pub.rev;
      const draftWasClean = work ? contentKey(work) === contentKey(pub) : true;
      pub = fresh;
      store.set(KEY.cache, JSON.stringify(pub));
      // An untouched working copy follows the new version; a real draft is kept
      // (publishing it will then report the newer version).
      if (work && draftWasClean) { work = clone(pub); workBase = pub.rev; }
      if (differs) render(shown());
      return true;
    }

    /* ------------------------------ start ------------------------------ */
    if (unlocked()) {
      try {
        const d = JSON.parse(store.get(KEY.draft) || 'null');
        if (d && d.config) { work = cleanConfig(d.config); workBase = Number(d.baseRev) || 0; }
      } catch (e) { /* no draft */ }
    }
    render(shown());
    let startView = conf.settings.view;
    const savedView = store.get(KEY.view);
    if (savedView && conf.settings.switch) startView = savedView;   // the visitor's own choice
    try {
      const q = new URLSearchParams(window.location.search).get('vue');
      if (q && VIEW_PARAM[q.toLowerCase()]) startView = VIEW_PARAM[q.toLowerCase()];
    } catch (e) { /* old browser */ }
    layout();
    setView(startView, false);
    views.classList.remove('is-entering');

    if (apiUrl) {
      // First visit: wait (briefly) for the published version rather than
      // flashing the block's own content and then swapping it.
      const hasCache = Boolean(store.get(KEY.cache));
      if (!hasCache) root.classList.add('is-loading');
      const reveal = setTimeout(() => root.classList.remove('is-loading'), 1500);
      loadPublished().then(() => {
        clearTimeout(reveal);
        root.classList.remove('is-loading');
        if (adm) refreshAdmin();
      });
    }

    /* ==================================================================
       DEVELOPER MODE
       ".dev" -> access code -> administration drawer. With a server, the
       code is checked by the server, which returns a signed session token
       (8 hours); publishing and uploads require it. Without a server, the
       code is checked here and changes stay in this browser (export them
       to make them permanent).
       ================================================================== */
    const lockForm = dlg.querySelector('.pcdc-lock');
    const lockNote = dlg.querySelector('.pcdc-lock-note');
    const codeInput = lockForm.elements.code;
    const codeErr = lockForm.querySelector('.pcdc-err');
    const admBox = dlg.querySelector('.pcdc-adm');
    let adm = null;                                // admin UI, built on first unlock

    root.querySelectorAll('[data-open-dev]').forEach((b) => b.addEventListener('click', openDev));
    dlg.addEventListener('click', (e) => {
      if (e.target === dlg) dlg.close();           // click on the backdrop
      else if (e.target.closest('[data-close]')) dlg.close();
    });
    dlg.addEventListener('close', () => { dlg.classList.remove('is-admin'); dlg.classList.add('is-lock'); });

    // "#dev" at the end of the page address opens the developer mode too.
    const devHash = () => { if (/^#(pcdc-)?dev$/i.test(window.location.hash)) openDev(); };
    window.addEventListener('hashchange', devHash);
    setTimeout(devHash, 0);

    function openDev() {
      if (typeof dlg.showModal === 'function') { if (!dlg.open) dlg.showModal(); } else dlg.setAttribute('open', '');
      if (unlocked()) showAdmin(); else showLock('');
    }
    function showLock(note) {
      dlg.classList.add('is-lock');
      dlg.classList.remove('is-admin');
      lockForm.hidden = false;
      admBox.hidden = true;
      lockNote.hidden = !note;
      lockNote.textContent = note;
      codeInput.value = '';
      codeErr.textContent = '';
      codeInput.focus();
    }

    async function pbkdf2Hex(code) {
      const enc = new TextEncoder();
      const key = await crypto.subtle.importKey('raw', enc.encode(code), 'PBKDF2', false, ['deriveBits']);
      const salt = new Uint8Array(DEV_KDF.salt.match(/../g).map((x) => parseInt(x, 16)));
      const bits = await crypto.subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: DEV_KDF.iter }, key, 256);
      return [...new Uint8Array(bits)].map((b) => b.toString(16).padStart(2, '0')).join('');
    }

    lockForm.addEventListener('submit', async (e) => {
      e.preventDefault();
      const code = codeInput.value;
      const submit = lockForm.querySelector('[type="submit"]');
      submit.disabled = true;
      codeErr.textContent = 'Vérification...';
      let msg = 'Code incorrect.';
      let ok = false;
      if (apiUrl) {
        const res = await api('login', { method: 'POST', body: { code } });
        if (res.status === 200 && res.data && res.data.ok && res.data.token) {
          token = res.data.token;
          serverApi = Number(res.data.api) || 1;
          sess.set(KEY.token, token);
          localMode = false;
          sess.del(KEY.local);
          serverState = 'online';
          ok = true;
        } else if (res.status === 429) {
          msg = `Trop d'essais. Réessayez dans ${Math.ceil(((res.data && res.data.retryAfter) || 900) / 60)} min.`;
        } else if (res.status === 401) {
          msg = 'Code incorrect.';
        } else {
          serverState = res.status === 0 ? 'offline' : 'error';
        }
        // Server unreachable or not configured: offline check, changes stay here.
        if (!ok && res.status !== 401 && res.status !== 429 && await offlineCheck(code)) ok = true;
      } else if (await offlineCheck(code)) {
        ok = true;
      }
      submit.disabled = false;
      if (ok) {
        codeErr.textContent = '';
        startEditing();
        showAdmin();
      } else {
        codeErr.textContent = msg;
        codeInput.value = '';
        lockForm.classList.remove('is-wrong'); void lockForm.offsetWidth; lockForm.classList.add('is-wrong');
        codeInput.focus();
      }
    });
    async function offlineCheck(code) {
      if (!window.crypto || !window.crypto.subtle) return false;
      try {
        if ((await pbkdf2Hex(code)) !== DEV_KDF.hash) return false;
      } catch (e) { return false; }
      if (!token) { localMode = true; sess.set(KEY.local, '1'); }
      return true;
    }

    function startEditing() {
      if (!work) {
        try {
          const d = JSON.parse(store.get(KEY.draft) || 'null');
          if (d && d.config) { work = cleanConfig(d.config); workBase = Number(d.baseRev) || 0; }
        } catch (e) { /* no draft */ }
      }
      if (!work) { work = clone(pub); workBase = pub.rev; }
      render(work);
    }

    /* Every edit goes through here: the working copy is saved as a draft in
       this browser, and the showcase behind the drawer is redrawn. */
    let renderTimer = 0;
    function changed(opts) {
      if (isDirty()) store.set(KEY.draft, JSON.stringify({ baseRev: workBase, config: work }));
      else store.del(KEY.draft);
      clearTimeout(renderTimer);
      renderTimer = setTimeout(() => {
        render(work);
        exportPreviewSoon();
        if (opts && opts.view) setView(work.settings.view, false);
        if (opts && opts.focusId) {
          const i = cards.findIndex((c) => c.p.id === opts.focusId);
          if (i >= 0 && view === 'carousel') goTo(i);
        }
      }, 120);
      updateState();
    }

    function showAdmin() {
      if (!adm) buildAdmin();
      dlg.classList.remove('is-lock');
      dlg.classList.add('is-admin');
      lockForm.hidden = true;
      admBox.hidden = false;
      refreshAdmin();
      adm.head.querySelector('.pcdc-x').focus();
    }

    /* --------------------------- admin UI --------------------------- */
    function buildAdmin() {
      const tpl = root.querySelector('.pcdc-adm-tpl');
      admBox.append(tpl.content.cloneNode(true));
      adm = {
        head: admBox.querySelector('.pcdc-adm-head'),
        server: admBox.querySelector('.pcdc-server'),
        tabs: [...admBox.querySelectorAll('.pcdc-tab')],
        panels: [...admBox.querySelectorAll('.pcdc-panel')],
        cardsView: admBox.querySelector('.pcdc-cards-view'),
        cardsList: admBox.querySelector('.pcdc-adm-cards'),
        editor: admBox.querySelector('.pcdc-editor'),
        setPanels: [...admBox.querySelectorAll('.pcdc-set')],
        qrPrev: null,                              // QR preview of the footer tab
        pubInfo: admBox.querySelector('.pcdc-pub-info'),
        versions: admBox.querySelector('.pcdc-versions'),
        ioState: admBox.querySelector('.pcdc-io-state'),
        state: admBox.querySelector('.pcdc-state'),
        publishBtn: admBox.querySelector('[data-publish]'),
        discardBtn: admBox.querySelector('[data-discard]'),
        editing: null,                             // id of the card in the editor
        force: false,                              // next publish overwrites a newer version
      };

      adm.tabs.forEach((t) => t.addEventListener('click', () => selectTab(t.dataset.tab)));
      admBox.querySelector('[data-add]').addEventListener('click', addCard);
      adm.publishBtn.addEventListener('click', publish);
      adm.discardBtn.addEventListener('click', discard);
      admBox.querySelector('[data-logout]').addEventListener('click', logout);
      admBox.querySelector('[data-export]').addEventListener('click', exportJson);
      admBox.querySelector('[data-copy-block]').addEventListener('click', copyForBlock);
      admBox.querySelector('[data-import]').addEventListener('click', importJson);
      admBox.querySelector('[data-from-block]').addEventListener('click', () => {
        work = clone(embedded);
        workBase = pub.rev;
        changed({ view: true });
        refreshAdmin();
        adm.ioState.textContent = 'Contenu du bloc Elementor chargé dans le brouillon : vérifiez, puis « Publier ».';
      });
      admBox.querySelector('[data-versions]').addEventListener('click', listVersions);
    }

    function selectTab(name) {
      adm.tabs.forEach((t) => t.setAttribute('aria-selected', String(t.dataset.tab === name)));
      adm.panels.forEach((p) => { p.hidden = p.dataset.panel !== name; });
      if (name === 'cards') { adm.editor.hidden = true; adm.cardsView.hidden = false; adm.editing = null; renderCardList(); }
      if (name === 'footer') exportPreviewSoon();
    }

    function refreshAdmin() {
      if (!adm) return;
      const labels = {
        online: 'Serveur connecté : les publications sont vues par tous',
        offline: 'Serveur injoignable : brouillon gardé sur cet appareil',
        error: 'Le serveur répond mal : brouillon gardé sur cet appareil',
        none: 'Pas de serveur (data-api vide) : exportez le JSON pour publier',
        unknown: 'Connexion au serveur...',
      };
      let state = serverState;
      let label = labels[serverState] || labels.unknown;
      if (!token && localMode) {
        state = 'local';
        label = !apiUrl ? labels.none
          : serverState === 'online' ? 'Mode local : reconnectez-vous pour publier'
            : 'Mode local : serveur injoignable, brouillon gardé sur cet appareil';
      }
      if (state === 'online' && serverOutdated()) {
        state = 'outdated';
        label = 'showcase.php sur le serveur est l’ancienne version : envoyez showcase/api/showcase.php (FileZilla), sinon la publication perdrait les réglages';
      }
      adm.server.dataset.state = state;
      adm.server.textContent = label;
      renderCardList();
      buildSettings();
      fillPublishInfo();
      updateState();
    }

    function updateState(message, kind) {
      if (!adm) return;
      const dirty = isDirty();
      adm.state.className = 'pcdc-state' + (kind ? ' is-' + kind : dirty ? ' is-dirty' : '');
      adm.state.textContent = '';
      if (message) {
        adm.state.append(h('strong', null, message));
      } else if (dirty) {
        adm.state.append(h('strong', null, 'Modifications non publiées'), ' · visibles sur cet appareil seulement');
      } else {
        adm.state.append(h('strong', null, 'À jour'), pub.rev ? ` · version en ligne n° ${pub.rev}` : ' · contenu du bloc');
      }
      adm.discardBtn.disabled = !dirty;
      adm.publishBtn.disabled = !dirty || !token || serverOutdated();
      adm.publishBtn.title = !token ? 'Connexion au serveur nécessaire'
        : serverOutdated() ? 'Mettez d’abord à jour showcase.php sur le serveur' : '';
      adm.publishBtn.textContent = adm.force ? 'Publier quand même' : 'Publier';
    }

    /* ---- Cards tab: list ---- */
    function thumbFor(p) {
      const t = h('span', { class: 'pcdc-thumb' + (p.icon ? ' is-icon' : '') });
      t.style.setProperty('--pcdc-accent', p.accent);
      const src = p.icon || (p.media ? (p.media.type === 'video' ? p.media.poster : p.media.src) : '');
      if (src) t.append(h('img', { src: resolve(src), alt: '' }));
      else t.textContent = p.title.charAt(0) || '?';
      return t;
    }
    function renderCardList() {
      if (!adm || adm.editing) return;
      const list = adm.cardsList;
      list.textContent = '';
      work.projects.forEach((p, i) => {
        const up = h('button', { type: 'button', class: 'pcdc-ico', 'aria-label': `Monter ${p.title}`, disabled: i === 0 });
        up.innerHTML = ICONS.up;
        up.addEventListener('click', () => moveCard(i, -1));
        const down = h('button', { type: 'button', class: 'pcdc-ico', 'aria-label': `Descendre ${p.title}`, disabled: i === work.projects.length - 1 });
        down.innerHTML = ICONS.down;
        down.addEventListener('click', () => moveCard(i, 1));
        const eye = h('button', { type: 'button', class: 'pcdc-ico', 'aria-label': p.hidden ? `Afficher ${p.title}` : `Masquer ${p.title}`, 'aria-pressed': String(!p.hidden) });
        eye.innerHTML = p.hidden ? ICONS.eyeOff : ICONS.eye;
        eye.addEventListener('click', () => { p.hidden = !p.hidden; changed(); renderCardList(); });
        const edit = h('button', { type: 'button', class: 'pcdc-ico', 'aria-label': `Modifier ${p.title}` });
        edit.innerHTML = ICONS.edit;
        edit.addEventListener('click', () => openEditor(p.id));
        const open = h('button', { type: 'button', class: 'pcdc-adm-open' },
          h('strong', null, p.title, p.hidden ? h('em', null, 'Masquée') : null),
          h('small', null, p.kind || (p.appUrl || p.articleUrl || 'Sans lien')));
        open.addEventListener('click', () => openEditor(p.id));
        list.append(h('div', { class: 'pcdc-adm-card' + (p.hidden ? ' is-hidden' : '') },
          thumbFor(p), open, h('div', { class: 'pcdc-adm-tools' }, up, down, eye, edit)));
      });
      if (!work.projects.length) list.append(h('p', { class: 'pcdc-dlg-note' }, 'Aucune carte. Ajoutez-en une.'));
    }
    function moveCard(i, d) {
      const j = i + d;
      if (j < 0 || j >= work.projects.length) return;
      const arr = work.projects;
      [arr[i], arr[j]] = [arr[j], arr[i]];
      changed();
      renderCardList();
    }
    function addCard() {
      const ids = new Set(work.projects.map((p) => p.id));
      let id = 'nouvelle-carte';
      for (let k = 2; ids.has(id); k++) id = `nouvelle-carte-${k}`;
      work.projects.push({
        id, title: 'Nouvelle carte', kind: '', badge: '', description: '', tags: [], icon: '', media: null,
        appUrl: '', articleUrl: '', cta: '', accent: '#1001ef', hidden: false,
      });
      changed({ focusId: id });
      openEditor(id);
    }

    /* ---- Cards tab: editor ---- */
    function field(label, input, help) {
      const id = 'pcdc-f-' + Math.random().toString(36).slice(2, 8);
      input.id = id;
      return [h('label', { class: 'pcdc-lbl', for: id }, ...label), input, help].filter(Boolean);
    }
    function openEditor(id) {
      const p = work.projects.find((x) => x.id === id);
      if (!p) return;
      adm.editing = id;
      adm.cardsView.hidden = true;
      const ed = adm.editor;
      ed.hidden = false;
      ed.textContent = '';

      const back = h('button', { type: 'button', class: 'pcdc-btn is-small' }, '← Toutes les cartes');
      back.addEventListener('click', () => { adm.editing = null; ed.hidden = true; adm.cardsView.hidden = false; renderCardList(); });
      const visible = h('input', { type: 'checkbox', class: 'pcdc-toggle', 'aria-label': 'Carte visible' });
      visible.checked = !p.hidden;
      visible.addEventListener('change', () => { p.hidden = !visible.checked; changed(); });
      ed.append(h('div', { class: 'pcdc-edit-head' }, back,
        h('label', { class: 'pcdc-inline' }, h('span', { class: 'pcdc-help' }, 'Visible'), visible)));

      const text = (name, value, max, attrs) => {
        const el = h('input', { class: 'pcdc-field', value, maxlength: max, ...(attrs || {}) });
        el.addEventListener('input', () => { p[name] = str(el.value, max); changed({ focusId: p.id }); });
        return el;
      };
      const urlInput = (name) => {
        const el = h('input', { class: 'pcdc-field', value: p[name], placeholder: 'https://...', inputmode: 'url' });
        const warn = h('p', { class: 'pcdc-warn', hidden: true }, 'Adresse ignorée : elle doit commencer par https:// ou http://');
        el.addEventListener('input', () => {
          p[name] = link(el.value);
          warn.hidden = !(el.value.trim() && !p[name]);
          changed({ focusId: p.id });
        });
        return [el, warn];
      };

      ed.append(...field(['Titre'], text('title', p.title, 80, { required: true })));
      ed.append(...field(['Catégorie ', h('small', null, '(ligne au-dessus du titre)')], text('kind', p.kind, 80)));
      // Badge: suggestions, but any short text is accepted.
      const badgeList = 'pcdc-badges-' + Math.random().toString(36).slice(2, 8);
      ed.append(...field(['Badge ', h('small', null, '(en haut à droite ; vide : Application ou Projet selon les liens)')],
        text('badge', p.badge, 40, { list: badgeList, placeholder: p.appUrl ? UI.badgeApp : UI.badgeProject })));
      ed.append(h('datalist', { id: badgeList },
        ...['Application', 'Expérience immersive', 'Exposition augmentée', 'Installation', 'Prototype', 'Projet'].map((v) => h('option', { value: v }))));
      const desc = h('textarea', { class: 'pcdc-field', maxlength: 600, rows: 3 });
      desc.value = p.description;
      desc.addEventListener('input', () => { p.description = str(desc.value, 600); changed({ focusId: p.id }); });
      ed.append(...field(['Description ', h('small', null, '(visible au survol)')], desc));
      const tagsIn = h('input', { class: 'pcdc-field', value: p.tags.join(', '), placeholder: '3D, WebXR, IA' });
      tagsIn.addEventListener('input', () => {
        p.tags = tagsIn.value.split(',').map((t) => str(t, 40)).filter(Boolean).slice(0, 10);
        changed({ focusId: p.id });
      });
      ed.append(...field(['Mots-clés ', h('small', null, '(séparés par des virgules)')], tagsIn));

      const [appIn, appWarn] = urlInput('appUrl');
      ed.append(...field(['Lien de l’application'], appIn, appWarn));
      const [artIn, artWarn] = urlInput('articleUrl');
      ed.append(...field(['Lien de l’article de présentation'], artIn, artWarn));
      ed.append(...field(['Texte du bouton ', h('small', null, '(vide : « Ouvrir l’application », ou « Découvrir le projet » sans application)')],
        text('cta', p.cta, 40, { placeholder: p.appUrl ? UI.openApp : UI.discover })));

      // Icon.
      ed.append(h('p', { class: 'pcdc-sec-title' }, 'Icône'));
      ed.append(mediaBox({
        kind: 'icon',
        accept: 'image/*',
        get: () => p.icon,
        set: (v) => { p.icon = v; },
        thumb: () => (p.icon ? { src: p.icon, contain: true } : null),
        help: 'Carré, 512 px conseillé (PNG ou WebP transparent). Sans icône, le média remplit le carré de verre.',
        onUploaded: (info) => { if (info.accent) p.accent = info.accent; },
        refresh: () => openEditor(p.id),
        focusId: p.id,
      }));

      // Featured media.
      ed.append(h('p', { class: 'pcdc-sec-title' }, 'Média à la une'));
      const typeSel = h('select', { class: 'pcdc-field', 'aria-label': 'Type de média' },
        h('option', { value: '' }, 'Aucun'), h('option', { value: 'image' }, 'Image'),
        h('option', { value: 'gif' }, 'GIF animé'), h('option', { value: 'video' }, 'Vidéo (MP4, WebM)'));
      typeSel.value = p.media ? p.media.type : '';
      typeSel.addEventListener('change', () => {
        if (!typeSel.value) p.media = null;
        else p.media = { type: typeSel.value, src: p.media ? p.media.src : '', poster: p.media ? p.media.poster : '' };
        changed({ focusId: p.id });
        openEditor(p.id);
      });
      ed.append(typeSel);
      if (p.media) {
        ed.append(mediaBox({
          kind: 'media',
          accept: p.media.type === 'video' ? 'video/mp4,video/webm' : p.media.type === 'gif' ? 'image/gif' : 'image/*',
          get: () => p.media.src,
          set: (v) => { p.media.src = v; },
          thumb: () => (p.media.type === 'video' ? (p.media.poster ? { src: p.media.poster } : null) : p.media.src ? { src: p.media.src } : null),
          help: p.media.type === 'video'
            ? 'MP4 court et léger, carré 1024 px conseillé. L’image d’aperçu est créée automatiquement au téléversement.'
            : p.media.type === 'gif' ? 'Un GIF lourd ralentit la tablette : préférez une vidéo MP4 quand c’est possible.'
              : 'Carré 1024 × 1024 conseillé. Les grandes images sont réduites automatiquement.',
          onUploaded: (info) => {
            if (info.poster) p.media.poster = info.poster;
            if (!p.icon && info.accent) p.accent = info.accent;
          },
          refresh: () => openEditor(p.id),
          focusId: p.id,
        }));
        if (p.media.type === 'video') {
          ed.append(mediaBox({
            kind: 'poster',
            accept: 'image/*',
            label: 'Image d’aperçu de la vidéo',
            get: () => p.media.poster,
            set: (v) => { p.media.poster = v; },
            thumb: () => (p.media.poster ? { src: p.media.poster } : null),
            help: 'Affichée avant la lecture et au dos de la carte.',
            refresh: () => openEditor(p.id),
            focusId: p.id,
          }));
        }
      }

      // Accent colour.
      ed.append(h('p', { class: 'pcdc-sec-title' }, 'Couleur d’accent'));
      const col = h('input', { type: 'color', class: 'pcdc-color', value: p.accent, 'aria-label': 'Couleur d’accent' });
      col.addEventListener('input', () => { p.accent = color(col.value); changed({ focusId: p.id }); });
      ed.append(h('div', { class: 'pcdc-inline' }, col, h('span', { class: 'pcdc-help' }, 'Teinte de la carte, reprise automatiquement de l’icône téléversée.')));

      // Delete: two steps, so a slip of the finger on the tablet costs nothing.
      const del = h('button', { type: 'button', class: 'pcdc-btn is-small' }, 'Supprimer cette carte');
      let armed = 0;
      del.addEventListener('click', () => {
        if (!armed) {
          del.textContent = 'Confirmer la suppression';
          del.classList.add('is-danger');
          armed = setTimeout(() => { armed = 0; del.textContent = 'Supprimer cette carte'; del.classList.remove('is-danger'); }, 4000);
          return;
        }
        clearTimeout(armed);
        work.projects = work.projects.filter((x) => x.id !== p.id);
        adm.editing = null;
        ed.hidden = true;
        adm.cardsView.hidden = false;
        changed();
        renderCardList();
        updateState(`« ${p.title} » supprimée du brouillon`, 'dirty');
      });
      ed.append(h('div', { class: 'pcdc-danger-zone' },
        h('span', { class: 'pcdc-help' }, 'La suppression reste annulable tant que rien n’est publié.'), del));

      changed({ focusId: p.id });
    }

    // One media slot: preview, path or URL, upload and remove buttons.
    function mediaBox(o) {
      const box = h('div', { class: 'pcdc-media-box' });
      const th = o.thumb();
      const t = h('span', { class: 'pcdc-thumb' + (th && th.contain ? ' is-icon' : '') });
      if (th) t.append(h('img', { src: resolve(th.src), alt: '' }));
      else t.textContent = '·';
      const input = h('input', { class: 'pcdc-field', value: o.get(), placeholder: 'uploads/fichier.webp ou https://...' });
      const warn = h('p', { class: 'pcdc-warn', hidden: true }, 'Chemin ignoré : un chemin du dossier média ou une adresse https://');
      input.addEventListener('input', () => {
        o.set(mediaPath(input.value));
        warn.hidden = !(input.value.trim() && !mediaPath(input.value));
        changed({ focusId: o.focusId });
      });
      input.addEventListener('change', () => o.refresh());
      const bar = h('div', { class: 'pcdc-progress', hidden: true }, h('i'));
      const up = h('button', { type: 'button', class: 'pcdc-btn is-small', disabled: !token }, 'Téléverser');
      if (!token) up.title = 'Connexion au serveur nécessaire';
      up.addEventListener('click', () => pickFile(o.accept, async (file) => {
        const info = await uploadMedia(file, o.kind, bar);
        if (!info) return;
        o.set(info.path);
        if (o.onUploaded) o.onUploaded(info);
        changed({ focusId: o.focusId });
        o.refresh();
      }));
      const rm = h('button', { type: 'button', class: 'pcdc-btn is-small is-quiet' }, 'Retirer');
      rm.addEventListener('click', () => { o.set(''); changed({ focusId: o.focusId }); o.refresh(); });
      box.append(t, h('div', null,
        o.label ? h('p', { class: 'pcdc-help' }, o.label) : null,
        input, warn,
        h('div', { class: 'pcdc-media-btns' }, up, rm),
        bar,
        h('p', { class: 'pcdc-help' }, o.help)));
      return box;
    }

    /* ---- Uploads ---- */
    // The file input and the download link are created INSIDE the dialog:
    // while a modal dialog is open, the rest of the page is inert.
    function pickFile(accept, cb) {
      const inp = h('input', { type: 'file', accept, hidden: true });
      inp.addEventListener('change', () => {
        const file = inp.files && inp.files[0];
        inp.remove();
        if (file) cb(file);
      });
      admBox.append(inp);
      inp.click();
    }
    function loadImage(file) {
      return new Promise((ok, ko) => {
        const url = URL.createObjectURL(file);
        const img = new Image();
        img.onload = () => ok({ img, url });
        img.onerror = () => { URL.revokeObjectURL(url); ko(new Error('image')); };
        img.src = url;
      });
    }
    const toBlob = (canvas, type, q) => new Promise((ok) => canvas.toBlob(ok, type, q));
    // Dominant saturated colour of a picture: the card accent.
    function accentOf(source) {
      const c = document.createElement('canvas');
      c.width = c.height = 48;
      const x = c.getContext('2d', { willReadFrequently: true });
      x.drawImage(source, 0, 0, 48, 48);
      const d = x.getImageData(0, 0, 48, 48).data;
      const buckets = new Map();
      for (let i = 0; i < d.length; i += 4) {
        const [r, g, b, a] = [d[i], d[i + 1], d[i + 2], d[i + 3]];
        if (a < 200) continue;
        const mx = Math.max(r, g, b), mn = Math.min(r, g, b);
        const sat = mx ? (mx - mn) / mx : 0;
        if (sat < 0.35 || mx < 77) continue;
        let hue = 0;
        if (mx !== mn) {
          if (mx === r) hue = ((g - b) / (mx - mn)) % 6; else if (mx === g) hue = (b - r) / (mx - mn) + 2; else hue = (r - g) / (mx - mn) + 4;
        }
        const k = Math.floor((((hue * 60) + 360) % 360) / 15);
        const e = buckets.get(k) || { w: 0, r: 0, g: 0, b: 0, n: 0 };
        e.w += sat * (mx / 255); e.r += r; e.g += g; e.b += b; e.n++;
        buckets.set(k, e);
      }
      let best = null;
      buckets.forEach((e) => { if (!best || e.w > best.w) best = e; });
      if (!best) return null;
      return '#' + [best.r, best.g, best.b].map((v) => Math.round(v / best.n).toString(16).padStart(2, '0')).join('');
    }
    // Images are reduced before upload (never enlarged): 512 px for an icon,
    // 1024 px for a featured image. GIFs and videos are sent untouched.
    async function prepareImage(file, kind) {
      const max = kind === 'icon' ? 512 : kind === 'logo' ? 480 : 1024;
      const { img, url } = await loadImage(file);
      try {
        const w0 = img.naturalWidth || 1024;
        const h0 = img.naturalHeight || 1024;
        const scale = Math.min(1, max / Math.max(w0, h0));
        const c = document.createElement('canvas');
        c.width = Math.max(1, Math.round(w0 * scale));
        c.height = Math.max(1, Math.round(h0 * scale));
        c.getContext('2d').drawImage(img, 0, 0, c.width, c.height);
        let accent = null;
        try { accent = accentOf(c); } catch (e) { /* unreadable canvas */ }
        const isSvg = file.type === 'image/svg+xml';
        const blob = await toBlob(c, 'image/webp', 0.86);
        const base = file.name.replace(/\.[^.]+$/, '') || 'image';
        if (!blob || (!isSvg && scale === 1 && blob.size >= file.size)) return { blob: file, name: file.name, accent };
        return { blob, name: base + (blob.type === 'image/webp' ? '.webp' : '.png'), accent };
      } finally {
        URL.revokeObjectURL(url);
      }
    }
    // First frame of a video, as its poster image.
    async function posterOf(file) {
      const url = URL.createObjectURL(file);
      try {
        const v = document.createElement('video');
        v.muted = true; v.playsInline = true; v.preload = 'auto'; v.src = url;
        await new Promise((ok, ko) => { v.onloadeddata = ok; v.onerror = ko; setTimeout(ko, 10000); });
        await new Promise((ok) => { v.onseeked = ok; v.currentTime = Math.min(1, (v.duration || 3) / 3); setTimeout(ok, 4000); });
        const scale = Math.min(1, 1024 / Math.max(v.videoWidth || 1, v.videoHeight || 1));
        const c = document.createElement('canvas');
        c.width = Math.max(1, Math.round((v.videoWidth || 640) * scale));
        c.height = Math.max(1, Math.round((v.videoHeight || 640) * scale));
        c.getContext('2d').drawImage(v, 0, 0, c.width, c.height);
        let accent = null;
        try { accent = accentOf(c); } catch (e) { /* ignore */ }
        const blob = await toBlob(c, 'image/webp', 0.82);
        return blob ? { blob, name: file.name.replace(/\.[^.]+$/, '') + '-apercu.webp', accent } : null;
      } catch (e) {
        return null;
      } finally {
        URL.revokeObjectURL(url);
      }
    }
    function sendFile(blob, name, onProgress) {
      return new Promise((resolveUp) => {
        const xhr = new XMLHttpRequest();
        xhr.open('POST', endpoint('upload'));
        xhr.setRequestHeader('X-PCDC-Token', token);
        xhr.responseType = 'json';
        xhr.upload.onprogress = (e) => { if (e.lengthComputable && onProgress) onProgress(e.loaded / e.total); };
        xhr.onload = () => resolveUp({ status: xhr.status, data: xhr.response });
        xhr.onerror = () => resolveUp({ status: 0, data: null });
        const fd = new FormData();
        fd.append('file', blob, name);
        xhr.send(fd);
      });
    }
    const UPLOAD_ERRORS = {
      0: 'Serveur injoignable.',
      401: 'Session expirée : saisissez à nouveau le code.',
      413: 'Fichier trop lourd pour le serveur.',
      415: 'Format refusé : PNG, JPEG, WebP, AVIF, GIF, MP4 ou WebM.',
    };
    async function uploadWithBar(file, kind, bar, onProgress) {
      if (!token) { updateState('Téléversement impossible sans connexion au serveur', 'error'); return null; }
      bar.hidden = false;
      const fill = bar.firstElementChild;
      fill.style.width = '0%';
      updateState('Téléversement de « ' + file.name + ' »...');
      let prepared = { blob: file, name: file.name, accent: null };
      if (/^image\/(png|jpeg|webp|avif|svg\+xml)$/.test(file.type)) {
        try { prepared = await prepareImage(file, kind); } catch (e) { /* send as is */ }
      }
      const res = await sendFile(prepared.blob, prepared.name, (r) => { fill.style.width = Math.round(r * 100) + '%'; if (onProgress) onProgress(r); });
      bar.hidden = true;
      if (res.status === 200 && res.data && res.data.ok) {
        updateState('Fichier téléversé : ' + res.data.path, 'ok');
        return { path: res.data.path, accent: prepared.accent };
      }
      if (res.status === 401) expireSession();
      updateState(UPLOAD_ERRORS[res.status] || `Échec du téléversement (erreur ${res.status}).`, 'error');
      return null;
    }
    async function uploadMedia(file, kind, bar) {
      const main = await uploadWithBar(file, kind, bar);
      if (!main) return null;
      const info = { path: main.path, accent: main.accent, poster: '' };
      if (kind === 'media' && file.type.startsWith('video/')) {
        const poster = await posterOf(file);
        if (poster) {
          const res = await sendFile(poster.blob, poster.name);
          if (res.status === 200 && res.data && res.data.ok) { info.poster = res.data.path; info.accent = poster.accent; }
        }
      }
      return info;
    }

    /* ---- Settings tabs (Affichage, En-tête, Pied de page) ----
       Built from descriptors: each control reads and writes ONE path of the
       working copy, and every value goes through the SPEC rule of that path
       before it is kept, exactly as the server will clean it. */
    const ruleAt = (path) => path.split('.').reduce((a, k) => (a ? a[k] : undefined), SPEC);
    const getAt = (path) => path.split('.').reduce((a, k) => (a ? a[k] : undefined), work);
    function setAt(path, raw, opts) {
      const keys = path.split('.');
      const last = keys.pop();
      keys.reduce((a, k) => a[k], work)[last] = cleanBy(ruleAt(path), raw);
      changed(opts);
    }
    const uid = () => 'pcdc-s-' + Math.random().toString(36).slice(2, 8);
    const labelled = (label, help) => h('span', null, label, help ? h('small', null, help) : null);

    function cToggle(path, label, help, opts) {
      const inp = h('input', { type: 'checkbox', class: 'pcdc-toggle' });
      inp.checked = getAt(path);
      inp.addEventListener('change', () => setAt(path, inp.checked, opts));
      return h('label', { class: 'pcdc-row' }, labelled(label, help), inp);
    }
    function cRange(path, label, o = {}) {
      const r = ruleAt(path);
      const inp = h('input', { type: 'range', min: r[1], max: o.max !== undefined ? o.max : r[2], step: o.step || 1, 'aria-label': label });
      inp.value = getAt(path);
      const out = h('output');
      const show = () => { const v = Number(inp.value); out.textContent = o.fmt ? o.fmt(v) : v + ' %'; };
      show();
      inp.addEventListener('input', () => { show(); setAt(path, Number(inp.value)); });
      return h('div', { class: 'pcdc-row is-wide' }, labelled(label, o.help), h('div', { class: 'pcdc-range' }, inp, out));
    }
    function cSeg(path, label, options, o = {}) {
      const name = uid();
      const seg = h('div', { class: 'pcdc-seg', role: 'radiogroup', 'aria-label': label });
      options.forEach(([v, text]) => {
        const inp = h('input', { type: 'radio', name, value: v });
        inp.checked = getAt(path) === v;
        inp.addEventListener('change', () => { if (!inp.checked) return; if (o.before) o.before(v); setAt(path, v, o.opts); });
        seg.append(h('label', null, inp, h('span', null, text)));
      });
      return h('div', { class: 'pcdc-row' }, labelled(label, o.help), seg);
    }
    function cText(path, label, o = {}) {
      const id = uid();
      const inp = h('input', { class: 'pcdc-field', id, maxlength: ruleAt(path)[1], placeholder: o.placeholder || null });
      inp.value = getAt(path);
      inp.addEventListener('input', () => setAt(path, inp.value));
      return [h('label', { class: 'pcdc-lbl', for: id }, label), inp];
    }
    function cUrl(path, label, o = {}) {
      const id = uid();
      const inp = h('input', { class: 'pcdc-field', id, placeholder: 'https://...', inputmode: 'url' });
      inp.value = getAt(path);
      const warn = h('p', { class: 'pcdc-warn', hidden: true }, 'Adresse ignorée : elle doit commencer par https:// ou http://');
      inp.addEventListener('input', () => {
        setAt(path, inp.value);
        warn.hidden = !(inp.value.trim() && !getAt(path));
        if (o.after) o.after();
      });
      return [h('label', { class: 'pcdc-lbl', for: id }, label, o.small ? h('small', null, ' ' + o.small) : null), inp, warn];
    }
    // Colour: "Auto" follows the theme (value ""), otherwise the colour picked.
    function cColor(path, label) {
      const cur = getAt(path);
      const auto = h('input', { type: 'checkbox' });
      auto.checked = !cur;
      const col = h('input', { type: 'color', class: 'pcdc-color', 'aria-label': label, disabled: !cur });
      col.value = cur || '#1001ef';
      auto.addEventListener('change', () => { col.disabled = auto.checked; setAt(path, auto.checked ? '' : col.value); });
      col.addEventListener('input', () => { if (!auto.checked) setAt(path, col.value); });
      return h('div', null, h('span', { class: 'pcdc-lbl' }, label),
        h('div', { class: 'pcdc-colorctl' }, h('label', null, auto, 'Auto (thème)'), col));
    }
    function cSelect(path, label, options) {
      const id = uid();
      const sel = h('select', { class: 'pcdc-field', id });
      options.forEach(([v, text]) => sel.append(h('option', { value: v }, text)));
      sel.value = getAt(path);
      sel.addEventListener('change', () => setAt(path, sel.value));
      return [h('label', { class: 'pcdc-lbl', for: id }, label), sel];
    }
    const fontOptions = (path, inherit) => ruleAt(path)[1].map((k) => [k, k ? fontLabel(k) : inherit]);
    const WEIGHT_NAMES = { '': 'Par défaut', 300: 'Light', 400: 'Regular', 500: 'Medium', 600: 'Semi-bold', 700: 'Bold', 800: 'Extra-bold', 900: 'Black' };
    // Size, font, weight and colour of one text element.
    const sizeOf = (k, label) => cRange(`els.${k}.size`, label || 'Taille', { step: 5 });
    const styleOf = (k) => [
      h('div', { class: 'pcdc-duo' },
        h('div', null, ...cSelect(`els.${k}.font`, 'Police', fontOptions(`els.${k}.font`, 'Police générale'))),
        h('div', null, ...cSelect(`els.${k}.weight`, 'Graisse', ruleAt(`els.${k}.weight`)[1].map((w) => [w, WEIGHT_NAMES[w]])))),
      cColor(`els.${k}.color`, 'Couleur'),
    ];

    // One folding box per element: name, on / off switch, details underneath.
    const openBoxes = new Set();                   // unfolded boxes survive a rebuild
    function box(key, title, o) {
      const wrap = h('div', { class: 'pcdc-el' });
      const bodyId = uid();
      const isOpen = openBoxes.has(key);
      const name = h('button', { type: 'button', class: 'pcdc-el-name', 'aria-expanded': String(isOpen), 'aria-controls': bodyId });
      name.innerHTML = CHEVRON;
      name.append(h('span', null, title, o.sub ? h('small', null, o.sub) : null));
      const body = h('div', { class: 'pcdc-el-body', id: bodyId, hidden: !isOpen });
      name.addEventListener('click', () => {
        const open = body.hidden;
        body.hidden = !open;
        name.setAttribute('aria-expanded', String(open));
        if (open) openBoxes.add(key); else openBoxes.delete(key);
        if (open && o.onOpen) o.onOpen();
      });
      const top = h('div', { class: 'pcdc-el-head' }, name);
      if (o.show) {
        const sw = h('input', { type: 'checkbox', class: 'pcdc-toggle', 'aria-label': 'Afficher : ' + title });
        sw.checked = getAt(o.show);
        wrap.classList.toggle('is-off', !sw.checked);
        sw.addEventListener('change', () => { wrap.classList.toggle('is-off', !sw.checked); setAt(o.show, sw.checked, o.opts); });
        top.append(sw);
      }
      body.append(...o.body().flat());
      wrap.append(top, body);
      return wrap;
    }
    const sec = (text) => h('p', { class: 'pcdc-sec-title' }, text);
    const intro = (text) => h('p', { class: 'pcdc-set-intro' }, text);

    const PANELS = {
      display: () => [
        sec('Sections'),
        intro('Chaque section s’allume et s’éteint séparément. Le détail de chaque élément se règle dans les onglets En-tête et Pied de page.'),
        part ? h('p', { class: 'pcdc-warn' }, part === 'footer'
          ? 'Ce bloc est le bandeau seul (data-part="footer") : il n’affiche que le pied de page, quels que soient ces réglages.'
          : 'Ce bloc affiche la vitrine sans pied de page (data-part="showcase").') : null,
        cToggle('layout.header', 'En-tête', 'Sur-titre, titre et sélecteur d’affichage'),
        cToggle('layout.showcase', 'Vitrine', 'Les cartes : carrousel, grille ou liste'),
        cToggle('layout.controls', 'Commandes du carrousel', 'Flèches, légende, points et aide'),
        cToggle('layout.footer', 'Pied de page', 'Éteint, « .dev » reste accessible, discret, en bas à gauche'),

        sec('Hauteur et espacements'),
        cRange('layout.height', 'Hauteur du bloc', { step: 5, fmt: (v) => (v ? v + ' % de l’écran' : 'selon le contenu'),
          help: 'À 100 %, la section remplit exactement l’écran, marges de la page comprises (tablette, plein écran F11), et les cartes prennent toute la hauteur libre' }),
        cRange('layout.offset', 'Position verticale du carrousel', { fmt: (v) => (v > 0 ? '+' : '') + v + ' %', help: 'Descend (+) ou remonte (−) l’anneau de cartes' }),
        cRange('layout.pad', 'Marges du bloc', { step: 5 }),
        cRange('layout.gapHead', 'Espace sous l’en-tête', { step: 5 }),
        cRange('layout.gapNav', 'Espace entre les cartes et les commandes', { step: 5 }),
        cRange('layout.gapFoot', 'Espace au-dessus du pied de page', { step: 5 }),

        sec('Apparence'),
        cSeg('settings.theme', 'Thème', [['auto', 'Auto'], ['dark', 'Sombre'], ['light', 'Clair']], { help: 'Auto suit le réglage du navigateur' }),
        cColor('settings.bg', 'Couleur de fond'),
        cToggle('layout.bgNone', 'Fond transparent', 'Le bloc laisse voir la page derrière lui (bandeau posé sur une image, une vidéo)'),
        ...cSelect('settings.font', 'Police générale', fontOptions('settings.font')),
        cRange('layout.text', 'Taille générale des textes', { step: 5, help: 'Tous les textes hors cartes. Chaque élément a en plus sa propre taille' }),

        sec('Cartes'),
        cSeg('settings.view', 'Affichage par défaut', [['carousel', 'Carrousel'], ['grid', 'Grille'], ['list', 'Liste']],
          { before: () => store.del(KEY.view), opts: { view: true } }),
        cRange('layout.cardSize', 'Taille des cartes du carrousel', { step: 5 }),
        cRange('layout.cardText', 'Taille des textes des cartes', { step: 5 }),
        cRange('settings.tile', 'Taille du carré', { fmt: (v) => v + ' % de la carte' }),
        cToggle('layout.cardNum', 'Numéro'),
        cToggle('layout.cardBadge', 'Badge', 'Application, Expérience immersive...'),
        cToggle('layout.cardKind', 'Catégorie', 'Ligne au-dessus du titre'),
        cToggle('layout.cardDesc', 'Description', 'Au survol ou au toucher'),
        cToggle('layout.cardTags', 'Mots-clés'),

        sec('Comportement'),
        cRange('settings.attract', 'Rotation automatique', { step: 5, max: 60, fmt: (v) => (v ? v + ' s' : 'désactivée'),
          help: 'Délai sans interaction avant que le carrousel tourne seul (salon)' }),
        cToggle('settings.wheel', 'Défilement latéral à la molette', 'Ordinateur : la molette fait tourner le carrousel au lieu de faire défiler la page'),
        cSeg('settings.cardLink', 'Clic sur la carte', [['app', 'Application'], ['article', 'Article']]),
        cToggle('settings.newTab', 'Liens dans un nouvel onglet'),
      ],

      header: () => [
        sec('En-tête'),
        cSeg('header.align', 'Alignement', [['left', 'À gauche'], ['center', 'Centré']]),
        box('kicker', 'Sur-titre', { show: 'els.kicker.show', body: () => [cText('header.kicker', 'Texte'), sizeOf('kicker'), styleOf('kicker')] }),
        box('title', 'Titre', { show: 'els.title.show', body: () => [cText('header.title', 'Texte'), sizeOf('title'), styleOf('title')] }),
        box('switch', 'Sélecteur d’affichage', { sub: 'Carrousel, Grille, Liste', show: 'settings.switch', opts: { view: true }, body: () => [sizeOf('switch')] }),
        sec('Commandes du carrousel'),
        box('arrows', 'Flèches', { show: 'els.arrows.show', body: () => [sizeOf('arrows')] }),
        box('caption', 'Légende', { sub: 'Numéro, titre et catégorie de la carte de face', show: 'els.caption.show', body: () => [sizeOf('caption'), styleOf('caption')] }),
        box('dots', 'Points', { show: 'els.dots.show', body: () => [sizeOf('dots')] }),
        box('hint', 'Aide', { sub: 'S’efface au premier geste', show: 'els.hint.show', body: () => [cText('header.hint', 'Texte'), sizeOf('hint'), styleOf('hint')] }),
      ],

      footer: () => [
        sec('Pied de page'),
        cToggle('layout.footLine', 'Filet de séparation', 'Ligne fine au-dessus du pied de page'),
        box('logo', 'Logo de marque', { sub: 'En bas à gauche', show: 'footer.logo', body: () => [sizeOf('logo', 'Hauteur'), logoCtl()] }),
        box('copy', 'Mention', { sub: '© PCdlabs...', show: 'els.copy.show', body: () => [cText('footer.copyText', 'Texte'), sizeOf('copy'), styleOf('copy')] }),
        box('site', 'Lien du site', { show: 'els.site.show', body: () => [cText('footer.siteText', 'Texte'), cUrl('footer.siteUrl', 'Adresse', { small: '(aussi celle du logo)' }), sizeOf('site'), styleOf('site')] }),
        box('slogan', 'Slogan', { sub: 'Au centre', show: 'footer.slogan', body: () => [
          cText('footer.sloganText', 'Texte'),
          cSeg('layout.sloganAnchor', 'Centré sur', [['screen', 'L’écran'], ['block', 'Le bloc']]),
          sizeOf('slogan'), styleOf('slogan')] }),
        box('sub', 'Sous-titre', { sub: 'Sous le slogan', show: 'els.sub.show', body: () => [cText('footer.subText', 'Texte'), sizeOf('sub'), styleOf('sub')] }),
        box('qr', 'QR code', { sub: 'À droite, dépasse du pied de page', show: 'footer.qr', body: () => [
          sizeOf('qr'),
          cUrl('footer.qrUrl', 'Adresse encodée', { small: '(vide : l’adresse de cette page)', after: qrPreview }),
          cText('footer.qrCaption', 'Légende'),
          (adm.qrPrev = h('div', { class: 'pcdc-qr-preview' }, h('div'), h('p')))] }),
        box('qrHint', 'Texte flottant avec flèche', { sub: 'Invite à flasher le QR code', show: 'els.qrHint.show', body: () => [
          cText('footer.qrHintText', 'Texte'), sizeOf('qrHint'), styleOf('qrHint')] }),
        sec('Bandeau pour la vidéo'),
        box('export', 'Exporter le bandeau', { sub: 'Image ou vidéo transparente, à poser sur une vidéo (Premiere Pro...)', onOpen: exportPreviewSoon, body: exportCtl }),
      ],
    };

    function buildSettings() {
      adm.setPanels.forEach((panel) => {
        panel.textContent = '';
        panel.append(...PANELS[panel.dataset.panel]().flat().filter(Boolean));
      });
      qrPreview();
    }

    // Logo: a path or an address, or a file sent to the server.
    function logoCtl() {
      const id = uid();
      const inp = h('input', { class: 'pcdc-field', id, placeholder: 'uploads/mon-logo.webp ou https://...' });
      inp.value = work.footer.logoUrl;
      const warn = h('p', { class: 'pcdc-warn', hidden: true }, 'Chemin ignoré : un chemin du dossier média ou une adresse https://');
      inp.addEventListener('input', () => {
        setAt('footer.logoUrl', inp.value);
        warn.hidden = !(inp.value.trim() && !work.footer.logoUrl);
      });
      const bar = h('div', { class: 'pcdc-progress', hidden: true }, h('i'));
      const up = h('button', { type: 'button', class: 'pcdc-btn is-small', disabled: !token }, 'Téléverser');
      if (!token) up.title = 'Connexion au serveur nécessaire';
      up.addEventListener('click', () => pickFile('image/*', async (file) => {
        const res = await uploadWithBar(file, 'logo', bar);
        if (!res) return;
        work.footer.logo = true;
        setAt('footer.logoUrl', res.path);
        buildSettings();
      }));
      return [h('label', { class: 'pcdc-lbl', for: id }, 'Logo personnalisé ', h('small', null, '(vide : logo Présent Composé design officiel)')),
        h('div', { class: 'pcdc-inline' }, inp, up), bar, warn];
    }

    function qrPreview() {
      const prev = adm.qrPrev;
      if (!prev) return;
      const text = work.footer.qrUrl || pageUrl();
      prev.firstElementChild.innerHTML = QR.svg(text);
      prev.lastElementChild.textContent = (work.footer.qrUrl ? '' : 'Cette page : ') + text;
    }

    /* ==================================================================
       BANNER EXPORT (footer tab)
       The footer is laid out again, off screen, at the width of the video
       frame; then it is painted on a canvas element by element: every word
       where the browser placed it, the logo, the QR code and the arrow as
       SVG images. The QR card and its note are painted at the exact point
       of their CSS animation (same keyframes, same easing), so an export of
       4.8 s, one float of the card, loops without a jump. Nothing is cut:
       the frame is checked against the card at every point of its float,
       shadow included.
       Output: transparent PNG, PNG sequence (ZIP), or a QuickTime MOV with
       the PNG codec and an alpha channel, read by Premiere Pro.
       ================================================================== */
    const LOOP_S = 4.8;                            // period of pcdc-qr-float (CSS)
    const EXPORT_FRAMES = {
      fhd: { w: 1920, h: 1080, layout: 1920 },
      uhd: { w: 3840, h: 2160, layout: 1920 },    // laid out as 1920, painted at twice the size
      vertical: { w: 1080, h: 1920, layout: 900 },
      strip: { w: 1920, h: 0, layout: 1920 },     // height of the banner only
    };
    const EXPORT_CHOICES = { format: ['mov', 'zip', 'png'], frame: Object.keys(EXPORT_FRAMES), fps: ['25', '30', '50'], loops: ['1', '2', '3'], theme: ['dark', 'light', 'page'], bg: ['none', 'theme'] };
    const exportOpts = { format: 'mov', frame: 'fhd', fps: '25', loops: '1', theme: 'dark', bg: 'none', text: 120, shadow: true };
    try {
      const saved = obj(JSON.parse(store.get('pcdc-export') || '{}'));
      for (const [k, list] of Object.entries(EXPORT_CHOICES)) exportOpts[k] = oneOf(saved[k], list, exportOpts[k]);
      exportOpts.text = int(saved.text, 60, 200, exportOpts.text);
      exportOpts.shadow = bool(saved.shadow, exportOpts.shadow);
    } catch (e) { /* defaults */ }
    const saveExport = () => store.set('pcdc-export', JSON.stringify(exportOpts));

    // CSS "ease-in-out", that is cubic-bezier(.42, 0, .58, 1), solved for x.
    function easeInOut(x) {
      const bx = (s) => 3 * 0.42 * s * (1 - s) * (1 - s) + 3 * 0.58 * s * s * (1 - s) + s * s * s;
      let lo = 0;
      let hi = 1;
      for (let i = 0; i < 40; i++) { const m = (lo + hi) / 2; if (bx(m) < x) lo = m; else hi = m; }
      const s = (lo + hi) / 2;
      return 3 * s * s * (1 - s) + s * s * s;
    }
    // pcdc-qr-float and pcdc-hint-float at a point p of the loop (0 to 1).
    function floatAt(p) {
      const q = ((p % 1) + 1) % 1;
      const e = easeInOut(q < 0.5 ? q * 2 : (q - 0.5) * 2);
      const k = q < 0.5 ? e : 1 - e;               // 0 at rest, 1 at the top of the float
      return { rot: -4 + 2 * k, ty: -7 * k, hx: -4 * k, hy: -6 * k };
    }

    const scratch = document.createElement('canvas').getContext('2d');
    const loadImg = (src, cors) => new Promise((ok, ko) => {
      const i = new Image();
      if (cors) i.crossOrigin = 'anonymous';
      i.onload = () => ok(i);
      i.onerror = () => ko(new Error('image'));
      i.src = src;
    });
    // An inline SVG of the page as an image, at the size it will be painted.
    function svgImage(svg, color, w, h) {
      const c = svg.cloneNode(true);
      c.setAttribute('width', String(Math.max(1, Math.round(w))));
      c.setAttribute('height', String(Math.max(1, Math.round(h))));
      c.setAttribute('color', color);              // "currentColor" inside the drawing
      c.removeAttribute('class');
      c.removeAttribute('style');
      return loadImg('data:image/svg+xml;charset=utf-8,' + encodeURIComponent(new XMLSerializer().serializeToString(c)));
    }
    // Every word of an element, where the browser placed it, with its font.
    function wordsOf(el, ref) {
      const out = [];
      const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
      for (let node = walk.nextNode(); node; node = walk.nextNode()) {
        const parent = node.parentElement;
        if (!parent || !parent.getClientRects().length) continue;
        const cs = getComputedStyle(parent);
        const font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
        scratch.font = font;
        const re = /\S+/g;
        for (let m = re.exec(node.data); m; m = re.exec(node.data)) {
          const r = document.createRange();
          r.setStart(node, m.index);
          r.setEnd(node, m.index + m[0].length);
          const b = r.getClientRects()[0];
          if (!b) continue;
          out.push({
            text: m[0], font, color: cs.color,
            spacing: cs.letterSpacing === 'normal' ? '0px' : cs.letterSpacing,
            x: b.left - ref.left, y: b.top - ref.top + scratch.measureText(m[0]).fontBoundingBoxAscent,
          });
        }
      }
      return out;
    }

    // Lays the footer out again at the width of the frame and measures it.
    async function exportStage() {
      const F = EXPORT_FRAMES[exportOpts.frame];
      const W = F.layout;
      const scale = F.w / W;
      const pad = Math.max(72, Math.round(W * 0.045));   // video "title safe" margin
      const u = Math.min(26, Math.max(13.5, 6 + 0.007 * (W - 2 * pad))) * (conf.layout.text / 100) * (exportOpts.text / 100);
      const box = document.createElement('section');
      box.className = root.className;
      box.classList.remove('is-fill', 'is-loading', 'is-dragging');
      box.dataset.theme = exportOpts.theme === 'page' ? root.dataset.theme : exportOpts.theme;
      box.setAttribute('aria-hidden', 'true');
      box.style.cssText = root.style.cssText;
      box.style.removeProperty('--pcdc-mid-shift');
      box.style.removeProperty('--pcdc-qr-band');
      box.style.setProperty('--pcdc-u', u.toFixed(3) + 'px');
      box.style.setProperty('--pcdc-pad', pad + 'px');
      Object.assign(box.style, { position: 'fixed', left: '-40000px', top: '0', width: W + 'px', minHeight: '0', background: 'none', overflow: 'visible', pointerEvents: 'none' });
      const f = foot.cloneNode(true);
      f.hidden = false;
      f.querySelectorAll('.pcdc-dev-link').forEach((e) => e.remove());
      const q = f.querySelector('.pcdc-qr');
      const hn = f.querySelector('.pcdc-qr-hint');
      [q, hn].forEach((e) => { e.style.animation = 'none'; e.style.transform = 'none'; });
      box.append(f);
      document.body.append(box);
      try {
        if (document.fonts && document.fonts.ready) await document.fonts.ready;
        const shown = (e) => Boolean(e) && e.getClientRects().length > 0;
        const ref = f.getBoundingClientRect();
        const rel = (r, o) => ({ x: r.left - o.left, y: r.top - o.top, w: r.width, h: r.height });
        const st = { scale, W: F.w, texts: [], images: [], warn: '' };
        const vars = getComputedStyle(box);
        st.bgColor = vars.getPropertyValue('--pcdc-bg').trim();
        const fcs = getComputedStyle(f);
        const lineW = parseFloat(fcs.borderTopWidth) || 0;
        if (lineW && !/,\s*0\)$/.test(fcs.borderTopColor) && fcs.borderTopColor !== 'transparent') st.line = { w: ref.width, h: lineW, color: fcs.borderTopColor };

        const brandBox = f.querySelector('.pcdc-brand');
        if (shown(brandBox)) {
          const img = brandBox.querySelector('.pcdc-brand-img');
          const svg = brandBox.querySelector('svg');
          if (shown(img) && img.getAttribute('src')) {
            try { st.images.push({ ...rel(img.getBoundingClientRect(), ref), img: await loadImg(img.src, true) }); } catch (e) {
              st.warn = 'Logo personnalisé non exporté : son serveur n’autorise pas la copie d’image (CORS).';
            }
          } else if (shown(svg)) {
            const r = svg.getBoundingClientRect();
            st.images.push({ ...rel(r, ref), img: await svgImage(svg, getComputedStyle(svg).color, r.width * scale * 2, r.height * scale * 2) });
          }
        }
        ['.pcdc-copy', '.pcdc-site', '.pcdc-slogan', '.pcdc-sub'].forEach((sel) => {
          const el = f.querySelector(sel);
          if (shown(el)) st.texts.push(...wordsOf(el, ref));
        });

        if (shown(q)) {
          const qRef = q.getBoundingClientRect();
          const qcs = getComputedStyle(q);
          const [ox, oy] = qcs.transformOrigin.split(' ').map(parseFloat);
          const Q = { ...rel(qRef, ref), ox, oy, radius: parseFloat(qcs.borderTopLeftRadius) || 0, bg: qcs.backgroundColor };
          const code = q.querySelector('.pcdc-qr-code svg');
          if (shown(code)) {
            const r = code.getBoundingClientRect();
            Q.code = { ...rel(r, qRef), img: await svgImage(code, '#000', r.width * scale * 3, r.height * scale * 3) };
          }
          const cap = q.querySelector('.pcdc-qr-cap');
          if (shown(cap) && cap.textContent.trim()) {
            Q.cap = { ...rel(cap.getBoundingClientRect(), qRef), radius: parseFloat(getComputedStyle(cap).borderBottomLeftRadius) || 0,
              c1: vars.getPropertyValue('--pcdc-blue').trim() || '#1001ef', c2: vars.getPropertyValue('--pcdc-blue-2').trim() || '#4a5bff', words: wordsOf(cap, qRef) };
          }
          st.qr = Q;
        }
        if (shown(hn)) {
          const hRef = hn.getBoundingClientRect();
          const H = { ...rel(hRef, ref), words: wordsOf(hn, hRef) };
          const arrow = hn.querySelector('svg');
          if (shown(arrow)) {
            const r = arrow.getBoundingClientRect();
            H.arrow = { ...rel(r, hRef), img: await svgImage(arrow, getComputedStyle(arrow).color, r.width * scale * 2, r.height * scale * 2) };
          }
          st.hint = H;
        }

        // Everything the banner can cover during the loop, shadows included.
        const ext = { top: 0, bottom: ref.height, left: 0, right: ref.width };
        // Margins: sideways, above, below (m alone: the same everywhere).
        const grow = (x, y, m, up = m, down = m) => {
          ext.top = Math.min(ext.top, y - up); ext.bottom = Math.max(ext.bottom, y + down);
          ext.left = Math.min(ext.left, x - m); ext.right = Math.max(ext.right, x + m);
        };
        // Margins cover the whole blur of the shadows (a canvas blur fades out
        // over about three times half its radius), so not even a shadow is cut.
        st.images.forEach((i) => { grow(i.x, i.y, 24); grow(i.x + i.w, i.y + i.h, 24); });
        st.texts.forEach((w) => grow(w.x, w.y, 40));
        if (st.hint) { grow(st.hint.x - 4, st.hint.y - 6, 24); grow(st.hint.x + st.hint.w, st.hint.y + st.hint.h, 24); }
        if (st.qr) {
          const Q = st.qr;
          for (let i = 0; i <= 48; i++) {
            const fa = floatAt(i / 48);
            const a = (fa.rot * Math.PI) / 180;
            const c = Math.cos(a);
            const s = Math.sin(a);
            [[0, 0], [Q.w, 0], [0, Q.h], [Q.w, Q.h]].forEach(([px, py]) => {
              const X = px - Q.ox;
              const Y = py - Q.oy + fa.ty;
              // Card shadows: drop shadow 22 px down with a 44 px blur, glow 40 px blur.
              grow(Q.x + Q.ox + X * c - Y * s, Q.y + Q.oy + X * s + Y * c, 64, 56, 76);
            });
          }
        }
        st.ext = ext;

        // Where the banner sits in the frame: at the bottom, with the video margin.
        const boxRect = box.getBoundingClientRect();
        const frameW = F.w / scale;
        st.ox = Math.min(ref.left - boxRect.left, frameW - ext.right);
        if (st.ox + ext.left < 0) throw new Error('too-wide');
        if (F.h) {
          const frameH = F.h / scale;
          st.oy = Math.min(frameH - pad - ref.height, frameH - ext.bottom);
          if (st.oy + ext.top < 0) throw new Error('too-tall');
          st.H = F.h;
        } else {
          st.oy = -ext.top;
          st.H = Math.ceil(((ext.bottom - ext.top) * scale) / 2) * 2;   // even: video tools prefer it
        }
        return st;
      } finally {
        box.remove();
      }
    }

    function roundedPath(ctx, x, y, w, h, rt, rb) {
      ctx.beginPath();
      ctx.moveTo(x + rt, y);
      ctx.arcTo(x + w, y, x + w, y + h, rt);
      ctx.arcTo(x + w, y + h, x, y + h, rb);
      ctx.arcTo(x, y + h, x, y, rb);
      ctx.arcTo(x, y, x + w, y, rt);
      ctx.closePath();
    }

    // One frame of the banner at the point p of the loop; k shrinks it (preview).
    function paintExport(ctx, st, p, k = 1) {
      const s = st.scale * k;
      ctx.setTransform(1, 0, 0, 1, 0, 0);
      ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
      if (exportOpts.bg === 'theme' && st.bgColor) { ctx.fillStyle = st.bgColor; ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); }
      ctx.setTransform(s, 0, 0, s, st.ox * s, st.oy * s);
      ctx.textBaseline = 'alphabetic';
      ctx.textAlign = 'left';
      // Shadows are in canvas pixels, whatever the transform: scaled by hand.
      const shade = (on) => {
        ctx.shadowColor = on ? 'rgba(0, 0, 0, .5)' : 'rgba(0, 0, 0, 0)';
        ctx.shadowBlur = on ? 12 * s : 0;
        ctx.shadowOffsetX = 0;
        ctx.shadowOffsetY = on ? 2 * s : 0;
      };
      const words = (list) => list.forEach((w) => {
        ctx.font = w.font;
        ctx.fillStyle = w.color;
        if ('letterSpacing' in ctx) ctx.letterSpacing = w.spacing;
        ctx.fillText(w.text, w.x, w.y);
      });
      shade(false);
      if (st.line) { ctx.fillStyle = st.line.color; ctx.fillRect(0, 0, st.line.w, st.line.h); }
      shade(exportOpts.shadow);
      st.images.forEach((i) => ctx.drawImage(i.img, i.x, i.y, i.w, i.h));
      words(st.texts);
      const fa = floatAt(p);
      if (st.hint) {
        const H = st.hint;
        ctx.save();
        ctx.translate(H.x + fa.hx, H.y + fa.hy);
        words(H.words);
        if (H.arrow) ctx.drawImage(H.arrow.img, H.arrow.x, H.arrow.y, H.arrow.w, H.arrow.h);
        ctx.restore();
      }
      shade(false);
      if (st.qr) {
        const Q = st.qr;
        const r = Q.radius;
        ctx.save();
        // Same transform as the CSS: rotate() then translateY(), around the bottom centre.
        ctx.translate(Q.x + Q.ox, Q.y + Q.oy);
        ctx.rotate((fa.rot * Math.PI) / 180);
        ctx.translate(0, fa.ty);
        ctx.translate(-Q.ox, -Q.oy);
        // Box shadows of the card (dark drop shadow, blue glow).
        ctx.fillStyle = '#fff';
        ctx.save();
        ctx.shadowColor = 'rgba(0, 0, 0, .7)'; ctx.shadowBlur = 44 * s; ctx.shadowOffsetY = 22 * s;
        roundedPath(ctx, 18, 18, Q.w - 36, Q.h - 36, Math.max(0, r - 18), Math.max(0, r - 18)); ctx.fill();
        ctx.shadowColor = 'rgba(74, 91, 255, .55)'; ctx.shadowBlur = 40 * s; ctx.shadowOffsetY = 0;
        roundedPath(ctx, 8, 8, Q.w - 16, Q.h - 16, Math.max(0, r - 8), Math.max(0, r - 8)); ctx.fill();
        ctx.restore();
        ctx.fillStyle = Q.bg;
        roundedPath(ctx, 0, 0, Q.w, Q.h, r, r); ctx.fill();
        ctx.strokeStyle = 'rgba(255, 255, 255, .5)'; ctx.lineWidth = 1;
        roundedPath(ctx, -0.5, -0.5, Q.w + 1, Q.h + 1, r + 0.5, r + 0.5); ctx.stroke();
        if (Q.code) ctx.drawImage(Q.code.img, Q.code.x, Q.code.y, Q.code.w, Q.code.h);
        if (Q.cap) {
          const C = Q.cap;
          const a = (135 * Math.PI) / 180;         // linear-gradient(135deg, ...)
          const dx = Math.sin(a);
          const dy = -Math.cos(a);
          const len = Math.abs(C.w * dx) + Math.abs(C.h * dy);
          const cx = C.x + C.w / 2;
          const cy = C.y + C.h / 2;
          const g = ctx.createLinearGradient(cx - (dx * len) / 2, cy - (dy * len) / 2, cx + (dx * len) / 2, cy + (dy * len) / 2);
          g.addColorStop(0, C.c1);
          g.addColorStop(1, C.c2);
          ctx.fillStyle = g;
          roundedPath(ctx, C.x, C.y, C.w, C.h, 0, C.radius); ctx.fill();
          words(C.words);
        }
        ctx.restore();
      }
    }

    /* ---- file writers ---- */
    const CRC_TABLE = (() => {
      const tb = new Uint32Array(256);
      for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; tb[n] = c >>> 0; }
      return tb;
    })();
    function crc32(bytes) {
      let c = 0xffffffff;
      for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
      return (c ^ 0xffffffff) >>> 0;
    }
    // ZIP archive, files stored as they are (PNG is already compressed).
    async function zipStore(entries) {
      const enc = new TextEncoder();
      const now = new Date();
      const time = (now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1);
      const date = ((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate();
      const parts = [];
      const central = [];
      let offset = 0;
      for (const [name, blob] of entries) {
        const crc = crc32(new Uint8Array(await blob.arrayBuffer()));
        const nm = enc.encode(name);
        const lh = new DataView(new ArrayBuffer(30));
        lh.setUint32(0, 0x04034b50, true); lh.setUint16(4, 20, true); lh.setUint16(6, 0x0800, true);
        lh.setUint16(8, 0, true); lh.setUint16(10, time, true); lh.setUint16(12, date, true);
        lh.setUint32(14, crc, true); lh.setUint32(18, blob.size, true); lh.setUint32(22, blob.size, true);
        lh.setUint16(26, nm.length, true); lh.setUint16(28, 0, true);
        parts.push(lh.buffer, nm, blob);
        const ch = new DataView(new ArrayBuffer(46));
        ch.setUint32(0, 0x02014b50, true); ch.setUint16(4, 20, true); ch.setUint16(6, 20, true); ch.setUint16(8, 0x0800, true);
        ch.setUint16(10, 0, true); ch.setUint16(12, time, true); ch.setUint16(14, date, true);
        ch.setUint32(16, crc, true); ch.setUint32(20, blob.size, true); ch.setUint32(24, blob.size, true);
        ch.setUint16(28, nm.length, true); ch.setUint32(42, offset, true);
        central.push(ch.buffer, nm);
        offset += 30 + nm.length + blob.size;
      }
      const cdSize = central.reduce((n, b) => n + b.byteLength, 0);
      const end = new DataView(new ArrayBuffer(22));
      end.setUint32(0, 0x06054b50, true); end.setUint16(8, entries.length, true); end.setUint16(10, entries.length, true);
      end.setUint32(12, cdSize, true); end.setUint32(16, offset, true);
      return new Blob([...parts, ...central, end.buffer], { type: 'application/zip' });
    }
    // QuickTime movie, one PNG per frame ("PNG" codec, 32 bits: with alpha).
    function movPng(frames, w, h, fps) {
      const enc = new TextEncoder();
      const cat = (arrs) => {
        const out = new Uint8Array(arrs.reduce((n, a) => n + a.length, 0));
        let at = 0;
        arrs.forEach((a) => { out.set(a, at); at += a.length; });
        return out;
      };
      const u8 = (...v) => new Uint8Array(v);
      const u16 = (v) => u8((v >>> 8) & 255, v & 255);
      const u32 = (v) => u8((v >>> 24) & 255, (v >>> 16) & 255, (v >>> 8) & 255, v & 255);
      const cc = (s) => enc.encode(s);
      const zeros = (n) => new Uint8Array(n);
      const atom = (type, ...body) => { const b = cat(body); return cat([u32(8 + b.length), cc(type), b]); };
      const fullAtom = (type, flags, ...body) => atom(type, u32(flags & 0xffffff), ...body);   // version 0
      const pascal = (s, size) => { const b = zeros(size); const x = enc.encode(s).slice(0, size - 1); b[0] = x.length; b.set(x, 1); return b; };
      const matrix = cat([u32(0x10000), u32(0), u32(0), u32(0), u32(0x10000), u32(0), u32(0), u32(0), u32(0x40000000)]);
      const n = frames.length;
      const sizes = frames.map((b) => b.size);
      const total = sizes.reduce((a, b) => a + b, 0);
      if (total + 8 > 0xffffffff) throw new Error('Vidéo trop lourde : choisissez moins d’images ou un cadre plus petit.');
      const ftyp = atom('ftyp', cc('qt  '), u32(0x20050300), cc('qt  '));
      let at = ftyp.length + 8;
      const offsets = sizes.map((sz) => { const o = at; at += sz; return o; });
      const movieDur = Math.round((n * 1000) / fps);
      const mvhd = fullAtom('mvhd', 0, u32(0), u32(0), u32(1000), u32(movieDur), u32(0x10000), u16(0x100), zeros(10), matrix,
        u32(0), u32(0), u32(0), u32(0), u32(0), u32(0), u32(2));
      const tkhd = fullAtom('tkhd', 3, u32(0), u32(0), u32(1), u32(0), u32(movieDur), zeros(8), u16(0), u16(0), u16(0), u16(0), matrix,
        u32(w * 65536), u32(h * 65536));
      const mdhd = fullAtom('mdhd', 0, u32(0), u32(0), u32(fps * 100), u32(n * 100), u16(0), u16(0));
      const hdlrM = fullAtom('hdlr', 0, cc('mhlr'), cc('vide'), u32(0), u32(0), u32(0), pascal('VideoHandler', 13));
      const vmhd = fullAtom('vmhd', 1, u16(0x40), u16(0x8000), u16(0x8000), u16(0x8000));
      const hdlrD = fullAtom('hdlr', 0, cc('dhlr'), cc('alis'), u32(0), u32(0), u32(0), pascal('DataHandler', 12));
      const dinf = atom('dinf', fullAtom('dref', 0, u32(1), fullAtom('alis', 1)));
      const stsd = fullAtom('stsd', 0, u32(1), atom('png ', zeros(6), u16(1), u16(0), u16(0), u32(0), u32(0), u32(0x400),
        u16(w), u16(h), u32(0x480000), u32(0x480000), u32(0), u16(1), pascal('PNG', 32), u16(32), u16(0xffff)));
      const stts = fullAtom('stts', 0, u32(1), u32(n), u32(100));
      const stsc = fullAtom('stsc', 0, u32(1), u32(1), u32(1), u32(1));
      const stsz = fullAtom('stsz', 0, u32(0), u32(n), ...sizes.map(u32));
      const stco = fullAtom('stco', 0, u32(n), ...offsets.map(u32));
      const moov = atom('moov', mvhd, atom('trak', tkhd, atom('mdia', mdhd, hdlrM,
        atom('minf', vmhd, hdlrD, dinf, atom('stbl', stsd, stts, stsc, stsz, stco)))));
      return new Blob([ftyp, u32(total + 8), cc('mdat'), ...frames, moov], { type: 'video/quicktime' });
    }
    function exportReadme(st, fps, n) {
      return [
        'Bandeau PCdlabs : séquence PNG transparente',
        `${n} images, ${fps} images par seconde, ${(n / fps).toFixed(1).replace('.', ',')} s (boucle sans raccord de 4,8 s)`,
        `Cadre : ${st.W} x ${st.H} pixels`,
        '',
        'Premiere Pro : Fichier > Importer, choisir bandeau_0001.png et cocher « Séquence d’images ».',
        `Si la cadence n’est pas de ${fps} images/s : clic droit sur le média > Modifier > Interpréter le métrage.`,
        'Poser le média sur une piste au-dessus de la vidéo : la transparence est conservée.',
        '',
      ].join('\r\n');
    }

    /* ---- the export box ---- */
    let exportBusy = false;
    let exportCancel = false;
    let exportPrev = null;                         // { cv, ctx, st, k, raf }
    let exportPrevTimer = 0;
    function exportPreviewSoon() {
      clearTimeout(exportPrevTimer);
      exportPrevTimer = setTimeout(async () => {
        const P = exportPrev;
        if (!P || !P.cv.isConnected || !P.cv.offsetParent) return;
        try {
          const st = await exportStage();
          P.st = st;
          P.k = Math.min(1, 520 / st.W);
          P.cv.width = Math.round(st.W * P.k);
          P.cv.height = Math.max(1, Math.round(st.H * P.k));
          P.cv.parentElement.classList.toggle('is-light', (exportOpts.theme === 'page' ? root.dataset.theme : exportOpts.theme) === 'light');
          P.note.textContent = `${st.W} × ${st.H} px` + (st.warn ? ' · ' + st.warn : '');
          if (!P.raf) P.raf = requestAnimationFrame(previewLoop);
        } catch (e) {
          P.note.textContent = e.message === 'too-tall' ? 'Le bandeau est plus haut que ce cadre : réduisez les tailles ou choisissez « Bandeau seul ».'
            : e.message === 'too-wide' ? 'Le bandeau est plus large que ce cadre : réduisez la taille des textes ou du QR code.' : 'Aperçu impossible.';
        }
      }, 250);
    }
    function previewLoop() {
      const P = exportPrev;
      if (!P || !P.st || !P.cv.isConnected || !P.cv.offsetParent) { if (P) P.raf = 0; return; }
      const moving = exportOpts.format !== 'png' && !reduceMotion.matches;
      paintExport(P.ctx, P.st, moving ? (performance.now() / 1000 / LOOP_S) % 1 : 0, P.k);
      P.raf = requestAnimationFrame(previewLoop);
    }

    function exportCtl() {
      const choice = (key, label, options, help) => {
        const name = uid();
        const seg = h('div', { class: 'pcdc-seg', role: 'radiogroup', 'aria-label': label });
        options.forEach(([v, text]) => {
          const inp = h('input', { type: 'radio', name, value: v });
          inp.checked = exportOpts[key] === v;
          inp.addEventListener('change', () => { if (!inp.checked) return; exportOpts[key] = v; saveExport(); exportPreviewSoon(); });
          seg.append(h('label', null, inp, h('span', null, text)));
        });
        return h('div', { class: 'pcdc-row is-wide is-choice' }, labelled(label, help), seg);
      };
      const size = h('input', { type: 'range', min: 60, max: 200, step: 5, value: exportOpts.text, 'aria-label': 'Taille des textes' });
      const sizeOut = h('output', null, exportOpts.text + ' %');
      size.addEventListener('input', () => { exportOpts.text = int(size.value, 60, 200, 120); sizeOut.textContent = exportOpts.text + ' %'; saveExport(); exportPreviewSoon(); });
      const shadow = h('input', { type: 'checkbox', class: 'pcdc-toggle' });
      shadow.checked = exportOpts.shadow;
      shadow.addEventListener('change', () => { exportOpts.shadow = shadow.checked; saveExport(); exportPreviewSoon(); });
      const cv = h('canvas', { class: 'pcdc-exp-canvas', width: 520, height: 80 });
      const note = h('p', { class: 'pcdc-help' });
      exportPrev = { cv, ctx: cv.getContext('2d'), st: null, k: 1, raf: 0, note };
      const go = h('button', { type: 'button', class: 'pcdc-btn is-primary' }, 'Exporter');
      const bar = h('div', { class: 'pcdc-progress', hidden: true }, h('i'));
      const state = h('p', { class: 'pcdc-help', 'aria-live': 'polite' });
      go.addEventListener('click', () => runExport({ go, bar, fill: bar.firstElementChild, state }));
      return [
        h('p', { class: 'pcdc-help' }, 'Le pied de page seul, sur fond transparent, à poser sur une vidéo. Le QR code flotte comme sur la page ; le cadre le contient toujours en entier, ombre comprise.'),
        choice('format', 'Format', [['mov', 'Vidéo MOV'], ['zip', 'Séquence PNG'], ['png', 'Image PNG']],
          'MOV : codec PNG avec transparence, à glisser dans Premiere Pro. Séquence PNG : lue par tous les logiciels. Image : le bandeau fixe'),
        choice('frame', 'Cadre', [['fhd', '1920 × 1080'], ['uhd', '4K'], ['vertical', '1080 × 1920'], ['strip', 'Bandeau seul']],
          'Plein cadre : le bandeau est déjà en bas, il suffit de le poser sur la piste au-dessus'),
        choice('fps', 'Images par seconde', [['25', '25'], ['30', '30'], ['50', '50']], 'La cadence de votre séquence Premiere'),
        choice('loops', 'Durée', [['1', '4,8 s'], ['2', '9,6 s'], ['3', '14,4 s']], 'Boucle sans raccord : dupliquez-la bout à bout pour une vidéo plus longue'),
        choice('theme', 'Couleurs', [['dark', 'Textes clairs'], ['light', 'Textes foncés'], ['page', 'Comme la page']]),
        choice('bg', 'Fond', [['none', 'Transparent'], ['theme', 'Couleur du thème']]),
        h('div', { class: 'pcdc-row is-wide' }, labelled('Taille des textes', 'En plus des tailles réglées pour la page'), h('div', { class: 'pcdc-range' }, size, sizeOut)),
        h('label', { class: 'pcdc-row' }, labelled('Ombre sous les textes', 'Lisibles sur une image claire comme sombre'), shadow),
        h('div', { class: 'pcdc-exp-prev' }, cv),
        note,
        h('div', { class: 'pcdc-dlg-actions is-start' }, go),
        bar,
        state,
      ];
    }

    async function runExport(ui) {
      if (exportBusy) { exportCancel = true; return; }
      exportBusy = true;
      exportCancel = false;
      ui.go.textContent = 'Annuler';
      ui.bar.hidden = false;
      ui.fill.style.width = '0%';
      ui.state.textContent = 'Préparation...';
      try {
        const st = await exportStage();
        const cv = document.createElement('canvas');
        cv.width = st.W;
        cv.height = st.H;
        const ctx = cv.getContext('2d');
        const base = `bandeau-${st.W}x${st.H}`;
        if (exportOpts.format === 'png') {
          paintExport(ctx, st, 0);
          const png = await toBlob(cv, 'image/png');
          download(base + '.png', png);
          ui.fill.style.width = '100%';
          ui.state.textContent = `Image PNG prête : ${st.W} × ${st.H} px.` + (st.warn ? ' ' + st.warn : '');
          return;
        }
        const fps = Number(exportOpts.fps);
        const n = Math.round(LOOP_S * Number(exportOpts.loops) * fps);
        const frames = [];
        for (let i = 0; i < n; i++) {
          if (exportCancel) { ui.state.textContent = 'Export annulé.'; return; }
          paintExport(ctx, st, i / (LOOP_S * fps));
          const b = await toBlob(cv, 'image/png');
          if (!b) throw new Error('image');
          frames.push(b);
          ui.fill.style.width = Math.round(((i + 1) / n) * 92) + '%';
          ui.state.textContent = `Image ${i + 1} sur ${n}...`;
        }
        ui.state.textContent = 'Assemblage du fichier...';
        const name = `${base}-${fps}ips`;
        const out = exportOpts.format === 'zip'
          ? await zipStore(frames.map((b, i) => [`bandeau_${String(i + 1).padStart(4, '0')}.png`, b])
            .concat([['LISEZMOI.txt', new Blob([exportReadme(st, fps, n)], { type: 'text/plain' })]]))
          : movPng(frames, st.W, st.H, fps);
        download(name + (exportOpts.format === 'zip' ? '.zip' : '.mov'), out);
        ui.fill.style.width = '100%';
        ui.state.textContent = `${exportOpts.format === 'zip' ? 'Séquence PNG' : 'Vidéo MOV'} prête : ${n} images, ${(out.size / 1048576).toFixed(1).replace('.', ',')} Mo.` + (st.warn ? ' ' + st.warn : '');
      } catch (e) {
        ui.state.textContent = e && e.message === 'too-tall' ? 'Le bandeau est plus haut que ce cadre : réduisez les tailles ou choisissez « Bandeau seul ».'
          : e && e.message === 'too-wide' ? 'Le bandeau est plus large que ce cadre : réduisez la taille des textes ou du QR code.'
            : 'Export impossible : ' + ((e && e.message) || e);
      } finally {
        exportBusy = false;
        ui.go.textContent = 'Exporter';
        setTimeout(() => { ui.bar.hidden = true; }, 1500);
      }
    }

    /* ---- Publication tab ---- */
    function fillPublishInfo() {
      const when = (iso) => { try { return new Date(iso).toLocaleString('fr-FR', { dateStyle: 'medium', timeStyle: 'short' }); } catch (e) { return iso; } };
      if (!apiUrl) adm.pubInfo.textContent = 'Aucun serveur configuré : la vitrine affiche le contenu intégré au bloc.';
      else if (pub.rev) adm.pubInfo.textContent = `Version n° ${pub.rev}, publiée le ${when(pub.updatedAt)}.`;
      else adm.pubInfo.textContent = serverState === 'online'
        ? 'Rien n’est encore publié : les visiteurs voient le contenu intégré au bloc.'
        : 'Serveur non joint : version en ligne inconnue.';
    }
    async function listVersions() {
      const box = adm.versions;
      box.hidden = false;
      box.textContent = '';
      if (!token) { box.append(h('p', { class: 'pcdc-help' }, 'Connexion au serveur nécessaire.')); return; }
      box.append(h('p', { class: 'pcdc-help' }, 'Chargement...'));
      const res = await api('versions');
      box.textContent = '';
      if (res.status === 401) { expireSession(); return; }
      const list = res.data && res.data.ok ? res.data.versions : null;
      if (!list || !list.length) { box.append(h('p', { class: 'pcdc-help' }, 'Aucune version précédente.')); return; }
      list.forEach((v) => {
        const load = h('button', { type: 'button', class: 'pcdc-btn is-small' }, 'Charger');
        load.addEventListener('click', async () => {
          const r = await api('version', { extra: { name: v.name } });
          if (!(r.data && r.data.ok && r.data.config)) { updateState('Version illisible', 'error'); return; }
          work = cleanConfig(r.data.config);
          workBase = pub.rev;
          changed();
          refreshAdmin();
          updateState(`Version n° ${v.rev} chargée dans le brouillon : publiez pour la remettre en ligne`, 'dirty');
        });
        const d = new Date(v.updatedAt);
        box.append(h('div', { class: 'pcdc-version' },
          h('span', null, `n° ${v.rev} `, h('small', null, `${isNaN(d) ? '' : d.toLocaleString('fr-FR', { dateStyle: 'short', timeStyle: 'short' })} · ${v.cards} cartes`)),
          load));
      });
    }
    function download(name, data) {
      const blob = data instanceof Blob ? data : new Blob([data], { type: 'application/json' });
      const a = h('a', { href: URL.createObjectURL(blob), download: name, hidden: true });
      admBox.append(a);
      a.click();
      setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 60000);
    }
    const exportText = () => JSON.stringify({ ...work, rev: pub.rev, updatedAt: pub.updatedAt }, null, 2);
    function exportJson() {
      download(`tourniquet-pcdlabs-${new Date().toISOString().slice(0, 10)}.json`, exportText());
      adm.ioState.textContent = 'Fichier JSON téléchargé.';
    }
    async function copyForBlock() {
      const text = `<script type="application/json" class="pcdc-data">\n${exportText().replace(/<\//g, '<\\/')}\n<\/script>`;
      try {
        await navigator.clipboard.writeText(text);
        adm.ioState.textContent = 'Copié : remplacez la balise <script class="pcdc-data"> du bloc Elementor par ce texte.';
      } catch (e) {
        download('bloc-pcdc-data.txt', text);
        adm.ioState.textContent = 'Presse-papiers indisponible : le texte a été téléchargé.';
      }
    }
    function importJson() {
      pickFile('application/json,.json', async (file) => {
        try {
          const data = cleanConfig(JSON.parse(await file.text()));
          work = data;
          workBase = pub.rev;
          changed({ view: true });
          refreshAdmin();
          adm.ioState.textContent = `« ${file.name} » importé : ${data.projects.length} cartes dans le brouillon.`;
        } catch (e) {
          adm.ioState.textContent = 'Fichier illisible : ce n’est pas un JSON de la vitrine.';
        }
      });
    }

    /* ---- Publish, discard, leave ---- */
    async function publish() {
      if (!token || !isDirty()) return;
      if (serverOutdated()) { refreshAdmin(); updateState('Publication refusée : showcase.php doit d’abord être mis à jour sur le serveur', 'error'); return; }
      adm.publishBtn.disabled = true;
      updateState('Publication...');
      const res = await api('publish', { method: 'POST', body: { config: work, baseRev: workBase, force: adm.force } });
      if (res.status === 200 && res.data && res.data.ok && res.data.config && !(res.data.config.schema >= 2 && res.data.config.layout)) {
        // An old showcase.php took the publication but kept only what it knows:
        // visitors now see that version, the full draft stays here.
        serverApi = 1;
        pub = cleanConfig(res.data.config);
        store.set(KEY.cache, JSON.stringify(pub));
        workBase = pub.rev;
        changed();
        refreshAdmin();
        updateState('Le serveur a enregistré une version incomplète (ancien showcase.php). Vos réglages sont gardés ici : mettez showcase.php à jour, puis publiez à nouveau.', 'error');
        return;
      }
      if (res.status === 200 && res.data && res.data.ok && res.data.config) {
        serverApi = Number(res.data.api) || serverApi;
        pub = cleanConfig(res.data.config);
        store.set(KEY.cache, JSON.stringify(pub));
        work = clone(pub);
        workBase = pub.rev;
        adm.force = false;
        store.del(KEY.draft);
        render(work);
        refreshAdmin();
        updateState(`Publié : version n° ${pub.rev}, visible par tous les visiteurs`, 'ok');
        return;
      }
      if (res.status === 401) { expireSession(); return; }
      if (res.status === 409) {
        adm.force = true;
        updateState(`Une version plus récente (n° ${res.data && res.data.rev}) a été publiée ailleurs. « Publier quand même » la remplacera.`, 'error');
        adm.publishBtn.disabled = false;
        adm.publishBtn.textContent = 'Publier quand même';
        return;
      }
      updateState(res.status === 0 ? 'Serveur injoignable : rien n’a été publié, le brouillon est gardé.' : `Publication refusée (erreur ${res.status}).`, 'error');
      adm.publishBtn.disabled = false;
    }
    function discard() {
      work = clone(pub);
      workBase = pub.rev;
      adm.force = false;
      store.del(KEY.draft);
      adm.editing = null;
      render(work);
      setView(work.settings.view, false);
      refreshAdmin();
      selectTab(adm.tabs.find((t) => t.getAttribute('aria-selected') === 'true').dataset.tab);
      updateState('Modifications annulées : retour à la version en ligne', 'ok');
    }
    function expireSession() {
      token = '';
      sess.del(KEY.token);
      showLock('Session expirée : saisissez à nouveau le code. Vos modifications sont gardées.');
    }
    function logout() {
      token = '';
      localMode = false;
      sess.del(KEY.token);
      sess.del(KEY.local);
      work = null;                                 // the draft stays saved for the next session
      dlg.close();
      render(pub);
      setView(pub.settings.view, false);
    }
  }
})();
</script>
</section>
				</div>
				</div>
				</article>
				</div>
		<p>Cet article <a href="https://presentcomposedesign.fr/applications/">Applications</a> est apparu en premier sur <a href="https://presentcomposedesign.fr">Présent Composé design</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>BirdSignal-infos</title>
		<link>https://presentcomposedesign.fr/birdsignal-infos/</link>
		
		<dc:creator><![CDATA[admin]]></dc:creator>
		<pubDate>Tue, 15 Sep 2026 11:23:50 +0000</pubDate>
				<category><![CDATA[Applications]]></category>
		<category><![CDATA[Design graphique]]></category>
		<category><![CDATA[Design produit]]></category>
		<category><![CDATA[PCdlabs]]></category>
		<guid isPermaLink="false">https://presentcomposedesign.fr/?p=36789</guid>

					<description><![CDATA[<p>Activer le son Étude de cas &#183; Application web sur mesure BirdSignal Une application web qui analyse le son pour identifier l&#8217;oiseau qui chante, et transforme chaque écoute en carte à collectionner géolocalisée. Imaginée, nommée, dessinée, développée, mise en ligne et hébergée en France. Idéation Naming Identité graphique UX / UI Développement 3D temps réel IA embarquée Hébergement Voir l&#8217;application Demander un rendez-vous Sans magasin d&#8217;applications Elle s&#8217;installe sur tous les téléphones, gratuitement BirdSignal est une application web. Elle s&#8217;ouvre dans un navigateur, puis s&#8217;installe d&#8217;un geste sur l&#8217;écran d&#8217;accueil : son icône, son écran de lancement, son fonctionnement hors ligne. Aucun magasin d&#8217;applications, aucun compte, aucun téléchargement payant. Une seule adresse, et le même produit sur Android, sur iPhone, sur ordinateur. Gratuit Android iPhone et iPad Windows et macOS Installée depuis le navigateur, elle se range sur le bureau comme un logiciel. Ouvrir et installer 6&#8239;522espèces 2langues 137tests auto 0octet audio envoyé Points clés Ce que j&#8217;ai réalisé Idéation, positionnement et naming de l&#8217;application Création du logo, du système d&#8217;icônes et de l&#8217;identité graphique complète Conception de l&#8217;expérience, des parcours et de l&#8217;interface, en deux langues Développement intégral, front et back, et intégration d&#8217;un modèle d&#8217;intelligence artificielle Rendu 3D temps réel des cartes, avec gyroscope sur mobile Mise en ligne, sécurisation, hébergement en France et documentation Le cœur de l&#8217;application Tout part d&#8217;un son L&#8217;application enregistre quelques secondes de chant, en construit le spectrogramme, et soumet cette image de fréquences à un modèle d&#8217;intelligence artificielle de 6522 espèces. Le tout dans le navigateur, en moins de temps qu&#8217;il n&#8217;en faut pour ranger son téléphone. Ce qui défile ci-dessous n&#8217;est pas une illustration : c&#8217;est l&#8217;analyse d&#8217;un vrai enregistrement, une rue de Toulouse à six heures du matin. Enregistrement réel 8,5 kHz4 kHz0,4 Le temps en abscisse, la fréquence en ordonnée, l&#8217;intensité en couleur. Chaque trait vertical est un oiseau ; la bande claire du bas est la ville qui se réveille. Activez le son pour entendre ce que vous voyez. 1. CapterMicro ou fichier importé, rééchantillonné à 48 kHz. 2. Voir le sonTransformée de Fourier, spectrogramme, découpage en segments de 3 secondes. 3. ReconnaîtreModèle BirdNET exécuté en WebAssembly, filtré par le lieu et la saison. Design produit Le son devient un objet Chaque analyse conservée devient une carte numérotée, avec sa photo, son spectrogramme, sa date et son lieu. Elle se retourne en 3D et s&#8217;incline au gyroscope, de sorte que le reflet suit le poignet comme sur une vraie carte. BirdSignal Corvidés Pie bavarde Pica pica chant &#183; Défense de territoire Rareté &#183; LC8 % Confiance91 % 13 sept. 2026, 07:4312,8 s &#183; localisé BirdSignal Détails EspècePie bavarde MoteurBirdNET Filtre local157 espèces Durée12,8 s Position43.6080, 1.4812 Spectrogramme ÉMISE PAR BirdSignalR_001presentcomposedesign.fr Or Les quatre rangs Le rang change la matière de la carte, et il se mérite. Passez la souris sur les poinçons : ce sont exactement ceux frappés au verso des cartes. Uc Non classéeAucune espèce reconnue Sd StandardArgent Or AuthentiqueCaptée sur place, localisée Dd DiamantIrisé, le plus rare Le point fort Votre collection raconte vos sorties Chaque carte retient l&#8217;endroit où le chant a été capté. La collection devient une carte de vos promenades, et surtout une carte des espèces : on voit où elles sont, et où elles ne sont plus. Merle noir08:14 · LC Mésange charbonnière09:02 · LC Pie bavarde09:47 · LC Tourterelle turque10:21 · LC Représentation de principe. La position n&#8217;est enregistrée qu&#8217;avec l&#8217;accord de l&#8217;utilisateur, et elle est arrondie avant tout partage. Identité graphique Un signe qui se lit à 16 pixels Un oiseau et une lettre dans la même forme. Une seule couleur pour le signe : une identité qui a besoin d&#8217;un dégradé pour exister ne survit pas à une impression en noir. Le favicon, lui, existe en deux fichiers, parce qu&#8217;un gris clair disparaît sur une barre d&#8217;onglets blanche. Signe principal#1001ef sur blanc En réserveBlanc sur aplat de marque Icône d&#8217;applicationZone sûre du masque Android Thème clair Thème sombreFaviconDeux fichiers, un par thème d&#8217;onglet Logo de pied de pagePlaque blanche, signe bleu 48 32 16LisibilitéValidé à sa plus petite taille Méthode De l&#8217;idée à la mise en ligne 01 Idéation et positionnement Définir ce que l&#8217;application fait, ce qu&#8217;elle refuse de faire, et à qui elle s&#8217;adresse. 02 Naming Un nom qui tient dans une icône, se prononce en français comme en anglais, et dit quelque chose de vrai sur le produit. 03 Logo et identité graphique Du croquis au vectoriel, décliné en favicon, icônes d&#8217;application, thème clair et thème sombre. 04 Architecture et UX Parcours, écrans, arborescence, et une architecture choisie pour tenir sur un hébergement mutualisé français. 05 Développement Interface, API, traitement du signal audio, IA embarquée, rendu 3D temps réel et tests automatisés. 06 Mise en ligne et suivi Sécurisation, hébergement en France, application installable et hors ligne, documentation et mises à jour. Ce que cela veut dire pour vous Un seul interlocuteur, du premier croquis à la mise en ligne Application web sur mesure Outil métier, configurateur, application mobile installable. Conçue, développée et hébergée. Identité et design produit Naming, logo, système d&#8217;icônes, interface et déclinaisons, pensés pour tenir à toutes les tailles. 3D, AR et expériences immersives Configurateurs 3D temps réel, visualiseurs, réalité augmentée, directement dans le navigateur. Vous souhaitez faire appel à mes services ? Parlons de votre projet. J&#8217;interviens sur l&#8217;ensemble de la chaîne, ou sur la partie qui vous manque. Demander un rendez-vous Voir les autres réalisations BirdSignal est une application gratuite et non commerciale, conçue et hébergée en France par Present Composé Design, nom commercial de l&#8217;entreprise individuelle Alban DESBARAX, à Toulouse. Les hypothèses d&#8217;espèce produites sont probabilistes et ne constituent pas une détermination scientifique.</p>
<p>Cet article <a href="https://presentcomposedesign.fr/birdsignal-infos/">BirdSignal-infos</a> est apparu en premier sur <a href="https://presentcomposedesign.fr">Présent Composé design</a>.</p>
]]></description>
										<content:encoded><![CDATA[		<div data-elementor-type="wp-post" data-elementor-id="36789" class="elementor elementor-36789">
				<div class="elementor-element elementor-element-ba2361d e-con-full e-flex wpr-particle-no wpr-jarallax-no wpr-parallax-no wpr-sticky-section-no wpr-column-slider-no wpr-equal-height-no e-con e-parent" data-id="ba2361d" data-element_type="container" data-e-type="container">
				<div class="elementor-element elementor-element-3260263 elementor-widget__width-inherit elementor-widget elementor-widget-html" data-id="3260263" data-element_type="widget" data-e-type="widget" data-widget_type="html.default">
				<div class="elementor-widget-container">
					<!--
// ║  Birdsignal, © All rights reserved                                       ║
// ║  2026 · PCdlabs ·  Present Composé Design                                ║
// ║  Alban DESBARAX                                                          ║
// ║  360° Design & Creative Technologist - Toulouse, FRANCE                  ║
// ║                                                                          ║
// ║  · Expertise & Support 2D, 3D, AR, VR, XR, AI                            ║
// ║  · Product Design & Industrial Design                                    ║
// ║  · Digital communication                                                 ║
// ║  · Immersive experiences  -ᯅ- ✔️                                        ║
//                                                                            
//  · 🡺 https://presentcomposedesign.fr/      
//  · 🡺 https://pcdlabs.presentcomposedesign.fr/tools/birdsignal 

// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@..........::::::::::::::::::::::::::::::@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@.....::::::::::::::::::::::::::::::---@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@:.:::@@@@@@@@@@@@@@:..::::::::::::::::::::::::::::::-----@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@.:::::::::::::::::.: @@@@@@@@@@::::::::::::::::::::::::::::::-------@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@:::::::::::::::::::::::::::::::@@@@@@@@::::::::::::::::::::::::::::--------
// @@@@@@@@@@@@@@@@@@@@@@@@@@@:.:::::::::::::::::::::::::::::::::..@@@@@@@::::::::::::::::::::::::::--------
// @@@@@@@@@@@@@@@@@@@@@@@:..::::::::::::::::::::::::::::::::::::::..@@@@@@@::::::::::::::::::::::::--------
// @@@@@@@@@@@@@@@@@@@@@@.::::::::::::::::::::::::::::::::::::::::::::@@@@@@::::::::::::::::::::::::--------
// @@@@@@@@@@@@@@@@@@@ .:::::::::::::::::::::::::::::@@@@@:::::::::::::@@@@@@:::::::::::::::::::::::--------
// @@@@@@@@@@@@@@@@@@:::::::::::::::::::::::::::::::.@@@@@.::::::::::::::@@@@@::::::::::::::::::::::--------
// @@@@@@@@@@@@@@@@::::::::::::::::::::::::::::::::::@@@@@.:::::::::::::::.:@@@@@:::::::::::::::::::--------
// @@@@@@@@@@@@@@::::::::::::::::::::::::::::::::::::::::::::::::::::::::@@@@@@@@@@@@@@:::::::::::::--------
// @@@@@@@@@@@@@@.:::::::::::::::::::::::::::::::::::::::::::::::::::@@@@@@@@@@@@@@@@@@@@@@:::::::::--------
// @@@@@@@@@@@@-:::::::::::::::::::::::::::::::::::::::::::::::::@@@@@@@@@@@@@@@@@@@@@-:::::::::::::--------
// @@@@@@@@@@@ .:::::::::::::::::::::::::::::::::::::::::::::::@@@@@@@@@@@@@@@@@@:::::::::::::::::::--------
// @@@@@@@@@@@:::::::: ::::::::::::::::::::::::::::::::::::::-@@@@@@@@@@@@@@::::::::::::::::::::::::--------
// @@@@@@@@@@:::::::::.@@.::::::::::::::::::::::::::::::::: @@@@@@@@@@@:::::::::::::::::::::::::::::--------
// @@@@@@@@@..:::::::::@@@.:::::::::::::::::::::::::::::::@@@@@@@@@@:.::::::::::::::::::::::::::::::--------
// @@@@@@@@@.:::::::::::@@@@@@::::::::::::::::::::::::::::@@@@@@@@....::::::::::::::::::::::::::::::--------
// @@@@@@@@@:::::::::::.:@@@@@@@.::::::::::::::::::::::..@@@@@@@......::::::::::::::::::::::::::::::--------
// @@@@@@@@ :::::::::::::.@@@@@@@@@@@::::::::::::::::::.@@@@@@@.......::::::::::::::::::::::::::::::--------
// @@@@@@@@::::::::::::::::@@@@@@@@@@@@@..::::::::::::::@@@@@@........::::::::::::::::::::::::::::::--------
// @@@@@@@@::::::::::::::::.@@@@@@@@@@@@@@@@-::::::::::@@@@@@@........::::::::::::::::::::::::::::::--------
// @@@@@@@@:::::::::::::::::::@@@@@@@@@@@@@@@@@@@@:.:::@@@@@@@........::::::::::::::::::::::::::::::--------
// @@@@@@@@::::::::::::::::::::: @@@@@@@@@@@@@@@@@@@@@@@@@@@@@........::::::::::::::::::::::::::::::--------
// @@@@@@@@::::::::::::::::::::::::.@@@@@@@@@@@@@@@@@@@@@@@@@@........::::::::::::::::::::::::::::::--------
// @@@@@@@@::::::::::::::::::::::::::..@@@@@@@@@@@@@@@@@@@@@@@........::::::::::::::::::::::::::::::--------
// @@@@@@@@::::::::::::::::::::::::::::::.:@@@@@@@@@@@@@@@@@@@@:......::::::::::::::::::::::::::::::--------
// @@@@@@@@:::::::::::::::::::::::::::::::::::::@@@@@@@@@@@@@@@@@@@:..::::::::::::::::::::::::::::::--------
// @@@@@@@@::::::::::::::::::::::::::::::::::::::::.@@@@@@@@@@@@@@@@@@::::::::::::::::::::::::::::::--------
// @@@@@@@@:::::::::::::::::::::::::::::::::::::::::::::.@@@@@@@@@@@@@@@::::::::::::::::::::::::::::--------
// @@@@@@@@:::::::::::::::::::::::::::::::::::::::::::::::.:@@@@@@@@@@@@@@::::::::::::::::::::::::::--------
// @@@@@@@@:::::::::::::::::::::::::::::::::::::::::::::::::::@@@@@@@@@@@@@@::::::::::::::::::::::::--------
// @@@@@@@@::::::::::::::::::::::::::::::::::::::::::::::::::::.@@@@@@@@@@@@@:::::::::::::::::::::::--------
// @@@@@@@@:::::::::::::::::::::::::::::::::::::::::::::::::::::.:@@@@@@@@@@@@::::::::::::::::::::::--------
// @@@@@@@@:::::::::::::::::::::::::::::::::::::::::::::::::::::::.@@@@@@@@@@@@:::::::::::::::::::::--------
// @@@@@@@@::::::::::::::::::::::::::::::::::::::::::::::::::::::::@@@@@@@@@@@@@::::::::::::::::::::--------
// @@@@@@@@:::::::::::::::::::::::::::::::::::::::::::::::::::::::::@@@@@@@@@@@@::::::::::::::::::::--------
// @@@@@@@@:::::::::::::::::::::::::::::::::::::::::::::::::::::::::@@@@@@@@@@@@@:::::::::::::::::::--------
// @@@@@@@@::::::::::::::::::::::::::::::::::::::::::::::::::::::::::@@@@@@@@@@@@:::::::::::::::::::--------
// @@@@@@@@:::::::::::::::::::::::::::::::::::::::::::::::::::::::::@@@@@@@@@@@@@:::::::::::::::::::--------
// @@@@@@@@:::::::::::::::::::::::::::::::::::::::::::::::::::::::::@@@@@@@@@@@@@:::::::::::::::::::--------
// @@@@@@@@::::::::::::::::::::::::::::::::::::::::::::::::::::::::@@@@@@@@@@@@@::::::::::::::::::::--------
// @@@@@@@@:::::::::::::::::::::::::::::::::::::::::::::::::::::::.@@@@@@@@@@@@:::::::::::::::::::::--------
// @@@@@@@@:::::::::::::::::::::::::::::::::::::::::::::::::::::::@@@@@@@@@@@@@:::::::::::::::::::::--------
// @@@@@@@@:::::::::::::::::::::::::::::::::::::::::::::::::::::@@@@@@@@@@@@@:::::::::::::::::::::::--------
// @@@@@@@@:::::::::::::::::::::::::::::::::::::::::::::::::.:@@@@@@@@@@@@@@::::::::::::::::::::::::--------
// @@@@@@@@::::::::::::::::::::::::::::::::::::::::::::::.:@@@@@@@@@@@@@@@@:::::::::::::::::::::::::--------
// @@@@@@@@:::::::::::::::::::::::::::::::::::::::::.@@@@@@@@@@@@@@@@@@@@:::::::::::::::::::::::::::--------
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@:::::::::::::::::::::::::::::-------@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@:::::::::::::::::::::::::::::::-----@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@....::::::::::::::::::::::::::::::---@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ .........::::::::::::::::::::::::::::::@@@@@@@@


// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%#*++==------===**%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#++-::::::::::::::::::::::-=*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@#+-::::::::::::::::::::::::::::::::=*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@#+::::::::::::::::::::::::::::::::::::::::=%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@%+:::::::::::::::::=++***+=-::::::::::::::::::::=%@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@#=::::::::::::::::+%@@@@@@@@@@%+-:::::::::::::::::::=#@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@#-::::::::::::::::#@@@@@@@@@@@@@@@@%-:::::::::::::::::::=#@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@%=::::::::::::::::*@@@@@@**==-=+##@@@@@*-:::::::::::::::::::=%@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@=:::::::::::::::::%@@@@@+::::::::::-*@@@@#-::::::::::::::::::::=@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@#::::::::::::::::::%@@@@=::::::::::::::-%@@@#-:::::::::::::::::::::#@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@=::::::::::::::::::*@@@@=::::::::::::::::-%@@@*::::::::::::::::::::::=@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@%-::::::::::::::::::-@@@@=::::::::::::::::::-%@@@-::::::::::::::::::::::-%@@@@@@@@@@@@@@
// @@@@@@@@@@@@#::::::::::::::::::::*@@@#::::::::::::::::::::=@@@*::::::::::::::::::::::::*@@@@@@@@@@@@@
// @@@@@@@@@@@#:::::::::::::::::::::%@@@+::::::::::::::::::::-@@@%:::::::::::::::::::::::::*@@@@@@@@@@@@
// @@@@@@@@@@+::::::::::::::::::::::@@@@-:::::::::::::::::::::%@@@::::::::::::::::::::::::::*@@@@@@@@@@@
// @@@@@@@@@*:::::::::::::::::::::::@@@@=:::::::::::::::::::::@@@@:::::::::::::::::::::::::::*@@@@@@@@@@
// @@@@@@@@@%::::::::::::::::::::::::@@@@*:::::::::::::::::::::@@@%::::::::::::::::::::::::::::*@@@@@@@@
// @@@@@@@%:::::::::::::::::::::::::@@@@%::::::::::::::::::::+@@@*:::::::::::::::::::::::::::::%@@@@@@@@
// @@@@@@@::::::::::::::::::::::::::@@@@@*::::::::::::::::::-@@@@::::::::::::::::::::::::::::::-@@@@@@@@
// @@@@@@=::::::::::::::::::::::::::@@@@@@*::::::::::::::::-@@@@+:::::::::::::::::::::::::::::::*@@@@@@@
// @@@@@%:::::::::::::::::::::::::::@@@@@@@*::::::::::::::-%@@@#:::::::::::::::::::::::::::::::::*@@@@@@
// @@@@@::::::::::::::::::::::::::::@@@@@@@@@*::::::::::+@@@@@*::::::::::::::::::::::::::::::::::-@@@@@@
// @@@@*::::::::::::::::::::::::::::@@@#=@@@@@@%#*+=**%@@@@@@=::::::::::::::::::::::::::::::::::::*@@@@@
// @@@@:::::::::::::::::::::::::::::@@@#::*@@@@@@@@@@@@@@@@*:::::::::::::::::::::::::::::::::::::::@@@@@
// @@@*:::::::::::::::::::::::::::::@@@#::::=*@@@@@@@@@%#=:::::::::::::::::::::::::::::::::::::::::#@@@@
// @@@=:::::::::::::::::::::::::::::@@@#::::::::-==+=-:::::::::::::::::::::::::::::::::::::::::::::=@@@@
// @@@::::::::::::::::::::::::::::::@@@#:::::::::::::::::::::::::::::::::::::::::::::::::--:::::::::@@@@
// @@*::::::::::::::::::::::::::::::@@@#:::::::::+######*:::::::::::::::::::::::::::::::+@=:::::::::#@@@
// @@*::::::::::::::::::::::::::::::@@@#:::--::::===+*+==:::::=-:::::::=-::::::-::::::::+@=:::::::::+@@@
// @@:::::::::::::::::::::::::::::::@@@#::+@#@@::-#@@%@%=::=@@@@+:::+@@@@@+-::@@@@%*-::#@@%#:::::::::@@@
// @@:::::::::::::::::+*##%%#*=-::::@@@#::+@@=::-%@:::-*@=:%@-:=:::*@*:::-@*::@@-:=@%::=*@*=:::::::::@@@
// @@::::::::::::::+%@@@@@@@@@@@%*-:@@@#::+@*:::*@@@@@@@@#:+@%*-:::@@@@@@@@@-:@%:::#@:::+@=::::::::::@@@
// @%::::::::::::*@@@@@@@@@@@@@@@@@#+@@#::+@+:::+@=-------:::=%@*::@%-------::@%:::#@:::+@=::::::::::%@@
// @%::::::::::-@@@@@@++-::::-=#@@@@@+*#::+@+:::-@%=-:+%%-:-=::#@::*@*-::+%=::@%:::#@:::+@=::::::::::#@@
// @%:::::::::=@@@@%+::::::::::::#@@@@**::+@+::::-#@@@@%=::#@%%@+:::+@@@@@*:::@%:::#@:::+@=::::::::::%@@
// @%::::::::-@@@@+:::::::::::::::-@@@@+:::-::::::::--:::::::=-:::::::-=-:::::--:::-=----=-::::::::::%@@
// @@:::::::-@@@@*:::::::::::::::::-%%%*:::::::::::::::::::::::::::::::::::::::::::+%%%%%%#::::::::::@@@
// @@:::::::*@@@#:::::::+#@%*-::::::=+*#*=+#*=-:::::+#%%#-::::::=#%%#=::::-#@@#-::::=##%#+-::::::::::@@@
// @@-::::::@@@@-:::::-%@#+*%@*:::::%@#+#@@+*@*::::%@+==#@*::::%@#+=%@*-::%@-=*=:::*@*+=+@%-::::::::-@@@
// @@+:::::-@@@@::::::#@=::::#@=::::%@::=@*::#@-::*@=::::-@+::*@*::::*@*::*@*-::::-@@####%@#::::::::*@@@
// @@#:::::-@@@*::::::@@:::::=@*::::%@::=@*::#@-::%@::::::@*::%@-::::-@%:::=%@%-::*@#******+::::::::*@@@
// @@@-::::-@@@%::::::#@+::::%@-::::%@::=@*::#@-::@@-::::=@-::*@*::::*@*:::-::@%::=@*::::-=-::::::::@@@@
// @@@+:::::@@@@-::::::%@%*#@@+:::::%@::=@*::#@-::@@@#=+#@+::::*@%*+@@*:::#@*+@*:::*@%*+%@%::::::::-@@@@
// @@@#:::::#@@@+:::::::=*##+-::::::+*::-*=::+*:::@%=*%#*-::::::=*%#*-:::::=*#+:::::-*##*=--=::::::*@@@@
// @@@@-::::=@@@@-::::::::::::::::::=**+::::::::::@%:::::::::::::::::::::::-==++**##%%@@@@@@@-::::-@@@@@
// @@@@*:::::*@@@@-::::::::::::::::+@@@#::::::::::::::::::::-==++**##=-#@@@@@@@@@@@@@@@@@@@@@=::::*@@@@@
// @@@@@-:::::#@@@@+-:::::::::::::*@@@@::::::-==++--##%@@@@@@@@@#*#@@%%@@@@@@@@@@@#@@@@@*#@@@+:::-@@@@@@
// @@@@@%::::::*@@@@@+-:::::::::+@@@@%:=@@@@@@@@@@==@@@**%@@@@*-#@*-@@*@@@*-+**@@@:%@@==@++@@*:::%@@@@@@
// @@@@@@*::::::=@@@@@@@%%**##@@@@@@=::=@@@@#-:--=-=@@=+@+=@@@:%@@@=+@-*@*-@@@+:@@=#@:*@@%-@@#::+@@@@@@@
// @@@@@@@-:::::::+%@@@@@@@@@@@@@@#-::::@@@+-%@@@*--@@:@@=*@@@%=*%@@@@*=@-*@@@*:+@=+=#@@@%:@@%:-@@@@@@@@
// @@@@@@@@-::::::::=*%@@@@@@@##-:::::::@@@:#@@@@@*:@@:%==@@@@@@%+-%@@#-@*+@@@:+:@*:*@@@@@:%@@-@@@@@@@@@
// @@@@@@@@#-:::::::::::::-:::::::::::::@@%:%@@@@@*-@@-#@@@#-@*%@@@-#@@:@@#=+:%@-#@:@@@@@@*=@@%@@@@@@@@@
// @@@@@@@@@*:::::::::::::::::::::::::::#@@--@@@@%:*@@%*+*=*@@=+@@@=+@@-#@@@@@@@=#@#@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@*-:::::::::::::::::::::::::#@@@+-==-:#@@@@@@@@@@@@#++*#@@@@@@@#@@@%:@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@*-::::::::::::::::::::::::+@@@@@%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@-=-:+**++==--::%@@@@@@@@@@@@
// @@@@@@@@@@@@#-:::::::::::::::::::::::=@@@@@@@@@@@@@@@@@@@@@%%##**++=---::::::::::::::::%@@@@@@@@@@@@@
// @@@@@@@@@@@@@@-::::::::::::::::::::::-@@@@@@%###**++=--::::::::::::::::::::::::::::::-%@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@+::::::::::::::::::::::--::::::::::::::::::::::::::::::::::::::::::::+@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@%-::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::-%@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@+-:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::+@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@%=::::::::::::::::::::::::::::::::::::::::::::::::::::::::::+%@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@%=::::::::::::::::::::::::::::::::::::::::::::::::::::::=%@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@%=-:::::::::::::::::::::::::::::::::::::::::::::::::+%@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@*-:::::::::::::::::::::::::::::::::::::::::::::#@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@%+-:::::::::::::::::::::::::::::::::::::::+%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%*=-:::::::::::::::::::::::::::::::+#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%**=-:::::::::::::::::::::=+#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%**++=====+++*##@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
-->

<!--
  ============================================================================
  BIRDSIGNAL · ETUDE DE CAS
  Bloc HTML personnalise Elementor, pour https://presentcomposedesign.fr/birdsignal-infos

  A COLLER TEL QUEL dans un widget "HTML" d'Elementor. Le bloc est autonome :
  styles et scripts vivent dedans et sont tous prefixes "bsm-", de sorte qu'ils
  ne peuvent ni deborder sur le theme ni en recevoir.

  RIEN N'EST A REMPLIR : le bloc est complet. Tout est dessine ici meme, en
  SVG et en canvas (logo et declinaisons, carte qui tourne, poincons de rang,
  carte des releves), sauf deux choses qui vivent sur le serveur et qui
  DOIVENT y rester :

    1. le visualiseur 3D enginR, appele en fond du bloc « Elle s'installe
       partout » ;
    2. l'enregistrement sonore
       /tools/birdsignal/Birds-in-town_6am_Toulouse_12b-r-l.plana.mp4
       que le bouton de son joue, et dont le spectrogramme de cette page a ete
       calcule.

  Si l'enregistrement disparaissait du serveur, le bouton resterait sans
  effet, mais la page, elle, continuerait de fonctionner : le spectrogramme
  est une image embarquee, il ne depend de rien.
  ============================================================================
-->

<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">

<style>
/* ===========================================================================
   Tout est enferme sous #bsm. Aucune regle ne vise un selecteur global : un
   bloc insere dans un theme ne doit jamais pouvoir repeindre ce qui l'entoure.
   =========================================================================== */
#bsm {
  --bsm-blue: #1001ef;
  --bsm-soft: #eeecff;
  --bsm-ink: #0d1020;
  --bsm-mid: #5d6479;
  --bsm-line: #e4e6f0;
  --bsm-wash: #f7f8fc;
  --bsm-night: #10131f;

  box-sizing: border-box;
  margin: 0; padding: 0;
  font-family: "Inter Tight", "Helvetica Neue", Arial, system-ui, sans-serif;
  color: var(--bsm-ink); background: #fff;
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
}
#bsm *, #bsm *::before, #bsm *::after { box-sizing: border-box; }
#bsm p, #bsm h1, #bsm h2, #bsm h3, #bsm ul, #bsm li, #bsm figure { margin: 0; }
#bsm ul { padding: 0; list-style: none; }
#bsm img, #bsm svg { max-width: 100%; display: block; }
/* [hidden] vient de la feuille du navigateur : la regle ci-dessus, qui est
   une regle d'auteur, la battait, et l'image source du spectrogramme
   s'affichait en clair au-dessus de la section. */
#bsm [hidden] { display: none; }
#bsm a { color: inherit; text-decoration: none; }

#bsm .bsm-wrap { width: 100%; max-width: 1060px; margin: 0 auto; padding: 0 20px; }
#bsm .bsm-sec { padding: 76px 0; position: relative; overflow: hidden; }
#bsm .bsm-sec--wash { background: var(--bsm-wash); }
#bsm .bsm-sec--night { background: var(--bsm-night); color: #eef0f8; }

/* --------------------------------------------------------------- typographie */
#bsm .bsm-kicker {
  display: inline-flex; align-items: center; gap: 8px;
  font-size: 11.5px; font-weight: 700; letter-spacing: .16em; text-transform: uppercase;
  color: var(--bsm-blue); margin-bottom: 14px;
}
#bsm .bsm-kicker::before { content: ""; width: 24px; height: 2px; background: currentColor; }
/* Sur fond sombre, le bleu de marque tombe a un contraste illisible : il passe
   a sa version claire. Le trait suit, il est en currentColor. */
#bsm .bsm-sec--night .bsm-kicker { color: #8f97ff; }
#bsm h1.bsm-h1 { font-size: clamp(2.6rem, 7vw, 4.6rem); font-weight: 800; line-height: .98; letter-spacing: -.04em; }
#bsm h2.bsm-h2 { font-size: clamp(1.55rem, 3.4vw, 2.3rem); font-weight: 800; line-height: 1.1; letter-spacing: -.03em; margin-bottom: 14px; }
#bsm .bsm-sec--night h2.bsm-h2 { color: #fff; }
#bsm h3.bsm-h3 { font-size: 1.05rem; font-weight: 700; letter-spacing: -.015em; margin-bottom: 6px; }
#bsm .bsm-lede { font-size: clamp(1.05rem, 2vw, 1.25rem); color: var(--bsm-mid); max-width: 58ch; }
#bsm .bsm-p { color: var(--bsm-mid); font-size: 15px; max-width: 62ch; }
#bsm .bsm-sec--night .bsm-p, #bsm .bsm-sec--night .bsm-lede { color: #aeb5cb; }
#bsm .bsm-p strong, #bsm .bsm-lede strong { color: var(--bsm-ink); font-weight: 700; }
#bsm .bsm-sec--night .bsm-p strong { color: #fff; }

/* ---------------------------------------------------------------------- hero */
#bsm .bsm-hero {
  position: relative; overflow: hidden; padding: 92px 0 68px;
  background:
    radial-gradient(1000px 480px at 78% -10%, rgba(16,1,239,.14), transparent 60%),
    radial-gradient(700px 380px at 4% 108%, rgba(122,92,255,.12), transparent 58%),
    #fff;
}
#bsm .bsm-hero-grid { display: grid; gap: 40px; align-items: center; grid-template-columns: minmax(0,1.2fr) minmax(0,.8fr); }
#bsm .bsm-mark { width: min(280px, 72%); margin-inline: auto; }
/* Le signe est pose par <use> : c'est le <svg> qui porte la couleur, car
   `fill` s'herite jusque dans l'arbre d'ombre, contrairement aux selecteurs
   CSS et a `opacity`. L'entree se joue donc sur le conteneur (.bsm-zoom). */
#bsm .bsm-mark svg { width: 100%; fill: var(--bsm-blue); filter: drop-shadow(0 24px 44px rgba(16,1,239,.22)); }

#bsm .bsm-tags { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 22px; }
#bsm .bsm-tag {
  font-size: 12px; font-weight: 600; padding: 6px 12px; border-radius: 999px;
  border: 1px solid var(--bsm-line); background: #fff; color: var(--bsm-mid);
}
#bsm .bsm-tag--on { background: var(--bsm-blue); border-color: var(--bsm-blue); color: #fff; }

#bsm .bsm-eq { display: flex; align-items: flex-end; gap: 3px; height: 46px; margin-top: 28px; opacity: .45; }
#bsm .bsm-eq i {
  flex: 1 1 0; min-width: 2px; border-radius: 3px 3px 0 0;
  background: linear-gradient(180deg, var(--bsm-blue), rgba(16,1,239,.22));
  transform-origin: bottom; animation: bsm-bar 1.6s ease-in-out infinite alternate;
}
@keyframes bsm-bar { from { transform: scaleY(.16); } to { transform: scaleY(1); } }

/* ------------------------------------------------------------------- boutons */
#bsm .bsm-cta { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 26px; }
#bsm .bsm-btn {
  display: inline-flex; align-items: center; gap: 8px;
  padding: 13px 22px; border-radius: 13px; font-weight: 700; font-size: 14.5px;
  border: 1px solid var(--bsm-blue); background: var(--bsm-blue); color: #fff;
  transition: transform .16s ease, box-shadow .16s ease, background .16s ease;
}
#bsm .bsm-btn:hover { transform: translateY(-2px); box-shadow: 0 14px 28px rgba(16,1,239,.3); }
#bsm .bsm-btn--ghost { background: transparent; color: var(--bsm-blue); }
#bsm .bsm-btn--ghost:hover { background: var(--bsm-soft); box-shadow: none; }
#bsm .bsm-sec--night .bsm-btn--ghost { color: #bfc5ff; border-color: rgba(191,197,255,.45); }
#bsm .bsm-sec--night .bsm-btn--ghost:hover { background: rgba(143,151,255,.14); }

/* --------------------------------------------------------------- points cles */
#bsm .bsm-keys { display: grid; gap: 10px; margin-top: 8px; }
#bsm .bsm-keys li {
  display: flex; gap: 12px; align-items: flex-start;
  font-size: 15.5px; font-weight: 600; color: var(--bsm-ink);
  padding: 13px 16px; border: 1px solid var(--bsm-line); border-radius: 13px; background: #fff;
  transition: transform .2s ease, border-color .2s ease, box-shadow .2s ease;
}
#bsm .bsm-keys li:hover { transform: translateX(4px); border-color: rgba(16,1,239,.3); box-shadow: 0 10px 26px rgba(13,16,32,.07); }
#bsm .bsm-keys li::before {
  content: ""; flex: 0 0 auto; width: 8px; height: 8px; border-radius: 50%;
  background: var(--bsm-blue); margin-top: 8px;
}

/* ------------------------------------------------------------------- chiffres */
#bsm .bsm-stats { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 14px; margin-top: 30px; }
#bsm .bsm-stat { text-align: center; padding: 20px 10px; border-radius: 16px; background: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.12); }
#bsm .bsm-stat b { display: block; font-size: clamp(1.6rem, 3.6vw, 2.3rem); font-weight: 800; letter-spacing: -.04em; color: #fff; }
#bsm .bsm-stat span { font-size: 11px; text-transform: uppercase; letter-spacing: .1em; color: #8f97ff; font-weight: 700; }

/* --------------------------------------------------------------------- etapes */
#bsm .bsm-steps { display: grid; gap: 14px; margin-top: 30px; grid-template-columns: repeat(2, minmax(0,1fr)); }
#bsm .bsm-step {
  border: 1px solid var(--bsm-line); border-radius: 16px; padding: 22px; background: #fff;
  transition: transform .2s ease, box-shadow .2s ease, border-color .2s ease;
}
#bsm .bsm-step:hover { transform: translateY(-3px); border-color: rgba(16,1,239,.35); box-shadow: 0 16px 36px rgba(13,16,32,.08); }
#bsm .bsm-num {
  display: inline-grid; place-items: center; width: 30px; height: 30px; border-radius: 9px;
  background: var(--bsm-soft); color: var(--bsm-blue); font-weight: 800; font-size: 13px; margin-bottom: 12px;
}

/* -------------------------------------------------------------- emplacements */
#bsm .bsm-slot { margin-top: 26px; }
#bsm .bsm-ph {
  border: 2px dashed rgba(16,1,239,.28); border-radius: 16px; background: var(--bsm-soft);
  min-height: 200px; display: grid; place-items: center; text-align: center; padding: 24px;
  color: var(--bsm-blue); font-size: 13px; font-weight: 600;
}
#bsm .bsm-slot img { border-radius: 16px; border: 1px solid var(--bsm-line); width: 100%; }
#bsm .bsm-cap { font-size: 12.5px; color: var(--bsm-mid); margin-top: 9px; font-style: italic; }
#bsm .bsm-sec--night .bsm-cap { color: #8b92a8; }

/* --------------------------------------------------------------- visualiseur */

/* ---------------------------------------------------------------- services */
#bsm .bsm-serv { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 14px; margin-top: 30px; }
#bsm .bsm-serv .bsm-step { background: var(--bsm-wash); }

/* ---------------------------------------------------------------------- final */
#bsm .bsm-final { text-align: center; }
#bsm .bsm-final .bsm-lede { margin-inline: auto; }
#bsm .bsm-final .bsm-cta { justify-content: center; }
#bsm .bsm-legal { font-size: 12px; color: var(--bsm-mid); margin-top: 34px; max-width: 74ch; margin-inline: auto; }

/* ===========================================================================
   SPECTROGRAMME ANIME
   Le dessin est en canvas et non en CSS : un spectrogramme est une image de
   donnees, pas une decoration. Des barres CSS auraient l'air d'un egaliseur
   de chaine hi-fi, ce qui raconterait exactement le contraire de ce que fait
   l'application.
   =========================================================================== */
#bsm .bsm-spectro {
  position: relative; border-radius: 18px; overflow: hidden; margin-top: 26px;
  border: 1px solid rgba(255,255,255,.12); background: #070a16;
  aspect-ratio: 1000 / 260;
}
#bsm .bsm-spectro canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
/* Image de repli, posee SOUS le canvas : elle montre tout l'extrait d'un coup.
   Sans script, c'est elle qu'on voit, et le propos reste entier. */
#bsm .bsm-spectro-fond { position: absolute; inset: 0; width: 100%; height: 100%; }
#bsm .bsm-spectro-legend {
  position: absolute; left: 14px; top: 12px; display: flex; gap: 8px; align-items: center;
  font-size: 11px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; color: #8f97ff;
  pointer-events: none;
}
#bsm .bsm-spectro-legend i {
  width: 7px; height: 7px; border-radius: 50%; background: #7ef0ff;
  animation: bsm-pulse 1.8s ease-out infinite;
}
@keyframes bsm-pulse {
  0% { box-shadow: 0 0 0 0 rgba(126,240,255,.55); }
  70% { box-shadow: 0 0 0 10px rgba(126,240,255,0); }
  100% { box-shadow: 0 0 0 0 rgba(126,240,255,0); }
}
#bsm .bsm-spectro-axis {
  position: absolute; right: 12px; top: 12px; bottom: 12px;
  display: flex; flex-direction: column; justify-content: space-between;
  font-size: 10px; font-weight: 600; color: rgba(174,181,203,.6); text-align: right; pointer-events: none;
  /* La graduation du bas tombe sur la bande claire de la ville : sans cette
     ombre portee, elle s'y dissout. */
  text-shadow: 0 1px 4px rgba(7,10,22,.95), 0 0 2px rgba(7,10,22,.95);
}
#bsm .bsm-chain { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 12px; margin-top: 18px; }
#bsm .bsm-chain div {
  border: 1px solid rgba(255,255,255,.12); border-radius: 14px; padding: 16px;
  background: rgba(255,255,255,.04);
}
#bsm .bsm-chain b { display: block; font-size: 13px; color: #fff; font-weight: 700; margin-bottom: 4px; }
#bsm .bsm-chain span { font-size: 12.5px; color: #aeb5cb; }

/* ===========================================================================
   POINCONS DE RANG
   Reproduits a l'identique de ceux imprimes au verso des cartes : meme encre,
   meme substrat metallise sombre, meme anneau de 3,5 px et meme filet
   interieur. Les valeurs sont copiees depuis le code de l'application
   (GRADE_INK et drawGradeLabel), pas approchees a l'oeil.
   =========================================================================== */
#bsm .bsm-badges {
  display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 16px; margin-top: 30px;
  perspective: 900px;
}
#bsm .bsm-badge-cell { text-align: center; }
#bsm .bsm-badge {
  --ink: linear-gradient(#c4cde4, #c4cde4);
  --ring-a: 1;
  --hair-a: .55;
  --disc: linear-gradient(135deg, rgba(26,31,58,.95), rgba(8,10,22,.95));
  position: relative; width: 104px; height: 104px; margin: 0 auto 12px;
  border-radius: 50%; background-image: var(--disc);
  transform-style: preserve-3d;
  transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
  transition: transform .2s ease-out;
  box-shadow: 0 14px 30px rgba(8,10,22,.3);
}
#bsm .bsm-badge i { position: absolute; border-radius: 50%; background-image: var(--ink); pointer-events: none; }
/* L'anneau et le filet sont des disques ajoures par un masque : c'est le seul
   moyen de leur donner une encre en degrade conique, qu'aucune couleur de
   bordure ne sait porter. */
#bsm .bsm-badge .bsm-ring {
  inset: 2px; opacity: var(--ring-a);
  -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 3.5px), #000 calc(100% - 3.5px));
          mask: radial-gradient(farthest-side, transparent calc(100% - 3.5px), #000 calc(100% - 3.5px));
}
#bsm .bsm-badge .bsm-hair {
  inset: 13px; opacity: var(--hair-a);
  -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 1px), #000 calc(100% - 1px));
          mask: radial-gradient(farthest-side, transparent calc(100% - 1px), #000 calc(100% - 1px));
}
/* Reflet qui suit le pointeur : c'est lui qui donne la sensation du metal,
   bien plus que la couleur. */
#bsm .bsm-badge .bsm-gloss {
  inset: 0; background-image: none;
  background: radial-gradient(58px 58px at var(--gx, 50%) var(--gy, 28%), rgba(255,255,255,.32), transparent 62%);
  mix-blend-mode: screen;
}
/* La lettre est peinte avec l'encre du rang, donc avec un degrade decoupe sur
   le texte. Si le navigateur ne sait pas faire, `-webkit-text-fill-color:
   transparent` s'appliquerait quand meme et la lettre disparaitrait : d'ou la
   couleur pleine par defaut, et le degrade seulement sous condition. */
#bsm .bsm-badge b {
  position: absolute; inset: 0; display: grid; place-items: center;
  font-size: 31px; font-weight: 800; letter-spacing: -.02em; line-height: 1;
  color: var(--plat, #c4cde4);
}
@supports ((-webkit-background-clip: text) or (background-clip: text)) {
  #bsm .bsm-badge b {
    background-image: var(--ink); -webkit-background-clip: text; background-clip: text;
    color: transparent; -webkit-text-fill-color: transparent;
  }
}
#bsm .bsm-badge--uc {
  --ink: linear-gradient(rgba(150,155,170,.75), rgba(150,155,170,.75));
  --plat: #8a8fa0;
  --ring-a: .7; --hair-a: .4;
  /* Rang Uc : aucun substrat metallise, exactement comme sur la carte, ou la
     mention doit se voir sans briller. */
  --disc: linear-gradient(#f1f2f7, #e6e8f1);
  box-shadow: 0 10px 22px rgba(13,16,32,.12);
}
#bsm .bsm-badge--uc .bsm-gloss { background: none; }
#bsm .bsm-badge--sd { --ink: linear-gradient(rgba(196,205,228,.9), rgba(196,205,228,.9)); --plat: #c4cde4; }
#bsm .bsm-badge--or { --ink: linear-gradient(#ffc95e, #ffc95e); --plat: #ffc95e; }
#bsm .bsm-badge--dd {
  --plat: #9be8ff;
  --ink: conic-gradient(from calc(-34deg + var(--sweep, 0deg)), #7ef0ff, #8f9dff, #e6a8ff, #ffd79a, #9dffdd, #7ef0ff);
}
#bsm .bsm-badge-name { display: block; font-size: 14.5px; font-weight: 700; }
#bsm .bsm-badge-cell span { display: block; font-size: 12.5px; color: var(--bsm-mid); margin-top: 2px; }

/* ===========================================================================
   CARTE QUI TOURNE
   Deux faces opaques dos a dos, en rotation continue. Chaque face porte
   `backface-visibility: hidden`, sans quoi on lirait le texte du verso par
   transparence a chaque demi-tour.
   =========================================================================== */
#bsm .bsm-stage { perspective: 1500px; display: grid; place-items: center; padding: 14px 0 4px; }
#bsm .bsm-card3d {
  position: relative; width: min(290px, 74vw); aspect-ratio: 63 / 88;
  transform-style: preserve-3d; animation: bsm-spin 18s linear infinite;
}
#bsm .bsm-stage:hover .bsm-card3d { animation-play-state: paused; }
@keyframes bsm-spin { from { transform: rotateY(0deg); } to { transform: rotateY(360deg); } }
#bsm .bsm-face {
  position: absolute; inset: 0; border-radius: 18px; overflow: hidden;
  -webkit-backface-visibility: hidden; backface-visibility: hidden;
  box-shadow: 0 26px 60px rgba(8,10,22,.45), inset 0 0 0 1px rgba(255,255,255,.1);
  padding: 12px; display: flex; flex-direction: column; gap: 8px;
  background: linear-gradient(158deg, #232b4f, #161c33 64%, #1d2440);
  color: #f5f6ff;
}
#bsm .bsm-face--back { transform: rotateY(180deg); }
#bsm .bsm-cardtop { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
#bsm .bsm-cardbrand { display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 800; letter-spacing: -.01em; }
#bsm .bsm-cardbrand svg { width: 15px; height: 15px; }
#bsm .bsm-cardbrand em { font-style: normal; color: #8b96ff; }
#bsm .bsm-pill {
  font-size: 9px; font-weight: 700; padding: 3px 8px; border-radius: 999px;
  border: 1px solid rgba(245,246,255,.28); color: rgba(245,246,255,.86);
}
#bsm .bsm-cardphoto {
  border-radius: 12px; aspect-ratio: 4 / 3; position: relative; overflow: hidden;
  background: linear-gradient(150deg, #3c4f8a, #22305c 58%, #4a3b74);
  display: grid; place-items: center;
}
#bsm .bsm-cardphoto svg { width: 54%; opacity: .3; fill: #fff; }
#bsm .bsm-cardname { font-size: 17px; font-weight: 800; letter-spacing: -.02em; line-height: 1.1; }
#bsm .bsm-cardsci { font-size: 11.5px; font-style: italic; color: rgba(245,246,255,.62); }
#bsm .bsm-cardmeta { font-size: 9.5px; color: rgba(245,246,255,.62); line-height: 1.35; }
#bsm .bsm-gauge { margin-top: auto; }
#bsm .bsm-gauge-row { display: flex; justify-content: space-between; align-items: baseline; font-size: 8.5px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; color: rgba(245,246,255,.62); }
#bsm .bsm-gauge-row b { font-size: 12px; letter-spacing: 0; color: #f5f6ff; }
#bsm .bsm-gauge-track { height: 5px; border-radius: 999px; background: rgba(255,255,255,.14); margin: 3px 0 7px; overflow: hidden; }
#bsm .bsm-gauge-fill { height: 100%; border-radius: 999px; }
#bsm .bsm-gauge-fill--rar { background: linear-gradient(90deg, #4ade80, #86efac); width: 8%; }
#bsm .bsm-gauge-fill--conf { background: linear-gradient(90deg, #8b96ff, #c4b5fd); width: 91%; }
#bsm .bsm-cardfoot { display: flex; align-items: flex-end; justify-content: space-between; gap: 8px; }
/* Le scelle holographique du recto : meme film irise que le poincon Dd. */
#bsm .bsm-seal {
  width: 44px; height: 44px; border-radius: 50%; flex: 0 0 auto; position: relative;
  background-image: conic-gradient(from -34deg, #7ef0ff, #8f9dff, #e6a8ff, #ffd79a, #9dffdd, #7ef0ff);
  -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 3px));
          mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 3px));
  animation: bsm-sealspin 9s linear infinite;
}
@keyframes bsm-sealspin { to { filter: hue-rotate(360deg); } }
#bsm .bsm-cardspectro { border-radius: 10px; overflow: hidden; height: 62px; background: #0a0f24; }
#bsm .bsm-cardspectro canvas { width: 100%; height: 100%; display: block; }
#bsm .bsm-cardrows { display: grid; gap: 5px; font-size: 9px; }
#bsm .bsm-cardrows div { display: flex; justify-content: space-between; gap: 8px; }
#bsm .bsm-cardrows span { color: rgba(245,246,255,.55); letter-spacing: .06em; text-transform: uppercase; font-weight: 700; }
#bsm .bsm-cardrows b { font-weight: 700; text-align: right; }
#bsm .bsm-cardback-mark { display: grid; place-items: center; padding: 6px 0; }
#bsm .bsm-cardback-mark svg { width: 44%; fill: url(#bsm-gold); }

/* ===========================================================================
   CARTE DES RELEVES
   Fond abstrait volontaire : ce n'est pas une carte geographique reelle, et
   en dessiner une fausse laisserait croire a des donnees qui n'existent pas.
   Ce qui est vrai, et qui est le propos, c'est le lien entre une prise de son
   et l'endroit ou elle a ete faite.
   =========================================================================== */
#bsm .bsm-map {
  position: relative; border-radius: 20px; overflow: hidden; margin-top: 26px;
  background: linear-gradient(150deg, #101a3a, #0a0f24);
  border: 1px solid rgba(255,255,255,.12); aspect-ratio: 16 / 9;
}
#bsm .bsm-map svg { position: absolute; inset: 0; width: 100%; height: 100%; }
#bsm .bsm-trail { fill: none; stroke: #8f97ff; stroke-width: 1.6; stroke-dasharray: 5 7; stroke-linecap: round; opacity: .75; }
#bsm.bsm-js .bsm-trail { stroke-dashoffset: 900; }
#bsm.bsm-js .bsm-in .bsm-trail { animation: bsm-draw 3.4s cubic-bezier(.35,.1,.25,1) forwards; }
@keyframes bsm-draw { to { stroke-dashoffset: 0; } }
/* Transition et non animation, comme partout ailleurs dans ce bloc : un seul
   mecanisme d'apparition, donc un seul endroit ou se tromper. Une animation
   `forwards` qui ne demarrerait pas laisserait l'epingle invisible pour
   toujours, alors qu'une transition non jouee laisse simplement l'etat final. */
#bsm .bsm-pin {
  transform-origin: center;
  transition: opacity .5s ease, transform .5s cubic-bezier(.2,1.5,.45,1);
  transition-delay: calc(var(--i, 0) * 520ms);
}
#bsm.bsm-js .bsm-pin { opacity: 0; transform: translateY(9px) scale(.55); }
#bsm.bsm-js .bsm-in .bsm-pin { opacity: 1; transform: none; }
#bsm .bsm-pin text { font-family: inherit; font-size: 11px; font-weight: 700; fill: #eef0f8; }
#bsm .bsm-pin tspan { font-size: 9px; font-weight: 600; fill: rgba(174,181,203,.85); }

/* ===========================================================================
   DECLINAISONS DU LOGO
   =========================================================================== */
#bsm .bsm-logos { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 14px; margin-top: 28px; }
#bsm .bsm-logo {
  border: 1px solid var(--bsm-line); border-radius: 16px; background: #fff; overflow: hidden;
  transition: transform .2s ease, box-shadow .2s ease, border-color .2s ease;
}
#bsm .bsm-logo:hover { transform: translateY(-4px); border-color: rgba(16,1,239,.3); box-shadow: 0 16px 36px rgba(13,16,32,.09); }
#bsm .bsm-logo-art { aspect-ratio: 1; display: grid; place-items: center; padding: 22px; }
#bsm .bsm-logo-art svg { width: 100%; fill: var(--bsm-blue); }
#bsm .bsm-logo-art--blue { background: var(--bsm-blue); }
#bsm .bsm-logo-art--blue svg { fill: #fff; }
#bsm .bsm-logo-art--tile { padding: 0; }
#bsm .bsm-tile {
  width: 100%; height: 100%; display: grid; place-items: center;
  background: var(--bsm-blue); border-radius: 22%;
}
#bsm .bsm-tile svg { width: 72%; fill: #fff; }
#bsm .bsm-logo-lab { font-size: 11.5px; font-weight: 700; padding: 11px 14px; border-top: 1px solid var(--bsm-line); }
#bsm .bsm-logo-lab span { display: block; font-weight: 500; color: var(--bsm-mid); font-size: 11px; }
#bsm .bsm-favrow { display: flex; align-items: flex-end; gap: 16px; justify-content: center; }
#bsm .bsm-favrow figure { text-align: center; }
#bsm .bsm-favrow svg { fill: var(--bsm-blue); }
#bsm .bsm-favrow figcaption { font-size: 9px; color: var(--bsm-mid); margin-top: 6px; font-weight: 600; }

/* ===========================================================================
   APPARITIONS
   L'etat cache n'existe QUE si le script a pu poser .bsm-js. Sans cette
   condition, un script bloque laisserait la page entierement vide, sans
   message et sans erreur. L'animation est un supplement, jamais la condition
   d'affichage.
   Le decalage vient d'une variable --i posee dans le HTML, et non d'une serie
   de :nth-child : une liste de six elements reclamerait six regles, et la
   septieme serait oubliee.
   =========================================================================== */
#bsm .bsm-rise, #bsm .bsm-left, #bsm .bsm-zoom {
  transition: opacity .65s ease, transform .65s cubic-bezier(.2,.75,.25,1);
  transition-delay: calc(var(--i, 0) * 70ms);
}
#bsm.bsm-js .bsm-rise { opacity: 0; transform: translateY(24px); }
#bsm.bsm-js .bsm-left { opacity: 0; transform: translateX(-26px); }
#bsm.bsm-js .bsm-zoom { opacity: 0; transform: scale(.93); }
#bsm.bsm-js .bsm-in.bsm-rise, #bsm.bsm-js .bsm-in .bsm-rise,
#bsm.bsm-js .bsm-in.bsm-left, #bsm.bsm-js .bsm-in .bsm-left,
#bsm.bsm-js .bsm-in.bsm-zoom, #bsm.bsm-js .bsm-in .bsm-zoom { opacity: 1; transform: none; }

/* Sans pointeur, rien ne peut suivre la souris : le poincon respire de
   lui-meme, sinon les quatre rangs seraient quatre pastilles inertes. */
@media (hover: none) {
  #bsm .bsm-badge { animation: bsm-idle 6s ease-in-out infinite alternate; }
  #bsm .bsm-badge-cell:nth-child(2) .bsm-badge { animation-delay: -1.5s; }
  #bsm .bsm-badge-cell:nth-child(3) .bsm-badge { animation-delay: -3s; }
  #bsm .bsm-badge-cell:nth-child(4) .bsm-badge { animation-delay: -4.5s; }
  @keyframes bsm-idle {
    from { transform: rotateY(-11deg) rotateX(5deg); }
    to   { transform: rotateY(11deg) rotateX(-5deg); }
  }
}

/* ===========================================================================
   LE BLOC QUI PORTE LE VISUALISEUR EN FOND
   ---------------------------------------------------------------------------
   Le visualiseur n'est pas pose DANS une boite : il EST le fond de la section,
   et le texte se lit par-dessus. Trois consequences, toutes voulues :

   1. Le cadre du visualiseur est blanc, pas transparent. La section est donc
      blanche elle aussi : un bloc sombre aurait laisse une plaque blanche au
      milieu de la page. On epouse ce qu'on ne controle pas.
   2. Le texte est dans un calque « pointer-events: none ». Le lecteur fait
      donc tourner le telephone a travers le texte, ce qui est exactement ce
      qu'on lui demande de faire. Seuls les liens reprennent la souris.
   3. Un voile blanc, degrade, protege la colonne de texte. Sans lui, la
      lisibilite dependrait de l'angle du modele 3D, c'est-a-dire du hasard.
   =========================================================================== */
#bsm .bsm-hold {
  position: relative; overflow: hidden; background: #fff; padding: 0;
  display: flex; align-items: flex-start;
  min-height: clamp(640px, 92vh, 940px);
}
#bsm .bsm-hold-bg {
  position: absolute; inset: 0; width: 100%; height: 100%;
  border: 0; background: transparent; display: block;
}
#bsm .bsm-hold-fg {
  position: relative; z-index: 2; width: 100%; padding: 64px 0 40px;
  pointer-events: none;
  background: linear-gradient(180deg, #fff 0%, rgba(255,255,255,.95) 40%, rgba(255,255,255,0) 72%);
}
#bsm .bsm-hold-fg a, #bsm .bsm-hold-fg button { pointer-events: auto; }
#bsm .bsm-hold-col { max-width: 33rem; }
#bsm .bsm-hold .bsm-stats { margin-top: 26px; }
#bsm .bsm-hold .bsm-stat {
  background: rgba(255,255,255,.86); border-color: var(--bsm-line);
  backdrop-filter: blur(6px);
}
#bsm .bsm-hold .bsm-stat b { color: var(--bsm-ink); }
#bsm .bsm-hold .bsm-stat span { color: var(--bsm-blue); }
@media (min-width: 901px) {
  /* Le modele est centre dans son cadre : on decale le cadre pour qu'il se
     pose a droite du texte plutot que dessus. */
  #bsm .bsm-hold-bg { left: 26%; width: 74%; }
  #bsm .bsm-hold { align-items: center; }
  #bsm .bsm-hold-fg {
    padding: 48px 0;
    background: linear-gradient(100deg, #fff 0%, rgba(255,255,255,.96) 38%, rgba(255,255,255,0) 66%);
  }
}

/* -------------------------------------------------- systemes et gratuite */
#bsm .bsm-os { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 22px; }
#bsm .bsm-os li {
  display: inline-flex; align-items: center; gap: 7px;
  font-size: 12.5px; font-weight: 600; color: var(--bsm-ink);
  border: 1px solid var(--bsm-line); background: rgba(255,255,255,.92);
  border-radius: 999px; padding: 7px 13px;
}
#bsm .bsm-os svg { width: 15px; height: 15px; flex: 0 0 auto; fill: none; stroke: var(--bsm-blue); stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
#bsm .bsm-os .bsm-os--free { background: var(--bsm-blue); border-color: var(--bsm-blue); color: #fff; letter-spacing: .06em; text-transform: uppercase; font-weight: 800; font-size: 11.5px; }
#bsm .bsm-os .bsm-os--free svg { stroke: #fff; }

/* ------------------------------------------- capture du bureau Windows */
#bsm .bsm-shot { margin-top: 24px; max-width: 300px; }
#bsm .bsm-shot img {
  display: block; width: 100%; height: auto; border-radius: 16px;
  border: 1px solid var(--bsm-line); box-shadow: 0 22px 46px rgba(13,16,32,.2);
}
#bsm .bsm-shot figcaption { font-size: 12px; color: var(--bsm-mid); margin-top: 9px; font-style: italic; max-width: 34ch; }

/* ===========================================================================
   COMMANDE DE SON
   ---------------------------------------------------------------------------
   Aucun navigateur ne laisse une page demarrer un son sans geste du lecteur :
   la bande tourne donc muette, et ce bouton l'allume. Son intitule dit l'etat
   SUIVANT, jamais l'etat courant, parce que c'est ce qu'on clique.

   Il est cache tant que le script n'a pas pris la main : un bouton de son qui
   ne coupe rien vaut moins que pas de bouton du tout.
   =========================================================================== */
#bsm .bsm-sound { display: none; }
#bsm.bsm-js .bsm-sound {
  position: fixed; right: 18px; bottom: 18px; z-index: 40;
  display: inline-flex; align-items: center; gap: 9px;
  padding: 10px 16px 10px 12px; border-radius: 999px;
  border: 1px solid rgba(13,16,32,.12); background: rgba(255,255,255,.93);
  -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
  box-shadow: 0 14px 34px rgba(13,16,32,.18);
  font-family: inherit; font-size: 12.5px; font-weight: 700; color: var(--bsm-ink);
  cursor: pointer; opacity: 0; transform: translateY(14px); pointer-events: none;
  transition: opacity .35s ease, transform .35s ease, box-shadow .2s ease;
}
#bsm.bsm-js.bsm-audible .bsm-sound { opacity: 1; transform: none; pointer-events: auto; }
#bsm.bsm-js .bsm-sound:hover { box-shadow: 0 18px 40px rgba(16,1,239,.24); }
#bsm .bsm-sound svg { width: 17px; height: 17px; fill: none; stroke: var(--bsm-blue); stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
#bsm .bsm-sound .bsm-sound-on { display: none; }
#bsm.bsm-son .bsm-sound .bsm-sound-on { display: block; }
#bsm.bsm-son .bsm-sound .bsm-sound-off { display: none; }
/* Un fil d'onde qui ne bat que lorsque le son sort vraiment. */
#bsm .bsm-sound i {
  width: 6px; height: 6px; border-radius: 50%; background: var(--bsm-line); flex: 0 0 auto;
}
#bsm.bsm-son .bsm-sound i { background: #22c55e; animation: bsm-pulse 1.8s ease-out infinite; }
#bsm .bsm-audio-src { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }

/* ------------------------------------------------ vignettes du logo */
#bsm .bsm-logo-art--pair { display: flex; align-items: center; gap: 12px; padding: 16px; }
#bsm .bsm-fav { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 8px; }
#bsm .bsm-fav-plate { display: grid; place-items: center; width: 100%; aspect-ratio: 1; border-radius: 15px; padding: 11px; }
#bsm .bsm-fav-plate--light { background: #fff; border: 1px solid var(--bsm-line); }
#bsm .bsm-fav-plate--dark { background: var(--bsm-night); }
#bsm .bsm-fav-plate svg { width: 100%; }
#bsm .bsm-fav figcaption { font-size: 9.5px; font-weight: 700; color: var(--bsm-mid); }
#bsm .bsm-logo-art--footer { background: #eceff7; padding: 24px; }
#bsm .bsm-logo-art--footer svg { width: 100%; filter: drop-shadow(0 10px 22px rgba(13,16,32,.14)); }

/* ------------------------------------------------------------------ responsive */
@media (max-width: 900px) {
  /*
   * Sur telephone, le visualiseur ne peut plus etre le fond de TOUTE la
   * section : le modele est centre dans son cadre, et le cadre fait alors la
   * hauteur du texte, si bien que le telephone se serait retrouve derriere
   * les vignettes, invisible. Il prend donc une bande qui lui est propre, en
   * pied de bloc, et le texte cesse d'avoir besoin d'un voile.
   */
  /* `#bsm .bsm-sec { padding: 58px 0 }` vit plus bas dans CE MEME bloc et a
     la meme specificite : elle gagnait, et le pied de 600 pixels reserve au
     visualiseur disparaissait. D'ou le selecteur compose, qui tranche. */
  #bsm .bsm-sec.bsm-hold { min-height: 0; padding: 0 0 600px; align-items: flex-start; }
  #bsm .bsm-hold-bg { top: auto; bottom: 0; height: 600px; }
  #bsm .bsm-hold-fg { padding: 52px 0 26px; background: none; }
  #bsm .bsm-hold-col { max-width: none; }
  #bsm .bsm-hero-grid { grid-template-columns: 1fr; }
  #bsm .bsm-mark { width: min(200px, 56%); margin-inline: 0; }
  #bsm .bsm-serv, #bsm .bsm-stats, #bsm .bsm-logos, #bsm .bsm-chain { grid-template-columns: repeat(2, minmax(0,1fr)); }
  #bsm .bsm-sec { padding: 58px 0; }
  #bsm .bsm-hero { padding: 64px 0 50px; }
}
@media (max-width: 620px) {
  #bsm .bsm-steps, #bsm .bsm-serv, #bsm .bsm-chain { grid-template-columns: 1fr; }
  #bsm .bsm-logos { grid-template-columns: repeat(2, minmax(0,1fr)); }
  #bsm .bsm-hold { min-height: clamp(560px, 86vh, 760px); }
  #bsm .bsm-hold-fg { padding-top: 44px; }
  #bsm .bsm-shot { max-width: 230px; }
  #bsm.bsm-js .bsm-sound { right: 12px; bottom: 12px; padding: 9px 13px 9px 10px; font-size: 12px; }
  #bsm .bsm-badges { grid-template-columns: repeat(2, minmax(0,1fr)); }
  #bsm .bsm-badge { width: 88px; height: 88px; }
  #bsm .bsm-badge b { font-size: 26px; }
  #bsm .bsm-badge .bsm-hair { inset: 11px; }
  #bsm .bsm-map { aspect-ratio: 4 / 3; }
  #bsm .bsm-spectro { aspect-ratio: 5 / 3; }
}

@media (prefers-reduced-motion: reduce) {
  #bsm .bsm-rise, #bsm .bsm-left, #bsm .bsm-zoom,
  #bsm.bsm-js .bsm-rise, #bsm.bsm-js .bsm-left, #bsm.bsm-js .bsm-zoom {
    opacity: 1; transform: none; transition: none;
  }
  #bsm .bsm-eq i { animation: none; transform: scaleY(.5); }
  #bsm .bsm-card3d { animation: none; }
  #bsm .bsm-seal { animation: none; }
  #bsm .bsm-spectro-legend i { animation: none; }
  #bsm.bsm-js .bsm-trail, #bsm.bsm-js .bsm-in .bsm-trail { stroke-dashoffset: 0; animation: none; }
  #bsm.bsm-js .bsm-pin, #bsm.bsm-js .bsm-in .bsm-pin { opacity: 1; transform: none; transition: none; }
  #bsm .bsm-btn:hover, #bsm .bsm-step:hover, #bsm .bsm-logo:hover, #bsm .bsm-keys li:hover { transform: none; }
  #bsm .bsm-badge { transition: none; animation: none; }
  #bsm.bsm-son .bsm-sound i { animation: none; }
}
</style>

<div id="bsm">

  <!--
    La bande sonore de la page : l'enregistrement dont le spectrogramme
    ci-dessous a ete calcule. preload="none" est delibere : rien n'est
    telecharge tant que personne n'a demande a entendre.
  -->
  <audio class="bsm-audio-src" data-bsm-audio preload="none" aria-hidden="true"
         src="https://pcdlabs.presentcomposedesign.fr/tools/birdsignal/Birds-in-town_6am_Toulouse_12b-r-l.plana.mp4"></audio>

  <button class="bsm-sound" type="button" data-bsm-sound aria-pressed="false">
    <i aria-hidden="true"></i>
    <svg class="bsm-sound-off" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9.5v5h3.6L12 18.5v-13L7.6 9.5H4z"/><path d="M16.5 9.5l4.5 5M21 9.5l-4.5 5"/></svg>
    <svg class="bsm-sound-on" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9.5v5h3.6L12 18.5v-13L7.6 9.5H4z"/><path d="M16 9a4.4 4.4 0 010 6M18.9 6.6a8.2 8.2 0 010 10.8"/></svg>
    <span data-bsm-sound-lab>Activer le son</span>
  </button>

  <!-- Le signe est defini UNE fois et reutilise par <use> : il apparait douze
       fois dans la page, et douze copies de deux longs traces seraient douze
       occasions de les voir diverger. La couleur vient du CSS par heritage de
       `fill`, puisque les traces n'en portent aucune. -->
  <svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
    <defs>
      <symbol id="bsm-mark" viewBox="0 0 1024 1024">
        <path d="M880.58,0h-332.01c93.06,27.65,165.69,99.56,193.31,188.87l92.64,33.12c23,8.23,43.99,18.77,62.66,34.42-59.95,1.18-110.83,22.83-166.15,38.43-80.13,22.63-183.32,95.69-149.54,225.33,21.75,10.15,42.13,21.43,62.34,34.82,79.85,52.92,122.25,137.15,115.8,228.44-6.06,85.83-52.86,162.2-131.59,208.4-22.87,13.41-47.25,24.32-72.81,32.17h325.36c79.2,0,143.39-62.09,143.41-138.68V138.68c-.02-76.59-64.22-138.68-143.41-138.68h-.01Z"/>
        <path d="M534.78,604.76l-124.11-59.62-86.94-42.16c-20.47-9.92-37.86-23.29-55.29-38.04-55.23-46.72-86.17-115.15-82.82-188.48,39.48,62.43,103.62,97.78,171.01,128.58l141.51,61.85c2.18-38.77,21.32-106.04,38.48-135.46,39.58-67.83,101.74-105.27,180.27-123.72-27.9-9.85-49.77-27.77-65.27-53.06-20.07-32.75-47.58-56.6-81.1-75.48-32.12-14.98-65.33-27.04-102.54-27.06h-.3c-213.16-.01-385.98,178.89-385.96,399.58l.04,482.54,371.43-.34c48.61-.04,93.59-15.44,128.19-46.41,58.92-52.73,69.44-139.02,31.46-207.36-18.21-32.81-45.44-56.94-78.06-75.37h-.01ZM511.94,144.09c20.23,0,36.63,16.32,36.63,36.45s-16.4,36.47-36.63,36.47-36.63-16.32-36.63-36.47,16.42-36.45,36.63-36.45Z"/>
      </symbol>
      <linearGradient id="bsm-gold" x1="0" y1="0" x2="1" y2="1">
        <stop offset="0" stop-color="#ffe7a8"/><stop offset=".5" stop-color="#ffc95e"/><stop offset="1" stop-color="#b3801f"/>
      </linearGradient>
    </defs>
  </svg>

  <!-- ============================== HERO ================================ -->
  <header class="bsm-hero" data-bsm-reveal>
    <div class="bsm-wrap">
      <div class="bsm-hero-grid">
        <div>
          <p class="bsm-kicker bsm-rise">Étude de cas &middot; Application web sur mesure</p>
          <h1 class="bsm-h1 bsm-rise" style="--i:1">BirdSignal</h1>
          <p class="bsm-lede bsm-rise" style="--i:2;margin-top:18px">
            Une application web qui <strong>analyse le son</strong> pour identifier
            l'oiseau qui chante, et transforme chaque écoute en carte à
            collectionner géolocalisée. Imaginée, nommée, dessinée, développée,
            mise en ligne et <strong>hébergée en France</strong>.
          </p>
          <div class="bsm-tags bsm-rise" style="--i:3">
            <span class="bsm-tag bsm-tag--on">Idéation</span>
            <span class="bsm-tag">Naming</span>
            <span class="bsm-tag">Identité graphique</span>
            <span class="bsm-tag">UX / UI</span>
            <span class="bsm-tag">Développement</span>
            <span class="bsm-tag">3D temps réel</span>
            <span class="bsm-tag">IA embarquée</span>
            <span class="bsm-tag">Hébergement</span>
          </div>
          <div class="bsm-cta bsm-rise" style="--i:4">
            <a class="bsm-btn" href="https://pcdlabs.presentcomposedesign.fr/tools/birdsignal/" target="_blank" rel="noopener">Voir l'application</a>
            <a class="bsm-btn bsm-btn--ghost" href="https://presentcomposedesign.fr/contact/">Demander un rendez-vous</a>
          </div>
        </div>

        <div class="bsm-mark bsm-zoom" aria-hidden="true">
          <svg viewBox="0 0 1024 1024" role="img" aria-label="Signe BirdSignal"><use href="#bsm-mark"/></svg>
        </div>
      </div>
      <div class="bsm-eq" data-bsm-eq aria-hidden="true"></div>
    </div>
  </header>

  <!-- ============== INSTALLABLE PARTOUT, VISUALISEUR EN FOND ============ -->
  <section class="bsm-sec bsm-hold" data-bsm-reveal>
    <!--
      Le visualiseur enginR, en fond de section. Il occupe toute la surface :
      le telephone que vous voyez derriere le texte est un vrai modele 3D,
      qu'on peut faire tourner. enginR est un autre de mes outils, developpe
      et heberge au meme endroit que BirdSignal.
    -->
    <iframe
      class="bsm-hold-bg"
      src="https://pcdlabs.presentcomposedesign.fr/tools/enginR/viewer.html?s=p6hfafvq8iai&amp;embed=1"
      title="BirdSignal en volume : maquette 3D manipulable"
      allow="xr-spatial-tracking; fullscreen; camera; gyroscope; accelerometer; magnetometer; autoplay"
      allowfullscreen
      loading="lazy"
      referrerpolicy="strict-origin-when-cross-origin"></iframe>

    <div class="bsm-hold-fg">
      <div class="bsm-wrap">
        <div class="bsm-hold-col">
          <p class="bsm-kicker bsm-rise">Sans magasin d'applications</p>
          <h2 class="bsm-h2 bsm-rise" style="--i:1">Elle s'installe sur tous les téléphones, gratuitement</h2>
          <p class="bsm-p bsm-rise" style="--i:2">
            BirdSignal est une application web. Elle s'ouvre dans un navigateur,
            puis s'installe d'un geste sur l'écran d'accueil : son icône, son
            écran de lancement, son fonctionnement hors ligne. <strong>Aucun
            magasin d'applications, aucun compte, aucun téléchargement payant.</strong>
            Une seule adresse, et le même produit sur Android, sur iPhone, sur
            ordinateur.
          </p>
          <ul class="bsm-os bsm-rise" style="--i:3">
            <li class="bsm-os--free">
              <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4v16M4 12h16"/></svg>
              Gratuit
            </li>
            <li>
              <svg viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="2" width="12" height="20" rx="2.5"/><path d="M10.5 18.5h3"/></svg>
              Android
            </li>
            <li>
              <svg viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="2" width="12" height="20" rx="3"/><path d="M10 5h4"/></svg>
              iPhone et iPad
            </li>
            <li>
              <svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="5" width="18" height="12" rx="1.5"/><path d="M2 20h20"/></svg>
              Windows et macOS
            </li>
          </ul>

          <figure class="bsm-shot bsm-zoom" style="--i:4">
            <img loading="lazy"
              src="data:image/jpeg;base64,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"
              alt="Raccourci BirdSignal sur le bureau de Windows, avec son icône"
              width="327" height="317" loading="lazy" decoding="async">
            <figcaption>Installée depuis le navigateur, elle se range sur le bureau comme un logiciel.</figcaption>
          </figure>

          <div class="bsm-cta bsm-rise" style="--i:5">
            <a class="bsm-btn" href="https://pcdlabs.presentcomposedesign.fr/tools/birdsignal/" target="_blank" rel="noopener">Ouvrir et installer</a>
          </div>

          <div class="bsm-stats">
            <div class="bsm-stat bsm-rise" style="--i:0"><b data-bsm-count="6522">6&#8239;522</b><span>espèces</span></div>
            <div class="bsm-stat bsm-rise" style="--i:1"><b data-bsm-count="2">2</b><span>langues</span></div>
            <div class="bsm-stat bsm-rise" style="--i:2"><b data-bsm-count="137">137</b><span>tests auto</span></div>
            <div class="bsm-stat bsm-rise" style="--i:3"><b data-bsm-count="0">0</b><span>octet audio envoyé</span></div>
          </div>
        </div>
      </div>
    </div>
  </section>


  <!-- ============================== POINTS CLES ========================= -->
  <section class="bsm-sec" data-bsm-reveal>
    <div class="bsm-wrap">
      <p class="bsm-kicker bsm-rise">Points clés</p>
      <h2 class="bsm-h2 bsm-rise" style="--i:1">Ce que j'ai réalisé</h2>
      <ul class="bsm-keys">
        <li class="bsm-left" style="--i:1">Idéation, positionnement et naming de l'application</li>
        <li class="bsm-left" style="--i:2">Création du logo, du système d'icônes et de l'identité graphique complète</li>
        <li class="bsm-left" style="--i:3">Conception de l'expérience, des parcours et de l'interface, en deux langues</li>
        <li class="bsm-left" style="--i:4">Développement intégral, front et back, et intégration d'un modèle d'intelligence artificielle</li>
        <li class="bsm-left" style="--i:5">Rendu 3D temps réel des cartes, avec gyroscope sur mobile</li>
        <li class="bsm-left" style="--i:6">Mise en ligne, sécurisation, hébergement en France et documentation</li>
      </ul>
    </div>
  </section>

  <!-- ============================== LE SON ============================== -->
  <section class="bsm-sec bsm-sec--night" data-bsm-reveal>
    <div class="bsm-wrap">
      <p class="bsm-kicker bsm-rise">Le cœur de l'application</p>
      <h2 class="bsm-h2 bsm-rise" style="--i:1">Tout part d'un son</h2>
      <p class="bsm-p bsm-rise" style="--i:2">
        L'application enregistre quelques secondes de chant, en construit le
        <strong>spectrogramme</strong>, et soumet cette image de fréquences à un
        modèle d'intelligence artificielle de 6522 espèces. Le tout dans le
        navigateur, en moins de temps qu'il n'en faut pour ranger son téléphone.
        Ce qui défile ci-dessous n'est pas une illustration : c'est
        <strong>l'analyse d'un vrai enregistrement</strong>, une rue de Toulouse
        à six heures du matin.
      </p>

      <!--
        L'image ci-dessous n'illustre pas un spectrogramme : elle EST le
        spectrogramme de la bande qui joue sur cette page, calcule par
        transformee de Fourier sur quarante secondes enregistrees a Toulouse a
        six heures du matin. Meme rampe de couleurs que dans l'application.
        Elle est cuite a l'avance plutot qu'a l'ouverture de la page : quatre
        megaoctets a telecharger et une analyse frequentielle pour une page de
        presentation, ce serait faire payer au lecteur le prix d'une
        demonstration.
      -->

      <div class="bsm-spectro bsm-zoom" style="--i:3" data-bsm-spectro>
        <img class="bsm-spectro-fond" data-bsm-spectro-img decoding="async"
           alt="Spectrogramme de quarante secondes enregistrées à Toulouse à six heures du matin : chaque trait vertical clair est une vocalisation d'oiseau."
           width="1118" height="96" src="data:image/png;base64,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">
        <canvas aria-hidden="true"></canvas>
        <p class="bsm-spectro-legend"><i></i> Enregistrement réel</p>
        <div class="bsm-spectro-axis" aria-hidden="true"><span>8,5 kHz</span><span>4 kHz</span><span>0,4</span></div>
      </div>
      <p class="bsm-cap">
        Le temps en abscisse, la fréquence en ordonnée, l'intensité en couleur.
        Chaque trait vertical est un oiseau ; la bande claire du bas est la
        ville qui se réveille. Activez le son pour entendre ce que vous voyez.
      </p>

      <div class="bsm-chain">
        <div class="bsm-rise" style="--i:1"><b>1. Capter</b><span>Micro ou fichier importé, rééchantillonné à 48 kHz.</span></div>
        <div class="bsm-rise" style="--i:2"><b>2. Voir le son</b><span>Transformée de Fourier, spectrogramme, découpage en segments de 3 secondes.</span></div>
        <div class="bsm-rise" style="--i:3"><b>3. Reconnaître</b><span>Modèle BirdNET exécuté en WebAssembly, filtré par le lieu et la saison.</span></div>
      </div>
    </div>
  </section>

  <!-- ============================== LA CARTE ============================ -->
  <section class="bsm-sec bsm-sec--wash" data-bsm-reveal>
    <div class="bsm-wrap">
      <p class="bsm-kicker bsm-rise">Design produit</p>
      <h2 class="bsm-h2 bsm-rise" style="--i:1">Le son devient un objet</h2>
      <p class="bsm-p bsm-rise" style="--i:2">
        Chaque analyse conservée devient une carte numérotée, avec sa photo, son
        spectrogramme, sa date et son lieu. Elle se retourne en 3D et s'incline au
        gyroscope, de sorte que le reflet suit le poignet comme sur une vraie carte.
      </p>

      <div class="bsm-stage bsm-zoom" style="--i:3" aria-hidden="true">
        <div class="bsm-card3d">
          <!-- RECTO -->
          <div class="bsm-face bsm-face--front">
            <div class="bsm-cardtop">
              <span class="bsm-cardbrand"><svg viewBox="0 0 1024 1024" style="fill:#8b96ff"><use href="#bsm-mark"/></svg>Bird<em>Signal</em></span>
              <span class="bsm-pill">Corvidés</span>
            </div>
            <div class="bsm-cardphoto"><svg viewBox="0 0 1024 1024" style="fill:#fff"><use href="#bsm-mark"/></svg></div>
            <div>
              <p class="bsm-cardname">Pie bavarde</p>
              <p class="bsm-cardsci">Pica pica</p>
              <p class="bsm-cardmeta" style="margin-top:4px">chant &middot; Défense de territoire</p>
            </div>
            <div class="bsm-gauge">
              <div class="bsm-gauge-row"><span>Rareté &middot; LC</span><b>8 %</b></div>
              <div class="bsm-gauge-track"><div class="bsm-gauge-fill bsm-gauge-fill--rar"></div></div>
              <div class="bsm-gauge-row"><span>Confiance</span><b>91 %</b></div>
              <div class="bsm-gauge-track"><div class="bsm-gauge-fill bsm-gauge-fill--conf"></div></div>
              <div class="bsm-cardfoot">
                <p class="bsm-cardmeta">13 sept. 2026, 07:43<br>12,8 s &middot; localisé</p>
                <span class="bsm-seal"></span>
              </div>
            </div>
          </div>
          <!-- VERSO -->
          <div class="bsm-face bsm-face--back">
            <div class="bsm-cardtop">
              <span class="bsm-cardbrand"><svg viewBox="0 0 1024 1024" style="fill:#8b96ff"><use href="#bsm-mark"/></svg>Bird<em>Signal</em></span>
              <span class="bsm-pill">Détails</span>
            </div>
            <div class="bsm-cardrows">
              <div><span>Espèce</span><b>Pie bavarde</b></div>
              <div><span>Moteur</span><b>BirdNET</b></div>
              <div><span>Filtre local</span><b>157 espèces</b></div>
              <div><span>Durée</span><b>12,8 s</b></div>
              <div><span>Position</span><b>43.6080, 1.4812</b></div>
            </div>
            <div class="bsm-cardback-mark"><svg viewBox="0 0 1024 1024"><use href="#bsm-mark"/></svg></div>
            <p class="bsm-cardmeta" style="letter-spacing:.08em;text-transform:uppercase;font-weight:700">Spectrogramme</p>
            <div class="bsm-cardspectro"><canvas data-bsm-mini aria-hidden="true"></canvas></div>
            <div class="bsm-cardfoot" style="margin-top:auto">
              <p class="bsm-cardmeta">ÉMISE PAR BirdSignalR_001<br>presentcomposedesign.fr</p>
              <span class="bsm-badge bsm-badge--or" style="width:38px;height:38px">
                <i class="bsm-ring"></i><i class="bsm-hair" style="inset:6px"></i><i class="bsm-gloss"></i>
                <b style="font-size:13px">Or</b>
              </span>
            </div>
          </div>
        </div>
      </div>

      <p class="bsm-kicker bsm-rise" style="margin-top:34px">Les quatre rangs</p>
      <p class="bsm-p bsm-rise" style="--i:1">
        Le rang change la matière de la carte, et il se mérite. Passez la souris
        sur les poinçons : ce sont exactement ceux frappés au verso des cartes.
      </p>
      <div class="bsm-badges" data-bsm-badges>
        <div class="bsm-badge-cell bsm-zoom" style="--i:1">
          <span class="bsm-badge bsm-badge--uc" data-bsm-badge><i class="bsm-ring"></i><i class="bsm-hair"></i><i class="bsm-gloss"></i><b>Uc</b></span>
          <b class="bsm-badge-name">Non classée</b><span>Aucune espèce reconnue</span>
        </div>
        <div class="bsm-badge-cell bsm-zoom" style="--i:2">
          <span class="bsm-badge bsm-badge--sd" data-bsm-badge><i class="bsm-ring"></i><i class="bsm-hair"></i><i class="bsm-gloss"></i><b>Sd</b></span>
          <b class="bsm-badge-name">Standard</b><span>Argent</span>
        </div>
        <div class="bsm-badge-cell bsm-zoom" style="--i:3">
          <span class="bsm-badge bsm-badge--or" data-bsm-badge><i class="bsm-ring"></i><i class="bsm-hair"></i><i class="bsm-gloss"></i><b>Or</b></span>
          <b class="bsm-badge-name">Authentique</b><span>Captée sur place, localisée</span>
        </div>
        <div class="bsm-badge-cell bsm-zoom" style="--i:4">
          <span class="bsm-badge bsm-badge--dd" data-bsm-badge><i class="bsm-ring"></i><i class="bsm-hair"></i><i class="bsm-gloss"></i><b>Dd</b></span>
          <b class="bsm-badge-name">Diamant</b><span>Irisé, le plus rare</span>
        </div>
      </div>
    </div>
  </section>

  <!-- ============================== GEOLOCALISATION ===================== -->
  <section class="bsm-sec bsm-sec--night" data-bsm-reveal>
    <div class="bsm-wrap">
      <p class="bsm-kicker bsm-rise">Le point fort</p>
      <h2 class="bsm-h2 bsm-rise" style="--i:1">Votre collection raconte vos sorties</h2>
      <p class="bsm-p bsm-rise" style="--i:2">
        Chaque carte retient l'endroit où le chant a été capté. La collection
        devient une carte de vos promenades, et surtout une <strong>carte des
        espèces</strong> : on voit où elles sont, et où elles ne sont plus.
      </p>

      <div class="bsm-map bsm-zoom" style="--i:3" aria-hidden="true">
        <svg viewBox="0 0 800 450" preserveAspectRatio="xMidYMid slice">
          <g opacity=".16" stroke="#8f97ff" stroke-width=".7">
            <path d="M0 90H800M0 180H800M0 270H800M0 360H800"/>
            <path d="M120 0V450M240 0V450M360 0V450M480 0V450M600 0V450M720 0V450"/>
          </g>
          <path d="M60 330c60-80 150-40 210-120 50-66 150-52 210 6 52 50 140 24 180-40 22-36 60-40 90-30"
                fill="none" stroke="#8f97ff" stroke-width="46" stroke-linecap="round" opacity=".08"/>
          <path class="bsm-trail" pathLength="900"
                d="M80 356C170 300 200 214 300 220 400 226 396 316 470 300 560 281 570 170 660 156"/>
          <g class="bsm-pin" style="--i:1">
            <circle cx="80" cy="356" r="7" fill="#7ef0ff"/><circle cx="80" cy="356" r="14" fill="none" stroke="#7ef0ff" stroke-width="1.4" opacity=".45"/>
            <text x="80" y="392" text-anchor="middle">Merle noir<tspan x="80" dy="13">08:14 · LC</tspan></text>
          </g>
          <g class="bsm-pin" style="--i:2">
            <circle cx="300" cy="220" r="7" fill="#9dffdd"/><circle cx="300" cy="220" r="14" fill="none" stroke="#9dffdd" stroke-width="1.4" opacity=".45"/>
            <text x="300" y="184" text-anchor="middle">Mésange charbonnière<tspan x="300" dy="13">09:02 · LC</tspan></text>
          </g>
          <g class="bsm-pin" style="--i:3">
            <circle cx="470" cy="300" r="7" fill="#ffd79a"/><circle cx="470" cy="300" r="14" fill="none" stroke="#ffd79a" stroke-width="1.4" opacity=".45"/>
            <text x="470" y="336" text-anchor="middle">Pie bavarde<tspan x="470" dy="13">09:47 · LC</tspan></text>
          </g>
          <g class="bsm-pin" style="--i:4">
            <circle cx="660" cy="156" r="7" fill="#e6a8ff"/><circle cx="660" cy="156" r="14" fill="none" stroke="#e6a8ff" stroke-width="1.4" opacity=".45"/>
            <text x="660" y="120" text-anchor="middle">Tourterelle turque<tspan x="660" dy="13">10:21 · LC</tspan></text>
          </g>
        </svg>
      </div>
      <p class="bsm-cap">Représentation de principe. La position n'est enregistrée qu'avec l'accord de l'utilisateur, et elle est arrondie avant tout partage.</p>
    </div>
  </section>

  <!-- ============================== IDENTITE ============================ -->
  <section class="bsm-sec" data-bsm-reveal>
    <div class="bsm-wrap">
      <p class="bsm-kicker bsm-rise">Identité graphique</p>
      <h2 class="bsm-h2 bsm-rise" style="--i:1">Un signe qui se lit à 16 pixels</h2>
      <p class="bsm-p bsm-rise" style="--i:2">
        Un oiseau et une lettre dans la même forme. Une seule couleur pour le
        signe : une identité qui a besoin d'un dégradé pour exister ne survit
        pas à une impression en noir. Le favicon, lui, existe en deux fichiers,
        parce qu'un gris clair disparaît sur une barre d'onglets blanche.
      </p>

      <div class="bsm-logos">
        <figure class="bsm-logo bsm-zoom" style="--i:1">
          <div class="bsm-logo-art"><svg viewBox="0 0 1024 1024"><use href="#bsm-mark"/></svg></div>
          <figcaption class="bsm-logo-lab">Signe principal<span>#1001ef sur blanc</span></figcaption>
        </figure>
        <figure class="bsm-logo bsm-zoom" style="--i:2">
          <div class="bsm-logo-art bsm-logo-art--blue"><svg viewBox="0 0 1024 1024"><use href="#bsm-mark"/></svg></div>
          <figcaption class="bsm-logo-lab">En réserve<span>Blanc sur aplat de marque</span></figcaption>
        </figure>
        <figure class="bsm-logo bsm-zoom" style="--i:3">
          <div class="bsm-logo-art bsm-logo-art--tile"><span class="bsm-tile"><svg viewBox="0 0 1024 1024"><use href="#bsm-mark"/></svg></span></div>
          <figcaption class="bsm-logo-lab">Icône d'application<span>Zone sûre du masque Android</span></figcaption>
        </figure>

        <!--
          Les deux vignettes suivantes ne sont pas des redessins : ce sont les
          fichiers reellement servis par l'application, recopies ici trait pour
          trait. Le favicon existe en deux versions parce qu'un onglet n'a pas
          la meme couleur de fond selon le theme du navigateur, et qu'un signe
          gris clair disparait sur une barre blanche.
        -->
        <figure class="bsm-logo bsm-zoom" style="--i:4">
          <div class="bsm-logo-art bsm-logo-art--pair">
            <figure class="bsm-fav">
              <span class="bsm-fav-plate bsm-fav-plate--light"><svg viewBox="0 0 1024 1024" role="img" aria-label="Favicon BirdSignal"><defs><linearGradient id="bsm-favgrad-l" x1="548.56" y1="512.11" x2="1024" y2="512.11" gradientTransform="translate(0 1024.1102) scale(1 -1)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#def0f5"/><stop offset=".45" stop-color="#c0e3eb"/><stop offset="1" stop-color="#8fd0dd"/></linearGradient></defs><path fill="url(#bsm-favgrad-l)" d="M880.58,0h-332.01c93.06,27.65,165.69,99.56,193.31,188.87l92.64,33.12c23,8.23,43.99,18.77,62.66,34.42-59.95,1.18-110.83,22.83-166.15,38.43-80.13,22.63-183.32,95.69-149.54,225.33,21.75,10.15,42.13,21.43,62.34,34.82,79.85,52.92,122.25,137.15,115.8,228.44-6.06,85.83-52.86,162.2-131.59,208.4-22.87,13.41-47.25,24.32-72.81,32.17h325.36c79.2,0,143.39-62.09,143.41-138.68V138.68c-.02-76.59-64.22-138.68-143.41-138.68h-.01Z"/><path fill="#a8a9a9" d="M534.78,604.76l-124.11-59.62-86.94-42.16c-20.47-9.92-37.86-23.29-55.29-38.04-55.23-46.72-86.17-115.15-82.82-188.48,39.48,62.43,103.62,97.78,171.01,128.58l141.51,61.85c2.18-38.77,21.32-106.04,38.48-135.46,39.58-67.83,101.74-105.27,180.27-123.72-27.9-9.85-49.77-27.77-65.27-53.06-20.07-32.75-47.58-56.6-81.1-75.48-32.12-14.98-65.33-27.04-102.54-27.06h-.3c-213.16-.01-385.98,178.89-385.96,399.58l.04,482.54,371.43-.34c48.61-.04,93.59-15.44,128.19-46.41,58.92-52.73,69.44-139.02,31.46-207.36-18.21-32.81-45.44-56.94-78.06-75.37h-.01ZM511.94,144.09c20.23,0,36.63,16.32,36.63,36.45s-16.4,36.47-36.63,36.47-36.63-16.32-36.63-36.47,16.42-36.45,36.63-36.45Z"/></svg></span>
              <figcaption>Thème clair</figcaption>
            </figure>
            <figure class="bsm-fav">
              <span class="bsm-fav-plate bsm-fav-plate--dark"><svg viewBox="0 0 1024 1024" role="img" aria-label="Favicon BirdSignal"><defs><linearGradient id="bsm-favgrad-d" x1="548.56" y1="512.11" x2="1024" y2="512.11" gradientTransform="translate(0 1024.1102) scale(1 -1)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#def0f5"/><stop offset=".45" stop-color="#c0e3eb"/><stop offset="1" stop-color="#8fd0dd"/></linearGradient></defs><path fill="url(#bsm-favgrad-d)" d="M880.58,0h-332.01c93.06,27.65,165.69,99.56,193.31,188.87l92.64,33.12c23,8.23,43.99,18.77,62.66,34.42-59.95,1.18-110.83,22.83-166.15,38.43-80.13,22.63-183.32,95.69-149.54,225.33,21.75,10.15,42.13,21.43,62.34,34.82,79.85,52.92,122.25,137.15,115.8,228.44-6.06,85.83-52.86,162.2-131.59,208.4-22.87,13.41-47.25,24.32-72.81,32.17h325.36c79.2,0,143.39-62.09,143.41-138.68V138.68c-.02-76.59-64.22-138.68-143.41-138.68h-.01Z"/><path fill="#e2e1e1" d="M534.78,604.76l-124.11-59.62-86.94-42.16c-20.47-9.92-37.86-23.29-55.29-38.04-55.23-46.72-86.17-115.15-82.82-188.48,39.48,62.43,103.62,97.78,171.01,128.58l141.51,61.85c2.18-38.77,21.32-106.04,38.48-135.46,39.58-67.83,101.74-105.27,180.27-123.72-27.9-9.85-49.77-27.77-65.27-53.06-20.07-32.75-47.58-56.6-81.1-75.48-32.12-14.98-65.33-27.04-102.54-27.06h-.3c-213.16-.01-385.98,178.89-385.96,399.58l.04,482.54,371.43-.34c48.61-.04,93.59-15.44,128.19-46.41,58.92-52.73,69.44-139.02,31.46-207.36-18.21-32.81-45.44-56.94-78.06-75.37h-.01ZM511.94,144.09c20.23,0,36.63,16.32,36.63,36.45s-16.4,36.47-36.63,36.47-36.63-16.32-36.63-36.47,16.42-36.45,36.63-36.45Z"/></svg></span>
              <figcaption>Thème sombre</figcaption>
            </figure>
          </div>
          <figcaption class="bsm-logo-lab">Favicon<span>Deux fichiers, un par thème d'onglet</span></figcaption>
        </figure>

        <figure class="bsm-logo bsm-zoom" style="--i:5">
          <div class="bsm-logo-art bsm-logo-art--footer"><svg viewBox="0 0 1024 1024" role="img" aria-label="Logo BirdSignal de pied de page"><path fill="#ffffff" d="M759.62,783.43c6.45-91.29-35.95-175.52-115.8-228.44-20.21-13.39-40.59-24.67-62.34-34.82-33.78-129.64,69.41-202.7,149.54-225.33,55.32-15.6,106.2-37.26,166.15-38.43-18.68-15.65-39.66-26.19-62.66-34.42l-92.64-33.12C714.25,99.56,641.62,27.65,548.56,0H0v1024h555.22c25.55-7.86,49.94-18.76,72.81-32.17,78.72-46.19,125.53-122.56,131.59-208.4ZM581.4,887.48c-34.61,30.97-79.58,46.37-128.19,46.41l-371.43.34-.04-482.54c-.02-220.69,172.8-399.59,385.96-399.58h.3c37.21.02,70.43,12.08,102.54,27.06,33.52,18.87,61.02,42.73,81.1,75.48,15.5,25.29,37.37,43.21,65.27,53.06-78.53,18.45-140.69,55.89-180.27,123.72-17.16,29.42-36.3,96.69-38.48,135.46l-141.51-61.85c-67.39-30.81-131.52-66.15-171.01-128.58-3.35,73.33,27.59,141.76,82.82,188.48,17.43,14.76,34.82,28.13,55.29,38.04l86.94,42.16,124.11,59.62h.01c32.62,18.42,59.85,42.55,78.06,75.36,37.98,68.34,27.47,154.63-31.46,207.36Z"/><path fill="#1001ef" d="M534.79,604.75h-.01s-124.11-59.61-124.11-59.61l-86.94-42.16c-20.47-9.92-37.86-23.29-55.29-38.04-55.23-46.72-86.17-115.15-82.82-188.48,39.48,62.43,103.62,97.78,171.01,128.58l141.51,61.85c2.18-38.77,21.32-106.04,38.48-135.46,39.58-67.83,101.74-105.27,180.27-123.72-27.9-9.85-49.77-27.77-65.27-53.06-20.07-32.75-47.58-56.6-81.1-75.48-32.12-14.98-65.33-27.04-102.54-27.06h-.3c-213.16-.01-385.98,178.89-385.96,399.58l.04,482.54,371.43-.34c48.61-.04,93.59-15.44,128.19-46.41,58.92-52.73,69.44-139.02,31.46-207.36-18.21-32.81-45.44-56.94-78.06-75.37ZM511.94,144.09c20.23,0,36.63,16.32,36.63,36.45s-16.4,36.47-36.63,36.47-36.63-16.32-36.63-36.47,16.42-36.45,36.63-36.45Z"/><path fill="#ffffff" d="M511.94,217c20.23,0,36.63-16.34,36.63-36.47s-16.4-36.45-36.63-36.45-36.63,16.3-36.63,36.45,16.4,36.47,36.63,36.47Z"/></svg></div>
          <figcaption class="bsm-logo-lab">Logo de pied de page<span>Plaque blanche, signe bleu</span></figcaption>
        </figure>

        <figure class="bsm-logo bsm-zoom" style="--i:6">
          <div class="bsm-logo-art">
            <div class="bsm-favrow">
              <figure><svg viewBox="0 0 1024 1024" style="width:48px"><use href="#bsm-mark"/></svg><figcaption>48</figcaption></figure>
              <figure><svg viewBox="0 0 1024 1024" style="width:32px"><use href="#bsm-mark"/></svg><figcaption>32</figcaption></figure>
              <figure><svg viewBox="0 0 1024 1024" style="width:16px"><use href="#bsm-mark"/></svg><figcaption>16</figcaption></figure>
            </div>
          </div>
          <figcaption class="bsm-logo-lab">Lisibilité<span>Validé à sa plus petite taille</span></figcaption>
        </figure>
      </div>
    </div>
  </section>

  <!-- ============================== METHODE ============================= -->
  <section class="bsm-sec bsm-sec--wash" data-bsm-reveal>
    <div class="bsm-wrap">
      <p class="bsm-kicker bsm-rise">Méthode</p>
      <h2 class="bsm-h2 bsm-rise" style="--i:1">De l'idée à la mise en ligne</h2>
      <div class="bsm-steps">
        <article class="bsm-step bsm-rise" style="--i:1"><span class="bsm-num">01</span>
          <h3 class="bsm-h3">Idéation et positionnement</h3>
          <p class="bsm-p">Définir ce que l'application fait, ce qu'elle refuse de faire, et à qui elle s'adresse.</p></article>
        <article class="bsm-step bsm-rise" style="--i:2"><span class="bsm-num">02</span>
          <h3 class="bsm-h3">Naming</h3>
          <p class="bsm-p">Un nom qui tient dans une icône, se prononce en français comme en anglais, et dit quelque chose de vrai sur le produit.</p></article>
        <article class="bsm-step bsm-rise" style="--i:3"><span class="bsm-num">03</span>
          <h3 class="bsm-h3">Logo et identité graphique</h3>
          <p class="bsm-p">Du croquis au vectoriel, décliné en favicon, icônes d'application, thème clair et thème sombre.</p></article>
        <article class="bsm-step bsm-rise" style="--i:4"><span class="bsm-num">04</span>
          <h3 class="bsm-h3">Architecture et UX</h3>
          <p class="bsm-p">Parcours, écrans, arborescence, et une architecture choisie pour tenir sur un hébergement mutualisé français.</p></article>
        <article class="bsm-step bsm-rise" style="--i:5"><span class="bsm-num">05</span>
          <h3 class="bsm-h3">Développement</h3>
          <p class="bsm-p">Interface, API, traitement du signal audio, IA embarquée, rendu 3D temps réel et tests automatisés.</p></article>
        <article class="bsm-step bsm-rise" style="--i:6"><span class="bsm-num">06</span>
          <h3 class="bsm-h3">Mise en ligne et suivi</h3>
          <p class="bsm-p">Sécurisation, hébergement en France, application installable et hors ligne, documentation et mises à jour.</p></article>
      </div>

      <!--
        Gabarit pret a l'emploi, si des captures d'ecran sont ajoutees un jour.
        Il reste en commentaire : un cadre pointille « emplacement image » sur
        une page publiee ne dit qu'une chose, c'est que la page n'est pas finie.

        <div class="bsm-slot bsm-zoom">
          <img decoding="async" src="ADRESSE-DE-L-IMAGE" alt="Interface de BirdSignal">
          <p class="bsm-cap">L'analyse, la collection, la carte en volume.</p>
        </div>
      -->
    </div>
  </section>

  <!-- ============================== SERVICES ============================ -->
  <section class="bsm-sec" data-bsm-reveal>
    <div class="bsm-wrap">
      <p class="bsm-kicker bsm-rise">Ce que cela veut dire pour vous</p>
      <h2 class="bsm-h2 bsm-rise" style="--i:1">Un seul interlocuteur, du premier croquis à la mise en ligne</h2>
      <div class="bsm-serv">
        <article class="bsm-step bsm-rise" style="--i:1">
          <h3 class="bsm-h3">Application web sur mesure</h3>
          <p class="bsm-p">Outil métier, configurateur, application mobile installable. Conçue, développée et hébergée.</p></article>
        <article class="bsm-step bsm-rise" style="--i:2">
          <h3 class="bsm-h3">Identité et design produit</h3>
          <p class="bsm-p">Naming, logo, système d'icônes, interface et déclinaisons, pensés pour tenir à toutes les tailles.</p></article>
        <article class="bsm-step bsm-rise" style="--i:3">
          <h3 class="bsm-h3">3D, AR et expériences immersives</h3>
          <p class="bsm-p">Configurateurs 3D temps réel, visualiseurs, réalité augmentée, directement dans le navigateur.</p></article>
      </div>
    </div>
  </section>

  <!-- ============================== APPEL =============================== -->
  <section class="bsm-sec bsm-sec--wash bsm-final" data-bsm-reveal>
    <div class="bsm-wrap">
      <h2 class="bsm-h2 bsm-rise">Vous souhaitez faire appel à mes services ?</h2>
      <p class="bsm-lede bsm-rise" style="--i:1">
        Parlons de votre projet. J'interviens sur l'ensemble de la chaîne, ou sur la
        partie qui vous manque.
      </p>
      <div class="bsm-cta bsm-rise" style="--i:2">
        <a class="bsm-btn" href="https://presentcomposedesign.fr/contact/">Demander un rendez-vous</a>
        <a class="bsm-btn bsm-btn--ghost" href="https://presentcomposedesign.fr/realisations/">Voir les autres réalisations</a>
      </div>
      <p class="bsm-legal bsm-rise" style="--i:3">
        BirdSignal est une application gratuite et non commerciale, conçue et hébergée
        en France par Present Composé Design, nom commercial de l'entreprise
        individuelle Alban DESBARAX, à Toulouse. Les hypothèses d'espèce produites sont
        probabilistes et ne constituent pas une détermination scientifique.
      </p>
    </div>
  </section>
</div>

<script>
(function () {
  var root = document.getElementById('bsm');
  if (!root) return;

  /* Marque la presence du script AVANT tout le reste : c'est cette classe, et
     elle seule, qui autorise la feuille de style a cacher ce qui va apparaitre.
     Si cette ligne n'est jamais atteinte, l'article reste lisible, sans
     animation. */
  root.classList.add('bsm-js');

  var calme = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  var survol = !window.matchMedia || window.matchMedia('(hover: hover)').matches;

  /* ------------------------------------------------------------ egaliseur
     Genere plutot qu'ecrit : 44 barres a la main seraient 44 lignes a relire
     pour rien. */
  var eq = root.querySelector('[data-bsm-eq]');
  if (eq) {
    var html = '';
    for (var i = 0; i < 44; i++) {
      var hb = 18 + Math.round(Math.abs(Math.sin(i * 0.7)) * 70 + (i % 5) * 4);
      html += '<i style="height:' + hb + '%;animation-delay:' + ((i % 11) * 0.09).toFixed(2) +
              's;animation-duration:' + (1 + (i % 6) * 0.14).toFixed(2) + 's"></i>';
    }
    eq.innerHTML = html;
  }

  /* ----------------------------------------------------------- apparitions
     Seuil a zero, et non a un pourcentage : sur un ecran court, 12 % d'une
     section haute peut depasser la hauteur de la fenetre, et la section ne
     s'afficherait jamais. */
  var sections = root.querySelectorAll('[data-bsm-reveal]');
  if (calme || !('IntersectionObserver' in window)) {
    for (var k = 0; k < sections.length; k++) sections[k].classList.add('bsm-in');
  } else {
    var io = new IntersectionObserver(function (es) {
      es.forEach(function (e) {
        if (e.isIntersecting) { e.target.classList.add('bsm-in'); io.unobserve(e.target); }
      });
    }, { threshold: 0, rootMargin: '0px 0px -10% 0px' });
    for (var j = 0; j < sections.length; j++) io.observe(sections[j]);
  }

  /* -------------------------------------------------------------- compteurs
     Le HTML porte deja la valeur finale : sans script, les chiffres restent
     justes. C'est ici, et seulement ici, qu'on repart de zero. */
  function separe(v) {
    var s = String(v), out = '', c = 0;
    for (var p = s.length - 1; p >= 0; p--) {
      out = s.charAt(p) + out;
      if (++c % 3 === 0 && p > 0) out = ' ' + out;
    }
    return out;
  }
  function compte(el) {
    var cible = parseInt(el.getAttribute('data-bsm-count'), 10) || 0;
    if (calme || cible === 0) { el.textContent = separe(cible); return; }
    el.textContent = separe(0);
    var t0 = null, duree = 1000;
    function pas(t) {
      if (t0 === null) t0 = t;
      var p = Math.min(1, (t - t0) / duree);
      el.textContent = separe(Math.round(cible * (1 - Math.pow(1 - p, 3))));
      if (p < 1) requestAnimationFrame(pas);
    }
    requestAnimationFrame(pas);
  }
  var compteurs = root.querySelectorAll('[data-bsm-count]');
  if (!('IntersectionObserver' in window)) {
    for (var m = 0; m < compteurs.length; m++) compte(compteurs[m]);
  } else {
    var io2 = new IntersectionObserver(function (es) {
      es.forEach(function (e) { if (e.isIntersecting) { compte(e.target); io2.unobserve(e.target); } });
    }, { threshold: 0.5 });
    for (var q = 0; q < compteurs.length; q++) io2.observe(compteurs[q]);
  }

  /* ====================================================== SPECTROGRAMME
   *
   * Un spectrogramme, c'est le temps en abscisse, la frequence en ordonnee et
   * l'intensite en couleur. Celui-ci n'est pas synthetique : c'est la
   * transformee de Fourier de quarante secondes reellement enregistrees a
   * Toulouse a six heures du matin, la meme bande que le bouton de son
   * allume. Elle a ete calculee une fois pour toutes et rangee dans une image
   * indexee de quarante kilo-octets, plutot que recalculee dans le navigateur
   * de chaque lecteur : l'enregistrement pese quatre megaoctets, et personne
   * n'a a payer le prix d'une demonstration qu'il n'a pas demandee.
   *
   * Le defilement, lui, est vivant : la colonne de droite est l'instant
   * present. Quand la bande joue, cet instant est la tete de lecture, et ce
   * qui s'affiche est exactement ce qui s'entend.
   */
  var SPECTRO = { cols: 1118, bandes: 96, debut: 4, duree: 40, parSeconde: 28 };

  var image = root.querySelector('[data-bsm-spectro-img]');
  var piste = root.querySelector('[data-bsm-audio]');
  var origine = Date.now();

  /** Seconde courante DANS l'extrait, que la bande joue ou non. */
  function instant() {
    if (piste && !piste.paused && !piste.ended) {
      var t = piste.currentTime - SPECTRO.debut;
      if (t >= 0 && t <= SPECTRO.duree) return t;
    }
    return ((Date.now() - origine) / 1000) % SPECTRO.duree;
  }

  /**
   * Dessine la fenetre de `visibles` colonnes qui se termine a `fin`.
   * L'extrait boucle : une fenetre a cheval sur la fin et le debut se dessine
   * en deux morceaux plutot que de sauter.
   */
  function fenetre(ctx, w, h, fin, visibles) {
    if (!image || !image.complete || !image.naturalWidth) return;
    var cols = SPECTRO.cols;
    var depart = Math.round(fin - visibles) % cols;
    if (depart < 0) depart += cols;
    var premier = Math.min(visibles, cols - depart);
    var echelle = w / visibles;
    ctx.drawImage(image, depart, 0, premier, SPECTRO.bandes, 0, 0, premier * echelle, h);
    if (premier < visibles) {
      ctx.drawImage(
        image, 0, 0, visibles - premier, SPECTRO.bandes,
        premier * echelle, 0, (visibles - premier) * echelle, h
      );
    }
  }

  function defile(host, opts) {
    var cv = host.querySelector('canvas');
    if (!cv || !cv.getContext) return;
    var ctx = cv.getContext('2d');
    var w = 0, h = 0, raf = 0, vu = false;

    function taille() {
      /*
       * offsetWidth et non getBoundingClientRect : le spectrogramme du verso
       * vit DANS la carte qui tourne, et un rectangle mesure a l'ecran y
       * change a chaque image. Le canvas se serait redimensionne, donc vide,
       * soixante fois par seconde. Les dimensions de mise en page, elles,
       * ignorent les transformations.
       */
      var nw = Math.max(60, host.offsetWidth), nh = Math.max(30, host.offsetHeight);
      if (nw === w && nh === h) return;
      w = nw; h = nh; cv.width = w; cv.height = h;
    }

    function peins() {
      taille();
      if (!w || !h) return;
      var visibles = Math.max(8, Math.round(opts.secondes * SPECTRO.parSeconde));
      var fin = (instant() + opts.decalage) * SPECTRO.parSeconde;
      fenetre(ctx, w, h, fin, visibles);
    }

    function boucle() {
      raf = 0;
      if (!vu || document.hidden) return;
      peins();
      raf = requestAnimationFrame(boucle);
    }
    function relance() { if (!raf && vu && !document.hidden) raf = requestAnimationFrame(boucle); }

    peins();
    if (image && !image.complete) image.addEventListener('load', peins);
    if (calme) return; // image fixe : le propos reste lisible, rien ne bouge
    window.addEventListener('resize', peins);
    document.addEventListener('visibilitychange', relance);

    if ('IntersectionObserver' in window) {
      new IntersectionObserver(function (es) {
        vu = es[0].isIntersecting;
        if (vu) relance(); else if (raf) { cancelAnimationFrame(raf); raf = 0; }
      }, { threshold: 0 }).observe(host);
    } else { vu = true; relance(); }
  }

  var grand = root.querySelector('[data-bsm-spectro]');
  if (grand) defile(grand, { secondes: 13, decalage: 0 });
  var mini = root.querySelector('[data-bsm-mini]');
  // Le verso de la carte montre un AUTRE moment du meme enregistrement :
  // deux fois la meme seconde a l'ecran aurait eu l'air d'un decor duplique.
  if (mini) defile(mini.parentNode, { secondes: 4.5, decalage: 19 });

  /* ============================================================== LE SON
   *
   * Aucun navigateur ne laisse une page emettre du son sans geste du lecteur,
   * et c'est heureux. La bande n'est donc meme pas telechargee avant le clic :
   * quatre megaoctets imposes a qui passait lire deux paragraphes seraient
   * un abus, sur un forfait mobile comme sur une connexion lente.
   *
   * Au demarrage, la tete de lecture est posee la ou le spectrogramme en est
   * rendu, et non au debut : l'image ne saute pas, elle se met simplement a
   * dire la verite.
   */
  var bouton = root.querySelector('[data-bsm-sound]');
  if (piste && bouton) {
    var fin = SPECTRO.debut + SPECTRO.duree;
    var intitule = bouton.querySelector('[data-bsm-sound-lab]');

    function boucleExtrait() {
      if (!piste.duration) return;
      if (piste.currentTime >= fin || piste.currentTime < SPECTRO.debut) {
        try { piste.currentTime = SPECTRO.debut; } catch (e) { /* metadonnees pas encore la */ }
      }
    }
    piste.addEventListener('timeupdate', boucleExtrait);

    bouton.addEventListener('click', function () {
      if (piste.paused) {
        piste.muted = false;
        var reprise = SPECTRO.debut + instant();
        var lecture = piste.play();
        if (lecture && lecture.then) {
          lecture.then(function () {
            try { piste.currentTime = reprise; } catch (e) { /* on repart du debut */ }
          }, function () {
            // Refus du navigateur : on ne ment pas sur l'etat du bouton.
            piste.muted = true;
            root.classList.remove('bsm-son');
            bouton.setAttribute('aria-pressed', 'false');
            if (intitule) intitule.textContent = 'Activer le son';
          });
        }
      } else {
        piste.muted = !piste.muted;
      }
      var audible = !piste.muted;
      root.classList.toggle('bsm-son', audible);
      bouton.setAttribute('aria-pressed', audible ? 'true' : 'false');
      if (intitule) intitule.textContent = audible ? 'Couper le son' : 'Activer le son';
    });

    // Le bouton ne s'affiche que tant que l'article est a l'ecran : il n'a
    // rien a faire par-dessus le reste du site.
    if ('IntersectionObserver' in window) {
      new IntersectionObserver(function (es) {
        if (es[0].isIntersecting) root.classList.add('bsm-audible');
        else root.classList.remove('bsm-audible');
      }, { threshold: 0 }).observe(root);
    } else {
      root.classList.add('bsm-audible');
    }
  }

  /* ================================================ POINCONS QUI SUIVENT
   *
   * Les poincons s'inclinent vers le pointeur, leur reflet se deplace, et le
   * film irise du rang Diamant tourne avec l'angle. C'est ce reflet mobile
   * qui donne la sensation du metal, bien plus que la couleur.
   *
   * Tout est calcule dans UNE seule frame d'animation, quel que soit le
   * nombre d'evenements recus : `pointermove` se declenche des dizaines de
   * fois par seconde, et lire une position d'element a chaque fois obligerait
   * le navigateur a recalculer la mise en page autant de fois.
   */
  var badges = root.querySelectorAll('[data-bsm-badge]');
  if (badges.length && survol && !calme) {
    var px = 0, py = 0, attend = false;

    function applique() {
      attend = false;
      var vh = window.innerHeight || 800;
      for (var b = 0; b < badges.length; b++) {
        var el = badges[b], r = el.getBoundingClientRect();
        if (r.bottom < -40 || r.top > vh + 40) continue;
        var cx = r.left + r.width / 2, cy = r.top + r.height / 2;
        var dx = (px - cx) / (r.width * 2.4), dy = (py - cy) / (r.height * 2.4);
        dx = dx < -1 ? -1 : dx > 1 ? 1 : dx;
        dy = dy < -1 ? -1 : dy > 1 ? 1 : dy;
        el.style.setProperty('--ry', (dx * 17).toFixed(2) + 'deg');
        el.style.setProperty('--rx', (-dy * 17).toFixed(2) + 'deg');
        el.style.setProperty('--gx', (50 + dx * 44).toFixed(1) + '%');
        el.style.setProperty('--gy', (30 + dy * 44).toFixed(1) + '%');
        el.style.setProperty('--sweep', (Math.atan2(py - cy, px - cx) * 57.2958).toFixed(1) + 'deg');
      }
    }
    window.addEventListener('pointermove', function (e) {
      px = e.clientX; py = e.clientY;
      if (!attend) { attend = true; requestAnimationFrame(applique); }
    }, { passive: true });
  }
})();
</script>
				</div>
				</div>
				</div>
				</div>
		<p>Cet article <a href="https://presentcomposedesign.fr/birdsignal-infos/">BirdSignal-infos</a> est apparu en premier sur <a href="https://presentcomposedesign.fr">Présent Composé design</a>.</p>
]]></content:encoded>
					
		
		<enclosure url="https://pcdlabs.presentcomposedesign.fr/tools/birdsignal/Birds-in-town_6am_Toulouse_12b-r-l.plana.mp4" length="4117624" type="video/mp4" />

			</item>
		<item>
		<title>PCd 1210 Virtual Direct Drive &#8211; infos</title>
		<link>https://presentcomposedesign.fr/pcd-1210_infos/</link>
		
		<dc:creator><![CDATA[admin]]></dc:creator>
		<pubDate>Sun, 13 Sep 2026 03:53:15 +0000</pubDate>
				<category><![CDATA[[ia]]]></category>
		<category><![CDATA[Design produit]]></category>
		<guid isPermaLink="false">https://presentcomposedesign.fr/?p=36774</guid>

					<description><![CDATA[<p>PCd-1210Virtual Direct Drive Étude de cas &#8211; conception à mise en ligne Concevoir une platine vinyle dans un navigateur. PCd-1210 est née d&#8217;une idée simple : garder le contrôle sur la musique que l&#8217;on écoute, même quand la source est numérique. J&#8217;ai piloté le projet de A à Z, de l&#8217;idée initiale à l&#8217;identité graphique, de l&#8217;architecture de l&#8217;expérience aux itérations fonctionnelles, puis jusqu&#8217;aux tests, au déploiement PWA et à la mise en ligne sur PCdlabs. Ouvrir PCd-1210Voir le code sur GitHubSoutenir le projet Une platine virtuelle dans votre navigateur, jusque dans votre poche. L&#8217;intention Retrouver un geste physique dans une écoute numérique. L&#8217;enjeu n&#8217;était pas de dessiner une simple skin de lecteur audio, mais de construire une interface où le disque, le bras, le pitch, le choix 33/45 et la bibliothèque se répondent comme un seul instrument. 01 Idée et positionnement Définition du concept, du vocabulaire visuel et des interactions qui donnent une vraie présence à la platine. 02 Identité graphique Création du branding PCd-1210, du favicon, de l&#8217;univers sombre et lumineux et des composants visuels de l&#8217;application. 03 Direction produit Arbitrage des fonctions, du responsive, du comportement du bras, de la discothèque et des modules optionnels. Le résultat Une PWA qui reste cohérente du desktop au téléphone. Le même objet passe du grand écran au smartphone : une platine virtuelle que l’on retrouve dans sa poche, avec un plateau carré, une discothèque au swipe ou au gyroscope, la recherche accessible et un branding intégrable ou isolable. PCd-1210 Virtual Direct Drive Recherche intégrée au menu Branding isolable Mon rôle Une conception itérative, jusque dans les détails. Chaque version a servi à valider une hypothèse précise sans perdre les éléments déjà stabilisés. Conception Définition du produit, hiérarchie de l&#8217;interface, analogie avec une platine réelle et choix des interactions prioritaires. Design Logo, favicon, typographie d&#8217;interface, couleurs, effets de matière, équilibre entre skeuomorphisme et interface web contemporaine. Développement Pilotage du développement HTML, CSS et JavaScript, PWA, gestion de la lecture, bibliothèque, imports, pitch, bras, scratch, gyroscope et responsive. Tests Comparaison des versions, corrections ciblées, vérification desktop/mobile, recherche des régressions et stabilisation de l&#8217;expérience. Mise en ligne Préparation du package statique, service worker, manifest, déploiement sur IONOS et documentation de reprise du projet. Technique Le navigateur comme matière de design. La version finale reste volontairement déployable sans compilation obligatoire. L&#8217;architecture privilégie des fichiers lisibles, un comportement progressif et la possibilité d&#8217;installer l&#8217;application comme PWA. Manipulation directe Le bras et le disque ne sont pas de simples illustrations. Ils répondent à la progression, au pitch, aux gestes de souris et aux interactions tactiles. Responsive La platine conserve sa géométrie. Les modules auxiliaires changent d&#8217;emplacement sans réduire le plateau ni couper leurs sliders. Progressive Web App Manifest, service worker, installation et fonctionnement web partagent la même base de code. Open source Un projet ouvert à l&#8217;inspection et à l&#8217;évolution. PCd-1210 v15.2 est distribuée sous licence MIT. Le code peut être étudié, modifié et redéployé dans le respect de cette licence. Code disponible Le dépôt GitHub public doit accueillir la même base que celle déployée en production. Le ZIP source peut aussi être téléchargé directement depuis l&#8217;application. GitHubZIP source Stack HTML + CSS + JavaScript PWA / Service Worker YouTube iframe API Web Audio pour les fichiers compatibles Three.js pour les mises en scène 3D Déploiement statique IONOS Usage légal Lire et manipuler, sans devenir une plateforme de piratage. L&#8217;application est conçue comme une interface de lecture. Elle n&#8217;héberge pas un catalogue musical tiers, ne contourne pas les DRM et conserve YouTube dans son lecteur/API. Les fichiers locaux restent ceux fournis par l&#8217;utilisateur. Cette architecture vise un usage légal, mais elle ne peut pas transformer une source non autorisée en source autorisée. L&#8217;utilisateur reste responsable des droits attachés aux fichiers et liens qu&#8217;il choisit d&#8217;utiliser. Essayez-la Gardez le contrôle sur la musique que vous écoutez. PCd-1210 est accessible dans le navigateur et installable comme PWA. Ouvrir l&#8217;applicationSoutenir le créateurVoir GitHub PCd-1210 Virtual Direct Drive &#8211; Present Composé Design / PCdlabs &#8211; Alban &#38; Emilio DESBARAX &#8211; 2026 PCdlabs / Présent Composé Design PCd-1210 Virtual Direct Drive PCd-1210 est une platine vinyle virtuelle conçue comme une véritable expérience d&#8217;interface. Le projet transpose dans le navigateur des repères et des gestes issus de la platine physique, puis les associe à une discothèque visuelle, une playlist, un contrôle 33T/45T, un pitch, un bras dynamique, le scratch et une installation PWA. Tester PCd-1210 Voir le projet sur GitHub Sponsoriser le créateur Une idée d&#8217;abord centrée sur le geste Le point de départ n&#8217;était pas de reproduire une platine uniquement comme objet graphique. L&#8217;objectif était de construire une interface où la logique physique reste lisible : un disque qui tourne, un bras qui accompagne l&#8217;avancement d&#8217;une piste, des vitesses 33T et 45T, un pitch lié à la lecture, puis une manipulation directe du disque pour le scratch. Identité graphique J&#8217;ai défini l&#8217;identité visuelle PCd-1210, le branding, les proportions générales, la hiérarchie de l&#8217;interface, les états lumineux et la cohérence entre la platine et Ma Discothèque. Le travail sur les favicons et le SVG animé prolonge cette identité jusque dans l&#8217;onglet et l&#8217;installation PWA. Conception de l&#8217;expérience Les interactions ont été pensées autour d&#8217;actions compréhensibles sans mode d&#8217;emploi lourd : lancer ou arrêter un titre, déplacer le bras, modifier la vitesse, régler le pitch, faire défiler les pochettes, sélectionner un disque et le manipuler directement. Développement par itérations Le projet a progressé par versions successives. Chaque étape a servi à consolider la lecture, les imports, la playlist, le bras, le pitch, le scratch, la discothèque, les modules audio et le responsive. Cette méthode a permis de conserver ce qui fonctionnait déjà tout en corrigeant des points précis. Une PWA volontairement simple à déployer PCd-1210 repose sur HTML, CSS et JavaScript natifs. La version de production n&#8217;impose aucune compilation. Le manifest, le service worker et les ressources locales permettent un déploiement direct sur un hébergement mutualisé IONOS par FileZilla. Ma Discothèque, du swipe au gyroscope La bibliothèque n&#8217;est pas présentée comme une</p>
<p>Cet article <a href="https://presentcomposedesign.fr/pcd-1210_infos/">PCd 1210 Virtual Direct Drive &#8211; infos</a> est apparu en premier sur <a href="https://presentcomposedesign.fr">Présent Composé design</a>.</p>
]]></description>
										<content:encoded><![CDATA[		<div data-elementor-type="wp-post" data-elementor-id="36774" class="elementor elementor-36774">
				<div class="elementor-element elementor-element-072ca91 e-con-full e-flex wpr-particle-no wpr-jarallax-no wpr-parallax-no wpr-sticky-section-no wpr-column-slider-no wpr-equal-height-no e-con e-parent" data-id="072ca91" data-element_type="container" data-e-type="container" data-settings="{&quot;background_background&quot;:&quot;classic&quot;}">
				<div class="elementor-element elementor-element-860c849 elementor-widget elementor-widget-html" data-id="860c849" data-element_type="widget" data-e-type="widget" data-widget_type="html.default">
				<div class="elementor-widget-container">
					<!--
PCd-1210 v15.2 - bloc HTML WordPress prêt à coller dans une page /pcd-1210_infos/
IMPORTANT : remplacer {{GITHUB_REPOSITORY_URL}} par l'URL du dépôt GitHub une fois le dépôt publié.
Le bloc reste lisible si WordPress retire le script Three.js : les visuels et le contenu ne dépendent pas du canvas.
-->
<section id="pcd1210-story" class="pcd1210-story">
  <style>
    #pcd1210-story{--bg:#090b13;--panel:#121827;--text:#f8f9fc;--muted:#a4aec0;--line:rgba(255,255,255,.12);--pink:#ff5e78;--cyan:#26dfd8;--blue:#5570ff;background:radial-gradient(circle at 12% 4%,rgba(255,60,100,.15),transparent 26%),radial-gradient(circle at 80% 12%,rgba(70,90,255,.15),transparent 28%),var(--bg);color:var(--text);border-radius:34px;overflow:hidden;font-family:Inter,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.pcd1210-story *{box-sizing:border-box}.pcd1210-wrap{width:min(calc(100% - 34px),1160px);margin:auto}.pcd1210-hero{display:grid;grid-template-columns:minmax(0,1fr) minmax(360px,.85fr);align-items:center;gap:34px;padding:72px 0 54px}.pcd1210-brand{display:flex;align-items:center;gap:14px;margin-bottom:26px}.pcd1210-logo{position:relative;width:66px;height:66px;flex:0 0 66px}.pcd1210-logo img{position:absolute;inset:0;width:100%;height:100%;object-fit:contain}.pcd1210-logo .anim{animation:pcdLogo 3s ease-in-out infinite}.pcd1210-brand strong{display:block;font-size:1.12rem}.pcd1210-brand span{color:var(--muted);font-size:.85rem}.pcd1210-kicker{color:var(--pink);text-transform:uppercase;font-weight:950;letter-spacing:.16em;font-size:.76rem}.pcd1210-story h1{font-size:clamp(3rem,7vw,6.5rem);line-height:.88;letter-spacing:-.065em;margin:10px 0 18px}.pcd1210-story h2{font-size:clamp(2rem,4.6vw,4.2rem);line-height:.96;letter-spacing:-.05em;margin:8px 0 16px}.pcd1210-story h3{margin:0 0 8px}.pcd1210-lead{font-size:clamp(1.15rem,2vw,1.7rem);line-height:1.25;color:#edf0f8;max-width:690px}.pcd1210-copy{color:var(--muted);line-height:1.72}.pcd1210-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:24px}.pcd1210-btn{display:inline-flex;align-items:center;justify-content:center;min-height:48px;padding:11px 17px;border:1px solid var(--line);border-radius:999px;color:#fff!important;text-decoration:none!important;font-weight:850;background:rgba(255,255,255,.05)}.pcd1210-btn.primary{background:linear-gradient(135deg,#ff496d,#ff8195);color:#12131a!important;border-color:transparent}.pcd1210-stage{position:relative;min-height:500px;border:1px solid var(--line);border-radius:30px;overflow:hidden;background:linear-gradient(145deg,rgba(255,255,255,.08),rgba(255,255,255,.02));box-shadow:0 34px 90px rgba(0,0,0,.35)}#pcd1210-three{position:absolute;inset:0;width:100%;height:100%}.pcd1210-stage-logo{position:absolute;width:90px;aspect-ratio:1;left:50%;top:50%;transform:translate(-50%,-50%);border-radius:50%;object-fit:cover}.pcd1210-section{padding:58px 0}.pcd1210-section-head{display:grid;grid-template-columns:minmax(0,1fr) minmax(250px,.55fr);gap:24px;align-items:end;margin-bottom:26px}.pcd1210-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:15px}.pcd1210-card{border:1px solid var(--line);border-radius:24px;padding:23px;background:linear-gradient(145deg,rgba(255,255,255,.07),rgba(255,255,255,.02))}.pcd1210-card p{margin:0;color:var(--muted);line-height:1.6}.pcd1210-num{display:grid;place-items:center;width:36px;height:36px;border-radius:50%;background:rgba(255,94,120,.14);color:var(--pink);font-weight:950;margin-bottom:30px}.pcd1210-visuals{display:grid;grid-template-columns:1.2fr .8fr;gap:15px}.pcd1210-shot{position:relative;min-height:310px;border:1px solid var(--line);border-radius:26px;overflow:hidden;background:#0c101a}.pcd1210-shot img{width:100%;height:100%;display:block;object-fit:cover}.pcd1210-shot.main{min-height:520px}.pcd1210-shot figcaption{position:absolute;left:15px;bottom:15px;padding:9px 12px;border:1px solid var(--line);border-radius:13px;background:rgba(7,9,15,.72);backdrop-filter:blur(12px);font-weight:800}.pcd1210-timeline{display:grid;gap:14px}.pcd1210-step{display:grid;grid-template-columns:120px 1fr;gap:18px;padding:20px 0;border-bottom:1px solid var(--line)}.pcd1210-step b{color:var(--pink)}.pcd1210-step p{margin:0;color:var(--muted);line-height:1.6}.pcd1210-open{display:grid;grid-template-columns:.8fr 1.2fr;gap:16px}.pcd1210-code{border:1px solid rgba(38,223,216,.25);border-radius:26px;padding:24px;background:linear-gradient(145deg,rgba(38,223,216,.08),rgba(85,112,255,.07))}.pcd1210-code pre{margin:14px 0 0;white-space:pre-wrap;color:#b8fff2;font:600 .82rem/1.65 ui-monospace,Menlo,monospace}.pcd1210-legal{border-color:rgba(67,218,143,.28);background:linear-gradient(145deg,rgba(67,218,143,.09),rgba(255,255,255,.02))}.pcd1210-footer-cta{text-align:center;padding:44px 24px;border:1px solid var(--line);border-radius:30px;background:radial-gradient(circle at 50% 0,rgba(255,94,120,.16),transparent 55%),rgba(255,255,255,.03)}.pcd1210-footer-cta .pcd1210-actions{justify-content:center}.pcd1210-meta{padding:26px 0 34px;color:var(--muted);text-align:center;font-size:.84rem}@keyframes pcdLogo{0%,100%{opacity:.7;filter:drop-shadow(0 0 0 rgba(255,94,120,0))}50%{opacity:1;filter:drop-shadow(0 0 18px rgba(255,94,120,.45))}}@media(max-width:900px){.pcd1210-hero,.pcd1210-section-head,.pcd1210-open,.pcd1210-visuals{grid-template-columns:1fr}.pcd1210-grid{grid-template-columns:1fr 1fr}.pcd1210-stage{min-height:420px}.pcd1210-shot.main{min-height:380px}}@media(max-width:600px){.pcd1210-grid{grid-template-columns:1fr}.pcd1210-wrap{width:min(calc(100% - 20px),1160px)}.pcd1210-hero{padding-top:46px}.pcd1210-step{grid-template-columns:1fr;gap:5px}.pcd1210-stage{min-height:340px}.pcd1210-story{border-radius:22px}}@media(prefers-reduced-motion:reduce){.pcd1210-logo .anim{animation:none}}.pcd1210-pocket-scene{position:absolute;inset:0;display:grid;grid-template-columns:1fr .78fr;align-items:center;gap:8px;padding:34px;pointer-events:none}.pcd1210-pocket-deck{position:relative;aspect-ratio:1;border-radius:28px;background:linear-gradient(145deg,#252d3c,#0e131e);box-shadow:0 28px 70px rgba(0,0,0,.35),inset 0 1px 0 rgba(255,255,255,.12);transform:perspective(900px) rotateX(58deg) rotateZ(-10deg)}.pcd1210-pocket-record{position:absolute;inset:14%;border-radius:50%;background:repeating-radial-gradient(circle,#05070a 0 10px,#1c222d 11px 12px);box-shadow:0 18px 38px rgba(0,0,0,.5),inset 0 0 0 2px rgba(255,255,255,.07)}.pcd1210-pocket-record:after{content:"PCd";position:absolute;inset:38%;display:grid;place-items:center;border-radius:50%;background:linear-gradient(135deg,var(--pink),var(--cyan));color:#090b13;font-weight:950}.pcd1210-pocket-phone{position:relative;width:min(220px,100%);justify-self:center;aspect-ratio:.52;border:8px solid #05070b;border-radius:34px;background:#05070b;box-shadow:0 30px 70px rgba(0,0,0,.48);transform:rotate(7deg);overflow:hidden}.pcd1210-pocket-phone:before{content:"";position:absolute;z-index:3;top:6px;left:50%;width:34%;height:14px;transform:translateX(-50%);border-radius:999px;background:#05070b}.pcd1210-pocket-phone img{width:100%;height:100%;object-fit:cover;object-position:left top;display:block}.pcd1210-pocket-caption{position:absolute;left:24px;bottom:20px;right:24px;text-align:center;color:#fff;font-weight:900;text-shadow:0 2px 12px rgba(0,0,0,.8)}@media(max-width:600px){.pcd1210-pocket-scene{padding:18px;grid-template-columns:1fr .7fr}.pcd1210-pocket-phone{border-width:5px;border-radius:22px}.pcd1210-pocket-caption{font-size:.78rem;left:12px;right:12px;bottom:10px}}
  </style>

  <div class="pcd1210-wrap">
    <header class="pcd1210-hero">
      <div>
        <div class="pcd1210-brand"><span class="pcd1210-logo"><img decoding="async" src="https://pcdlabs.presentcomposedesign.fr/experiences/platine-virtuelle/assets/icons/favicon_platine-virtuelle_PCd1210_nogradient.svg" alt="" /><img decoding="async" class="anim" src="https://pcdlabs.presentcomposedesign.fr/experiences/platine-virtuelle/assets/icons/favicon_platine-virtuelle_PCd1210_foranimatedgradient.svg" alt="" /></span><span><strong>PCd-1210</strong><span>Virtual Direct Drive</span></span></div>
        <span class="pcd1210-kicker">Étude de cas - conception à mise en ligne</span>
        <h1>Concevoir une platine vinyle dans un navigateur.</h1>
        <p class="pcd1210-lead">PCd-1210 est née d'une idée simple : garder le contrôle sur la musique que l'on écoute, même quand la source est numérique.</p>
        <p class="pcd1210-copy">J'ai piloté le projet de A à Z, de l'idée initiale à l'identité graphique, de l'architecture de l'expérience aux itérations fonctionnelles, puis jusqu'aux tests, au déploiement PWA et à la mise en ligne sur PCdlabs.</p>
        <div class="pcd1210-actions"><a class="pcd1210-btn primary" href="https://pcdlabs.presentcomposedesign.fr/experiences/platine-virtuelle/">Ouvrir PCd-1210</a><a class="pcd1210-btn" href="{{GITHUB_REPOSITORY_URL}}">Voir le code sur GitHub</a><a class="pcd1210-btn" href="https://buymeacoffee.com/presentcomposedesign">Soutenir le projet</a></div>
      </div>
      <div class="pcd1210-stage"><canvas id="pcd1210-three"></canvas><div class="pcd1210-pocket-scene" aria-label="PCd-1210, une platine virtuelle disponible sur ordinateur et dans la poche"><div class="pcd1210-pocket-deck"><div class="pcd1210-pocket-record"></div></div><div class="pcd1210-pocket-phone"><img decoding="async" src="https://pcdlabs.presentcomposedesign.fr/experiences/platine-virtuelle/assets/media/app-overview.webp" alt="PCd-1210 affichée sur smartphone" /></div><div class="pcd1210-pocket-caption">Une platine virtuelle dans votre navigateur, jusque dans votre poche.</div></div></div>
    </header>

    <section class="pcd1210-section">
      <div class="pcd1210-section-head"><div><span class="pcd1210-kicker">L'intention</span><h2>Retrouver un geste physique dans une écoute numérique.</h2></div><p class="pcd1210-copy">L'enjeu n'était pas de dessiner une simple skin de lecteur audio, mais de construire une interface où le disque, le bras, le pitch, le choix 33/45 et la bibliothèque se répondent comme un seul instrument.</p></div>
      <div class="pcd1210-grid"><article class="pcd1210-card"><span class="pcd1210-num">01</span><h3>Idée et positionnement</h3><p>Définition du concept, du vocabulaire visuel et des interactions qui donnent une vraie présence à la platine.</p></article><article class="pcd1210-card"><span class="pcd1210-num">02</span><h3>Identité graphique</h3><p>Création du branding PCd-1210, du favicon, de l'univers sombre et lumineux et des composants visuels de l'application.</p></article><article class="pcd1210-card"><span class="pcd1210-num">03</span><h3>Direction produit</h3><p>Arbitrage des fonctions, du responsive, du comportement du bras, de la discothèque et des modules optionnels.</p></article></div>
    </section>

    <section class="pcd1210-section">
      <div class="pcd1210-section-head"><div><span class="pcd1210-kicker">Le résultat</span><h2>Une PWA qui reste cohérente du desktop au téléphone.</h2></div><p class="pcd1210-copy">Le même objet passe du grand écran au smartphone : une platine virtuelle que l’on retrouve dans sa poche, avec un plateau carré, une discothèque au swipe ou au gyroscope, la recherche accessible et un branding intégrable ou isolable.</p></div>
      <div class="pcd1210-visuals"><figure class="pcd1210-shot main"><img decoding="async" src="https://pcdlabs.presentcomposedesign.fr/experiences/platine-virtuelle/assets/media/app-dark-reference.webp" alt="Vue complète de PCd-1210 en thème sombre" /><figcaption>PCd-1210 Virtual Direct Drive</figcaption></figure><div style="display:grid;gap:15px"><figure class="pcd1210-shot"><img decoding="async" src="https://pcdlabs.presentcomposedesign.fr/experiences/platine-virtuelle/assets/media/menu-integrated.webp" alt="Menu desktop avec recherche intégrée" /><figcaption>Recherche intégrée au menu</figcaption></figure><figure class="pcd1210-shot"><img decoding="async" src="https://pcdlabs.presentcomposedesign.fr/experiences/platine-virtuelle/assets/media/menu-isolated.webp" alt="Branding isolé du menu principal" /><figcaption>Branding isolable</figcaption></figure></div></div>
    </section>

    <section class="pcd1210-section">
      <div class="pcd1210-section-head"><div><span class="pcd1210-kicker">Mon rôle</span><h2>Une conception itérative, jusque dans les détails.</h2></div><p class="pcd1210-copy">Chaque version a servi à valider une hypothèse précise sans perdre les éléments déjà stabilisés.</p></div>
      <div class="pcd1210-timeline"><div class="pcd1210-step"><b>Conception</b><p>Définition du produit, hiérarchie de l'interface, analogie avec une platine réelle et choix des interactions prioritaires.</p></div><div class="pcd1210-step"><b>Design</b><p>Logo, favicon, typographie d'interface, couleurs, effets de matière, équilibre entre skeuomorphisme et interface web contemporaine.</p></div><div class="pcd1210-step"><b>Développement</b><p>Pilotage du développement HTML, CSS et JavaScript, PWA, gestion de la lecture, bibliothèque, imports, pitch, bras, scratch, gyroscope et responsive.</p></div><div class="pcd1210-step"><b>Tests</b><p>Comparaison des versions, corrections ciblées, vérification desktop/mobile, recherche des régressions et stabilisation de l'expérience.</p></div><div class="pcd1210-step"><b>Mise en ligne</b><p>Préparation du package statique, service worker, manifest, déploiement sur IONOS et documentation de reprise du projet.</p></div></div>
    </section>

    <section class="pcd1210-section">
      <div class="pcd1210-section-head"><div><span class="pcd1210-kicker">Technique</span><h2>Le navigateur comme matière de design.</h2></div><p class="pcd1210-copy">La version finale reste volontairement déployable sans compilation obligatoire. L'architecture privilégie des fichiers lisibles, un comportement progressif et la possibilité d'installer l'application comme PWA.</p></div>
      <div class="pcd1210-grid"><article class="pcd1210-card"><h3>Manipulation directe</h3><p>Le bras et le disque ne sont pas de simples illustrations. Ils répondent à la progression, au pitch, aux gestes de souris et aux interactions tactiles.</p></article><article class="pcd1210-card"><h3>Responsive</h3><p>La platine conserve sa géométrie. Les modules auxiliaires changent d'emplacement sans réduire le plateau ni couper leurs sliders.</p></article><article class="pcd1210-card"><h3>Progressive Web App</h3><p>Manifest, service worker, installation et fonctionnement web partagent la même base de code.</p></article></div>
    </section>

    <section class="pcd1210-section" id="pcd1210-open-source">
      <div class="pcd1210-section-head"><div><span class="pcd1210-kicker">Open source</span><h2>Un projet ouvert à l'inspection et à l'évolution.</h2></div><p class="pcd1210-copy">PCd-1210 v15.2 est distribuée sous licence MIT. Le code peut être étudié, modifié et redéployé dans le respect de cette licence.</p></div>
      <div class="pcd1210-open"><article class="pcd1210-card"><h3>Code disponible</h3><p>Le dépôt GitHub public doit accueillir la même base que celle déployée en production. Le ZIP source peut aussi être téléchargé directement depuis l'application.</p><div class="pcd1210-actions"><a class="pcd1210-btn primary" href="{{GITHUB_REPOSITORY_URL}}">GitHub</a><a class="pcd1210-btn" href="https://pcdlabs.presentcomposedesign.fr/experiences/platine-virtuelle/PCd-1210-source-v15.2.zip">ZIP source</a></div></article><div class="pcd1210-code"><span class="pcd1210-kicker">Stack</span><pre>HTML + CSS + JavaScript
PWA / Service Worker
YouTube iframe API
Web Audio pour les fichiers compatibles
Three.js pour les mises en scène 3D
Déploiement statique IONOS</pre></div></div>
    </section>

    <section class="pcd1210-section">
      <div class="pcd1210-section-head"><div><span class="pcd1210-kicker">Usage légal</span><h2>Lire et manipuler, sans devenir une plateforme de piratage.</h2></div><p class="pcd1210-copy">L'application est conçue comme une interface de lecture. Elle n'héberge pas un catalogue musical tiers, ne contourne pas les DRM et conserve YouTube dans son lecteur/API. Les fichiers locaux restent ceux fournis par l'utilisateur.</p></div>
      <article class="pcd1210-card pcd1210-legal"><p>Cette architecture vise un usage légal, mais elle ne peut pas transformer une source non autorisée en source autorisée. L'utilisateur reste responsable des droits attachés aux fichiers et liens qu'il choisit d'utiliser.</p></article>
    </section>

    <section class="pcd1210-section">
      <div class="pcd1210-footer-cta"><span class="pcd1210-kicker">Essayez-la</span><h2>Gardez le contrôle sur la musique que vous écoutez.</h2><p class="pcd1210-copy">PCd-1210 est accessible dans le navigateur et installable comme PWA.</p><div class="pcd1210-actions"><a class="pcd1210-btn primary" href="https://pcdlabs.presentcomposedesign.fr/experiences/platine-virtuelle/">Ouvrir l'application</a><a class="pcd1210-btn" href="https://buymeacoffee.com/presentcomposedesign">Soutenir le créateur</a><a class="pcd1210-btn" href="{{GITHUB_REPOSITORY_URL}}">Voir GitHub</a></div></div>
    </section>

    <div class="pcd1210-meta">PCd-1210 Virtual Direct Drive - Present Composé Design / PCdlabs - Alban &amp; Emilio DESBARAX - 2026</div>
  </div>

  <script type="module">
    import * as THREE from 'https://unpkg.com/three@0.160.1/build/three.module.js';
    const canvas=document.getElementById('pcd1210-three');if(canvas){const holder=canvas.parentElement;try{const renderer=new THREE.WebGLRenderer({canvas,alpha:true,antialias:true});renderer.setPixelRatio(Math.min(devicePixelRatio,1.7));const scene=new THREE.Scene();const camera=new THREE.PerspectiveCamera(36,1,.1,30);camera.position.set(0,2.5,5);camera.lookAt(0,0,0);scene.add(new THREE.AmbientLight(0xffffff,1.4));const light=new THREE.DirectionalLight(0xffd8e1,4);light.position.set(-3,5,4);scene.add(light);const rim=new THREE.PointLight(0x36e6ff,4,12);rim.position.set(4,1,2);scene.add(rim);const disc=new THREE.Mesh(new THREE.CylinderGeometry(1.65,1.65,.1,110),new THREE.MeshPhysicalMaterial({color:0x050609,roughness:.25,metalness:.45,clearcoat:1}));disc.rotation.x=Math.PI/2;scene.add(disc);const rings=new THREE.Group();for(let i=0;i<18;i++){const ring=new THREE.Mesh(new THREE.TorusGeometry(.56+i*.058,.006,7,100),new THREE.MeshBasicMaterial({color:i%4?0x242a36:0x596175,transparent:true,opacity:.5}));ring.position.z=.06;rings.add(ring)}scene.add(rings);const hub=new THREE.Mesh(new THREE.CylinderGeometry(.42,.42,.12,72),new THREE.MeshPhysicalMaterial({color:0xff5e78,roughness:.3,clearcoat:1}));hub.rotation.x=Math.PI/2;hub.position.z=.02;scene.add(hub);let px=0,py=0;holder.addEventListener('pointermove',e=>{const r=holder.getBoundingClientRect();px=((e.clientX-r.left)/r.width-.5)*2;py=((e.clientY-r.top)/r.height-.5)*2});const resize=()=>{const r=holder.getBoundingClientRect();renderer.setSize(r.width,r.height,false);camera.aspect=r.width/r.height;camera.updateProjectionMatrix()};new ResizeObserver(resize).observe(holder);resize();const reduced=matchMedia('(prefers-reduced-motion: reduce)').matches;const tick=()=>{if(!reduced){disc.rotation.z+=.004;rings.rotation.z=disc.rotation.z}disc.rotation.y+=(px*.08-disc.rotation.y)*.04;disc.rotation.x=Math.PI/2+(-py*.04-(disc.rotation.x-Math.PI/2))*.04;renderer.render(scene,camera);requestAnimationFrame(tick)};tick()}catch(e){canvas.style.display='none'}}
  </script>
</section>
				</div>
				</div>
				</div>
		<div class="elementor-element elementor-element-ae4a03d e-con-full e-flex wpr-particle-no wpr-jarallax-no wpr-parallax-no wpr-sticky-section-no wpr-column-slider-no wpr-equal-height-no e-con e-parent" data-id="ae4a03d" data-element_type="container" data-e-type="container" data-settings="{&quot;background_background&quot;:&quot;classic&quot;}">
				<div class="elementor-element elementor-element-04304b8 elementor-widget elementor-widget-html" data-id="04304b8" data-element_type="widget" data-e-type="widget" data-widget_type="html.default">
				<div class="elementor-widget-container">
					<!-- PCd-1210 Virtual Direct Drive, PCdlabs / Present Composé Design -->
<style>
.pcd1210-project{max-width:1160px;margin:0 auto;padding:32px 20px;font-family:inherit;line-height:1.66;color:inherit}
.pcd1210-project *{box-sizing:border-box}
.pcd1210-project a{word-break:break-word}
.pcd1210-hero{padding:clamp(30px,6vw,70px);border:1px solid rgba(127,127,127,.22);border-radius:30px;background:linear-gradient(145deg,rgba(255,255,255,.07),rgba(127,127,127,.035));margin-bottom:34px}
.pcd1210-hero h1{font-size:clamp(2.4rem,6vw,5.4rem);line-height:.94;letter-spacing:-.055em;margin:0 0 18px}
.pcd1210-kicker{font-size:.82rem;letter-spacing:.16em;text-transform:uppercase;font-weight:800;opacity:.72}
.pcd1210-lead{max-width:860px;font-size:clamp(1.05rem,1.7vw,1.25rem)}
.pcd1210-links{display:flex;flex-wrap:wrap;gap:12px;margin-top:26px}
.pcd1210-links a{display:inline-flex;align-items:center;justify-content:center;min-height:48px;padding:11px 20px;border:1px solid currentColor;border-radius:999px;text-decoration:none;font-weight:800}
.pcd1210-links a:first-child{background:#ff5575;color:#fff;border-color:#ff5575}
.pcd1210-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:22px;margin:28px 0 36px}
.pcd1210-card{padding:24px;border:1px solid rgba(127,127,127,.2);border-radius:22px;background:rgba(127,127,127,.045)}
.pcd1210-card h2{margin-top:0}
.pcd1210-project h2{margin-top:38px}
.pcd1210-tech{display:flex;flex-wrap:wrap;gap:8px;padding:0;list-style:none}
.pcd1210-tech li{padding:7px 12px;border:1px solid rgba(127,127,127,.22);border-radius:999px;font-size:.88rem}
.pcd1210-note{padding:20px 22px;border-left:4px solid #ff5575;background:rgba(127,127,127,.05);border-radius:0 18px 18px 0}
@media(max-width:760px){.pcd1210-grid{grid-template-columns:1fr}.pcd1210-hero{border-radius:22px}.pcd1210-links{flex-direction:column}.pcd1210-links a{width:100%}}
</style>

<article class="pcd1210-project">
  <header class="pcd1210-hero">
    <p class="pcd1210-kicker">PCdlabs / Présent Composé Design</p>
    <h1>PCd-1210 Virtual Direct Drive</h1>
    <p class="pcd1210-lead">PCd-1210 est une platine vinyle virtuelle conçue comme une véritable expérience d'interface. Le projet transpose dans le navigateur des repères et des gestes issus de la platine physique, puis les associe à une discothèque visuelle, une playlist, un contrôle 33T/45T, un pitch, un bras dynamique, le scratch et une installation PWA.</p>
    <div class="pcd1210-links">
      <a href="https://pcdlabs.presentcomposedesign.fr/experiences/platine-virtuelle/" target="_blank" rel="noopener">Tester PCd-1210</a>
      <a href="{{GITHUB_REPOSITORY_URL}}" target="_blank" rel="noopener">Voir le projet sur GitHub</a>
      <a href="https://buymeacoffee.com/presentcomposedesign" target="_blank" rel="noopener">Sponsoriser le créateur</a>
    </div>
  </header>

  <h2>Une idée d'abord centrée sur le geste</h2>
  <p>Le point de départ n'était pas de reproduire une platine uniquement comme objet graphique. L'objectif était de construire une interface où la logique physique reste lisible : un disque qui tourne, un bras qui accompagne l'avancement d'une piste, des vitesses 33T et 45T, un pitch lié à la lecture, puis une manipulation directe du disque pour le scratch.</p>

  <div class="pcd1210-grid">
    <section class="pcd1210-card">
      <h2>Identité graphique</h2>
      <p>J'ai défini l'identité visuelle PCd-1210, le branding, les proportions générales, la hiérarchie de l'interface, les états lumineux et la cohérence entre la platine et Ma Discothèque. Le travail sur les favicons et le SVG animé prolonge cette identité jusque dans l'onglet et l'installation PWA.</p>
    </section>
    <section class="pcd1210-card">
      <h2>Conception de l'expérience</h2>
      <p>Les interactions ont été pensées autour d'actions compréhensibles sans mode d'emploi lourd : lancer ou arrêter un titre, déplacer le bras, modifier la vitesse, régler le pitch, faire défiler les pochettes, sélectionner un disque et le manipuler directement.</p>
    </section>
    <section class="pcd1210-card">
      <h2>Développement par itérations</h2>
      <p>Le projet a progressé par versions successives. Chaque étape a servi à consolider la lecture, les imports, la playlist, le bras, le pitch, le scratch, la discothèque, les modules audio et le responsive. Cette méthode a permis de conserver ce qui fonctionnait déjà tout en corrigeant des points précis.</p>
    </section>
    <section class="pcd1210-card">
      <h2>Une PWA volontairement simple à déployer</h2>
      <p>PCd-1210 repose sur HTML, CSS et JavaScript natifs. La version de production n'impose aucune compilation. Le manifest, le service worker et les ressources locales permettent un déploiement direct sur un hébergement mutualisé IONOS par FileZilla.</p>
    </section>
  </div>

  <h2>Ma Discothèque, du swipe au gyroscope</h2>
  <p>La bibliothèque n'est pas présentée comme une grille administrative. Les pochettes restent carrées et défilent horizontalement. Le swipe reste la manipulation principale sur téléphone. Un contrôle gyroscopique peut compléter ce geste : incliner le téléphone vers la gauche ou la droite déplace le rail dans le même sens, tandis que le doigt permet d'affiner immédiatement la sélection.</p>

  <h2>Le rôle de la platine virtuelle</h2>
  <p>Le plateau, le bras et le disque ne sont pas seulement décoratifs. Ils servent de représentation de la lecture. Le pitch et les modes 33T/45T influencent la vitesse, le bras suit la progression sur la zone utile du disque et le scratch permet de déplacer la lecture en manipulant directement le vinyle.</p>

  <p class="pcd1210-note"><strong>Choix technique important :</strong> les fichiers audio directs et importés peuvent passer par Web Audio pour les traitements compatibles. YouTube reste contrôlé via son lecteur et son API. L'application ne prétend pas appliquer un traitement audio impossible au flux isolé d'une iframe.</p>

  <h2>Mon rôle, de l'idée à la mise en ligne</h2>
  <p>J'ai porté le projet depuis son idée initiale jusqu'à sa mise en ligne : définition du concept, identité graphique, conception de l'expérience, choix fonctionnels, prototypage, suivi et itération du développement, tests, corrections responsive, préparation de la PWA et déploiement. PCd-1210 conserve les crédits du projet à Alban &amp; Emilio DESBARAX.</p>

  <h2>Technologies et contraintes</h2>
  <ul class="pcd1210-tech">
    <li>HTML natif</li>
    <li>CSS responsive</li>
    <li>JavaScript natif</li>
    <li>Web Audio API</li>
    <li>YouTube Player API</li>
    <li>PWA</li>
    <li>Service Worker</li>
    <li>DeviceOrientation</li>
    <li>Pointer Events</li>
    <li>IONOS mutualisé</li>
  </ul>

  <h2>Un projet encore évolutif</h2>
  <p>La priorité reste une expérience 2D rapide, cohérente et compatible avec le plus grand nombre de périphériques. Le code conserve des points d'accroche pour une future intégration d'un vrai modèle 3D interactif inspiré d'une Technics SL-1210/MK2, mais cette partie n'est pas présentée comme une fonctionnalité disponible tant qu'elle n'est pas finalisée.</p>

  <h2>Tester, consulter et soutenir</h2>
  <p>L'application peut être testée directement depuis PCdlabs. Le dépôt GitHub permettra de consulter la structure du projet et ses évolutions. Le lien Buy Me a Coffee permet de soutenir le temps consacré à la conception, aux tests et aux nouvelles expérimentations.</p>
  <div class="pcd1210-links">
    <a href="https://pcdlabs.presentcomposedesign.fr/experiences/platine-virtuelle/" target="_blank" rel="noopener">Ouvrir l'application</a>
    <a href="{{GITHUB_REPOSITORY_URL}}" target="_blank" rel="noopener">GitHub</a>
    <a href="https://buymeacoffee.com/presentcomposedesign" target="_blank" rel="noopener">Buy Me a Coffee</a>
  </div>
</article>
				</div>
				</div>
				</div>
				</div>
		<p>Cet article <a href="https://presentcomposedesign.fr/pcd-1210_infos/">PCd 1210 Virtual Direct Drive &#8211; infos</a> est apparu en premier sur <a href="https://presentcomposedesign.fr">Présent Composé design</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Spotbaignade</title>
		<link>https://presentcomposedesign.fr/spotbaignade/</link>
		
		<dc:creator><![CDATA[admin]]></dc:creator>
		<pubDate>Fri, 17 Jul 2026 14:21:54 +0000</pubDate>
				<category><![CDATA[Design graphique]]></category>
		<category><![CDATA[Design produit]]></category>
		<guid isPermaLink="false">https://presentcomposedesign.fr/?p=36638</guid>

					<description><![CDATA[<p>Cet article <a href="https://presentcomposedesign.fr/spotbaignade/">Spotbaignade</a> est apparu en premier sur <a href="https://presentcomposedesign.fr">Présent Composé design</a>.</p>
]]></description>
										<content:encoded><![CDATA[<p>Cet article <a href="https://presentcomposedesign.fr/spotbaignade/">Spotbaignade</a> est apparu en premier sur <a href="https://presentcomposedesign.fr">Présent Composé design</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Les MoliAires</title>
		<link>https://presentcomposedesign.fr/les-moliaires-exposition-augmentee/</link>
		
		<dc:creator><![CDATA[admin]]></dc:creator>
		<pubDate>Sat, 16 May 2026 08:50:59 +0000</pubDate>
				<category><![CDATA[[ia]]]></category>
		<category><![CDATA[Design graphique]]></category>
		<guid isPermaLink="false">https://presentcomposedesign.fr/?p=36620</guid>

					<description><![CDATA[<p>Exposition augmentée &#183; Aspet &#183; 2026 Que se passe-t-il quand on demande à une intelligence artificielle d&#8217;illustrer littéralement « poser un lapin » ou « mettre les pieds dans le plat » ? Les MoliAires sont nés de cette question. Neuf proverbes français, neuf tableaux uniques, et une exposition augmentée visible à la Librairie Historia &#38; Laetitia, 8 grande rue Augustus Saint-Gaudens, à Aspet. Mettre les pieds dans le plat, l&#8217;un des neuf tableaux de la série. Des images qui mentent sur leur âge Aucune de ces images n&#8217;est une photographie d&#8217;archive. Elles ont été fabriquées de toutes pièces par Alban Desbarax, à l&#8217;aide de plusieurs outils génératifs enchaînés, en prenant l&#8217;expression elle-même comme point de départ du prompt. L&#8217;esthétique de plaque colorisée du début du vingtième siècle est un leurre assumé, tenu jusqu&#8217;au grain du papier. Le visiteur croit d&#8217;abord à un fonds ancien retrouvé dans un grenier. C&#8217;est exactement l&#8217;effet recherché. Une exposition augmentée, donc une seconde lecture Chaque cadre accroché au mur est reconnu par le téléphone des visiteurs. La scène s&#8217;anime alors sur l&#8217;écran, à sa place exacte sur le mur, sans application à installer : le navigateur suffit. Le filtre numérique décale l&#8217;image vers le surréaliste et le loufoque, et révèle ce que la première lecture cachait. Lecture augmentée devant l&#8217;accrochage, photographie parue dans La Dépêche. La carte postale reconnue, puis animée. Les cartes postales de la série portent la même reconnaissance. Une fois chez soi, la carte reste une carte, et redevient une scène animée dès qu&#8217;on la regarde à travers un téléphone. Repartir avec une pièce de la série Chaque tableau existe en un tirage original unique, encadré et signé, scellé d&#8217;un cachet d&#8217;authenticité. Sa déclinaison en carte postale existe en onze exemplaires, numérotés à la main au dos sur un sticker holographique, dans une série de quatre-vingt-dix-neuf numéros. Une fois le dernier attribué, la série est close, sans réimpression. Les cartes postales numérotées, scellées une à une. Voir les neuf tableaux Ouvrir l&#8217;expérience augmentée Tirages originaux à partir de 209 €, cartes postales numérotées à 14,90 €. Détail des neuf tableaux et acquisition sur la page de l&#8217;exposition.</p>
<p>Cet article <a href="https://presentcomposedesign.fr/les-moliaires-exposition-augmentee/">Les MoliAires</a> est apparu en premier sur <a href="https://presentcomposedesign.fr">Présent Composé design</a>.</p>
]]></description>
										<content:encoded><![CDATA[		<div data-elementor-type="wp-post" data-elementor-id="36620" class="elementor elementor-36620">
				<div class="elementor-element elementor-element-b9cc641 e-con-full e-flex wpr-particle-no wpr-jarallax-no wpr-parallax-no wpr-sticky-section-no wpr-column-slider-no wpr-equal-height-no e-con e-parent" data-id="b9cc641" data-element_type="container" data-e-type="container">
				<div class="elementor-element elementor-element-c129f17 elementor-widget elementor-widget-html" data-id="c129f17" data-element_type="widget" data-e-type="widget" data-widget_type="html.default">
				<div class="elementor-widget-container">
					<!-- Les MoliAires, bloc article pour Elementor.
     A coller dans un widget HTML, sur la page les-moliaires-exposition-augmentee.
     Autonome: HTML et CSS seulement, aucun script, aucune dependance.
     Tous les styles sont limites a #mol-art et ne touchent pas le theme.
     Les images sont lues depuis le dossier de la boutique, rien a televerser. -->

<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@300;400;500&display=swap" rel="stylesheet">

<style>
/* ============================================================
   Scoped under #mol-art: nothing here can leak into the theme,
   and the theme cannot reshape the block either.
   Same palette and same two typefaces as the shop page, so the
   visitor never feels he changed website when he follows the link.
   ============================================================ */
#mol-art {
  --paper: #F5F0E8;
  --paper-2: #EDE5D5;
  --ink: #1A1810;
  --ink-soft: #6B6557;
  --gold: #B8952A;
  --gold-deep: #90701A;
  --serif: "Libre Baskerville", Baskerville, "Baskerville Old Face", "Times New Roman", serif;
  --mono: "DM Mono", ui-monospace, Menlo, monospace;

  background: var(--paper);
  color: var(--ink);
  font-family: var(--serif);
  font-size: clamp(.92rem, .88rem + .25vw, 1.04rem);
  line-height: 1.75;
  padding: clamp(1.5rem, 5vw, 3.5rem) clamp(1.1rem, 5vw, 3rem);
}
#mol-art *, #mol-art *::before, #mol-art *::after { box-sizing: border-box; }
#mol-art .wrap { max-width: 74ch; margin: 0 auto; }
#mol-art p { margin: 0 0 1.2rem; }
#mol-art h2 {
  font-family: var(--serif); font-weight: 700; line-height: 1.2;
  font-size: clamp(1.25rem, 1rem + 1.2vw, 1.7rem);
  margin: clamp(2rem, 4vw, 2.8rem) 0 .9rem;
}
#mol-art .eyebrow {
  font-family: var(--mono); font-size: .68rem; letter-spacing: .2em;
  text-transform: uppercase; color: var(--ink-soft); margin: 0 0 1rem;
}
#mol-art .lede { font-size: clamp(1rem, .93rem + .45vw, 1.18rem); line-height: 1.65; }

/* Pictures, all read from the shop folder. */
#mol-art figure { margin: clamp(1.6rem, 4vw, 2.4rem) 0; }
#mol-art img { max-width: 100%; height: auto; display: block; border-radius: 2px; }
#mol-art figure img { width: 100%; box-shadow: 0 24px 48px -30px rgba(26, 24, 16, .65); }
#mol-art figcaption {
  font-family: var(--mono); font-size: .68rem; line-height: 1.5;
  color: var(--ink-soft); margin-top: .7rem;
}
#mol-art .duo { display: grid; gap: 1rem; grid-template-columns: 1fr; }

/* Calls to action. The gold one is the shop, always first. */
#mol-art .cta { display: flex; flex-wrap: wrap; gap: .7rem; margin: clamp(1.8rem, 4vw, 2.6rem) 0 .6rem; }
#mol-art .btn {
  display: inline-flex; align-items: center; justify-content: center;
  font-family: var(--mono); font-size: .78rem; letter-spacing: .09em; text-transform: uppercase;
  padding: .95rem 1.6rem; border: 1px solid transparent; border-radius: 6px;
  text-decoration: none; transition: background-color .18s ease, color .18s ease;
}
#mol-art .btn.gold { background: var(--gold); color: var(--ink); }
#mol-art .btn.gold:hover { background: var(--gold-deep); color: var(--paper); }
#mol-art .btn.line { border-color: rgba(26, 24, 16, .5); color: var(--ink); }
#mol-art .btn.line:hover { background: var(--ink); color: var(--paper); }
#mol-art .fine { font-family: var(--mono); font-size: .68rem; color: var(--ink-soft); }
#mol-art .fine a { color: var(--ink); text-underline-offset: 3px; }
#mol-art .favicon { display: inline-block; width: 1.05em; height: 1.05em; vertical-align: -.16em; margin: 0 .25em; box-shadow: none; }

@media (min-width: 700px) {
  #mol-art .duo { grid-template-columns: 1.4fr 1fr; align-items: start; }
}
</style>

<div id="mol-art">
<div class="wrap">

  <p class="eyebrow">Exposition augmentée &middot; Aspet &middot; 2026</p>

  <p class="lede">
    Que se passe-t-il quand on demande à une intelligence artificielle d'illustrer
    littéralement « poser un lapin » ou « mettre les pieds dans le plat » ? Les
    MoliAires sont nés de cette question. Neuf proverbes français, neuf tableaux
    uniques, et une exposition augmentée visible à la Librairie Historia &amp; Laetitia,
    8 grande rue Augustus Saint-Gaudens, à Aspet.
  </p>

  <figure>
    <img src="https://presentcomposedesign.fr/les-moliaires/assets/img/photos/hero-pieds-dans-le-plat.jpg"
         alt="Mettre les pieds dans le plat, tableau de la série Les MoliAires par Alban Desbarax"
         loading="lazy" decoding="async">
    <figcaption>Mettre les pieds dans le plat, l'un des neuf tableaux de la série.</figcaption>
  </figure>

  <h2>Des images qui mentent sur leur âge</h2>
  <p>
    Aucune de ces images n'est une photographie d'archive. Elles ont été fabriquées
    de toutes pièces par Alban Desbarax, à l'aide de plusieurs outils génératifs
    enchaînés, en prenant l'expression elle-même comme point de départ du prompt.
    L'esthétique de plaque colorisée du début du vingtième siècle est un leurre
    assumé, tenu jusqu'au grain du papier. Le visiteur croit d'abord à un fonds
    ancien retrouvé dans un grenier. C'est exactement l'effet recherché.
  </p>

  <h2>Une exposition augmentée, donc une seconde lecture</h2>
  <p>
    Chaque cadre accroché au mur est reconnu par le téléphone des visiteurs. La
    scène s'anime alors sur l'écran, à sa place exacte sur le mur, sans application
    à installer : le navigateur suffit. Le filtre numérique décale l'image vers le
    surréaliste et le loufoque, et révèle ce que la première lecture cachait.
  </p>

  <div class="duo">
    <figure>
      <img src="https://presentcomposedesign.fr/les-moliaires/assets/img/photos/expo-realite-augmentee.jpg"
           alt="Visiteurs découvrant une œuvre animée sur tablette devant les cadres"
           loading="lazy" decoding="async">
      <figcaption>
        Lecture augmentée devant l'accrochage, photographie parue dans
        <img loading="lazy" decoding="async" class="favicon" src="https://www.ladepeche.fr/favicon.ico" alt="" width="14" height="14"
             loading="lazy" onerror="this.style.display='none'">
        <a href="https://www.ladepeche.fr/2026/06/03/une-etrange-exposition-venue-du-futur-13400580.php"
           target="_blank" rel="noopener">La Dépêche</a>.
      </figcaption>
    </figure>
    <figure>
      <img src="https://presentcomposedesign.fr/les-moliaires/assets/img/photos/ar-telephone.jpg"
           alt="Carte postale des MoliAires animée dans l'écran d'un téléphone"
           loading="lazy" decoding="async">
      <figcaption>La carte postale reconnue, puis animée.</figcaption>
    </figure>
  </div>

  <p>
    Les cartes postales de la série portent la même reconnaissance. Une fois chez
    soi, la carte reste une carte, et redevient une scène animée dès qu'on la
    regarde à travers un téléphone.
  </p>

  <h2>Repartir avec une pièce de la série</h2>
  <p>
    Chaque tableau existe en un tirage original unique, encadré et signé, scellé
    d'un cachet d'authenticité. Sa déclinaison en carte postale existe en onze
    exemplaires, numérotés à la main au dos sur un sticker holographique, dans une
    série de quatre-vingt-dix-neuf numéros. Une fois le dernier attribué, la série
    est close, sans réimpression.
  </p>

  <figure>
    <img src="https://presentcomposedesign.fr/les-moliaires/assets/img/photos/cartes-hologrammes.jpg"
         alt="Cartes postales des MoliAires numérotées et scellées par un sticker holographique"
         loading="lazy" decoding="async">
    <figcaption>Les cartes postales numérotées, scellées une à une.</figcaption>
  </figure>

  <div class="cta">
    <a class="btn gold" href="https://presentcomposedesign.fr/les-moliaires/">
      Voir les neuf tableaux
    </a>
    <a class="btn line" href="https://pcdlabs.presentcomposedesign.fr/experiences/les-moliaires/"
       target="_blank" rel="noopener">
      Ouvrir l'expérience augmentée
    </a>
  </div>

  <p class="fine">
    Tirages originaux à partir de 209 €, cartes postales numérotées à 14,90 €.
    Détail des neuf tableaux et acquisition sur
    <a href="https://presentcomposedesign.fr/les-moliaires/">la page de l'exposition</a>.
  </p>

</div>
</div>
				</div>
				</div>
				</div>
				</div>
		<p>Cet article <a href="https://presentcomposedesign.fr/les-moliaires-exposition-augmentee/">Les MoliAires</a> est apparu en premier sur <a href="https://presentcomposedesign.fr">Présent Composé design</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>My Puzzle</title>
		<link>https://presentcomposedesign.fr/my-puzzle/</link>
		
		<dc:creator><![CDATA[admin]]></dc:creator>
		<pubDate>Tue, 12 May 2026 12:38:27 +0000</pubDate>
				<category><![CDATA[PCdlabs]]></category>
		<guid isPermaLink="false">https://presentcomposedesign.fr/?p=36614</guid>

					<description><![CDATA[<p>Importez une image Le plateau s’adaptera automatiquement à ses proportions. Puzzle terminé ! ↻ Remélanger 📱 Secouez le téléphone pour remélanger Niveau suivant → My Puzzle Votre image devient un puzzle interactif Three.js. ♪ ☀ Vue 2D 3D ＋ Importer une image Nombre de pièces Palier 2 / 5 9 pièces16 pièces25 pièces36 pièces64 piècesPersonnalisé… La grille régulière la plus proche sera utilisée. Style de découpe CarréePuzzle arrondiPuzzle classique Difficulté Initiation · aimantation maximaleFacile · aimantation forteNormal · équilibréDifficile · précisionExpert · assistance minimale ◉ Activer les mouvements du téléphone Mélanger Recommencer Recentrer la vue 0 / 0 pièce placée 0% Importez une image pour commencer. Le plateau conservera son ratio d’origine. My Puzzle · PCdlabs Present Composé Design · Alban DESBARAX · © 2026 ☕ Soutenir le projet</p>
<p>Cet article <a href="https://presentcomposedesign.fr/my-puzzle/">My Puzzle</a> est apparu en premier sur <a href="https://presentcomposedesign.fr">Présent Composé design</a>.</p>
]]></description>
										<content:encoded><![CDATA[		<div data-elementor-type="wp-post" data-elementor-id="36614" class="elementor elementor-36614">
				<div class="elementor-element elementor-element-3bc7f57 e-con-full e-flex wpr-particle-no wpr-jarallax-no wpr-parallax-no wpr-sticky-section-no wpr-column-slider-no wpr-equal-height-no e-con e-parent" data-id="3bc7f57" data-element_type="container" data-e-type="container">
				<div class="elementor-element elementor-element-a576948 elementor-widget elementor-widget-html" data-id="a576948" data-element_type="widget" data-e-type="widget" data-widget_type="html.default">
				<div class="elementor-widget-container">
					<!--
// ╔══════════════════════════════════════════════════════════════════════════╗
// ║  My Puzzle, © All rights reserved                                       ║
// ║  2026 · PCdlabs · Present Composé Design                                ║
// ║  Alban DESBARAX                                                         ║
// ║  360° Design & Creative Technologist - Toulouse, FRANCE                 ║
// ║                                                                          ║
// ║  · Expertise & Support 2D, 3D, AR, VR, XR, AI                           ║
// ║  · Product Design & Industrial Design                                   ║
// ║  · Digital communication                                                ║
// ║  · Immersive experiences  -ᯅ- ✔️                                       ║
// ║                                                                          ║
// ║  · https://presentcomposedesign.fr/                                     ║
// ║  · https://presentcomposedesign.fr/my-puzzle/                           ║
// ║  · https://buymeacoffee.com/presentcomposedesign                        ║
// ╚══════════════════════════════════════════════════════════════════════════╝
//
// Version : 1.2.0 · 30/09/2026
// Cible   : WordPress / Elementor · Widget HTML
// Moteur  : Three.js r161
-->

<div id="mypuzzle-app" data-theme="dark">
  <canvas id="mypuzzle-bg" aria-hidden="true"></canvas>

  <section id="mypuzzle-viewport" aria-label="Zone de jeu My Puzzle">
    <div id="mypuzzle-empty" class="mp-empty">
      <svg class="mp-empty-mark" viewBox="0 0 64 64" aria-hidden="true">
        <path d="M8 8h18v7.2a7 7 0 1 0 12 0V8h18v18h-7.2a7 7 0 1 0 0 12H56v18H38v-7.2a7 7 0 1 0-12 0V56H8V38h7.2a7 7 0 1 0 0-12H8V8Z"/>
      </svg>
      <strong>Importez une image</strong>
      <span>Le plateau s’adaptera automatiquement à ses proportions.</span>
    </div>

    <div id="mypuzzle-celebration" class="mp-celebration" aria-hidden="true"></div>

    <div id="mypuzzle-completion" class="mp-completion is-hidden" aria-live="polite">
      <div class="mp-completion-title">Puzzle terminé !</div>
      <div class="mp-completion-actions">
        <div class="mp-completion-choice">
          <button id="mypuzzle-remix" class="mp-btn mp-btn-secondary" type="button">↻ Remélanger</button>
          <span class="mp-shake-hint">📱 Secouez le téléphone pour remélanger</span>
        </div>
        <button id="mypuzzle-next" class="mp-btn mp-btn-primary mp-next-btn" type="button">Niveau suivant →</button>
      </div>
    </div>
  </section>

  <aside id="mypuzzle-hud" aria-label="Réglages My Puzzle">
    <header class="mp-head">
      <div class="mp-brand-row">
        <span class="mp-brand-mark" aria-hidden="true">
          <svg viewBox="0 0 64 64">
            <defs>
              <linearGradient id="mp-brand-gradient" x1="0" y1="0" x2="1" y2="1">
                <stop offset="0" stop-color="#7a6cff"/>
                <stop offset="0.5" stop-color="#39d8ff"/>
                <stop offset="1" stop-color="#ff74c8"/>
              </linearGradient>
            </defs>
            <path fill="url(#mp-brand-gradient)" d="M8 8h18v7.2a7 7 0 1 0 12 0V8h18v18h-7.2a7 7 0 1 0 0 12H56v18H38v-7.2a7 7 0 1 0-12 0V56H8V38h7.2a7 7 0 1 0 0-12H8V8Z"/>
          </svg>
        </span>
        <div class="mp-brand-copy">
          <div class="mp-title">My Puzzle</div>
          <div class="mp-subtitle">Votre image devient un puzzle interactif Three.js.</div>
        </div>
      </div>

      <div class="mp-mini-actions">
        <button id="mypuzzle-sound" class="mp-icon-btn" type="button" aria-pressed="true" title="Activer ou couper le son">♪</button>
        <button id="mypuzzle-theme" class="mp-icon-btn" type="button" aria-label="Passer en mode clair" title="Mode clair / sombre">☀</button>
      </div>
    </header>

    <div class="mp-control-scroll">
      <div class="mp-mode-row">
        <span class="mp-label">Vue</span>
        <label class="mp-mode-switch" title="Basculer entre la vue 2D et la vue 3D">
          <span>2D</span>
          <input id="mypuzzle-mode" type="checkbox" checked>
          <span class="mp-slider"></span>
          <span>3D</span>
        </label>
      </div>

      <label class="mp-file-label" for="mypuzzle-upload">
        <span>＋</span> Importer une image
      </label>
      <input id="mypuzzle-upload" type="file" accept="image/png,image/jpeg,image/webp">

      <div id="mypuzzle-settings" class="mp-settings is-hidden">
        <div class="mp-field">
          <div class="mp-field-head">
            <label for="mypuzzle-count">Nombre de pièces</label>
            <span id="mypuzzle-level-label" class="mp-level-pill">Palier 2 / 5</span>
          </div>
          <select id="mypuzzle-count">
            <option value="9">9 pièces</option>
            <option value="16" selected>16 pièces</option>
            <option value="25">25 pièces</option>
            <option value="36">36 pièces</option>
            <option value="64">64 pièces</option>
            <option value="custom">Personnalisé…</option>
          </select>
          <div id="mypuzzle-custom-wrap" class="mp-custom-count is-hidden">
            <input id="mypuzzle-custom-count" type="number" min="4" max="196" step="1" inputmode="numeric" value="49" aria-label="Nombre de pièces personnalisé">
            <span id="mypuzzle-grid-helper" class="mp-helper">La grille régulière la plus proche sera utilisée.</span>
          </div>
        </div>

        <div class="mp-field">
          <label for="mypuzzle-cut">Style de découpe</label>
          <select id="mypuzzle-cut">
            <option value="square">Carrée</option>
            <option value="rounded" selected>Puzzle arrondi</option>
            <option value="classic">Puzzle classique</option>
          </select>
        </div>

        <div class="mp-field">
          <label for="mypuzzle-difficulty">Difficulté</label>
          <select id="mypuzzle-difficulty">
            <option value="starter">Initiation · aimantation maximale</option>
            <option value="easy" selected>Facile · aimantation forte</option>
            <option value="normal">Normal · équilibré</option>
            <option value="hard">Difficile · précision</option>
            <option value="expert">Expert · assistance minimale</option>
          </select>
        </div>

        <button id="mypuzzle-motion" class="mp-btn mp-btn-secondary is-hidden" type="button">
          ◉ Activer les mouvements du téléphone
        </button>
      </div>

      <div class="mp-actions">
        <button id="mypuzzle-shuffle" class="mp-btn mp-btn-primary" type="button" disabled>Mélanger</button>
        <button id="mypuzzle-reset" class="mp-btn mp-btn-secondary" type="button" disabled>Recommencer</button>
        <button id="mypuzzle-center" class="mp-btn mp-btn-ghost" type="button">Recentrer la vue</button>
      </div>

      <div class="mp-progress-wrap" aria-live="polite">
        <div class="mp-progress-head">
          <span id="mypuzzle-progress">0 / 0 pièce placée</span>
          <span id="mypuzzle-percent">0%</span>
        </div>
        <div class="mp-progress-track"><span id="mypuzzle-progress-bar"></span></div>
      </div>

      <div id="mypuzzle-status" class="mp-status">
        Importez une image pour commencer. Le plateau conservera son ratio d’origine.
      </div>
    </div>

    <footer class="mp-footer">
      <div class="mp-signature">
        <strong>My Puzzle · PCdlabs</strong>
        <span>Present Composé Design · Alban DESBARAX · © 2026</span>
      </div>
      <a class="mp-coffee" href="https://buymeacoffee.com/presentcomposedesign" target="_blank" rel="noopener noreferrer">☕ Soutenir le projet</a>
    </footer>
  </aside>
</div>

<style>
  /* =========================================================
     MY PUZZLE · ISOLATION WORDPRESS / ELEMENTOR
  ========================================================= */
  #mypuzzle-app {
    all: initial !important;
    --mp-bg: #090b18;
    --mp-bg-2: #10162b;
    --mp-glass: rgba(18, 23, 45, .66);
    --mp-glass-strong: rgba(17, 22, 43, .90);
    --mp-border: rgba(255,255,255,.13);
    --mp-text: #f7f8ff;
    --mp-muted: rgba(239,242,255,.66);
    --mp-soft: rgba(255,255,255,.075);
    --mp-input: rgba(255,255,255,.078);
    --mp-shadow: 0 22px 70px rgba(0,0,0,.34);
    --mp-accent: #7a6cff;
    --mp-accent-2: #39d8ff;
    --mp-accent-3: #ff74c8;
    --mp-success: #62e7ae;
    --mp-board: #171b30;
    --mp-guide: #7380a5;
    --mp-side: #252b45;

    position: relative !important;
    isolation: isolate !important;
    contain: layout paint style !important;
    container-type: inline-size !important;
    display: grid !important;
    grid-template-columns: clamp(330px, 22vw, 380px) minmax(0, 1fr) !important;
    grid-template-rows: minmax(0, 1fr) !important;
    gap: clamp(16px, 1.7vw, 28px) !important;
    width: 100% !important;
    height: 82vh !important;
    height: clamp(720px, 82svh, 920px) !important;
    min-height: 720px !important;
    overflow: hidden !important;
    overflow: clip !important;
    margin: 0 !important;
    padding: clamp(16px, 1.7vw, 26px) !important;
    border: 0 !important;
    border-radius: 0 !important;
    color: var(--mp-text) !important;
    background:
      linear-gradient(135deg, rgba(122,108,255,.13), transparent 38%),
      linear-gradient(315deg, rgba(57,216,255,.10), transparent 40%),
      linear-gradient(160deg, var(--mp-bg), var(--mp-bg-2)) !important;
    font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif !important;
    font-size: 16px !important;
    line-height: 1.4 !important;
    text-align: left !important;
    -webkit-font-smoothing: antialiased !important;
    text-rendering: geometricPrecision !important;
  }

  #mypuzzle-app[data-theme="light"] {
    --mp-bg: #edf4ff;
    --mp-bg-2: #fbf7ff;
    --mp-glass: rgba(255,255,255,.66);
    --mp-glass-strong: rgba(255,255,255,.92);
    --mp-border: rgba(72,82,130,.17);
    --mp-text: #17182a;
    --mp-muted: rgba(30,34,58,.62);
    --mp-soft: rgba(68,76,120,.08);
    --mp-input: rgba(255,255,255,.82);
    --mp-shadow: 0 22px 70px rgba(69,78,125,.18);
    --mp-accent: #6357ef;
    --mp-accent-2: #00a9d8;
    --mp-accent-3: #e54eaa;
    --mp-success: #0aaa76;
    --mp-board: #dfe7f8;
    --mp-guide: #8591b5;
    --mp-side: #c8d2ea;
  }

  #mypuzzle-app *,
  #mypuzzle-app *::before,
  #mypuzzle-app *::after { box-sizing: border-box !important; }

  #mypuzzle-app button,
  #mypuzzle-app select,
  #mypuzzle-app input,
  #mypuzzle-app label,
  #mypuzzle-app a {
    font: inherit !important;
    letter-spacing: normal !important;
    text-transform: none !important;
    text-decoration: none !important;
  }

  #mypuzzle-app button,
  #mypuzzle-app select,
  #mypuzzle-app input { appearance: none !important; -webkit-appearance: none !important; box-shadow: none !important; }

  #mypuzzle-bg {
    position: absolute !important;
    z-index: 0 !important;
    inset: 0 !important;
    width: 100% !important;
    height: 100% !important;
    pointer-events: none !important;
    opacity: 1 !important;
  }

  #mypuzzle-viewport {
    position: relative !important;
    z-index: 2 !important;
    grid-column: 2 !important;
    grid-row: 1 !important;
    min-width: 0 !important;
    min-height: 0 !important;
    width: 100% !important;
    height: 100% !important;
    overflow: hidden !important;
    overflow: clip !important;
    border-radius: 0 !important;
  }

  #mypuzzle-viewport canvas:not(#mypuzzle-bg) {
    display: block !important;
    width: 100% !important;
    height: 100% !important;
    outline: 0 !important;
    touch-action: none !important;
    cursor: grab !important;
  }

  #mypuzzle-viewport canvas:active { cursor: grabbing !important; }
  #mypuzzle-viewport, #mypuzzle-bg { transform: translateZ(0) !important; backface-visibility: hidden !important; }
  #mypuzzle-viewport { contain: strict !important; }

  #mypuzzle-app .mp-empty {
    position: absolute !important;
    z-index: 4 !important;
    top: 50% !important;
    left: 50% !important;
    transform: translate(-50%,-50%) !important;
    display: flex !important;
    flex-direction: column !important;
    align-items: center !important;
    gap: 10px !important;
    width: min(430px, 62%) !important;
    padding: 18px !important;
    color: var(--mp-text) !important;
    text-align: center !important;
    pointer-events: none !important;
    transition: opacity .26s ease, transform .26s ease !important;
  }

  #mypuzzle-app .mp-empty.is-hidden { opacity: 0 !important; transform: translate(-50%,-47%) scale(.96) !important; }

  #mypuzzle-app .mp-empty-mark {
    width: 96px !important;
    height: 96px !important;
    overflow: visible !important;
    fill: none !important;
    stroke: var(--mp-accent-2) !important;
    stroke-width: 2.1 !important;
    stroke-linecap: round !important;
    stroke-linejoin: round !important;
    stroke-dasharray: 4 6 !important;
    opacity: .80 !important;
    filter: drop-shadow(0 0 18px color-mix(in srgb, var(--mp-accent-2) 20%, transparent)) !important;
  }

  #mypuzzle-app .mp-empty strong { color: var(--mp-text) !important; font-size: 17px !important; font-weight: 850 !important; }
  #mypuzzle-app .mp-empty span { color: var(--mp-muted) !important; font-size: 12px !important; }

  #mypuzzle-hud {
    position: relative !important;
    z-index: 20 !important;
    grid-column: 1 !important;
    grid-row: 1 !important;
    display: flex !important;
    flex-direction: column !important;
    width: 100% !important;
    height: 100% !important;
    min-height: 0 !important;
    max-height: none !important;
    overflow: hidden !important;
    padding: 0 !important;
    border: 1px solid var(--mp-border) !important;
    border-radius: 24px !important;
    color: var(--mp-text) !important;
    background: var(--mp-glass) !important;
    backdrop-filter: blur(24px) saturate(145%) !important;
    -webkit-backdrop-filter: blur(24px) saturate(145%) !important;
    box-shadow: var(--mp-shadow) !important;
  }

  #mypuzzle-app .mp-head {
    flex: 0 0 auto !important;
    display: flex !important;
    align-items: flex-start !important;
    justify-content: space-between !important;
    gap: 12px !important;
    padding: 18px 18px 14px !important;
    border-bottom: 1px solid color-mix(in srgb, var(--mp-border) 72%, transparent) !important;
  }

  #mypuzzle-app .mp-brand-row { display: flex !important; align-items: center !important; gap: 11px !important; min-width: 0 !important; }
  #mypuzzle-app .mp-brand-mark { display: grid !important; place-items: center !important; flex: 0 0 37px !important; width: 37px !important; height: 37px !important; }
  #mypuzzle-app .mp-brand-mark svg { display: block !important; width: 34px !important; height: 34px !important; filter: drop-shadow(0 7px 14px rgba(57,216,255,.14)) !important; }
  #mypuzzle-app .mp-brand-copy { min-width: 0 !important; }
  #mypuzzle-app .mp-title { margin: 0 !important; color: var(--mp-text) !important; font-size: 23px !important; line-height: 1.02 !important; font-weight: 900 !important; }
  #mypuzzle-app .mp-subtitle { margin: 5px 0 0 !important; color: var(--mp-muted) !important; font-size: 10.5px !important; line-height: 1.38 !important; max-width: 205px !important; }

  #mypuzzle-app .mp-mini-actions { display: flex !important; align-items: center !important; gap: 6px !important; flex: 0 0 auto !important; }

  #mypuzzle-app .mp-icon-btn {
    all: unset !important;
    box-sizing: border-box !important;
    display: grid !important;
    place-items: center !important;
    width: 32px !important;
    height: 32px !important;
    border: 1px solid var(--mp-border) !important;
    border-radius: 11px !important;
    color: var(--mp-text) !important;
    background: var(--mp-soft) !important;
    cursor: pointer !important;
    font: 800 14px/1 Arial, sans-serif !important;
    transition: transform .16s ease, background .16s ease, opacity .16s ease !important;
  }

  #mypuzzle-app .mp-icon-btn:hover { transform: translateY(-1px) scale(1.03) !important; background: var(--mp-input) !important; }
  #mypuzzle-app .mp-icon-btn[aria-pressed="false"] { opacity: .50 !important; }

  #mypuzzle-app .mp-control-scroll {
    flex: 1 1 auto !important;
    min-height: 0 !important;
    overflow: auto !important;
    padding: 14px 18px 16px !important;
    scrollbar-width: thin !important;
    scrollbar-color: var(--mp-soft) transparent !important;
  }

  #mypuzzle-app .mp-control-scroll::-webkit-scrollbar { width: 6px !important; }
  #mypuzzle-app .mp-control-scroll::-webkit-scrollbar-thumb { background: var(--mp-soft) !important; border-radius: 999px !important; }

  #mypuzzle-app .mp-mode-row {
    display: flex !important;
    align-items: center !important;
    justify-content: space-between !important;
    gap: 12px !important;
    min-height: 38px !important;
    margin: 0 0 10px !important;
    padding: 0 1px !important;
  }

  #mypuzzle-app .mp-label { color: var(--mp-muted) !important; font-size: 11px !important; font-weight: 800 !important; }

  #mypuzzle-app .mp-mode-switch {
    display: grid !important;
    grid-template-columns: auto 38px auto !important;
    align-items: center !important;
    gap: 6px !important;
    color: var(--mp-muted) !important;
    font-size: 10px !important;
    font-weight: 900 !important;
    cursor: pointer !important;
    user-select: none !important;
  }

  #mypuzzle-app .mp-mode-switch input { position: absolute !important; opacity: 0 !important; pointer-events: none !important; }
  #mypuzzle-app .mp-slider { position: relative !important; display: block !important; width: 38px !important; height: 22px !important; border: 1px solid var(--mp-border) !important; border-radius: 999px !important; background: var(--mp-soft) !important; }
  #mypuzzle-app .mp-slider::after { content: "" !important; position: absolute !important; top: 3px !important; left: 3px !important; width: 14px !important; height: 14px !important; border-radius: 50% !important; background: linear-gradient(135deg, var(--mp-accent), var(--mp-accent-2)) !important; box-shadow: 0 2px 8px rgba(0,0,0,.18) !important; transition: transform .2s ease !important; }
  #mypuzzle-app .mp-mode-switch input:checked + .mp-slider::after { transform: translateX(16px) !important; }

  #mypuzzle-upload { display: none !important; }

  #mypuzzle-app .mp-file-label,
  #mypuzzle-app .mp-btn,
  #mypuzzle-app select,
  #mypuzzle-app input[type="number"],
  #mypuzzle-app .mp-coffee { min-height: 46px !important; border-radius: 14px !important; font-size: 13px !important; font-weight: 800 !important; }

  #mypuzzle-app .mp-file-label {
    display: flex !important;
    align-items: center !important;
    justify-content: center !important;
    gap: 8px !important;
    width: 100% !important;
    padding: 10px 14px !important;
    border: 1px dashed color-mix(in srgb, var(--mp-accent-2) 58%, transparent) !important;
    color: var(--mp-text) !important;
    background: linear-gradient(135deg, color-mix(in srgb, var(--mp-accent) 14%, transparent), color-mix(in srgb, var(--mp-accent-2) 12%, transparent)) !important;
    cursor: pointer !important;
    transition: transform .16s ease, border-color .16s ease !important;
  }

  #mypuzzle-app .mp-file-label:hover { transform: translateY(-1px) !important; border-color: var(--mp-accent-2) !important; }
  #mypuzzle-app .mp-settings { display: grid !important; gap: 11px !important; margin: 13px 0 0 !important; }
  #mypuzzle-app .is-hidden { display: none !important; }

  #mypuzzle-app .mp-field label { display: block !important; margin: 0 0 6px !important; color: var(--mp-muted) !important; font-size: 10px !important; font-weight: 800 !important; }
  #mypuzzle-app .mp-field-head { display: flex !important; align-items: center !important; justify-content: space-between !important; gap: 8px !important; }
  #mypuzzle-app .mp-field-head label { margin-bottom: 6px !important; }
  #mypuzzle-app .mp-level-pill { display: inline-flex !important; align-items: center !important; min-height: 20px !important; padding: 0 7px !important; margin-bottom: 6px !important; border: 1px solid var(--mp-border) !important; border-radius: 999px !important; color: var(--mp-accent-2) !important; background: var(--mp-soft) !important; font-size: 8.5px !important; font-weight: 900 !important; }

  #mypuzzle-app select,
  #mypuzzle-app input[type="number"] {
    display: block !important;
    width: 100% !important;
    margin: 0 !important;
    border: 1px solid var(--mp-border) !important;
    color: var(--mp-text) !important;
    outline: 0 !important;
    background-color: var(--mp-input) !important;
  }

  #mypuzzle-app select {
    padding: 0 38px 0 12px !important;
    background:
      linear-gradient(45deg, transparent 50%, var(--mp-muted) 50%) calc(100% - 17px) 19px/5px 5px no-repeat,
      linear-gradient(135deg, var(--mp-muted) 50%, transparent 50%) calc(100% - 12px) 19px/5px 5px no-repeat,
      var(--mp-input) !important;
    cursor: pointer !important;
  }

  #mypuzzle-app select option { color: #16182a !important; background: #fff !important; }
  #mypuzzle-app input[type="number"] { padding: 0 12px !important; }
  #mypuzzle-app input[type="number"]::-webkit-inner-spin-button,
  #mypuzzle-app input[type="number"]::-webkit-outer-spin-button { opacity: .55 !important; }

  #mypuzzle-app .mp-custom-count { display: grid !important; grid-template-columns: 106px 1fr !important; align-items: center !important; gap: 9px !important; margin-top: 8px !important; }
  #mypuzzle-app .mp-custom-count.is-hidden { display: none !important; }
  #mypuzzle-app .mp-helper { color: var(--mp-muted) !important; font-size: 9px !important; line-height: 1.35 !important; }

  #mypuzzle-app .mp-actions { display: grid !important; grid-template-columns: 1fr 1fr !important; gap: 8px !important; margin: 13px 0 0 !important; }
  #mypuzzle-app .mp-btn { width: 100% !important; margin: 0 !important; padding: 0 12px !important; border: 1px solid transparent !important; outline: 0 !important; cursor: pointer !important; transition: transform .16s ease, opacity .16s ease, box-shadow .16s ease, filter .16s ease !important; }
  #mypuzzle-app .mp-btn:hover:not(:disabled) { transform: translateY(-1px) !important; filter: brightness(1.04) !important; }
  #mypuzzle-app .mp-btn:disabled { opacity: .38 !important; cursor: not-allowed !important; }
  #mypuzzle-app .mp-btn-primary { color: #fff !important; border-color: color-mix(in srgb, var(--mp-accent) 55%, transparent) !important; background: linear-gradient(135deg, var(--mp-accent), var(--mp-accent-3)) !important; box-shadow: 0 10px 24px color-mix(in srgb, var(--mp-accent) 24%, transparent) !important; }
  #mypuzzle-app .mp-btn-secondary { color: var(--mp-text) !important; border-color: var(--mp-border) !important; background: var(--mp-input) !important; }
  #mypuzzle-app .mp-btn-ghost { grid-column: 1 / -1 !important; color: var(--mp-muted) !important; border-color: var(--mp-border) !important; background: transparent !important; }

  #mypuzzle-app .mp-progress-wrap { margin: 14px 0 0 !important; }
  #mypuzzle-app .mp-progress-head { display: flex !important; align-items: center !important; justify-content: space-between !important; gap: 8px !important; color: var(--mp-text) !important; font-size: 11px !important; font-weight: 800 !important; }
  #mypuzzle-app #mypuzzle-percent { color: var(--mp-accent-2) !important; }
  #mypuzzle-app .mp-progress-track { width: 100% !important; height: 7px !important; margin: 7px 0 0 !important; overflow: hidden !important; border-radius: 999px !important; background: var(--mp-soft) !important; }
  #mypuzzle-progress-bar { display: block !important; width: 0%; height: 100% !important; border-radius: inherit !important; background: linear-gradient(90deg, var(--mp-accent), var(--mp-accent-2), var(--mp-success)) !important; transition: width .35s cubic-bezier(.2,.8,.2,1) !important; }

  #mypuzzle-app .mp-status { margin: 10px 0 0 !important; padding: 10px 11px !important; border: 1px solid var(--mp-border) !important; border-radius: 12px !important; color: var(--mp-muted) !important; background: var(--mp-soft) !important; font-size: 10px !important; line-height: 1.5 !important; }

  #mypuzzle-app .mp-footer {
    flex: 0 0 auto !important;
    display: flex !important;
    align-items: center !important;
    justify-content: space-between !important;
    gap: 10px !important;
    margin: 0 !important;
    padding: 12px 18px !important;
    border-top: 1px solid var(--mp-border) !important;
    color: var(--mp-text) !important;
    background: color-mix(in srgb, var(--mp-glass-strong) 92%, transparent) !important;
    backdrop-filter: blur(20px) saturate(140%) !important;
    -webkit-backdrop-filter: blur(20px) saturate(140%) !important;
  }

  #mypuzzle-app .mp-signature { display: flex !important; flex-direction: column !important; gap: 2px !important; min-width: 0 !important; }
  #mypuzzle-app .mp-signature strong { color: var(--mp-text) !important; font-size: 9px !important; font-weight: 900 !important; white-space: nowrap !important; overflow: hidden !important; text-overflow: ellipsis !important; }
  #mypuzzle-app .mp-signature span { color: var(--mp-muted) !important; font-size: 7.8px !important; white-space: nowrap !important; overflow: hidden !important; text-overflow: ellipsis !important; }

  #mypuzzle-app .mp-coffee {
    display: inline-flex !important;
    align-items: center !important;
    justify-content: center !important;
    flex: 0 0 auto !important;
    min-height: 34px !important;
    padding: 0 10px !important;
    border: 1px solid rgba(255,190,77,.50) !important;
    color: #241900 !important;
    background: linear-gradient(135deg, #ffe27a, #ffbe4d) !important;
    box-shadow: 0 8px 20px rgba(255,190,77,.18) !important;
    white-space: nowrap !important;
    font-size: 9px !important;
    transition: transform .16s ease !important;
  }

  #mypuzzle-app .mp-coffee:hover { transform: translateY(-1px) rotate(-.3deg) !important; }

  /* =========================================================
     FIN DE PARTIE
  ========================================================= */
  #mypuzzle-app .mp-completion {
    position: absolute !important;
    z-index: 25 !important;
    left: 50% !important;
    right: auto !important;
    bottom: clamp(18px, 2.4vh, 28px) !important;
    width: min(640px, calc(100% - 42px)) !important;
    margin: 0 !important;
    padding: 13px !important;
    border: 1px solid var(--mp-border) !important;
    border-radius: 18px !important;
    color: var(--mp-text) !important;
    background: var(--mp-glass-strong) !important;
    backdrop-filter: blur(24px) saturate(145%) !important;
    -webkit-backdrop-filter: blur(24px) saturate(145%) !important;
    box-shadow: var(--mp-shadow) !important;
    pointer-events: auto !important;
    transform: translateX(-50%) !important;
    animation: mpCompletionIn .42s cubic-bezier(.2,.85,.2,1) both !important;
  }

  #mypuzzle-app .mp-completion-title { margin: 0 0 9px !important; text-align: center !important; color: var(--mp-text) !important; font-size: 13px !important; font-weight: 900 !important; }
  #mypuzzle-app .mp-completion-actions { display: grid !important; grid-template-columns: 1fr 1fr !important; gap: 10px !important; align-items: start !important; }
  #mypuzzle-app .mp-completion-choice { display: grid !important; gap: 5px !important; }
  #mypuzzle-app .mp-next-btn { min-height: 48px !important; box-shadow: 0 12px 30px color-mix(in srgb, var(--mp-accent) 28%, transparent) !important; }
  #mypuzzle-app .mp-shake-hint { display: none !important; color: var(--mp-muted) !important; font-size: 8px !important; text-align: center !important; }

  @keyframes mpCompletionIn { from { opacity: 0; translate: 0 12px; scale: .97; } to { opacity: 1; translate: 0 0; scale: 1; } }

  /* =========================================================
     CELEBRATION RENFORCEE
  ========================================================= */
  #mypuzzle-app .mp-celebration { position: absolute !important; z-index: 30 !important; inset: 0 !important; overflow: hidden !important; pointer-events: none !important; }
  #mypuzzle-app .mp-celebration-flash { position: absolute !important; inset: 0 !important; background: radial-gradient(circle at 50% 48%, color-mix(in srgb, var(--mp-success) 22%, transparent), transparent 46%) !important; opacity: 0 !important; animation: mpFlash 1.25s ease-out forwards !important; }
  #mypuzzle-app .mp-win-ring { position: absolute !important; left: 50% !important; top: 47% !important; width: 80px !important; height: 80px !important; margin: -40px 0 0 -40px !important; border: 2px solid var(--mp-success) !important; border-radius: 50% !important; opacity: 0 !important; animation: mpRing 1.5s ease-out forwards !important; animation-delay: var(--delay) !important; }
  #mypuzzle-app .mp-confetti { position: absolute !important; top: -24px !important; width: var(--w) !important; height: var(--h) !important; border-radius: 2px !important; opacity: 0 !important; animation: mpConfetti var(--dur) cubic-bezier(.12,.7,.22,1) forwards !important; animation-delay: var(--delay) !important; }
  #mypuzzle-app .mp-win-badge { position: absolute !important; left: 50% !important; top: 42% !important; transform: translate(-50%,-50%) !important; padding: 12px 18px !important; border: 1px solid var(--mp-border) !important; border-radius: 999px !important; color: var(--mp-text) !important; background: var(--mp-glass-strong) !important; backdrop-filter: blur(16px) !important; -webkit-backdrop-filter: blur(16px) !important; box-shadow: var(--mp-shadow) !important; font-size: 16px !important; font-weight: 950 !important; opacity: 0 !important; animation: mpBadge 1.7s cubic-bezier(.2,.8,.2,1) forwards !important; }
  @keyframes mpFlash { 0% { opacity: 0; } 16% { opacity: 1; } 100% { opacity: 0; } }
  @keyframes mpRing { 0% { opacity: .9; transform: scale(.2); } 100% { opacity: 0; transform: scale(8); } }
  @keyframes mpBadge { 0% { opacity: 0; transform: translate(-50%,-50%) scale(.72); } 18% { opacity: 1; transform: translate(-50%,-50%) scale(1.08); } 45% { opacity: 1; transform: translate(-50%,-50%) scale(1); } 100% { opacity: 0; transform: translate(-50%,-58%) scale(.96); } }
  @keyframes mpConfetti { 0% { opacity: 0; transform: translate3d(0,-20px,0) rotate(0deg); } 8% { opacity: 1; } 100% { opacity: 0; transform: translate3d(var(--drift), 108vh, 0) rotate(var(--spin)); } }

  @media (max-width: 1120px) and (min-width: 861px) {
    #mypuzzle-app {
      grid-template-columns: 330px minmax(0, 1fr) !important;
      gap: 16px !important;
      padding: 16px !important;
    }
    #mypuzzle-app .mp-title { font-size: 21px !important; }
    #mypuzzle-app .mp-subtitle { max-width: 180px !important; }
  }

  @media (max-width: 860px) {
    #mypuzzle-app {
      display: flex !important;
      flex-direction: column !important;
      gap: 0 !important;
      height: auto !important;
      min-height: 0 !important;
      overflow: clip !important;
      padding: 0 !important;
      border-radius: 0 !important;
    }
    #mypuzzle-bg { height: 100% !important; }
    #mypuzzle-viewport {
      position: relative !important;
      order: 1 !important;
      width: 100% !important;
      height: 58vh !important;
      height: 58svh !important;
      min-height: 410px !important;
      max-height: 680px !important;
      overflow: clip !important;
      border-radius: 0 !important;
    }
    #mypuzzle-app .mp-empty { left: 50% !important; width: min(360px, calc(100% - 36px)) !important; }
    #mypuzzle-app .mp-empty-mark { width: 82px !important; height: 82px !important; }

    #mypuzzle-hud {
      position: relative !important;
      order: 2 !important;
      width: 100% !important;
      height: auto !important;
      max-height: none !important;
      overflow: hidden !important;
      padding: 0 !important;
      border-width: 1px 0 0 !important;
      border-radius: 0 !important;
      background: var(--mp-glass-strong) !important;
      box-shadow: none !important;
    }
    #mypuzzle-app .mp-head { padding: 15px 16px 13px !important; }
    #mypuzzle-app .mp-control-scroll { overflow: visible !important; padding: 14px 16px 16px !important; }
    #mypuzzle-app .mp-footer { padding: 12px 16px !important; }
    #mypuzzle-app .mp-file-label,
    #mypuzzle-app .mp-btn,
    #mypuzzle-app select,
    #mypuzzle-app input[type="number"] { min-height: 50px !important; }

    #mypuzzle-app .mp-completion {
      left: 50% !important;
      right: auto !important;
      bottom: 14px !important;
      width: min(620px, calc(100% - 28px)) !important;
      transform: translateX(-50%) !important;
    }
    #mypuzzle-app .mp-shake-hint { display: block !important; }
  }

  @media (max-width: 480px) {
    #mypuzzle-app .mp-brand-mark { flex-basis: 34px !important; width: 34px !important; height: 34px !important; }
    #mypuzzle-app .mp-title { font-size: 21px !important; }
    #mypuzzle-app .mp-subtitle { max-width: 170px !important; }
    #mypuzzle-app .mp-custom-count { grid-template-columns: 96px 1fr !important; }
    #mypuzzle-app .mp-footer { align-items: stretch !important; flex-direction: column !important; }
    #mypuzzle-app .mp-coffee { width: 100% !important; }
    #mypuzzle-app .mp-completion-actions { grid-template-columns: 1fr 1fr !important; gap: 8px !important; }
    #mypuzzle-app .mp-completion { padding: 11px !important; }
  }

  @media (prefers-reduced-motion: reduce) {
    #mypuzzle-app .mp-confetti,
    #mypuzzle-app .mp-win-ring,
    #mypuzzle-app .mp-win-badge,
    #mypuzzle-app .mp-celebration-flash,
    #mypuzzle-app .mp-completion { animation: none !important; }
  }
</style>

<script type="importmap">
{
  "imports": {
    "three": "https://cdn.jsdelivr.net/npm/three@0.161.0/build/three.module.js",
    "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.161.0/examples/jsm/"
  }
}
</script>

<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

const app = document.getElementById('mypuzzle-app');
const viewport = document.getElementById('mypuzzle-viewport');
const backgroundCanvas = document.getElementById('mypuzzle-bg');
const emptyState = document.getElementById('mypuzzle-empty');
const celebration = document.getElementById('mypuzzle-celebration');
const completionPanel = document.getElementById('mypuzzle-completion');
const uploadInput = document.getElementById('mypuzzle-upload');
const settingsPanel = document.getElementById('mypuzzle-settings');
const countSelect = document.getElementById('mypuzzle-count');
const customCountWrap = document.getElementById('mypuzzle-custom-wrap');
const customCountInput = document.getElementById('mypuzzle-custom-count');
const gridHelper = document.getElementById('mypuzzle-grid-helper');
const levelLabel = document.getElementById('mypuzzle-level-label');
const cutSelect = document.getElementById('mypuzzle-cut');
const difficultySelect = document.getElementById('mypuzzle-difficulty');
const modeToggle = document.getElementById('mypuzzle-mode');
const themeButton = document.getElementById('mypuzzle-theme');
const soundButton = document.getElementById('mypuzzle-sound');
const shuffleButton = document.getElementById('mypuzzle-shuffle');
const resetButton = document.getElementById('mypuzzle-reset');
const centerButton = document.getElementById('mypuzzle-center');
const motionButton = document.getElementById('mypuzzle-motion');
const remixButton = document.getElementById('mypuzzle-remix');
const nextButton = document.getElementById('mypuzzle-next');
const progressElement = document.getElementById('mypuzzle-progress');
const percentElement = document.getElementById('mypuzzle-percent');
const progressBar = document.getElementById('mypuzzle-progress-bar');
const statusElement = document.getElementById('mypuzzle-status');

if (app && viewport && app.dataset.mypuzzleInitialized !== 'true') {
  app.dataset.mypuzzleInitialized = 'true';
  startMyPuzzle();
}

function startMyPuzzle() {
  const STORAGE = {
    image: 'pcdlabs-mypuzzle-image-v2',
    state: 'pcdlabs-mypuzzle-state-v2',
    theme: 'pcdlabs-mypuzzle-theme-v2',
    sound: 'pcdlabs-mypuzzle-sound-v2'
  };

  const LEGACY = {
    image: 'pcdlabs-puzzle3d-image-v1',
    state: 'pcdlabs-puzzle3d-state-v1',
    theme: 'pcdlabs-puzzle3d-theme-v1',
    sound: 'pcdlabs-puzzle3d-sound-v1'
  };

  const LEVELS = [
    { count: 9,  difficulty: 'starter', cut: 'rounded' },
    { count: 16, difficulty: 'easy',    cut: 'rounded' },
    { count: 25, difficulty: 'normal',  cut: 'classic' },
    { count: 36, difficulty: 'hard',    cut: 'square' },
    { count: 64, difficulty: 'expert',  cut: 'square' }
  ];

  const difficultyProfiles = {
    starter:{ spread: .64, rotationXY: .07, rotationZ: .06, zSpread: .20, magnetRadius: 2.02, snapRadius: 1.72, magnetBase: .34, magnetGain: .56, dragLerp: .89 },
    easy:   { spread: .76, rotationXY: .11, rotationZ: .10, zSpread: .28, magnetRadius: 1.78, snapRadius: 1.48, magnetBase: .28, magnetGain: .52, dragLerp: .86 },
    normal: { spread: .96, rotationXY: .18, rotationZ: .18, zSpread: .38, magnetRadius: 1.34, snapRadius: 1.12, magnetBase: .18, magnetGain: .40, dragLerp: .80 },
    hard:   { spread: 1.12, rotationXY: .27, rotationZ: .28, zSpread: .50, magnetRadius: .96, snapRadius: .79, magnetBase: .11, magnetGain: .29, dragLerp: .75 },
    expert: { spread: 1.28, rotationXY: .34, rotationZ: .38, zSpread: .64, magnetRadius: .72, snapRadius: .59, magnetBase: .06, magnetGain: .20, dragLerp: .70 }
  };

  let scene;
  let perspectiveCamera;
  let orthographicCamera;
  let activeCamera;
  let renderer;
  let controls;
  let board;
  let boardMaterial;
  let sourceImage = null;
  let currentTexture = null;
  let pieces = [];
  let guideObjects = [];
  let selectedPiece = null;
  let is3D = true;
  let cutStyle = 'rounded';
  let difficulty = 'easy';
  let soundEnabled = readStorage(STORAGE.sound, LEGACY.sound) !== 'off';
  let audioContext = null;
  let completedOnce = false;

  let requestedCount = 16;
  let actualCount = 16;
  let gridCols = 4;
  let gridRows = 4;
  let imageAspect = 1;
  let boardWidth = 5.25;
  let boardHeight = 5.25;
  let pieceWidth = boardWidth / gridCols;
  let pieceHeight = boardHeight / gridRows;
  let cameraBaseRadius = 8.6;
  let cameraBasePitch = .14;

  const pieceDepth = .105;
  const raycaster = new THREE.Raycaster();
  const pointer = new THREE.Vector2();
  const dragPlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
  const intersection = new THREE.Vector3();
  const dragOffset = new THREE.Vector3();
  const localPoint = new THREE.Vector3();
  const snaps = [];
  const glows = [];

  let pointerFollowX = 0;
  let pointerFollowY = 0;
  let gyroX = 0;
  let gyroY = 0;
  let gyroEnabled = false;
  let motionEnabled = false;
  let gyroBaseBeta = null;
  let gyroBaseGamma = null;
  let lastManualOrbit = 0;
  let lastShakeAt = 0;
  let shakeHits = 0;
  let lastAccelMagnitude = null;
  let customDebounce = 0;

  const isTouchDevice = window.matchMedia('(pointer: coarse)').matches || 'ontouchstart' in window;

  const bgCtx = backgroundCanvas?.getContext('2d', { alpha: true, desynchronized: true });
  let bgWidth = 0;
  let bgHeight = 0;
  let bgDpr = 1;
  let bgNodes = [];
  let bgEdges = [];
  let bgContours = [];
  let bgFieldVersion = 0;

  let cameraFollowYaw = 0;
  let cameraFollowPitch = cameraBasePitch;
  let lastFrameTime = performance.now();
  let lastBgDraw = 0;
  let appInView = true;
  let bgResizeRaf = 0;
  const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true;

  migrateLegacyStorage();
  initTheme();
  init();
  restoreSession();
  animate();

  function migrateLegacyStorage() {
    try {
      if (!localStorage.getItem(STORAGE.image) && localStorage.getItem(LEGACY.image)) localStorage.setItem(STORAGE.image, localStorage.getItem(LEGACY.image));
      if (!localStorage.getItem(STORAGE.theme) && localStorage.getItem(LEGACY.theme)) localStorage.setItem(STORAGE.theme, localStorage.getItem(LEGACY.theme));
      if (!localStorage.getItem(STORAGE.sound) && localStorage.getItem(LEGACY.sound)) localStorage.setItem(STORAGE.sound, localStorage.getItem(LEGACY.sound));
    } catch (_) {}
  }

  function readStorage(key, fallbackKey = null) {
    try { return localStorage.getItem(key) ?? (fallbackKey ? localStorage.getItem(fallbackKey) : null); } catch (_) { return null; }
  }

  function initTheme() {
    const savedTheme = readStorage(STORAGE.theme, LEGACY.theme);
    const preferredLight = window.matchMedia?.('(prefers-color-scheme: light)').matches;
    applyTheme(savedTheme || (preferredLight ? 'light' : 'dark'), false);
    applySoundUI();
  }

  function init() {
    scene = new THREE.Scene();
    scene.background = null;

    perspectiveCamera = new THREE.PerspectiveCamera(46, 1, .1, 100);
    perspectiveCamera.position.set(0, .55, 8.6);

    orthographicCamera = new THREE.OrthographicCamera(-4, 4, 4, -4, .1, 100);
    orthographicCamera.position.set(0, 0, 10);

    activeCamera = perspectiveCamera;

    renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: 'high-performance', precision: 'highp' });
    renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2.35));
    renderer.outputColorSpace = THREE.SRGBColorSpace;
    renderer.toneMapping = THREE.ACESFilmicToneMapping;
    renderer.toneMappingExposure = 1.03;
    renderer.setClearColor(0x000000, 0);
    renderer.sortObjects = true;
    viewport.appendChild(renderer.domElement);

    controls = new OrbitControls(activeCamera, renderer.domElement);
    controls.enableDamping = true;
    controls.dampingFactor = .09;
    controls.enablePan = false;
    controls.enableZoom = true;
    controls.enableRotate = true;
    controls.minDistance = 4.8;
    controls.maxDistance = 18;
    controls.target.set(0, 0, 0);
    controls.mouseButtons.LEFT = THREE.MOUSE.PAN;
    controls.mouseButtons.MIDDLE = THREE.MOUSE.DOLLY;
    controls.mouseButtons.RIGHT = THREE.MOUSE.ROTATE;
    controls.touches.ONE = THREE.TOUCH.ROTATE;
    controls.touches.TWO = THREE.TOUCH.DOLLY_PAN;
    controls.addEventListener('start', () => { lastManualOrbit = performance.now(); });

    scene.add(new THREE.HemisphereLight(0xffffff, 0x58607c, 1.55));

    const key = new THREE.DirectionalLight(0xffffff, 2.05);
    key.position.set(4.6, 6.8, 8.4);
    scene.add(key);

    const fill = new THREE.DirectionalLight(0xcbd8ff, .66);
    fill.position.set(-5, -2.5, 4);
    scene.add(fill);

    boardMaterial = new THREE.MeshStandardMaterial({ color: cssColor('--mp-board'), roughness: .94, metalness: .015 });
    rebuildBoardMesh();

    renderer.domElement.addEventListener('contextmenu', event => event.preventDefault());
    renderer.domElement.addEventListener('pointerdown', onPointerDown);
    renderer.domElement.addEventListener('pointermove', onPointerMove);
    renderer.domElement.addEventListener('pointerup', onPointerUp);
    renderer.domElement.addEventListener('pointercancel', onPointerUp);
    renderer.domElement.addEventListener('pointerleave', onPointerLeave);

    app.addEventListener('pointerdown', unlockAudio, { passive: true });

    uploadInput.addEventListener('change', handleUpload);
    shuffleButton.addEventListener('click', shufflePuzzle);
    resetButton.addEventListener('click', restartPuzzle);
    centerButton.addEventListener('click', resetView);
    remixButton.addEventListener('click', () => { hideCompletion(); shufflePuzzle(); });
    nextButton.addEventListener('click', goToNextLevel);

    countSelect.addEventListener('change', onCountModeChanged);
    customCountInput.addEventListener('input', onCustomCountInput);
    customCountInput.addEventListener('change', () => rebuildFromSettings());
    cutSelect.addEventListener('change', rebuildFromSettings);
    difficultySelect.addEventListener('change', () => {
      difficulty = difficultySelect.value || 'easy';
      updateLevelLabel();
      statusElement.textContent = difficultyMessage(difficulty);
      saveState();
    });

    modeToggle.addEventListener('change', () => setViewMode(modeToggle.checked));
    themeButton.addEventListener('click', toggleTheme);
    soundButton.addEventListener('click', toggleSound);
    motionButton.addEventListener('click', requestMotionPermissions);

    if (isTouchDevice) {
      motionButton.classList.remove('is-hidden');
      tryEnableMotionWithoutPrompt();
    }

    const scheduleResponsiveLayout = () => {
      if (bgResizeRaf) cancelAnimationFrame(bgResizeRaf);
      bgResizeRaf = requestAnimationFrame(() => {
        resizeRenderer();
        resizeBackground();
        rebuildBackgroundField();
        bgResizeRaf = 0;
      });
    };

    const resizeObserver = new ResizeObserver(scheduleResponsiveLayout);
    resizeObserver.observe(app);
    resizeObserver.observe(viewport);

    if ('IntersectionObserver' in window) {
      const visibilityObserver = new IntersectionObserver(entries => {
        const entry = entries[0];
        appInView = Boolean(entry?.isIntersecting);
        if (appInView) lastFrameTime = performance.now();
      }, { threshold: .01, rootMargin: '120px' });
      visibilityObserver.observe(app);
    }

    resizeRenderer();
    resizeBackground();
    rebuildBackgroundField();
    applySceneTheme();
    updateLevelLabel();
  }

  function cssColor(variableName) {
    return getComputedStyle(app).getPropertyValue(variableName).trim() || '#ffffff';
  }

  function applyTheme(theme, persist = true) {
    const value = theme === 'light' ? 'light' : 'dark';
    app.dataset.theme = value;
    themeButton.textContent = value === 'light' ? '☾' : '☀';
    themeButton.setAttribute('aria-label', value === 'light' ? 'Passer en mode sombre' : 'Passer en mode clair');
    if (persist) {
      try { localStorage.setItem(STORAGE.theme, value); } catch (_) {}
    }
    if (renderer) applySceneTheme();
  }

  function toggleTheme() { applyTheme(app.dataset.theme === 'light' ? 'dark' : 'light'); }

  function applySceneTheme() {
    if (boardMaterial) {
      boardMaterial.color.set(cssColor('--mp-board'));
      boardMaterial.needsUpdate = true;
    }

    guideObjects.forEach(guide => {
      if (!guide.userData.solved) guide.material.color.set(cssColor('--mp-guide'));
    });

    pieces.forEach(piece => {
      const mats = Array.isArray(piece.material) ? piece.material : [piece.material];
      const side = mats[1];
      if (side?.color) side.color.set(cssColor('--mp-side'));
    });
  }

  function applySoundUI() {
    soundButton.setAttribute('aria-pressed', soundEnabled ? 'true' : 'false');
    soundButton.textContent = soundEnabled ? '♪' : '×';
    soundButton.title = soundEnabled ? 'Couper le son' : 'Activer le son';
  }

  function toggleSound() {
    soundEnabled = !soundEnabled;
    try { localStorage.setItem(STORAGE.sound, soundEnabled ? 'on' : 'off'); } catch (_) {}
    applySoundUI();
    if (soundEnabled) { unlockAudio(); playTone(560, .045, .02); }
  }

  function unlockAudio() {
    if (!soundEnabled) return;
    try {
      if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)();
      if (audioContext.state === 'suspended') audioContext.resume();
    } catch (_) {}
  }

  function playTone(frequency = 660, duration = .09, volume = .055) {
    if (!soundEnabled) return;
    unlockAudio();
    if (!audioContext) return;
    try {
      const now = audioContext.currentTime;
      const osc = audioContext.createOscillator();
      const gain = audioContext.createGain();
      osc.type = 'sine';
      osc.frequency.setValueAtTime(frequency, now);
      osc.frequency.exponentialRampToValueAtTime(frequency * 1.18, now + duration);
      gain.gain.setValueAtTime(.0001, now);
      gain.gain.exponentialRampToValueAtTime(volume, now + .012);
      gain.gain.exponentialRampToValueAtTime(.0001, now + duration);
      osc.connect(gain).connect(audioContext.destination);
      osc.start(now);
      osc.stop(now + duration + .02);
    } catch (_) {}
  }

  function playCompletionSound() {
    [520, 660, 820, 1040].forEach((frequency, index) => setTimeout(() => playTone(frequency, .13, .05), index * 82));
  }

  function handleUpload(event) {
    const file = event.target.files?.[0];
    if (!file) return;
    if (!file.type.startsWith('image/')) {
      alert('Veuillez sélectionner une image valide.');
      return;
    }

    const reader = new FileReader();
    reader.onload = async loadEvent => {
      sourceImage = loadEvent.target.result;
      try { localStorage.setItem(STORAGE.image, sourceImage); } catch (error) { console.warn('Image trop volumineuse pour le stockage local.', error); }
      try { localStorage.removeItem(STORAGE.state); } catch (_) {}
      showGameControls();
      hideCompletion();
      emptyState.classList.add('is-hidden');
      await buildPuzzle(sourceImage, true);
      statusElement.textContent = 'Puzzle prêt. Le plateau respecte les proportions de votre image.';
    };
    reader.readAsDataURL(file);
    event.target.value = '';
  }

  function showGameControls() {
    settingsPanel.classList.remove('is-hidden');
    shuffleButton.disabled = false;
    resetButton.disabled = false;
  }

  async function restoreSession() {
    const savedImage = readStorage(STORAGE.image, LEGACY.image);
    if (!savedImage) return;

    sourceImage = savedImage;
    showGameControls();
    emptyState.classList.add('is-hidden');

    let savedState = null;
    try { savedState = JSON.parse(readStorage(STORAGE.state) || 'null'); } catch (_) {}

    if (savedState?.config) {
      const count = Number(savedState.config.requestedCount || savedState.config.actualCount || 16);
      if ([9,16,25,36,64].includes(count)) {
        countSelect.value = String(count);
      } else {
        countSelect.value = 'custom';
        customCountInput.value = String(THREE.MathUtils.clamp(count, 4, 196));
        customCountWrap.classList.remove('is-hidden');
      }

      cutSelect.value = savedState.config.cutStyle === 'soft' ? 'rounded' : (savedState.config.cutStyle || 'rounded');
      difficultySelect.value = savedState.config.difficulty || 'easy';
      if (!difficultyProfiles[difficultySelect.value]) difficultySelect.value = 'easy';
      modeToggle.checked = savedState.config.is3D !== false;
      cutStyle = cutSelect.value;
      difficulty = difficultySelect.value;
      setViewMode(modeToggle.checked, false);
    }

    try {
      await buildPuzzle(sourceImage, false);
      if (!applySavedPieces(savedState)) shufflePuzzle();
      else updateProgress();
    } catch (error) {
      console.warn('Restauration impossible.', error);
      shufflePuzzle();
    }
  }

  function applySavedPieces(savedState) {
    if (!savedState?.pieces || savedState.pieces.length !== pieces.length) return false;

    savedState.pieces.forEach((state, index) => {
      const piece = pieces[index];
      if (!piece || !state?.position || !state?.rotation) return;
      piece.position.set(state.position.x, state.position.y, state.position.z);
      piece.rotation.set(state.rotation.x, state.rotation.y, state.rotation.z);
      piece.userData.snapped = Boolean(state.snapped);
      piece.userData.locked = Boolean(state.snapped);
      piece.userData.restZ = state.restZ ?? piece.position.z;
      updatePieceSolvedVisual(piece, piece.userData.snapped, false);
    });

    completedOnce = pieces.length > 0 && pieces.every(piece => piece.userData.snapped);
    if (completedOnce) showCompletion();
    return true;
  }

  function getRequestedCount() {
    if (countSelect.value === 'custom') return THREE.MathUtils.clamp(Math.round(Number(customCountInput.value) || 16), 4, 196);
    return Number(countSelect.value) || 16;
  }

  function onCountModeChanged() {
    const custom = countSelect.value === 'custom';
    customCountWrap.classList.toggle('is-hidden', !custom);
    rebuildFromSettings();
  }

  function onCustomCountInput() {
    clearTimeout(customDebounce);
    customDebounce = setTimeout(() => rebuildFromSettings(), 420);
  }

  async function rebuildFromSettings() {
    requestedCount = getRequestedCount();
    cutStyle = cutSelect.value || 'rounded';
    difficulty = difficultySelect.value || 'easy';
    completedOnce = false;
    hideCompletion();
    updateLevelLabel();
    if (!sourceImage) return;
    await buildPuzzle(sourceImage, true);
  }

  function bestFactorPair(n, aspect) {
    let best = null;
    for (let rows = 1; rows <= Math.sqrt(n); rows++) {
      if (n % rows !== 0) continue;
      const cols = n / rows;
      for (const pair of [[cols, rows], [rows, cols]]) {
        const [c, r] = pair;
        if (c < 2 || r < 2) continue;
        const ratio = c / r;
        const score = Math.abs(Math.log(Math.max(.01, ratio) / Math.max(.01, aspect)));
        if (!best || score < best.score) best = { cols: c, rows: r, score };
      }
    }
    return best;
  }

  function resolveGrid(requested, aspect) {
    const exact = bestFactorPair(requested, aspect);
    if (exact) return { requested, actual: requested, cols: exact.cols, rows: exact.rows, adjusted: false };

    let best = null;
    for (let delta = 1; delta <= 12; delta++) {
      for (const candidate of [requested - delta, requested + delta]) {
        if (candidate < 4 || candidate > 196) continue;
        const pair = bestFactorPair(candidate, aspect);
        if (!pair) continue;
        const score = delta * .075 + pair.score;
        if (!best || score < best.score) best = { requested, actual: candidate, cols: pair.cols, rows: pair.rows, adjusted: candidate !== requested, score };
      }
      if (best && delta >= 4) break;
    }

    return best || { requested, actual: 4, cols: 2, rows: 2, adjusted: requested !== 4 };
  }

  function updateGridHelper(layout) {
    if (countSelect.value !== 'custom') return;
    gridHelper.textContent = layout.adjusted
      ? `${layout.requested} demandé → ${layout.actual} pièces (${layout.cols} × ${layout.rows}) pour garder une grille régulière.`
      : `${layout.actual} pièces · grille ${layout.cols} × ${layout.rows}.`;
  }

  function setBoardDimensions(aspect) {
    const maxSize = 5.35;
    if (aspect >= 1) {
      boardWidth = maxSize;
      boardHeight = maxSize / aspect;
    } else {
      boardHeight = maxSize;
      boardWidth = maxSize * aspect;
    }
    rebuildBoardMesh();
  }

  function rebuildBoardMesh() {
    if (!scene || !boardMaterial) return;
    if (board) {
      scene.remove(board);
      board.geometry.dispose();
    }
    board = new THREE.Mesh(new THREE.BoxGeometry(boardWidth + .20, boardHeight + .20, .055), boardMaterial);
    board.position.z = -.115;
    board.renderOrder = 0;
    scene.add(board);
  }

  function buildPuzzle(src, shuffleAfterBuild) {
    clearPuzzle();
    clearGuides();
    requestedCount = getRequestedCount();
    cutStyle = cutSelect.value || 'rounded';

    return new Promise((resolve, reject) => {
      new THREE.TextureLoader().load(
        src,
        texture => {
          texture.colorSpace = THREE.SRGBColorSpace;
          texture.generateMipmaps = true;
          texture.minFilter = THREE.LinearMipmapLinearFilter;
          texture.magFilter = THREE.LinearFilter;
          texture.anisotropy = Math.min(8, renderer.capabilities.getMaxAnisotropy());
          currentTexture = texture;

          const image = texture.image;
          const width = image?.naturalWidth || image?.videoWidth || image?.width || 1;
          const height = image?.naturalHeight || image?.videoHeight || image?.height || 1;
          imageAspect = width / Math.max(1, height);
          setBoardDimensions(imageAspect);

          const layout = resolveGrid(requestedCount, imageAspect);
          actualCount = layout.actual;
          gridCols = layout.cols;
          gridRows = layout.rows;
          pieceWidth = boardWidth / gridCols;
          pieceHeight = boardHeight / gridRows;
          updateGridHelper(layout);
          updateLevelLabel();

          for (let row = 0; row < gridRows; row++) {
            for (let col = 0; col < gridCols; col++) {
              const index = row * gridCols + col;
              const correctX = -boardWidth / 2 + pieceWidth / 2 + col * pieceWidth;
              const correctY = boardHeight / 2 - pieceHeight / 2 - row * pieceHeight;
              const solvedZ = .004 + index * .00007;
              const edgeConfig = getEdgeConfig(col, row, gridCols, gridRows);
              const shape = makePieceShape(pieceWidth, pieceHeight, cutStyle, edgeConfig);

              const frontMaterial = new THREE.MeshStandardMaterial({
                map: texture,
                roughness: .73,
                metalness: .012,
                emissive: new THREE.Color(0x000000),
                emissiveIntensity: 0,
                polygonOffset: true,
                polygonOffsetFactor: -1,
                polygonOffsetUnits: -1
              });

              const sideMaterial = new THREE.MeshStandardMaterial({
                color: cssColor('--mp-side'),
                roughness: .88,
                metalness: .03,
                emissive: new THREE.Color(0x000000),
                emissiveIntensity: 0
              });

              const geometry = new THREE.ExtrudeGeometry(shape, {
                depth: pieceDepth,
                bevelEnabled: false,
                curveSegments: cutStyle === 'square' ? 1 : 10
              });
              geometry.computeVertexNormals();
              writeBoardUVs(geometry, correctX, correctY);

              const mesh = new THREE.Mesh(geometry, [frontMaterial, sideMaterial]);
              mesh.position.set(correctX, correctY, solvedZ);
              mesh.renderOrder = 3;
              mesh.userData.correct = new THREE.Vector3(correctX, correctY, solvedZ);
              mesh.userData.snapped = false;
              mesh.userData.locked = false;
              mesh.userData.row = row;
              mesh.userData.col = col;
              mesh.userData.index = index;
              mesh.userData.restZ = solvedZ;

              scene.add(mesh);
              pieces.push(mesh);

              const guide = addGuide(shape, correctX, correctY);
              mesh.userData.guide = guide;
            }
          }

          fitCamerasToBoard(true);
          applySceneTheme();
          updateProgress();
          if (shuffleAfterBuild) shufflePuzzle();
          resolve();
        },
        undefined,
        reject
      );
    });
  }

  function makePieceShape(width, height, style, edges) {
    const halfW = width / 2;
    const halfH = height / 2;
    const shape = new THREE.Shape();
    shape.moveTo(-halfW, halfH);
    addHorizontalEdge(shape, -halfW, halfH, halfW, edges.top, +1, width, height, style);
    addVerticalEdge(shape, halfW, halfH, -halfH, edges.right, +1, width, height, style);
    addHorizontalEdge(shape, halfW, -halfH, -halfW, edges.bottom, -1, width, height, style);
    addVerticalEdge(shape, -halfW, -halfH, halfH, edges.left, -1, width, height, style);
    shape.closePath();
    return shape;
  }

  function addHorizontalEdge(shape, x1, y, x2, sign, outwardDirection, width, height, style) {
    if (!sign || style === 'square') { shape.lineTo(x2, y); return; }
    const dir = Math.sign(x2 - x1) || 1;
    const length = Math.abs(x2 - x1);
    const neck = style === 'classic' ? .22 : .255;
    const bulb = style === 'classic' ? .235 : .175;
    const depth = Math.min(width, height) * (style === 'classic' ? .27 : .18);
    const offsetY = depth * sign * outwardDirection;
    const a = x1 + dir * length * (.5 - neck);
    const b = x1 + dir * length * (.5 - bulb);
    const c = x1 + dir * length * (.5 + bulb);
    const d = x1 + dir * length * (.5 + neck);
    shape.lineTo(a, y);
    shape.bezierCurveTo(b, y, b, y + offsetY, x1 + dir * length * .5, y + offsetY);
    shape.bezierCurveTo(c, y + offsetY, c, y, d, y);
    shape.lineTo(x2, y);
  }

  function addVerticalEdge(shape, x, y1, y2, sign, outwardDirection, width, height, style) {
    if (!sign || style === 'square') { shape.lineTo(x, y2); return; }
    const dir = Math.sign(y2 - y1) || 1;
    const length = Math.abs(y2 - y1);
    const neck = style === 'classic' ? .22 : .255;
    const bulb = style === 'classic' ? .235 : .175;
    const depth = Math.min(width, height) * (style === 'classic' ? .27 : .18);
    const offsetX = depth * sign * outwardDirection;
    const a = y1 + dir * length * (.5 - neck);
    const b = y1 + dir * length * (.5 - bulb);
    const c = y1 + dir * length * (.5 + bulb);
    const d = y1 + dir * length * (.5 + neck);
    shape.lineTo(x, a);
    shape.bezierCurveTo(x, b, x + offsetX, b, x + offsetX, y1 + dir * length * .5);
    shape.bezierCurveTo(x + offsetX, c, x, c, x, d);
    shape.lineTo(x, y2);
  }

  function getEdgeConfig(col, row, cols, rows) {
    return {
      top: row === 0 ? 0 : -horizontalBoundary(col, row - 1),
      right: col === cols - 1 ? 0 : verticalBoundary(col, row),
      bottom: row === rows - 1 ? 0 : horizontalBoundary(col, row),
      left: col === 0 ? 0 : -verticalBoundary(col - 1, row)
    };
  }

  function verticalBoundary(col, row) { return ((col * 7 + row * 13) % 2 === 0) ? 1 : -1; }
  function horizontalBoundary(col, row) { return ((col * 11 + row * 5 + 1) % 2 === 0) ? 1 : -1; }

  function writeBoardUVs(geometry, correctX, correctY) {
    const pos = geometry.attributes.position;
    const uv = geometry.attributes.uv;
    for (let i = 0; i < pos.count; i++) {
      const worldX = correctX + pos.getX(i);
      const worldY = correctY + pos.getY(i);
      uv.setXY(i, (worldX + boardWidth / 2) / boardWidth, (worldY + boardHeight / 2) / boardHeight);
    }
    uv.needsUpdate = true;
  }

  function addGuide(shape, x, y) {
    const guideGeometry = new THREE.ShapeGeometry(shape, 12);
    const edges = new THREE.EdgesGeometry(guideGeometry, 12);
    guideGeometry.dispose();

    const material = new THREE.LineBasicMaterial({
      color: cssColor('--mp-guide'),
      transparent: true,
      opacity: .56,
      depthTest: true,
      depthWrite: false
    });

    const line = new THREE.LineSegments(edges, material);
    line.position.set(x, y, -.052);
    line.renderOrder = 1;
    line.userData.solved = false;
    scene.add(line);
    guideObjects.push(line);
    return line;
  }

  function clearPuzzle() {
    pieces.forEach(piece => {
      scene.remove(piece);
      piece.geometry.dispose();
      const mats = Array.isArray(piece.material) ? piece.material : [piece.material];
      mats.forEach(material => material.dispose());
    });
    pieces = [];
    snaps.length = 0;
    glows.length = 0;
    if (currentTexture) { currentTexture.dispose(); currentTexture = null; }
    updateProgress();
  }

  function clearGuides() {
    guideObjects.forEach(guide => {
      scene.remove(guide);
      guide.geometry.dispose();
      guide.material.dispose();
    });
    guideObjects = [];
  }

  function shufflePuzzle() {
    if (!pieces.length) return;

    hideCompletion();
    const profile = difficultyProfiles[difficultySelect.value] || difficultyProfiles.easy;
    difficulty = difficultySelect.value || 'easy';
    completedOnce = false;

    const baseRadiusX = boardWidth * (.62 + profile.spread * .20) + pieceWidth * 1.4;
    const baseRadiusY = boardHeight * (.62 + profile.spread * .20) + pieceHeight * 1.4;

    pieces.forEach((piece, index) => {
      piece.userData.snapped = false;
      piece.userData.locked = false;
      cancelSnap(piece);
      updatePieceSolvedVisual(piece, false, false);

      const angle = (index / pieces.length) * Math.PI * 2 + (Math.random() - .5) * .26;
      const ring = .88 + Math.random() * .32;
      const x = Math.cos(angle) * baseRadiusX * ring + (Math.random() - .5) * pieceWidth * .58;
      const y = Math.sin(angle) * baseRadiusY * ring + (Math.random() - .5) * pieceHeight * .58;
      const layer = is3D ? .07 + (index % 11) * .022 + Math.random() * profile.zSpread : .008 + (index % 10) * .0032;

      piece.userData.restZ = layer;
      piece.position.set(x, y, layer);
      piece.rotation.set(
        is3D ? (Math.random() - .5) * profile.rotationXY * 1.72 : 0,
        is3D ? (Math.random() - .5) * profile.rotationXY * 1.72 : 0,
        (Math.random() - .5) * profile.rotationZ * 1.25
      );
    });

    statusElement.textContent = (difficulty === 'starter' || difficulty === 'easy')
      ? `${difficulty === 'starter' ? 'Initiation' : 'Facile'} : approchez une pièce de sa zone, l’aimantation vous aide à la placer.`
      : 'Puzzle remélangé. Les pièces correctement posées se verrouillent automatiquement.';
    updateProgress();
    saveState();
  }

  function restartPuzzle() {
    if (!pieces.length) return;
    shufflePuzzle();
    resetView();
  }

  function updatePointer(event) {
    const rect = renderer.domElement.getBoundingClientRect();
    pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
    pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
  }

  function distanceXY(a, b) {
    const dx = a.x - b.x;
    const dy = a.y - b.y;
    return Math.hypot(dx, dy);
  }

  function onPointerDown(event) {
    if (event.button !== 0) return;
    updatePointer(event);
    raycaster.setFromCamera(pointer, activeCamera);

    const movablePieces = pieces.filter(piece => !piece.userData.locked);
    const hits = raycaster.intersectObjects(movablePieces, false);
    if (!hits.length) return;

    selectedPiece = hits[0].object;
    cancelSnap(selectedPiece);
    selectedPiece.userData.snapped = false;
    selectedPiece.userData.locked = false;

    dragPlane.constant = 0;
    if (raycaster.ray.intersectPlane(dragPlane, intersection)) dragOffset.copy(intersection).sub(selectedPiece.position).setZ(0);

    controls.enabled = false;
    renderer.domElement.setPointerCapture?.(event.pointerId);
    selectedPiece.position.z = .52;
    selectedPiece.renderOrder = 8;
    selectedPiece.rotation.x *= .36;
    selectedPiece.rotation.y *= .36;
    renderer.domElement.style.cursor = 'grabbing';
    event.preventDefault();
  }

  function onPointerMove(event) {
    updatePointer(event);

    if (!isTouchDevice && !selectedPiece) {
      pointerFollowX = pointer.x;
      pointerFollowY = pointer.y;
    }

    if (!selectedPiece) return;

    raycaster.setFromCamera(pointer, activeCamera);
    if (!raycaster.ray.intersectPlane(dragPlane, intersection)) return;

    localPoint.copy(intersection).sub(dragOffset);
    const correct = selectedPiece.userData.correct;
    const profile = difficultyProfiles[difficulty] || difficultyProfiles.easy;
    const distance = Math.hypot(localPoint.x - correct.x, localPoint.y - correct.y);
    const magnetRadius = Math.min(pieceWidth, pieceHeight) * profile.magnetRadius;

    if (distance < magnetRadius) {
      const strength = THREE.MathUtils.clamp(1 - distance / magnetRadius, 0, 1);
      const eased = profile.magnetBase + strength * profile.magnetGain;
      localPoint.x = THREE.MathUtils.lerp(localPoint.x, correct.x, eased);
      localPoint.y = THREE.MathUtils.lerp(localPoint.y, correct.y, eased);
      selectedPiece.rotation.x *= .62;
      selectedPiece.rotation.y *= .62;
      selectedPiece.rotation.z *= .76;
      pulseGuide(selectedPiece.userData.guide, strength);
    }

    localPoint.z = .52;
    selectedPiece.position.lerp(localPoint, profile.dragLerp);
    event.preventDefault();
  }

  function onPointerUp(event) {
    if (!selectedPiece) return;

    const piece = selectedPiece;
    const correct = piece.userData.correct;
    const profile = difficultyProfiles[difficulty] || difficultyProfiles.easy;
    const snapRadius = Math.min(pieceWidth, pieceHeight) * profile.snapRadius;
    const distance = distanceXY(piece.position, correct);

    piece.renderOrder = 3;
    if (distance < snapRadius) smoothSnap(piece);
    else {
      piece.position.z = piece.userData.restZ ?? .03;
      resetGuideVisual(piece.userData.guide);
    }

    selectedPiece = null;
    controls.enabled = true;
    renderer.domElement.style.cursor = 'grab';
    try { renderer.domElement.releasePointerCapture?.(event.pointerId); } catch (_) {}
    updateProgress();
    saveState();
  }

  function onPointerLeave() {
    if (!selectedPiece && !isTouchDevice) { pointerFollowX = 0; pointerFollowY = 0; }
  }

  function smoothSnap(piece) {
    cancelSnap(piece);
    snaps.push({
      piece,
      fromPosition: piece.position.clone(),
      fromRotation: piece.rotation.clone(),
      toPosition: piece.userData.correct.clone(),
      start: performance.now(),
      duration: 235
    });
  }

  function cancelSnap(piece) {
    const index = snaps.findIndex(snap => snap.piece === piece);
    if (index >= 0) snaps.splice(index, 1);
  }

  function updateSnaps(now) {
    for (let i = snaps.length - 1; i >= 0; i--) {
      const snap = snaps[i];
      const t = THREE.MathUtils.clamp((now - snap.start) / snap.duration, 0, 1);
      const eased = 1 - Math.pow(1 - t, 4);

      snap.piece.position.lerpVectors(snap.fromPosition, snap.toPosition, eased);
      snap.piece.rotation.set(
        THREE.MathUtils.lerp(snap.fromRotation.x, 0, eased),
        THREE.MathUtils.lerp(snap.fromRotation.y, 0, eased),
        THREE.MathUtils.lerp(snap.fromRotation.z, 0, eased)
      );

      if (t >= 1) {
        snap.piece.position.copy(snap.toPosition);
        snap.piece.rotation.set(0, 0, 0);
        snap.piece.userData.snapped = true;
        snap.piece.userData.locked = true;
        snaps.splice(i, 1);
        validatePiece(snap.piece);
      }
    }
  }

  function validatePiece(piece) {
    updatePieceSolvedVisual(piece, true, true);
    playTone(680 + ((piece.userData.row + piece.userData.col) % 4) * 55, .085, .05);
    if (navigator.vibrate && isTouchDevice) navigator.vibrate(18);
    updateProgress();
    saveState();
  }

  function updatePieceSolvedVisual(piece, solved, animateGlow = false) {
    const front = Array.isArray(piece.material) ? piece.material[0] : piece.material;
    const side = Array.isArray(piece.material) ? piece.material[1] : null;
    const guide = piece.userData.guide;

    if (guide) {
      guide.userData.solved = solved;
      guide.material.opacity = solved ? .16 : .56;
      guide.material.color.set(solved ? cssColor('--mp-success') : cssColor('--mp-guide'));
    }

    if (solved && animateGlow) {
      const success = new THREE.Color(cssColor('--mp-success'));
      front.emissive.copy(success);
      front.emissiveIntensity = .8;
      if (side?.emissive) { side.emissive.copy(success); side.emissiveIntensity = .36; }
      glows.push({ piece, start: performance.now(), duration: 560 });
    } else if (!solved) {
      front.emissiveIntensity = 0;
      if (side?.emissive) side.emissiveIntensity = 0;
    }
  }

  function updateGlows(now) {
    for (let i = glows.length - 1; i >= 0; i--) {
      const glow = glows[i];
      const t = THREE.MathUtils.clamp((now - glow.start) / glow.duration, 0, 1);
      const front = Array.isArray(glow.piece.material) ? glow.piece.material[0] : glow.piece.material;
      const side = Array.isArray(glow.piece.material) ? glow.piece.material[1] : null;
      const pulse = (1 - t) * (.56 + Math.sin(t * Math.PI * 3) * .18);
      front.emissiveIntensity = Math.max(0, pulse);
      if (side?.emissive) side.emissiveIntensity = Math.max(0, pulse * .42);
      if (t >= 1) {
        front.emissiveIntensity = 0;
        if (side?.emissive) side.emissiveIntensity = 0;
        glows.splice(i, 1);
      }
    }
  }

  function pulseGuide(guide, strength) {
    if (!guide || guide.userData.solved) return;
    guide.material.color.set(cssColor('--mp-accent-2'));
    guide.material.opacity = .46 + strength * .42;
  }

  function resetGuideVisual(guide) {
    if (!guide || guide.userData.solved) return;
    guide.material.color.set(cssColor('--mp-guide'));
    guide.material.opacity = .56;
  }

  function updateProgress() {
    const total = pieces.length;
    const correctCount = pieces.filter(piece => piece.userData.snapped).length;
    const percent = total ? Math.round((correctCount / total) * 100) : 0;

    progressElement.textContent = total ? `${correctCount} / ${total} pièce${total > 1 ? 's' : ''} placée${correctCount > 1 ? 's' : ''}` : '0 / 0 pièce placée';
    percentElement.textContent = `${percent}%`;
    progressBar.style.width = `${percent}%`;

    if (total && correctCount === total && !completedOnce) {
      completedOnce = true;
      statusElement.textContent = 'Bravo ! Puzzle terminé. Vous pouvez remélanger ou passer au niveau suivant.';
      playCompletionSound();
      celebrate();
      showCompletion();
      if (navigator.vibrate && isTouchDevice) navigator.vibrate([35, 30, 60, 40, 80]);
    }
  }

  function showCompletion() { completionPanel.classList.remove('is-hidden'); }
  function hideCompletion() { completionPanel.classList.add('is-hidden'); }

  function celebrate() {
    celebration.innerHTML = '';

    const flash = document.createElement('span');
    flash.className = 'mp-celebration-flash';
    celebration.appendChild(flash);

    for (let i = 0; i < 3; i++) {
      const ring = document.createElement('span');
      ring.className = 'mp-win-ring';
      ring.style.setProperty('--delay', `${i * .16}s`);
      celebration.appendChild(ring);
    }

    const badge = document.createElement('span');
    badge.className = 'mp-win-badge';
    badge.textContent = '✨ Bravo !';
    celebration.appendChild(badge);

    const colors = ['#7a6cff', '#39d8ff', '#ff74c8', '#62e7ae', '#ffe27a', '#ffffff'];
    const count = isTouchDevice ? 64 : 92;
    for (let i = 0; i < count; i++) {
      const bit = document.createElement('span');
      bit.className = 'mp-confetti';
      bit.style.left = `${2 + Math.random() * 96}%`;
      bit.style.background = colors[i % colors.length];
      bit.style.setProperty('--w', `${5 + Math.random() * 6}px`);
      bit.style.setProperty('--h', `${8 + Math.random() * 12}px`);
      bit.style.setProperty('--drift', `${(Math.random() - .5) * 240}px`);
      bit.style.setProperty('--spin', `${(Math.random() - .5) * 1320}deg`);
      bit.style.setProperty('--dur', `${1.4 + Math.random() * 1.1}s`);
      bit.style.setProperty('--delay', `${Math.random() * .28}s`);
      celebration.appendChild(bit);
    }

    setTimeout(() => { celebration.innerHTML = ''; }, 2900);
  }

  function difficultyMessage(level) {
    const messages = {
      starter: 'Mode Initiation : aimantation maximale, rotations très faibles et dispersion contenue.',
      easy: 'Mode Facile : aimantation généreuse et rotations minimales.',
      normal: 'Mode Normal : aide équilibrée.',
      hard: 'Mode Difficile : zone magnétique réduite et dispersion plus forte.',
      expert: 'Mode Expert : assistance minimale, orientation et dispersion renforcées.'
    };
    return messages[level] || messages.easy;
  }

  function findCurrentLevelIndex() {
    const count = requestedCount || getRequestedCount();
    let index = 0;
    for (let i = 0; i < LEVELS.length; i++) {
      if (count >= LEVELS[i].count) index = i;
    }
    return index;
  }

  function updateLevelLabel() {
    const count = getRequestedCount();
    const exactIndex = LEVELS.findIndex(level => level.count === count);
    levelLabel.textContent = exactIndex >= 0 ? `Palier ${exactIndex + 1} / 5` : 'Personnalisé';
  }

  async function goToNextLevel() {
    if (!pieces.length) return;
    const currentCount = actualCount || getRequestedCount();
    const nextPreset = LEVELS.find(level => level.count > currentCount);

    hideCompletion();
    completedOnce = false;

    if (nextPreset) {
      countSelect.value = String(nextPreset.count);
      customCountWrap.classList.add('is-hidden');
      difficultySelect.value = nextPreset.difficulty;
      cutSelect.value = nextPreset.cut;
      statusElement.textContent = `Niveau suivant : ${nextPreset.count} pièces · ${nextPreset.cut === 'square' ? 'découpe carrée' : nextPreset.cut === 'rounded' ? 'puzzle arrondi' : 'puzzle classique'}.`;
    } else {
      const nextCustom = Math.min(196, Math.max(currentCount + 12, Math.round(currentCount * 1.25)));
      countSelect.value = 'custom';
      customCountWrap.classList.remove('is-hidden');
      customCountInput.value = String(nextCustom);
      difficultySelect.value = 'expert';
      cutSelect.value = 'square';
      statusElement.textContent = `Niveau expert : ${nextCustom} pièces demandées · découpe carrée imposée.`;
    }

    requestedCount = getRequestedCount();
    difficulty = difficultySelect.value;
    cutStyle = cutSelect.value;
    updateLevelLabel();
    await buildPuzzle(sourceImage, true);
  }

  function setViewMode(enable3D, persist = true) {
    is3D = Boolean(enable3D);

    if (is3D) {
      activeCamera = perspectiveCamera;
      controls.object = perspectiveCamera;
      controls.enableRotate = true;
      controls.enableZoom = true;
      pieces.forEach(piece => {
        if (!piece.userData.snapped) {
          piece.position.z = Math.max(piece.userData.restZ || .04, .04);
          const profile = difficultyProfiles[difficulty] || difficultyProfiles.easy;
          if (Math.abs(piece.rotation.x) < .01) piece.rotation.x = (Math.random() - .5) * profile.rotationXY * 1.35;
          if (Math.abs(piece.rotation.y) < .01) piece.rotation.y = (Math.random() - .5) * profile.rotationXY * 1.35;
        }
      });
      statusElement.textContent = isTouchDevice
        ? 'Vue 3D : le gyroscope crée une orbite légère comparable à un vrai changement de point de vue.'
        : 'Vue 3D : la caméra orbite légèrement avec la souris. Clic droit : orbite manuelle.';
    } else {
      activeCamera = orthographicCamera;
      controls.object = orthographicCamera;
      controls.enableRotate = false;
      controls.enableZoom = true;
      pieces.forEach(piece => {
        if (!piece.userData.snapped) piece.position.z = .006 + (piece.userData.index % 10) * .0024;
        piece.rotation.x = 0;
        piece.rotation.y = 0;
      });
      statusElement.textContent = 'Vue 2D : plateau et pièces à plat. Seule une légère rotation dans le plan peut subsister.';
    }

    fitCamerasToBoard(false);
    controls.update();
    if (persist) saveState();
  }

  function fitCamerasToBoard(resetPosition = false) {
    if (!renderer) return;
    const width = Math.max(viewport.clientWidth, 1);
    const height = Math.max(viewport.clientHeight, 1);
    const aspect = width / height;

    const marginX = boardWidth + pieceWidth * 4.2;
    const marginY = boardHeight + pieceHeight * 4.2;
    const vFov = THREE.MathUtils.degToRad(perspectiveCamera.fov);
    const neededForHeight = marginY / (2 * Math.tan(vFov / 2));
    const hFov = 2 * Math.atan(Math.tan(vFov / 2) * aspect);
    const neededForWidth = marginX / (2 * Math.tan(hFov / 2));
    cameraBaseRadius = Math.max(6.2, neededForHeight, neededForWidth) * .96;

    const orthoHeight = Math.max(marginY, marginX / Math.max(.2, aspect));
    const orthoWidth = orthoHeight * aspect;
    orthographicCamera.left = -orthoWidth / 2;
    orthographicCamera.right = orthoWidth / 2;
    orthographicCamera.top = orthoHeight / 2;
    orthographicCamera.bottom = -orthoHeight / 2;
    orthographicCamera.updateProjectionMatrix();

    if (resetPosition) resetView();
  }

  function resetView() {
    pointerFollowX = 0;
    pointerFollowY = 0;
    gyroX = 0;
    gyroY = 0;
    cameraFollowYaw = 0;
    cameraFollowPitch = cameraBasePitch;

    if (is3D) {
      perspectiveCamera.position.set(0, Math.sin(cameraBasePitch) * cameraBaseRadius, Math.cos(cameraBasePitch) * cameraBaseRadius);
      perspectiveCamera.rotation.set(0, 0, 0);
    } else {
      orthographicCamera.position.set(0, 0, 10);
      orthographicCamera.rotation.set(0, 0, 0);
      orthographicCamera.zoom = 1;
      orthographicCamera.updateProjectionMatrix();
    }

    controls.target.set(baseTargetX(), 0, 0);
    controls.update();
  }

  function baseTargetX() {
    return 0;
  }

  function resizeRenderer() {
    if (!renderer) return;
    const width = Math.max(viewport.clientWidth, 1);
    const height = Math.max(viewport.clientHeight, 1);
    const aspect = width / height;
    renderer.setSize(width, height, false);
    perspectiveCamera.aspect = aspect;
    perspectiveCamera.updateProjectionMatrix();
    fitCamerasToBoard(false);
  }

  async function requestMotionPermissions() {
    if (!isTouchDevice) return;

    let orientationGranted = true;
    let motionGranted = true;

    try {
      if (typeof DeviceOrientationEvent !== 'undefined' && typeof DeviceOrientationEvent.requestPermission === 'function') {
        orientationGranted = (await DeviceOrientationEvent.requestPermission()) === 'granted';
      }
      if (typeof DeviceMotionEvent !== 'undefined' && typeof DeviceMotionEvent.requestPermission === 'function') {
        motionGranted = (await DeviceMotionEvent.requestPermission()) === 'granted';
      }

      if (orientationGranted) {
        window.addEventListener('deviceorientation', onDeviceOrientation, { passive: true });
        gyroEnabled = true;
      }
      if (motionGranted) {
        window.addEventListener('devicemotion', onDeviceMotion, { passive: true });
        motionEnabled = true;
      }

      if (orientationGranted || motionGranted) {
        motionButton.textContent = '✓ Mouvements du téléphone activés';
        motionButton.disabled = true;
        statusElement.textContent = 'Mouvements actifs : inclinez pour une légère orbite, secouez pour remélanger.';
      } else {
        statusElement.textContent = 'Accès aux mouvements du téléphone refusé.';
      }
    } catch (error) {
      console.warn('Mouvements indisponibles ou refusés.', error);
      statusElement.textContent = 'Les capteurs de mouvement ne sont pas disponibles sur ce navigateur.';
    }
  }

  function tryEnableMotionWithoutPrompt() {
    try {
      if (typeof DeviceOrientationEvent !== 'undefined' && typeof DeviceOrientationEvent.requestPermission !== 'function') {
        window.addEventListener('deviceorientation', onDeviceOrientation, { passive: true });
        gyroEnabled = true;
      }
      if (typeof DeviceMotionEvent !== 'undefined' && typeof DeviceMotionEvent.requestPermission !== 'function') {
        window.addEventListener('devicemotion', onDeviceMotion, { passive: true });
        motionEnabled = true;
      }
    } catch (_) {}
  }

  function onDeviceOrientation(event) {
    if (!gyroEnabled || !is3D) return;
    if (typeof event.beta !== 'number' || typeof event.gamma !== 'number') return;

    if (gyroBaseBeta === null) gyroBaseBeta = event.beta;
    if (gyroBaseGamma === null) gyroBaseGamma = event.gamma;

    const betaDelta = THREE.MathUtils.clamp(event.beta - gyroBaseBeta, -18, 18);
    const gammaDelta = THREE.MathUtils.clamp(event.gamma - gyroBaseGamma, -18, 18);
    gyroX = gammaDelta / 18;
    gyroY = betaDelta / 18;
  }

  function onDeviceMotion(event) {
    if (!motionEnabled || !pieces.length) return;
    const acc = event.accelerationIncludingGravity || event.acceleration;
    if (!acc) return;
    const x = Number(acc.x) || 0;
    const y = Number(acc.y) || 0;
    const z = Number(acc.z) || 0;
    const magnitude = Math.sqrt(x*x + y*y + z*z);

    if (lastAccelMagnitude !== null) {
      const delta = Math.abs(magnitude - lastAccelMagnitude);
      if (delta > 10.8) {
        shakeHits += 1;
        if (shakeHits >= 2 && performance.now() - lastShakeAt > 1400) {
          lastShakeAt = performance.now();
          shakeHits = 0;
          hideCompletion();
          shufflePuzzle();
          statusElement.textContent = 'Téléphone secoué : puzzle remélangé.';
          if (navigator.vibrate) navigator.vibrate(22);
        }
      } else {
        shakeHits = Math.max(0, shakeHits - .08);
      }
    }
    lastAccelMagnitude = magnitude;
  }

  function updateCameraFollow(now, dt) {
    if (!is3D || selectedPiece) return;

    const manualBlend = THREE.MathUtils.clamp((now - lastManualOrbit - 650) / 850, 0, 1);
    if (manualBlend <= 0) return;

    let targetYaw = 0;
    let targetPitch = cameraBasePitch;

    if (isTouchDevice && gyroEnabled) {
      targetYaw = gyroX * .31;
      targetPitch += -gyroY * .20;
    } else if (!isTouchDevice) {
      targetYaw = pointerFollowX * .29;
      targetPitch += pointerFollowY * .18;
    }

    targetYaw *= manualBlend;
    targetPitch = THREE.MathUtils.lerp(cameraBasePitch, targetPitch, manualBlend);

    const response = isTouchDevice ? 9.0 : 11.5;
    const alpha = 1 - Math.exp(-response * Math.min(dt, .05));
    cameraFollowYaw = THREE.MathUtils.lerp(cameraFollowYaw, targetYaw, alpha);
    cameraFollowPitch = THREE.MathUtils.lerp(cameraFollowPitch, targetPitch, alpha);

    const radius = cameraBaseRadius;
    const pitch = THREE.MathUtils.clamp(cameraFollowPitch, -.10, .42);
    const yaw = THREE.MathUtils.clamp(cameraFollowYaw, -.38, .38);
    const desired = new THREE.Vector3(
      Math.sin(yaw) * Math.cos(pitch) * radius,
      Math.sin(pitch) * radius,
      Math.cos(yaw) * Math.cos(pitch) * radius
    );

    perspectiveCamera.position.lerp(desired, alpha);
    const targetAlpha = 1 - Math.exp(-13 * Math.min(dt, .05));
    controls.target.x = THREE.MathUtils.lerp(controls.target.x, 0, targetAlpha);
    controls.target.y = THREE.MathUtils.lerp(controls.target.y, 0, targetAlpha);
    controls.target.z = THREE.MathUtils.lerp(controls.target.z, 0, targetAlpha);
  }

  function saveState() {
    if (!sourceImage || !pieces.length) return;
    const state = {
      version: 2,
      config: {
        requestedCount: getRequestedCount(),
        actualCount,
        cols: gridCols,
        rows: gridRows,
        cutStyle: cutSelect.value || 'rounded',
        difficulty: difficultySelect.value || 'easy',
        is3D,
        imageAspect
      },
      pieces: pieces.map(piece => ({
        position: { x: piece.position.x, y: piece.position.y, z: piece.position.z },
        rotation: { x: piece.rotation.x, y: piece.rotation.y, z: piece.rotation.z },
        snapped: Boolean(piece.userData.snapped),
        restZ: piece.userData.restZ
      }))
    };
    try { localStorage.setItem(STORAGE.state, JSON.stringify(state)); } catch (_) {}
  }

  /* =========================================================
     BACKGROUND GENERATIF : CHAMP PROCEDURAL D'ASSEMBLAGE
     - maillage adaptatif plein écran
     - formes construites progressivement
     - liaisons et contours de groupes qui évoluent
     - cycles désynchronisés afin d'éviter une boucle perceptible
  ========================================================= */
  function resizeBackground() {
    if (!bgCtx || !backgroundCanvas) return;
    bgDpr = Math.min(window.devicePixelRatio || 1, 1.75);
    bgWidth = Math.max(1, app.clientWidth);
    bgHeight = Math.max(1, app.clientHeight);
    backgroundCanvas.width = Math.floor(bgWidth * bgDpr);
    backgroundCanvas.height = Math.floor(bgHeight * bgDpr);
    backgroundCanvas.style.width = `${bgWidth}px`;
    backgroundCanvas.style.height = `${bgHeight}px`;
    bgCtx.setTransform(bgDpr, 0, 0, bgDpr, 0, 0);
  }

  function seededNoise(a, b = 0) {
    const x = Math.sin(a * 12.9898 + b * 78.233 + bgFieldVersion * 17.17) * 43758.5453;
    return x - Math.floor(x);
  }

  function rebuildBackgroundField() {
    if (!bgWidth || !bgHeight) return;
    bgFieldVersion += 1;
    bgNodes = [];
    bgEdges = [];
    bgContours = [];

    const coarse = window.matchMedia('(pointer: coarse)').matches;
    const spacing = coarse ? 108 : Math.max(118, Math.min(165, bgWidth / 10.5));
    const cols = Math.ceil(bgWidth / spacing) + 2;
    const rows = Math.ceil(bgHeight / spacing) + 2;
    const sx = bgWidth / Math.max(1, cols - 1);
    const sy = bgHeight / Math.max(1, rows - 1);

    for (let row = 0; row < rows; row++) {
      for (let col = 0; col < cols; col++) {
        const idx = row * cols + col;
        const jx = (seededNoise(idx, 1) - .5) * sx * .62;
        const jy = (seededNoise(idx, 2) - .5) * sy * .62;
        bgNodes.push({
          x: col * sx + jx,
          y: row * sy + jy,
          ampX: 8 + seededNoise(idx, 3) * 22,
          ampY: 8 + seededNoise(idx, 4) * 18,
          f1: .000035 + seededNoise(idx, 5) * .000035,
          f2: .000028 + seededNoise(idx, 6) * .000042,
          phase: seededNoise(idx, 7) * Math.PI * 2,
          radius: 12 + seededNoise(idx, 8) * 24,
          sides: 3 + Math.floor(seededNoise(idx, 9) * 4),
          cycle: 15000 + seededNoise(idx, 10) * 29000,
          offset: seededNoise(idx, 11),
          color: Math.floor(seededNoise(idx, 12) * 3)
        });
      }
    }

    const addEdge = (a, b, seed) => {
      if (a < 0 || b < 0 || a >= bgNodes.length || b >= bgNodes.length) return;
      bgEdges.push({
        a, b,
        phase: seededNoise(seed, 21),
        cycle: 12000 + seededNoise(seed, 22) * 32000,
        color: Math.floor(seededNoise(seed, 23) * 3),
        dash: 2 + seededNoise(seed, 24) * 5
      });
    };

    for (let row = 0; row < rows; row++) {
      for (let col = 0; col < cols; col++) {
        const i = row * cols + col;
        if (col < cols - 1) addEdge(i, i + 1, i * 5 + 1);
        if (row < rows - 1) addEdge(i, i + cols, i * 5 + 2);
        if (col < cols - 1 && row < rows - 1 && seededNoise(i, 31) > .53) addEdge(i, i + cols + 1, i * 5 + 3);
      }
    }

    const contourCount = Math.max(5, Math.round((bgWidth * bgHeight) / 180000));
    for (let i = 0; i < contourCount; i++) {
      const centerIndex = Math.floor(seededNoise(i, 41) * bgNodes.length);
      const center = bgNodes[centerIndex];
      if (!center) continue;
      const nearest = bgNodes
        .map((node, index) => ({ index, d: Math.hypot(node.x - center.x, node.y - center.y) }))
        .sort((a,b) => a.d - b.d)
        .slice(0, 4 + Math.floor(seededNoise(i, 42) * 3))
        .map(v => v.index);
      bgContours.push({
        nodes: nearest,
        cycle: 22000 + seededNoise(i, 43) * 36000,
        phase: seededNoise(i, 44),
        color: Math.floor(seededNoise(i, 45) * 3)
      });
    }
  }

  function smooth01(t) {
    const x = THREE.MathUtils.clamp(t, 0, 1);
    return x * x * (3 - 2 * x);
  }

  function backgroundNodePosition(node, now) {
    const t = reduceMotion ? 0 : now;
    return {
      x: node.x + Math.sin(t * node.f1 + node.phase) * node.ampX + Math.sin(t * node.f2 * .71 + node.phase * 1.7) * node.ampX * .24,
      y: node.y + Math.cos(t * node.f2 + node.phase * 1.23) * node.ampY + Math.cos(t * node.f1 * .63 + node.phase * .7) * node.ampY * .22
    };
  }

  function lifecycle(now, cycle, offset) {
    const phase = reduceMotion ? (.32 + offset * .36) : (((now / cycle) + offset) % 1);
    const build = smooth01(phase / .24);
    const hold = phase < .72 ? 1 : 1 - smooth01((phase - .72) / .28);
    return { phase, build, alpha: build * hold };
  }

  function drawNodeShape(ctx, node, pos, now, color, baseAlpha) {
    const life = lifecycle(now, node.cycle, node.offset);
    if (life.alpha <= .001) return;
    const sides = node.sides;
    const radius = node.radius * (.78 + .18 * Math.sin(now * node.f1 * 1.8 + node.phase));
    const phase = node.phase + now * .000018;
    const points = [];
    for (let i = 0; i < sides; i++) {
      const a = phase + i / sides * Math.PI * 2;
      const irregular = .78 + seededNoise(i + sides * 20, Math.floor(node.x + node.y)) * .33;
      points.push({ x: pos.x + Math.cos(a) * radius * irregular, y: pos.y + Math.sin(a) * radius * irregular });
    }
    points.push(points[0]);

    let total = 0;
    const lengths = [];
    for (let i = 0; i < points.length - 1; i++) {
      const len = Math.hypot(points[i+1].x - points[i].x, points[i+1].y - points[i].y);
      lengths.push(len); total += len;
    }
    let remain = total * life.build;
    ctx.save();
    ctx.strokeStyle = color;
    ctx.globalAlpha = baseAlpha * life.alpha;
    ctx.lineWidth = 1.05;
    ctx.setLineDash([3.5, 7.5]);
    ctx.lineDashOffset = -now * .012;
    ctx.beginPath();
    ctx.moveTo(points[0].x, points[0].y);
    for (let i = 0; i < lengths.length && remain > 0; i++) {
      const len = lengths[i];
      const take = Math.min(remain, len);
      const t = len ? take / len : 0;
      ctx.lineTo(
        THREE.MathUtils.lerp(points[i].x, points[i+1].x, t),
        THREE.MathUtils.lerp(points[i].y, points[i+1].y, t)
      );
      remain -= take;
    }
    ctx.stroke();
    ctx.restore();
  }

  function drawBackground(now) {
    if (!bgCtx || !backgroundCanvas || !bgWidth || !bgHeight) return;
    bgCtx.clearRect(0, 0, bgWidth, bgHeight);

    const colors = [cssColor('--mp-accent'), cssColor('--mp-accent-2'), cssColor('--mp-accent-3')];
    const isLight = app.dataset.theme === 'light';
    const nodeAlpha = isLight ? .115 : .145;
    const edgeAlpha = isLight ? .075 : .105;
    const contourAlpha = isLight ? .060 : .082;
    const positions = bgNodes.map(node => backgroundNodePosition(node, now));

    bgCtx.save();
    bgCtx.globalCompositeOperation = isLight ? 'source-over' : 'screen';

    for (const edge of bgEdges) {
      const a = positions[edge.a];
      const b = positions[edge.b];
      if (!a || !b) continue;
      const life = lifecycle(now, edge.cycle, edge.phase);
      if (life.alpha <= .002) continue;
      const eased = smooth01(life.build);
      const ex = THREE.MathUtils.lerp(a.x, b.x, eased);
      const ey = THREE.MathUtils.lerp(a.y, b.y, eased);
      bgCtx.strokeStyle = colors[edge.color];
      bgCtx.globalAlpha = edgeAlpha * life.alpha;
      bgCtx.lineWidth = .9;
      bgCtx.setLineDash([edge.dash, edge.dash * 2.2]);
      bgCtx.lineDashOffset = -now * .0105 * (1 + edge.color * .13);
      bgCtx.beginPath();
      bgCtx.moveTo(a.x, a.y);
      bgCtx.lineTo(ex, ey);
      bgCtx.stroke();
    }

    for (const contour of bgContours) {
      const life = lifecycle(now, contour.cycle, contour.phase);
      if (life.alpha <= .002 || contour.nodes.length < 3) continue;
      const pts = contour.nodes.map(i => positions[i]).filter(Boolean);
      if (pts.length < 3) continue;
      const cx = pts.reduce((sum,p)=>sum+p.x,0)/pts.length;
      const cy = pts.reduce((sum,p)=>sum+p.y,0)/pts.length;
      pts.sort((p,q)=>Math.atan2(p.y-cy,p.x-cx)-Math.atan2(q.y-cy,q.x-cx));
      const count = Math.max(2, Math.floor(pts.length * life.build));
      bgCtx.strokeStyle = colors[contour.color];
      bgCtx.globalAlpha = contourAlpha * life.alpha;
      bgCtx.lineWidth = 1.35;
      bgCtx.setLineDash([1.5, 10]);
      bgCtx.lineDashOffset = now * .006;
      bgCtx.beginPath();
      bgCtx.moveTo(pts[0].x, pts[0].y);
      for (let i = 1; i < count; i++) bgCtx.lineTo(pts[i].x, pts[i].y);
      if (count >= pts.length) bgCtx.closePath();
      bgCtx.stroke();
    }

    bgNodes.forEach((node, index) => drawNodeShape(bgCtx, node, positions[index], now, colors[node.color], nodeAlpha));
    bgCtx.restore();
  }

  function animate(now = performance.now()) {
    requestAnimationFrame(animate);
    if (!appInView || document.hidden) {
      lastFrameTime = now;
      return;
    }

    const dt = Math.min(.05, Math.max(.001, (now - lastFrameTime) / 1000));
    lastFrameTime = now;
    updateSnaps(now);
    updateGlows(now);
    updateCameraFollow(now, dt);

    if (reduceMotion || now - lastBgDraw >= 30) {
      drawBackground(reduceMotion ? 0 : now);
      lastBgDraw = now;
    }

    controls.update();
    renderer.render(scene, activeCamera);
  }
}
</script>
				</div>
				</div>
				</div>
				</div>
		<p>Cet article <a href="https://presentcomposedesign.fr/my-puzzle/">My Puzzle</a> est apparu en premier sur <a href="https://presentcomposedesign.fr">Présent Composé design</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Yggdrasil</title>
		<link>https://presentcomposedesign.fr/yggdrasil/</link>
		
		<dc:creator><![CDATA[admin]]></dc:creator>
		<pubDate>Mon, 20 Apr 2026 10:39:50 +0000</pubDate>
				<category><![CDATA[Design produit]]></category>
		<guid isPermaLink="false">https://presentcomposedesign.fr/?p=36494</guid>

					<description><![CDATA[<p>YGGDRASIL — PCdlab 2026 Yggdrasil Plante un arbre. Nourris-le. Sauve la planète. Commencer Nouvelle partie 🌿 Croissance 0% Graine 💧 Eau utilisée 0 L &#160; 🪣 5.2 mL/s Mode Éco Actif : Empreinte Graphique Minimale SOBRIÉTÉ ÉCO (N&#38;B) 5.2 mL/s RICHE COULEUR (DÉTAIL) 87.6 mL/s 🤲 Creuser 🌰 Planter 🫳 Recouvrir 💧 Arroser 📖 Réfléchir i × 🌳 YGGDRASIL — PCdlab 2026 Souris / Clavier 🖱️ Glisser → Orbiter autour de l&#8217;arbre ⚙️ Molette → Zoom avant / arrière Ctrl+Maj+R → Réinitialiser la partie Tactile 👆 1 doigt glissé → Orbiter 🤏 2 doigts pincés → Zoom Quest 3 — Hand Tracking ✊ Pincer (index+pouce) → Arroser 🤚 Mains visibles → Curseur goutte 3D AR → Placer l&#8217;arbre dans l&#8217;espace réel Mode Affichage 🌑 Sobriété N&#038;B = 5.2 mL/s 🌈 Riche Couleur = 87.6 mL/s PCdlab 2026 · PrésentComposédesignCode : Claude Sonnet × Anthropic 📖 La Vérité sur ton Arbre Je comprends 🌱</p>
<p>Cet article <a href="https://presentcomposedesign.fr/yggdrasil/">Yggdrasil</a> est apparu en premier sur <a href="https://presentcomposedesign.fr">Présent Composé design</a>.</p>
]]></description>
										<content:encoded><![CDATA[		<div data-elementor-type="wp-post" data-elementor-id="36494" class="elementor elementor-36494">
				<div class="elementor-element elementor-element-6fddc91 e-flex e-con-boxed wpr-particle-no wpr-jarallax-no wpr-parallax-no wpr-sticky-section-no wpr-column-slider-no wpr-equal-height-no e-con e-parent" data-id="6fddc91" data-element_type="container" data-e-type="container">
					<div class="e-con-inner">
				<div class="elementor-element elementor-element-f2f4319 elementor-widget elementor-widget-html" data-id="f2f4319" data-element_type="widget" data-e-type="widget" data-widget_type="html.default">
				<div class="elementor-widget-container">
					<!--
╔══════════════════════════════════════════════════════════════════════════════════════════╗
║  YGGDRASIL — Plante ton arbre, sauve la planète                                          ║
║  PCdlab 2026 · PrésentComposédesign × Claude Sonnet (Anthropic)                          ║
╚══════════════════════════════════════════════════════════════════════════════════════════╝

@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+#@@@@@@%. :%@@@@@@*+@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@.  .#@@@*     *@@@*.  =@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%@@@@@@@@@:    #@*       *@#    +@@@@@@@@@@%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@.  -#%@@@@@@-   #%=       +%#   -@@@@@@%*:  .@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@.     .@@@@@@%*+%%    @   :%%++%@@@@@@.     .@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@*    #@@.      .%@%   =%@@*   @   *@@%-   @@#.      .@@#    *@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@=    -@#   ::  -@%     -@%:  @  :%@-     %@-  =.   %@-    *@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@%%   *@%    # -@%       @%-:@:-%@       %%: %   :%@+   %%@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@**=+**#@@@@@@+::%#@@   :.  :@@@@@@@:  ..   @@@@.:+@@@@@@#***+*#@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@=       :#@@@%*%%@@@@.  += :@@@@@@@: -=  .@@@@@%+@@@@#:       %@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@:     .%%          @@-     *@@*     =@@         *@%      -@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@=      *%-    :=: .@+        #@@@@@%:   :%@@@@@#       .*%. :=:    +@- .    =@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@+.  :*@@#+    :@*%@:        +@@@#.     .#@@@+        :@#*@:    *#@@@:   +%@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@%%%%%%@@@@@@@@@@@@@@@@    :=  -%@%        :%@#:  =.    @@@@@%%@@@@@@@@@%%%%%%@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@

  ╭─────────────────────────────────────────────────────────────╮
  │  ░░░ CLAUDE SONNET — Anthropic AI ░░░                       │
  │  Code : PCdlab 2026 — WebXR / Three.js / Quest 3            │
  │  Fix : display !important override · Hand Tracking           │
  ╰─────────────────────────────────────────────────────────────╯
-->
<!DOCTYPE html>
<html lang="fr">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
  <meta name="apple-mobile-web-app-capable" content="yes">
  <meta name="theme-color" content="#0a1008">
  <title>YGGDRASIL — PCdlab 2026</title>
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Playfair+Display:ital,wght@0,400;0,700;1,400&display=swap" rel="stylesheet">
  <style>
    /* ═══════════════════════════════════════════════════════════════
       YGGDRASIL — CSS ISOLATION WORDPRESS / ELEMENTOR / ROYAL ADDONS
       ─ Tous les sélecteurs scoped sous #ygg-pcdb-root
       ─ !important uniquement sur les props de LAYOUT (jamais sur display
         des éléments contrôlés par JS → sinon le JS ne peut plus les cacher)
       ═══════════════════════════════════════════════════════════════ */

    html, body { margin: 0 !important; padding: 0 !important; overflow: hidden !important; }

    /* ─── ROOT ─── */
    #ygg-pcdb-root {
      position: fixed !important;
      inset: 0 !important;
      width: 100% !important; height: 100% !important;
      overflow: hidden !important;
      font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif !important;
      font-size: 14px !important; line-height: 1.5 !important;
      background: #0a1008 !important;
      z-index: 99998 !important;
      box-sizing: border-box !important;
    }
    #ygg-pcdb-root *, #ygg-pcdb-root *::before, #ygg-pcdb-root *::after {
      box-sizing: border-box !important;
      margin: 0 !important; padding: 0 !important;
      /* PAS de border:none ici — on le met par sélecteur précis */
      font-family: inherit !important;
      -webkit-tap-highlight-color: transparent !important;
    }

    /* ─── CANVAS ─── */
    #ygg-pcdb-root #ygg-canvas {
      position: absolute !important;
      inset: 0 !important; display: block !important;
      width: 100% !important; height: 100% !important;
      z-index: 1 !important;
      transition: filter 0.9s ease !important;
    }
    #ygg-pcdb-root #ygg-canvas.ygg-nb {
      filter: grayscale(1) contrast(1.06) brightness(0.95) !important;
    }

    /* ─── UI LAYER ─── */
    #ygg-pcdb-root #ygg-ui {
      position: absolute !important; inset: 0 !important;
      z-index: 10 !important; pointer-events: none !important;
    }
    #ygg-pcdb-root #ygg-ui > * { pointer-events: auto !important; }

    /* ─── CARD ─── */
    #ygg-pcdb-root .ygg-card {
      background: rgba(8,14,8,0.85) !important;
      backdrop-filter: blur(14px) !important; -webkit-backdrop-filter: blur(14px) !important;
      border: 1px solid rgba(100,160,80,0.28) !important;
      border-radius: 10px !important; padding: 10px 14px !important;
      color: #c8dfc0 !important; font-size: 12px !important;
      pointer-events: auto !important;
    }
    #ygg-pcdb-root .ygg-lbl {
      font-size: 9px !important; text-transform: uppercase !important;
      letter-spacing: 1.3px !important; color: rgba(130,185,110,0.55) !important;
      margin-bottom: 3px !important; display: block !important;
    }
    #ygg-pcdb-root .ygg-val {
      font-size: 18px !important; font-weight: 600 !important;
      color: #9de090 !important; font-family: 'Playfair Display', Georgia, serif !important;
      display: block !important;
    }
    #ygg-pcdb-root .ygg-val.blue { color: #7ec8e3 !important; }
    #ygg-pcdb-root .ygg-sub {
      font-size: 10px !important; color: rgba(150,195,130,0.45) !important;
      margin-top: 2px !important; display: block !important;
    }

    /* ─── HUD ─── */
    #ygg-pcdb-root #ygg-hud {
      position: absolute !important;
      top: 12px !important; left: 12px !important; right: 12px !important;
      display: none; /* contrôlé par JS — PAS de !important */
      justify-content: space-between !important; align-items: flex-start !important;
      z-index: 20 !important; pointer-events: none !important;
    }
    #ygg-pcdb-root #ygg-hud > * { pointer-events: auto !important; }
    #ygg-pcdb-root #ygg-growth-card { max-width: 195px !important; }
    #ygg-pcdb-root #ygg-water-card  { max-width: 195px !important; text-align: right !important; }

    /* ─── LOGO CENTRE ─── */
    #ygg-pcdb-root #ygg-logo-center { text-align: center !important; pointer-events: none !important; }
    #ygg-pcdb-root #ygg-logo-img {
      width: 68px !important; height: 68px !important; object-fit: contain !important;
      transition: filter 0.8s ease !important;
      filter: drop-shadow(0 0 8px rgba(80,180,80,0.35)) !important;
    }
    /* Logo N&B : inversion pour fond sombre */
    #ygg-pcdb-root #ygg-logo-img.ygg-nb-logo {
      filter: brightness(0) invert(1) opacity(0.85) !important;
    }
    #ygg-pcdb-root #ygg-intro-logo {
      width: 110px !important; height: 110px !important; object-fit: contain !important;
      margin-bottom: 22px !important;
      animation: ygg-float 3.2s ease-in-out infinite !important;
      /* Logo intro en N&B : inversion pour fond sombre */
      filter: brightness(0) invert(1) opacity(0.9) !important;
    }
    #ygg-pcdb-root #ygg-intro-logo.ygg-color-logo {
      filter: drop-shadow(0 0 18px rgba(80,200,80,0.35)) !important;
    }

    /* ─── TOGGLE BAR ─── */
    #ygg-pcdb-root #ygg-toggle-bar {
      position: absolute !important;
      bottom: 80px !important; left: 50% !important;
      transform: translateX(-50%) !important;
      display: none; /* contrôlé par JS */
      align-items: center !important; gap: 14px !important;
      background: rgba(6,10,6,0.9) !important;
      backdrop-filter: blur(14px) !important; -webkit-backdrop-filter: blur(14px) !important;
      border: 1px solid rgba(100,160,80,0.22) !important;
      border-radius: 14px !important; padding: 10px 18px !important;
      z-index: 20 !important; white-space: nowrap !important;
    }
    #ygg-pcdb-root .ygg-tside {
      display: flex !important; flex-direction: column !important;
      align-items: center !important; gap: 3px !important;
    }
    #ygg-pcdb-root .ygg-tlabel {
      font-size: 9px !important; text-transform: uppercase !important;
      letter-spacing: 1px !important; color: rgba(130,185,110,0.5) !important;
    }
    #ygg-pcdb-root .ygg-cdisp {
      font-size: 13px !important; font-weight: 700 !important;
      font-family: 'Playfair Display', Georgia, serif !important;
    }
    #ygg-pcdb-root .ygg-cdisp.eco { color: #7ec8a0 !important; }
    #ygg-pcdb-root .ygg-cdisp.rich { color: #e07c4a !important; }
    #ygg-pcdb-root .ygg-cunit {
      font-size: 9px !important; font-weight: 400 !important;
      font-family: 'Inter', sans-serif !important; opacity: 0.7 !important;
    }
    /* Toggle switch */
    #ygg-pcdb-root #ygg-toggle-btn {
      position: relative !important;
      width: 52px !important; height: 28px !important;
      background: rgba(30,50,30,0.8) !important;
      border: 1.5px solid rgba(100,160,80,0.5) !important;
      border-radius: 14px !important; cursor: pointer !important;
      flex-shrink: 0 !important; transition: background 0.4s !important;
    }
    #ygg-pcdb-root #ygg-toggle-btn.ygg-on {
      background: rgba(100,45,10,0.8) !important;
      border-color: rgba(200,120,60,0.5) !important;
    }
    #ygg-pcdb-root #ygg-toggle-thumb {
      position: absolute !important;
      width: 22px !important; height: 22px !important;
      top: 2px !important; left: 2px !important;
      background: rgba(100,200,100,0.9) !important;
      border-radius: 50% !important;
      transition: left 0.3s ease, background 0.4s !important;
      display: flex !important; align-items: center !important; justify-content: center !important;
      overflow: hidden !important;
    }
    #ygg-pcdb-root #ygg-toggle-btn.ygg-on #ygg-toggle-thumb {
      left: 26px !important; background: rgba(210,120,50,0.9) !important;
    }
    #ygg-pcdb-root #ygg-toggle-logo {
      width: 18px !important; height: 18px !important; object-fit: contain !important;
    }

    /* ─── CONSOMMATION ACTIVE ─── */
    #ygg-pcdb-root #ygg-active-cons {
      position: absolute !important;
      top: 12px !important; left: 50% !important; transform: translateX(-50%) !important;
      background: rgba(6,10,6,0.87) !important;
      backdrop-filter: blur(12px) !important; -webkit-backdrop-filter: blur(12px) !important;
      border: 1px solid rgba(100,160,80,0.2) !important;
      border-radius: 8px !important; padding: 6px 14px !important;
      display: none; /* contrôlé par JS */
      align-items: center !important; gap: 10px !important;
      font-size: 11px !important; color: #c8dfc0 !important;
      z-index: 20 !important; white-space: nowrap !important;
    }
    #ygg-pcdb-root #ygg-cons-icon { font-size: 16px !important; }
    #ygg-pcdb-root #ygg-cons-val {
      font-size: 16px !important; font-weight: 700 !important;
      font-family: 'Playfair Display', Georgia, serif !important;
      color: #9de090 !important; transition: color 0.5s !important;
    }
    #ygg-pcdb-root #ygg-cons-val.rich { color: #e07c4a !important; }
    #ygg-pcdb-root #ygg-cons-desc {
      font-size: 9px !important; color: rgba(150,195,130,0.5) !important;
    }

    /* ─── BOUTONS ACTION
         NOTE CRITIQUE : PAS de display !important ici.
         La visibilité est gérée par JS (inline style).
         Si on met display:flex !important, le JS ne peut plus cacher les boutons. ─── */
    #ygg-pcdb-root #ygg-actions {
      position: absolute !important;
      bottom: 20px !important; left: 50% !important; transform: translateX(-50%) !important;
      display: none; /* contrôlé par JS */
      gap: 8px !important; z-index: 20 !important;
      flex-wrap: wrap !important; justify-content: center !important;
      max-width: 90vw !important;
    }
    #ygg-pcdb-root .ygg-btn {
      background: rgba(8,14,8,0.84);
      backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
      border: 1px solid rgba(100,160,80,0.35);
      border-radius: 10px; padding: 11px 20px;
      color: #c8dfc0; font-family: 'Inter', sans-serif !important;
      font-size: 13px; font-weight: 500; cursor: pointer;
      /* display: flex sans !important → le JS peut le contrôler */
      display: flex; align-items: center; gap: 7px;
      white-space: nowrap;
      transition: background 0.22s, border-color 0.22s, transform 0.1s;
    }
    #ygg-pcdb-root .ygg-btn:hover {
      background: rgba(30,60,30,0.78);
      border-color: rgba(100,190,80,0.52);
    }
    #ygg-pcdb-root .ygg-btn:active { transform: scale(0.96); }

    /* ─── BOUTONS XR ─── */
    #ygg-pcdb-root #ygg-xr-btns {
      position: absolute !important; bottom: 20px !important; right: 12px !important;
      display: flex !important; gap: 8px !important; z-index: 20 !important;
    }
    #ygg-pcdb-root .ygg-xrbtn {
      background: rgba(8,16,26,0.84);
      backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
      border: 1px solid rgba(80,140,200,0.4);
      border-radius: 10px; padding: 11px 18px;
      color: #a0c8e8; font-family: 'Inter', sans-serif !important;
      font-size: 13px; font-weight: 500; cursor: pointer;
      transition: background 0.22s;
    }
    #ygg-pcdb-root .ygg-xrbtn:hover { background: rgba(20,40,80,0.78); }

    /* ─── MESSAGE ÉCO ─── */
    #ygg-pcdb-root #ygg-eco-msg {
      position: absolute !important;
      bottom: 140px !important; left: 50% !important; transform: translateX(-50%) !important;
      background: rgba(4,8,4,0.92) !important;
      backdrop-filter: blur(14px) !important; -webkit-backdrop-filter: blur(14px) !important;
      border: 1px solid rgba(100,160,80,0.2) !important;
      border-radius: 10px !important; padding: 14px 20px !important;
      max-width: 460px !important; width: calc(100vw - 24px) !important;
      text-align: center !important; color: #c8dfc0 !important;
      font-size: 12px !important; line-height: 1.65 !important;
      z-index: 20 !important;
      opacity: 0; pointer-events: none !important;
      transition: opacity 0.7s ease !important;
    }
    #ygg-pcdb-root #ygg-eco-msg.vis { opacity: 1 !important; }
    #ygg-pcdb-root .ygg-etitle {
      font-family: 'Playfair Display', Georgia, serif !important;
      font-size: 14px !important; color: #9de090 !important;
      margin-bottom: 6px !important; display: block !important;
    }

    /* ─── JOURNAL ─── */
    #ygg-pcdb-root #ygg-journal {
      position: absolute !important;
      top: 100px !important; right: 12px !important;
      background: rgba(8,14,8,0.8) !important;
      backdrop-filter: blur(12px) !important; -webkit-backdrop-filter: blur(12px) !important;
      border: 1px solid rgba(100,160,80,0.18) !important;
      border-radius: 10px !important; padding: 10px !important;
      width: 210px !important; max-height: 275px !important; overflow-y: auto !important;
      display: none; z-index: 20 !important;
    }
    #ygg-pcdb-root #ygg-journal::-webkit-scrollbar { width: 4px !important; }
    #ygg-pcdb-root #ygg-journal::-webkit-scrollbar-thumb {
      background: rgba(100,160,80,0.3) !important; border-radius: 2px !important;
    }
    #ygg-pcdb-root .ygg-entry {
      font-size: 10px !important; color: rgba(165,205,150,0.65) !important;
      padding: 3px 0 !important; border-bottom: 1px solid rgba(100,160,80,0.1) !important;
      line-height: 1.44 !important;
    }
    #ygg-pcdb-root .ygg-entry:last-child { border-bottom: none !important; }
    #ygg-pcdb-root .ygg-etime { color: rgba(100,160,80,0.45) !important; }

    /* ─── BOUTON INFO ─── */
    #ygg-pcdb-root #ygg-info-btn {
      position: absolute !important; bottom: 20px !important; left: 12px !important;
      width: 42px !important; height: 42px !important;
      background: rgba(8,14,8,0.84) !important;
      backdrop-filter: blur(12px) !important; -webkit-backdrop-filter: blur(12px) !important;
      border: 1px solid rgba(100,160,80,0.35) !important; border-radius: 50% !important;
      color: #9de090 !important; font-family: 'Playfair Display', Georgia, serif !important;
      font-size: 18px !important; font-style: italic !important; font-weight: 700 !important;
      cursor: pointer !important; z-index: 21 !important;
      display: flex !important; align-items: center !important; justify-content: center !important;
      transition: background 0.22s !important;
    }
    #ygg-pcdb-root #ygg-info-btn:hover { background: rgba(25,55,25,0.88) !important; }

    /* ─── PANNEAU AIDE ─── */
    #ygg-pcdb-root #ygg-help-panel {
      position: absolute !important; bottom: 72px !important; left: 12px !important;
      width: 295px !important; max-width: calc(100vw - 24px) !important;
      background: rgba(4,8,4,0.96) !important;
      backdrop-filter: blur(18px) !important; -webkit-backdrop-filter: blur(18px) !important;
      border: 1px solid rgba(100,160,80,0.26) !important;
      border-radius: 12px !important; padding: 16px !important;
      z-index: 22 !important; color: #c8dfc0 !important;
    }
    #ygg-pcdb-root #ygg-help-panel[hidden] { display: none !important; }
    #ygg-pcdb-root #ygg-help-close {
      position: absolute !important; top: 10px !important; right: 12px !important;
      background: none !important; border: none !important;
      color: rgba(150,195,130,0.5) !important;
      font-size: 20px !important; cursor: pointer !important; padding: 2px 6px !important;
    }
    #ygg-pcdb-root #ygg-help-close:hover { color: #9de090 !important; }
    #ygg-pcdb-root #ygg-help-panel h2 {
      font-family: 'Playfair Display', Georgia, serif !important;
      font-size: 15px !important; color: #9de090 !important; margin-bottom: 10px !important;
    }
    #ygg-pcdb-root #ygg-help-panel h3 {
      font-size: 9px !important; text-transform: uppercase !important;
      letter-spacing: 1.2px !important; color: rgba(130,185,110,0.5) !important;
      margin: 8px 0 5px !important;
    }
    #ygg-pcdb-root #ygg-help-panel ul { list-style: none !important; }
    #ygg-pcdb-root #ygg-help-panel li {
      font-size: 11px !important; color: rgba(185,215,170,0.75) !important;
      padding: 2px 0 !important; line-height: 1.5 !important;
    }
    #ygg-pcdb-root .ygg-credit {
      margin-top: 10px !important; font-size: 9px !important;
      color: rgba(110,155,90,0.4) !important; text-align: center !important;
      padding-top: 8px !important; border-top: 1px solid rgba(100,160,80,0.1) !important;
    }

    /* ─── FLASH PINCH XR ─── */
    #ygg-pcdb-root #ygg-pinch-flash {
      position: absolute !important; inset: 0 !important;
      background: rgba(80,200,255,0.07) !important;
      z-index: 18 !important; pointer-events: none !important;
      opacity: 0 !important; transition: opacity 0.12s !important;
    }
    #ygg-pcdb-root #ygg-pinch-flash.fl { opacity: 1 !important; }

    /* ─── INTRO ─── */
    #ygg-pcdb-root #ygg-intro {
      position: absolute !important; inset: 0 !important;
      background: radial-gradient(ellipse at 50% 75%, #182a1a 0%, #050c05 70%) !important;
      z-index: 50 !important;
      display: flex !important; flex-direction: column !important;
      align-items: center !important; justify-content: center !important;
      text-align: center !important; padding: 30px !important;
      transition: opacity 1.1s ease !important;
    }
    #ygg-pcdb-root #ygg-intro.ygg-out {
      opacity: 0 !important; pointer-events: none !important;
    }
    #ygg-pcdb-root #ygg-intro h1 {
      font-family: 'Playfair Display', Georgia, serif !important;
      font-size: 46px !important; color: #9de090 !important;
      letter-spacing: 3px !important; margin-bottom: 6px !important;
    }
    #ygg-pcdb-root .ygg-sub-intro {
      font-size: 13px !important; font-style: italic !important;
      color: rgba(165,205,150,0.5) !important; margin-bottom: 34px !important;
    }
    #ygg-pcdb-root #ygg-start-btn {
      background: rgba(35,72,35,0.45) !important;
      border: 1px solid rgba(100,190,80,0.45) !important;
      border-radius: 12px !important; padding: 16px 44px !important;
      color: #9de090 !important; font-family: 'Playfair Display', Georgia, serif !important;
      font-size: 18px !important; cursor: pointer !important;
      transition: background 0.28s !important; margin-bottom: 14px !important;
    }
    #ygg-pcdb-root #ygg-start-btn:hover { background: rgba(45,90,45,0.58) !important; }
    #ygg-pcdb-root #ygg-reset-btn {
      font-size: 10px !important; color: rgba(110,155,90,0.36) !important;
      cursor: pointer !important; background: none !important; border: none !important;
      text-decoration: underline !important; text-underline-offset: 3px !important;
    }

    /* ─── MORAL ─── */
    #ygg-pcdb-root #ygg-moral {
      position: absolute !important; inset: 0 !important;
      background: rgba(2,4,2,0.96) !important;
      z-index: 60 !important;
      display: flex !important; flex-direction: column !important;
      align-items: center !important; justify-content: center !important;
      padding: 30px !important; text-align: center !important; overflow-y: auto !important;
    }
    #ygg-pcdb-root #ygg-moral[hidden] { display: none !important; }
    #ygg-pcdb-root #ygg-moral h2 {
      font-family: 'Playfair Display', Georgia, serif !important;
      font-size: 25px !important; color: #e6a8a8 !important; margin-bottom: 16px !important;
    }
    #ygg-pcdb-root #ygg-moral-text {
      font-size: 13px !important; color: rgba(215,195,185,0.75) !important;
      max-width: 500px !important; line-height: 1.8 !important; margin-bottom: 12px !important;
    }
    #ygg-pcdb-root .ygg-mstat {
      font-size: 17px !important; color: #7ec8e3 !important;
      font-weight: 600 !important; margin: 12px 0 !important; line-height: 1.7 !important;
    }
    #ygg-pcdb-root #ygg-moral-advice {
      font-size: 13px !important; color: rgba(215,195,185,0.65) !important;
      max-width: 500px !important; line-height: 1.8 !important; margin-bottom: 20px !important;
    }

    /* ─── ANIMATIONS ─── */
    @keyframes ygg-float {
      0%, 100% { transform: translateY(0); }
      50% { transform: translateY(-9px); }
    }
  </style>
</head>
<body>
<div id="ygg-pcdb-root">

  <canvas id="ygg-canvas"></canvas>

  <div id="ygg-ui">

    <!-- INTRO -->
    <div id="ygg-intro">
      <img decoding="async" id="ygg-intro-logo" src="https://presentcomposedesign.fr/wp-content/uploads/2026/04/YGGDRASIL-LOGO_v1stroke_PCdlab_2026.svg" alt="Yggdrasil">
      <h1>Yggdrasil</h1>
      <p class="ygg-sub-intro">Plante un arbre. Nourris-le. Sauve la planète.</p>
      <button id="ygg-start-btn" type="button">Commencer</button>
      <button id="ygg-reset-btn" type="button">Nouvelle partie</button>
    </div>

    <!-- HUD -->
    <div id="ygg-hud">
      <div class="ygg-card" id="ygg-growth-card">
        <span class="ygg-lbl">🌿 Croissance</span>
        <span class="ygg-val" id="ygg-growth-val">0%</span>
        <span class="ygg-sub" id="ygg-stage-name">Graine</span>
      </div>
      <div id="ygg-logo-center">
        <img decoding="async" id="ygg-logo-img"
          src="https://presentcomposedesign.fr/wp-content/uploads/2026/04/YGGDRASIL-LOGO_v1stroke_PCdlab_2026.svg"
          alt="YGGDRASIL" class="ygg-nb-logo">
      </div>
      <div class="ygg-card" id="ygg-water-card">
        <span class="ygg-lbl">💧 Eau utilisée</span>
        <span class="ygg-val blue" id="ygg-water-val">0 L</span>
        <span class="ygg-sub" id="ygg-water-eq">&nbsp;</span>
      </div>
    </div>

    <!-- CONSOMMATION ACTIVE -->
    <div id="ygg-active-cons">
      <span id="ygg-cons-icon">🪣</span>
      <span id="ygg-cons-val">5.2 mL/s</span>
      <span id="ygg-cons-desc">Mode Éco Actif : Empreinte Graphique Minimale</span>
    </div>

    <!-- TOGGLE -->
    <div id="ygg-toggle-bar">
      <div class="ygg-tside">
        <span class="ygg-tlabel">SOBRIÉTÉ ÉCO (N&amp;B)</span>
        <span class="ygg-cdisp eco"><span id="ygg-eco-v">5.2</span><span class="ygg-cunit"> mL/s</span></span>
      </div>
      <button id="ygg-toggle-btn" type="button" role="switch" aria-checked="false" aria-label="Mode affichage">
        <span id="ygg-toggle-thumb">
          <img decoding="async" id="ygg-toggle-logo"
            src="https://presentcomposedesign.fr/wp-content/uploads/2026/04/YGGDRASIL-LOGO_v1_PCdlab_2026.svg"
            alt="" aria-hidden="true">
        </span>
      </button>
      <div class="ygg-tside">
        <span class="ygg-tlabel">RICHE COULEUR (DÉTAIL)</span>
        <span class="ygg-cdisp rich"><span id="ygg-rich-v">87.6</span><span class="ygg-cunit"> mL/s</span></span>
      </div>
    </div>

    <!-- JOURNAL -->
    <div id="ygg-journal" aria-live="polite"></div>

    <!-- ACTIONS — boutons cachés/montrés individuellement par JS -->
    <div id="ygg-actions">
      <button class="ygg-btn" id="ygg-btn-dig"   type="button">🤲 Creuser</button>
      <button class="ygg-btn" id="ygg-btn-plant" type="button" style="display:none">🌰 Planter</button>
      <button class="ygg-btn" id="ygg-btn-cover" type="button" style="display:none">🫳 Recouvrir</button>
      <button class="ygg-btn" id="ygg-btn-water" type="button" style="display:none">💧 Arroser</button>
      <button class="ygg-btn" id="ygg-btn-moral" type="button" style="display:none">📖 Réfléchir</button>
    </div>

    <!-- XR (injectés si disponibles) -->
    <div id="ygg-xr-btns"></div>

    <!-- ECO MSG -->
    <div id="ygg-eco-msg" role="status" aria-live="polite"></div>

    <!-- FLASH XR -->
    <div id="ygg-pinch-flash"></div>

    <!-- BOUTON INFO -->
    <button id="ygg-info-btn" type="button" aria-label="Aide et commandes" aria-expanded="false">i</button>

    <!-- PANNEAU AIDE -->
    <div id="ygg-help-panel" role="dialog" aria-label="Aide" hidden>
      <button id="ygg-help-close" type="button" aria-label="Fermer">×</button>
      <h2>🌳 YGGDRASIL — PCdlab 2026</h2>
      <h3>Souris / Clavier</h3>
      <ul>
        <li>🖱️ Glisser → Orbiter autour de l'arbre</li>
        <li>⚙️ Molette → Zoom avant / arrière</li>
        <li>Ctrl+Maj+R → Réinitialiser la partie</li>
      </ul>
      <h3>Tactile</h3>
      <ul>
        <li>👆 1 doigt glissé → Orbiter</li>
        <li>🤏 2 doigts pincés → Zoom</li>
      </ul>
      <h3>Quest 3 — Hand Tracking</h3>
      <ul>
        <li>✊ Pincer (index+pouce) → Arroser</li>
        <li>🤚 Mains visibles → Curseur goutte 3D</li>
        <li>AR → Placer l'arbre dans l'espace réel</li>
      </ul>
      <h3>Mode Affichage</h3>
      <ul>
        <li>🌑 Sobriété N&B = <strong>5.2 mL/s</strong></li>
        <li>🌈 Riche Couleur = <strong>87.6 mL/s</strong></li>
      </ul>
      <p class="ygg-credit">PCdlab 2026 · PrésentComposédesign<br>Code : Claude Sonnet × Anthropic</p>
    </div>

    <!-- MORAL -->
    <div id="ygg-moral" role="dialog" aria-modal="true" hidden>
      <h2>📖 La Vérité sur ton Arbre</h2>
      <p id="ygg-moral-text"></p>
      <div class="ygg-mstat" id="ygg-moral-stat"></div>
      <p id="ygg-moral-advice"></p>
      <button class="ygg-btn" id="ygg-close-moral" type="button">Je comprends 🌱</button>
    </div>

  </div><!-- #ygg-ui -->
</div><!-- #ygg-pcdb-root -->

<script type="module">
/* ══════════════════════════════════════════════════════════════════════
   YGGDRASIL — PCdlab 2026
   Three.js 0.161 · WebXR · Hand Tracking Quest 3
   Bug Fix : display !important removed from .ygg-btn
             → JS peut maintenant gérer la visibilité des boutons
   ══════════════════════════════════════════════════════════════════════ */

import * as THREE from 'https://unpkg.com/three@0.161.0/build/three.module.js';

/* ─── CONSTANTES ─── */
const SAVE_KEY    = 'ygg_pcdb_2026';
const WATER_PAGE  = 0.5;
const WATER_ARR   = 1.5;
const SHOWER_LPM  = 12;
const CONS_ECO    = 5.2;
const CONS_RICH   = 87.6;

const LOGO_GRADIENT = 'https://presentcomposedesign.fr/wp-content/uploads/2026/04/YGGDRASIL-LOGO_gradient_PCdlab_2026.svg';
const LOGO_STROKE   = 'https://presentcomposedesign.fr/wp-content/uploads/2026/04/YGGDRASIL-LOGO_v1stroke_PCdlab_2026.svg';
const LOGO_MAIN     = 'https://presentcomposedesign.fr/wp-content/uploads/2026/04/YGGDRASIL-LOGO_v1_PCdlab_2026.svg';

const GOLDEN_ANGLE = Math.PI * 2 * (1 - 2 / (1 + Math.sqrt(5)));
const FIB = [2, 3, 5, 8, 13, 21, 34];

const STAGES = [
  { name:'Graine',       min:0  },
  { name:'Germination',  min:5  },
  { name:'Jeune pousse', min:15 },
  { name:'Arbuste',      min:30 },
  { name:'Jeune arbre',  min:50 },
  { name:'Arbre mature', min:70 },
  { name:'Yggdrasil',   min:90 },
];

const ECO_FACTS = [
  "🚿 Une douche de 5 min = 60 litres.",
  "🥩 1 kg de bœuf = 15 400 litres d'eau.",
  "👕 Un t-shirt coton = 2 700 litres.",
  "🌾 L'agriculture utilise 70% de l'eau douce mondiale.",
  "💧 2,2 milliards sans accès à l'eau potable.",
  "🌍 La moitié du monde en stress hydrique d'ici 2025.",
  "☕ Une tasse de café = 140 litres produits.",
  "📱 Un smartphone = 12 000 litres fabriqué.",
  "🥑 Un avocat = 320 litres d'eau.",
  "🏠 Un Français consomme ~150 L/jour.",
];

/* ─── ÉTAT ─── */
function defaultState() {
  return {
    phase:'start', growth:0, totalWater:0,
    waterCount:0, pageLoads:0, pageWater:0,
    lastWaterTime:null, createdAt:Date.now(),
    journal:[], overwatered:false,
  };
}
function loadState() {
  try { const d = JSON.parse(localStorage.getItem(SAVE_KEY)); if (d?.phase) return d; } catch(e){}
  return defaultState();
}
let gs = loadState();
const save = () => localStorage.setItem(SAVE_KEY, JSON.stringify(gs));

gs.pageLoads = (gs.pageLoads||0) + 1;
gs.pageWater = (gs.pageWater||0) + WATER_PAGE;
save();

/* ─── TOGGLE STATE ─── */
let isColor = false;

/* ─── THREE.JS ─── */
const canvas = document.getElementById('ygg-canvas');

const renderer = new THREE.WebGLRenderer({ canvas, antialias:true, alpha:false });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.95;
renderer.xr.enabled = true;

const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x162617, 0.013);

const camera = new THREE.PerspectiveCamera(50, window.innerWidth/window.innerHeight, 0.1, 200);

/* worldGroup : tout l'environnement. Déplacé en XR pour positionner l'utilisateur */
const worldGroup = new THREE.Group();
scene.add(worldGroup);

/* ─── LUMIÈRES ─── */
scene.add(new THREE.AmbientLight(0x3a5a3a, 0.72));

const sun = new THREE.DirectionalLight(0xffe8c0, 1.28);
sun.position.set(8, 15, 5);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.near = 0.5; sun.shadow.camera.far = 60;
sun.shadow.camera.left = -18; sun.shadow.camera.right = 18;
sun.shadow.camera.top  = 18; sun.shadow.camera.bottom = -18;
sun.shadow.bias = -0.001; sun.shadow.normalBias = 0.02;
scene.add(sun);

const rim = new THREE.DirectionalLight(0x88aaff, 0.32);
rim.position.set(-5, 8, -5);
scene.add(rim);

/* ─── CIEL (dégradé shader) ─── */
const skyMat = new THREE.ShaderMaterial({
  side: THREE.BackSide,
  uniforms: {
    topColor:    { value: new THREE.Color(0x0a1628) },
    bottomColor: { value: new THREE.Color(0x2a4a2a) },
    offset:  { value: 20 }, exponent: { value: 0.5 },
  },
  vertexShader:`
    varying vec3 vWP;
    void main(){ vWP=(modelMatrix*vec4(position,1.0)).xyz; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0); }`,
  fragmentShader:`
    uniform vec3 topColor,bottomColor; uniform float offset,exponent; varying vec3 vWP;
    void main(){float h=normalize(vWP+vec3(0,offset,0)).y; gl_FragColor=vec4(mix(bottomColor,topColor,max(pow(max(h,0.0),exponent),0.0)),1.0);}`,
});
worldGroup.add(new THREE.Mesh(new THREE.SphereGeometry(100,48,48), skyMat));

/* ─── SOL ─── */
const gnd = new THREE.Mesh(
  new THREE.PlaneGeometry(60,60,80,80),
  new THREE.MeshStandardMaterial({ color:0x2a5a1a, roughness:0.9 })
);
gnd.rotation.x = -Math.PI/2; gnd.receiveShadow = true;
worldGroup.add(gnd);

const soil = new THREE.Mesh(
  new THREE.CircleGeometry(1.5,48),
  new THREE.MeshStandardMaterial({ color:0x3a2a1a, roughness:1 })
);
soil.rotation.x = -Math.PI/2; soil.position.set(0,0.01,0); soil.receiveShadow = true;
worldGroup.add(soil);

/* ─── HERBE INSTANCIÉE ─── */
const GRASS_N = 10000;
const grassGeo = new THREE.PlaneGeometry(0.08, 0.5, 1, 3);
grassGeo.translate(0, 0.25, 0);

const grassMat = new THREE.ShaderMaterial({
  uniforms:{ time:{value:0}, wind:{value:0.3} },
  vertexShader:`
    uniform float time,wind;
    attribute float phase,ht;
    varying float vH;
    void main(){
      vH=position.y/0.5;
      vec3 p=position;
      p.x+=sin(time*2.0+phase)*wind*p.y*p.y + cos(time*1.3+phase*0.7)*wind*0.5*p.y;
      p.z+=cos(time*1.7+phase*1.3)*wind*0.3*p.y;
      p.x*=(1.0-vH*0.6); p.z*=(1.0-vH*0.6);
      gl_Position=projectionMatrix*modelViewMatrix*instanceMatrix*vec4(p*vec3(1,ht,1),1.0);
    }`,
  fragmentShader:`
    varying float vH;
    void main(){
      vec3 c=mix(vec3(0.14,0.34,0.09),vec3(0.3,0.6,0.14),vH);
      c=mix(c,vec3(0.38,0.68,0.18),vH*vH*0.5);
      gl_FragColor=vec4(c*mix(0.5,1.0,vH),1.0);
    }`,
  side:THREE.DoubleSide,
});

const grassMesh = new THREE.InstancedMesh(grassGeo, grassMat, GRASS_N);
const _pha = new Float32Array(GRASS_N);
const _ht  = new Float32Array(GRASS_N);
const _d   = new THREE.Object3D();
for(let i=0;i<GRASS_N;i++){
  const a=Math.random()*Math.PI*2, r=Math.random()*24+2;
  _d.position.set(Math.cos(a)*r, 0, Math.sin(a)*r);
  _d.rotation.set(0, Math.random()*Math.PI, 0);
  _d.scale.set(0.8+Math.random()*0.5, 1, 1);
  _d.updateMatrix();
  grassMesh.setMatrixAt(i, _d.matrix);
  _pha[i] = Math.random()*Math.PI*2;
  _ht[i]  = 0.6+Math.random()*0.8;
}
grassGeo.setAttribute('phase', new THREE.InstancedBufferAttribute(_pha,1));
grassGeo.setAttribute('ht',    new THREE.InstancedBufferAttribute(_ht,1));
grassMesh.instanceMatrix.needsUpdate = true;
worldGroup.add(grassMesh);

/* ─── FLEURS ─── */
const fCols = [0xff6b9d,0xffd93d,0xc084fc,0xffffff,0xff8fab];
for(let i=0;i<34;i++){
  const a=Math.random()*Math.PI*2, r=3+Math.random()*18;
  const fg = new THREE.Group();
  const stem = new THREE.Mesh(
    new THREE.CylinderGeometry(0.01,0.01,0.2,4),
    new THREE.MeshStandardMaterial({color:0x2e7d32})
  );
  stem.position.y=0.1; fg.add(stem);
  const petal = new THREE.Mesh(
    new THREE.SphereGeometry(0.05,6,6),
    new THREE.MeshStandardMaterial({color:fCols[i%fCols.length]})
  );
  petal.position.y=0.22; fg.add(petal);
  fg.position.set(Math.cos(a)*r, 0, Math.sin(a)*r);
  worldGroup.add(fg);
}

/* ─── CAILLOUX ─── */
for(let i=0;i<20;i++){
  const a=Math.random()*Math.PI*2, r=2+Math.random()*14;
  const pg = new THREE.SphereGeometry(0.05+Math.random()*0.08,6,6);
  pg.scale(1, 0.5, 1+Math.random()*0.3);
  const pm = new THREE.Mesh(pg, new THREE.MeshStandardMaterial({
    color: new THREE.Color().setHSL(0.08,0.1,0.3+Math.random()*0.2), roughness:0.95
  }));
  pm.position.set(Math.cos(a)*r, 0.02, Math.sin(a)*r);
  worldGroup.add(pm);
}

/* ─── GRAINE ─── */
const seedGeo = new THREE.SphereGeometry(0.12,16,16);
seedGeo.scale(1, 0.7, 0.8); // scale sur la géométrie (method correcte)
const seed = new THREE.Mesh(seedGeo, new THREE.MeshStandardMaterial({color:0x8B6914,roughness:0.8}));
seed.position.set(0, 0.5, 3);
seed.castShadow = true; seed.visible = false;
worldGroup.add(seed);

/* ─── TROU ─── */
const hole = new THREE.Mesh(
  new THREE.CylinderGeometry(0.4,0.3,0.3,32),
  new THREE.MeshStandardMaterial({color:0x1a0f05,roughness:1})
);
hole.position.set(0,-0.15,0); hole.visible = false;
worldGroup.add(hole);

/* ─── ARBRE ─── */
const treeGroup = new THREE.Group();
worldGroup.add(treeGroup);
let treeObjs = [];

/* ─── LUCIOLES ─── */
const ffN = 28;
const ffGeo = new THREE.BufferGeometry();
const ffPos = new Float32Array(ffN*3);
const ffPha = new Float32Array(ffN);
for(let i=0;i<ffN;i++){
  ffPos[i*3]=(Math.random()-.5)*18; ffPos[i*3+1]=0.5+Math.random()*4; ffPos[i*3+2]=(Math.random()-.5)*18;
  ffPha[i]=Math.random()*Math.PI*2;
}
ffGeo.setAttribute('position',new THREE.BufferAttribute(ffPos,3));
const ffMat = new THREE.PointsMaterial({color:0xffee88,size:0.1,transparent:true,opacity:0.55});
worldGroup.add(new THREE.Points(ffGeo,ffMat));
const fireflies = worldGroup.children[worldGroup.children.length-1];

/* ─── ARROSOIR ─── */
const canGroup = new THREE.Group();
canGroup.visible = false;
const canMat = new THREE.MeshStandardMaterial({color:0x4a7a8a,roughness:0.5,metalness:0.3});
const canBody = new THREE.Mesh(new THREE.CylinderGeometry(0.25,0.3,0.5,12), canMat);
canGroup.add(canBody);
const spout = new THREE.Mesh(new THREE.CylinderGeometry(0.04,0.06,0.4,8), canMat);
spout.position.set(0.2,0.15,0); spout.rotation.z=-0.8;
canGroup.add(spout);
canGroup.position.set(1.5,3,2); canGroup.rotation.z=-0.3;
worldGroup.add(canGroup);

/* ─── PARTICULES EAU ─── */
let waterP = null;
let waterA = {active:false,time:0};

(function initWater(){
  const n=200, geo=new THREE.BufferGeometry();
  const pos=new Float32Array(n*3), vel=new Float32Array(n*3);
  for(let i=0;i<n;i++){
    pos[i*3]=(Math.random()-.5)*1.5; pos[i*3+1]=3+Math.random()*2; pos[i*3+2]=(Math.random()-.5)*1.5;
    vel[i*3]=(Math.random()-.5)*.02; vel[i*3+1]=-.05-Math.random()*.08; vel[i*3+2]=(Math.random()-.5)*.02;
  }
  geo.setAttribute('position',new THREE.BufferAttribute(pos,3));
  geo.userData.velocity=vel;
  waterP = new THREE.Points(geo, new THREE.PointsMaterial({color:0x4dc8ff,size:.06,transparent:true,opacity:.7}));
  waterP.visible=false;
  worldGroup.add(waterP); // dans worldGroup pour coïncider avec l'arbre en XR
})();

function startWaterAnim(){
  waterA={active:true,time:0}; waterP.visible=true;
  const pos=waterP.geometry.attributes.position;
  for(let i=0;i<pos.count;i++) pos.setXYZ(i,(Math.random()-.5)*1.5,3+Math.random()*2,(Math.random()-.5)*1.5);
  pos.needsUpdate=true;
}

/* ─── RETICLE AR ─── */
const reticle = new THREE.Mesh(
  new THREE.RingGeometry(0.15,0.2,32).rotateX(-Math.PI/2),
  new THREE.MeshBasicMaterial({color:0x00ffbf})
);
reticle.matrixAutoUpdate=false; reticle.visible=false;
scene.add(reticle);

/* ─── HAND TRACKING QUEST 3 ─── */
const XR_JOINTS = [
  'wrist',
  'thumb-metacarpal','thumb-phalanx-proximal','thumb-phalanx-distal','thumb-tip',
  'index-finger-metacarpal','index-finger-phalanx-proximal','index-finger-phalanx-intermediate','index-finger-phalanx-distal','index-finger-tip',
  'middle-finger-metacarpal','middle-finger-phalanx-proximal','middle-finger-phalanx-intermediate','middle-finger-phalanx-distal','middle-finger-tip',
  'ring-finger-metacarpal','ring-finger-phalanx-proximal','ring-finger-phalanx-intermediate','ring-finger-phalanx-distal','ring-finger-tip',
  'pinky-finger-metacarpal','pinky-finger-phalanx-proximal','pinky-finger-phalanx-intermediate','pinky-finger-phalanx-distal','pinky-finger-tip',
];

const jGeo = new THREE.SphereGeometry(0.007,6,6);
const jMat = new THREE.MeshStandardMaterial({color:0xe8c8a0,roughness:0.6});
const handMaps = [{},{}]; // joint name → Mesh, per hand
for(let h=0;h<2;h++){
  for(const name of XR_JOINTS){
    const m=new THREE.Mesh(jGeo,jMat);
    m.visible=false; scene.add(m);
    handMaps[h][name]=m;
  }
}
const dropCursor = new THREE.Mesh(
  new THREE.SphereGeometry(0.022,8,8),
  new THREE.MeshStandardMaterial({color:0x4dc8ff,transparent:true,opacity:0.82})
);
dropCursor.visible=false; scene.add(dropCursor);

const xrHand0 = renderer.xr.getHand(0);
const xrHand1 = renderer.xr.getHand(1);
scene.add(xrHand0); scene.add(xrHand1);

// AR controller pour placement
const arCtrl = renderer.xr.getController(0);
arCtrl.addEventListener('select',()=>{
  if(reticle.visible){ treeGroup.position.setFromMatrixPosition(reticle.matrix); treeGroup.updateMatrixWorld(true); }
});
scene.add(arCtrl);

let pinchWas0=false, pinchWas1=false, lastPinch=0;

function pinchDist(hand){
  const a=hand.joints?.['index-finger-tip'], b=hand.joints?.['thumb-tip'];
  return (a&&b&&a.jointRadius!==undefined&&b.jointRadius!==undefined)
    ? a.position.distanceTo(b.position) : Infinity;
}
function updateHandViz(hand, map, isRight){
  if(!hand.joints){ Object.values(map).forEach(m=>m.visible=false); if(isRight)dropCursor.visible=false; return; }
  for(const [n,m] of Object.entries(map)){
    const j=hand.joints[n];
    if(j&&j.jointRadius!==undefined){ m.visible=true; m.position.copy(j.position); m.quaternion.copy(j.quaternion); }
    else { m.visible=false; }
  }
  if(isRight){
    const tip=hand.joints['index-finger-tip'];
    if(tip&&tip.jointRadius!==undefined){
      dropCursor.visible=true; dropCursor.position.copy(tip.position); dropCursor.position.y-=0.03;
    } else { dropCursor.visible=false; }
  }
}

/* ─── XR BOUTONS CUSTOM ─── */
let xrSession=null;
async function initXR(){
  if(!navigator.xr) return;
  const c=document.getElementById('ygg-xr-btns');
  try{ if(await navigator.xr.isSessionSupported('immersive-vr')){ const b=mkXRBtn('🥽 VR',()=>enterXR('immersive-vr')); c.appendChild(b); } }catch(e){}
  try{ if(await navigator.xr.isSessionSupported('immersive-ar')){ const b=mkXRBtn('📷 AR',()=>enterXR('immersive-ar')); c.appendChild(b); } }catch(e){}
}
function mkXRBtn(label,fn){
  const b=document.createElement('button');
  b.className='ygg-xrbtn'; b.textContent=label; b.addEventListener('click',fn); return b;
}
async function enterXR(mode){
  if(xrSession){ try{await xrSession.end();}catch(e){} return; }
  const opts={
    requiredFeatures:['local-floor'],
    optionalFeatures:['hand-tracking', mode==='immersive-ar'?'hit-test':'bounded-floor'],
  };
  if(mode==='immersive-ar'){
    opts.optionalFeatures.push('dom-overlay');
    opts.domOverlay={root:document.getElementById('ygg-ui')};
  }
  try{
    const s=await navigator.xr.requestSession(mode,opts);
    xrSession=s; renderer.xr.setSession(s);
    s.addEventListener('end',()=>{ xrSession=null; worldGroup.position.set(0,0,0); updateCam(); });
    // Décale l'utilisateur à 5m de l'arbre (évite de spawner dans le tronc)
    try{
      const rs=await s.requestReferenceSpace('local-floor');
      const off=new XRRigidTransform({x:0,y:0,z:-5,w:1});
      renderer.xr.setReferenceSpace(rs.getOffsetReferenceSpace(off));
    }catch(e){ worldGroup.position.z=-5; }
  }catch(err){ console.warn('XR failed:',err); }
}

let hitSrc=null, hitSrcReq=false;

/* ─── CAMÉRA ORBITE (desktop/mobile) ─── */
let drag=false, pmx=0, pmy=0, azimuth=0, polar=0.5, camR=12;
function updateCam(){
  if(renderer.xr.isPresenting) return;
  camera.position.set(camR*Math.sin(polar)*Math.cos(azimuth), camR*Math.cos(polar), camR*Math.sin(polar)*Math.sin(azimuth));
  camera.lookAt(0,2,0);
}
updateCam();

/* ─── LOGIQUE JEU ─── */
const getStage = g => { let s=STAGES[0]; for(const st of STAGES) if(g>=st.min) s=st; return s; };
const fmtW = l => l<1?`${(l*1000).toFixed(0)} mL`:`${l.toFixed(1)} L`;
const wEq  = l => { const s=(l/SHOWER_LPM)*60; return s<60?`≈ ${s.toFixed(0)}s de douche`:`≈ ${(s/60).toFixed(1)}min de douche`; };
const rEco = () => ECO_FACTS[Math.floor(Math.random()*ECO_FACTS.length)];

function addJournal(txt){
  const d=new Date(), t=`${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;
  gs.journal.unshift({time:t,text:txt});
  if(gs.journal.length>50) gs.journal.pop();
  save(); renderJournal();
}
function renderJournal(){
  document.getElementById('ygg-journal').innerHTML =
    gs.journal.map(e=>`<div class="ygg-entry"><span class="ygg-etime">${e.time}</span> ${e.text}</div>`).join('');
}

function showEco(title,text,dur=6000){
  const el=document.getElementById('ygg-eco-msg');
  el.innerHTML=`<span class="ygg-etitle">${title}</span>${text}`;
  el.classList.add('vis');
  clearTimeout(el._t); el._t=setTimeout(()=>el.classList.remove('vis'),dur);
}

function updateUI(){
  const st=getStage(gs.growth);
  document.getElementById('ygg-growth-val').textContent = `${Math.floor(gs.growth)}%`;
  document.getElementById('ygg-stage-name').textContent = st.name;
  document.getElementById('ygg-water-val').textContent  = fmtW(gs.totalWater);
  document.getElementById('ygg-water-eq').textContent   = wEq(gs.totalWater);
  if(gs.lastWaterTime){
    const mn=Math.floor((Date.now()-gs.lastWaterTime)/60000);
    const hr=Math.floor(mn/60), dy=Math.floor(hr/24);
    const ts=mn<1?"à l'instant":dy>0?`il y a ${dy}j`:hr>0?`il y a ${hr}h`:`il y a ${mn}min`;
    document.getElementById('ygg-water-eq').textContent=ts;
  }
}

/* ─── MODE AFFICHAGE ─── */
function setDisplayMode(color){
  isColor=color;
  const btn=document.getElementById('ygg-toggle-btn');
  btn.setAttribute('aria-checked', color?'true':'false');
  btn.classList.toggle('ygg-on', color);

  const logoImg  = document.getElementById('ygg-logo-img');
  const introLgo = document.getElementById('ygg-intro-logo');
  const togLgo   = document.getElementById('ygg-toggle-logo');

  if(color){
    canvas.classList.remove('ygg-nb');
    renderer.toneMappingExposure=1.1;
    skyMat.uniforms.topColor.value.set(0x0a1628);
    skyMat.uniforms.bottomColor.value.set(0x2a4a2a);
    logoImg.src=LOGO_GRADIENT; logoImg.classList.remove('ygg-nb-logo');
    introLgo.src=LOGO_GRADIENT; introLgo.classList.add('ygg-color-logo');
    togLgo.src=LOGO_GRADIENT;
    const cv=document.getElementById('ygg-cons-val');
    cv.classList.add('rich');
    animateCons(cv,CONS_RICH);
    document.getElementById('ygg-cons-desc').textContent='Mode Riche Actif : Empreinte Graphique Maximale';
    document.getElementById('ygg-cons-icon').textContent='💻';
  } else {
    canvas.classList.add('ygg-nb');
    renderer.toneMappingExposure=0.75;
    skyMat.uniforms.topColor.value.set(0x0d1520);
    skyMat.uniforms.bottomColor.value.set(0x1e2a1e);
    logoImg.src=LOGO_STROKE; logoImg.classList.add('ygg-nb-logo');
    introLgo.src=LOGO_STROKE; introLgo.classList.remove('ygg-color-logo');
    togLgo.src=LOGO_MAIN;
    const cv=document.getElementById('ygg-cons-val');
    cv.classList.remove('rich');
    animateCons(cv,CONS_ECO);
    document.getElementById('ygg-cons-desc').textContent='Mode Éco Actif : Empreinte Graphique Minimale';
    document.getElementById('ygg-cons-icon').textContent='🪣';
  }
}

function animateCons(el,target){
  const from=parseFloat(el.textContent)||0, t0=performance.now();
  (function step(now){
    const p=Math.min((now-t0)/800,1), e=p<.5?2*p*p:-1+(4-2*p)*p;
    el.textContent=(from+(target-from)*e).toFixed(1)+' mL/s';
    if(p<1) requestAnimationFrame(step);
  })(t0);
}

/* ─── CONSTRUCTION ARBRE ─── */
function buildTree(growth){
  treeObjs.forEach(o=>{
    treeGroup.remove(o);
    o.geometry?.dispose();
    (Array.isArray(o.material)?o.material:[o.material]).forEach(m=>m?.dispose());
  });
  treeObjs=[];
  if(growth<=0) return;
  const t=growth/100, trunkH=0.3+t*8, trunkR=0.05+t*0.6;
  const trunkCol=growth>=90?0x4a3a2a:0x3a2a1a;

  const tGeo=new THREE.CylinderGeometry(trunkR*.6,trunkR,trunkH,20,12);
  const tp=tGeo.attributes.position;
  for(let i=0;i<tp.count;i++) tp.setX(i,tp.getX(i)+Math.sin(tp.getY(i)/trunkH*2)*t*.3);
  tGeo.computeVertexNormals();
  const trunk=new THREE.Mesh(tGeo,new THREE.MeshStandardMaterial({color:trunkCol,roughness:.9}));
  trunk.position.y=trunkH/2; trunk.castShadow=true;
  treeGroup.add(trunk); treeObjs.push(trunk);

  if(growth>30){
    let fi=Math.max(0,Math.floor((growth-30)/10)); if(fi>=FIB.length) fi=FIB.length-1;
    for(let i=0;i<FIB[fi];i++){
      const ang=i*GOLDEN_ANGLE+Math.random()*.2;
      const bH=.5+t*3*(.5+Math.random()*.5), bR=.02+t*.15, bY=trunkH*(.4+(i/FIB[fi])*.5);
      const b=new THREE.Mesh(new THREE.CylinderGeometry(bR*.3,bR,bH,10),
        new THREE.MeshStandardMaterial({color:trunkCol,roughness:.9}));
      b.position.set(Math.cos(ang)*trunkR*.8,bY,Math.sin(ang)*trunkR*.8);
      b.rotation.z=(Math.cos(ang)>0?-1:1)*(.4+Math.random()*.6); b.rotation.y=ang;
      b.castShadow=true; treeGroup.add(b); treeObjs.push(b);
    }
  }

  if(growth>10){
    const layers=growth>=90?5:(growth>=50?3:1);
    const leafCol=growth>=90?0xd4af37:(growth>=70?0x1a6b1a:0x2e8b2e);
    for(let l=0;l<layers;l++){
      const cR=(.3+t*3.5)*(1-l*.15), cY=trunkH*.7+l*trunkH*.15;
      const cGeo=new THREE.SphereGeometry(cR,26,16);
      const cp=cGeo.attributes.position;
      for(let i=0;i<cp.count;i++){const n=.82+Math.random()*.36; cp.setXYZ(i,cp.getX(i)*n,cp.getY(i)*n,cp.getZ(i)*n);}
      cGeo.computeVertexNormals();
      const c=new THREE.Mesh(cGeo,new THREE.MeshStandardMaterial({color:leafCol,roughness:.8,metalness:growth>=90?.2:0}));
      c.name=`canopy_${l}`; c.position.set(Math.sin(l*GOLDEN_ANGLE)*.3,cY,Math.cos(l*GOLDEN_ANGLE)*.3);
      c.castShadow=true; treeGroup.add(c); treeObjs.push(c);
    }
    if(growth>=90){
      for(let i=0;i<8;i++){
        const ra=(i/8)*Math.PI*2, rl=2+Math.random()*2;
        const r=new THREE.Mesh(new THREE.CylinderGeometry(.05,.2,rl,10),
          new THREE.MeshStandardMaterial({color:0x3a2a1a,roughness:1}));
        r.position.set(Math.cos(ra)*rl*.4,.1,Math.sin(ra)*rl*.4); r.rotation.z=Math.cos(ra)*1.2; r.rotation.y=ra;
        treeGroup.add(r); treeObjs.push(r);
      }
      const gGeo=new THREE.BufferGeometry();
      const gp=new Float32Array(50*3);
      for(let i=0;i<50;i++){gp[i*3]=(Math.random()-.5)*6;gp[i*3+1]=2+Math.random()*(trunkH*.8);gp[i*3+2]=(Math.random()-.5)*6;}
      gGeo.setAttribute('position',new THREE.BufferAttribute(gp,3));
      const gpts=new THREE.Points(gGeo,new THREE.PointsMaterial({color:0xffd700,size:.15,transparent:true,opacity:.6}));
      gpts.name='yggGlow'; treeGroup.add(gpts); treeObjs.push(gpts);
    }
  }
}

/* ─── ARROSAGE ─── */
function doWatering(){
  const now=Date.now(), mins=gs.lastWaterTime?(now-gs.lastWaterTime)/60000:Infinity;
  const used=WATER_ARR+gs.pageWater;
  gs.totalWater+=used; gs.pageWater=0; gs.waterCount++; gs.lastWaterTime=now;

  let gain=0;
  if(gs.phase==='firstWater'){
    gain=5; addJournal(`💧 Premier arrosage ! ${fmtW(used)}.`);
  } else if(mins<1){
    gain=.5; gs.overwatered=true;
    addJournal(`⚠️ Trop fréquent. ${fmtW(used)} gaspillés.`);
    showEco('⚠️ Trop d\'eau !',`${fmtW(used)} utilisés — l'arbre n'en avait pas besoin.<br>${rEco()}`);
  } else if(mins<5){
    gain=2; addJournal(`💧 Arrosage. ${fmtW(used)}.`);
    showEco('💧 Arrosage',`${fmtW(used)} (${wEq(used)}). ${rEco()}`);
  } else if(mins<60){
    gain=5; addJournal(`💧 Bon arrosage ! ${fmtW(used)}.`);
    showEco('🌿 Bon timing !',`L'arbre apprécie un arrosage espacé. ${fmtW(used)}. ${rEco()}`);
  } else {
    gain=10+Math.min(mins/120,10);
    addJournal(`🌟 Arrosage patient ! ${fmtW(used)}.`);
    showEco('🌟 Vertueux !',`Ta patience est récompensée ! Croissance bonus. ${rEco()}`);
  }

  gs.growth=Math.min(100,gs.growth+gain);
  startWaterAnim();
  canGroup.visible=true;
  setTimeout(()=>{canGroup.visible=false;},3000);
  buildTree(gs.growth);

  if(gs.phase==='firstWater'){
    setPhase('growing');
    showEco('🌱 Germination !',`Ta graine germe ! ${fmtW(used)} utilisés (dont ${fmtW(WATER_PAGE)} à l'ouverture). ${rEco()}`);
  }
  if(gs.growth>=90&&gs.phase!=='yggdrasil'){
    setPhase('yggdrasil');
    addJournal('🌳✨ Ton arbre est devenu YGGDRASIL !');
    showEco('✨ YGGDRASIL ✨',`Forme ultime ! ${fmtW(gs.totalWater)} utilisés. Clique 📖 pour réfléchir.`,9000);
  }
  save(); updateUI();
  // Flash pinch XR
  const fl=document.getElementById('ygg-pinch-flash');
  fl.classList.add('fl'); setTimeout(()=>fl.classList.remove('fl'),180);
}

/* ─── PHASES ─── */
let digA={active:false,time:0}, plantA={active:false,time:0}, coverA={active:false,time:0};

/* Utilitaire : montre/cache les boutons sans conflit !important */
function showBtns(...ids){ ['dig','plant','cover','water','moral'].forEach(id=>{ const b=document.getElementById(`ygg-btn-${id}`); if(b) b.style.display=ids.includes(id)?'flex':'none'; }); }

function setPhase(phase){
  gs.phase=phase; save();
  const showHUD=!['start','digging','planting','covering'].includes(phase);
  document.getElementById('ygg-hud').style.display       = showHUD?'flex':'none';
  document.getElementById('ygg-active-cons').style.display = showHUD?'flex':'none';
  document.getElementById('ygg-journal').style.display    = showHUD?'block':'none';
  if(showHUD) renderJournal();

  switch(phase){
    case 'start':
      seed.visible=true; seed.position.set(0,.5,3); seed.scale.setScalar(1);
      showBtns('dig'); break;
    case 'digging':
      showBtns(); digA={active:true,time:0}; break;
    case 'planting':
      seed.visible=true; seed.position.set(0,.5,3); seed.scale.setScalar(1);
      showBtns('plant'); break;
    case 'covering':
      showBtns('cover'); break;
    case 'firstWater':
      showBtns('water'); break;
    case 'growing':
    case 'yggdrasil':
      showBtns('water','moral');
      buildTree(gs.growth); break;
  }
  updateUI();
}

function showMoral(){
  document.getElementById('ygg-moral').hidden=false;
  const tw=gs.totalWater, pw=gs.pageLoads*WATER_PAGE;
  document.getElementById('ygg-moral-text').innerHTML=`
    Ton arbre est magnifique. Chaque visite consomme <strong>${fmtW(WATER_PAGE)}</strong> symboliquement,
    et chaque arrosage <strong>${fmtW(WATER_ARR)}</strong> de plus.<br><br>
    <em>Les datacenters d'Internet consomment des milliards de litres pour leur refroidissement. Chaque pixel compte.</em>`;
  document.getElementById('ygg-moral-stat').innerHTML=
    `💧 Total : ${fmtW(tw)}<br>🌱 Dont ${fmtW(pw)} en ouvrant la page ${gs.pageLoads}×<br>🚿 ${wEq(tw)}`;
  document.getElementById('ygg-moral-advice').innerHTML=
    `<strong>La morale :</strong> Le meilleur joueur n'est pas celui qui a le plus gros arbre —
     c'est celui qui a utilisé le <em>moins d'eau</em> pour y arriver.<br><br>
     <strong style="color:#9de090;">Score écologique : ${Math.max(0,100-Math.floor(tw*2))}/100</strong>`;
}

/* ─── DÉMARRAGE DU JEU ─── */
function startGame(){
  const intro=document.getElementById('ygg-intro');
  intro.classList.add('ygg-out');
  setTimeout(()=>{ intro.style.display='none'; },1200);

  document.getElementById('ygg-actions').style.display='flex';
  document.getElementById('ygg-toggle-bar').style.display='flex';

  setDisplayMode(false); // N&B par défaut

  setPhase(gs.phase);

  if(gs.phase==='start'){
    showEco('🌰 Ta graine','Tu as une graine. Creuse un trou dans le sol pour la planter.');
  }
  if(['growing','yggdrasil'].includes(gs.phase)){
    setTimeout(()=>showEco('🌱 Connexion détectée',
      `Rien qu'en ouvrant cette page, <strong>${fmtW(WATER_PAGE)}</strong> dépensés. ${rEco()}`), 1500);
  }
}

/* ─── EVENT LISTENERS ─── */
document.getElementById('ygg-start-btn').addEventListener('click', startGame);

document.getElementById('ygg-reset-btn').addEventListener('click',()=>{
  localStorage.removeItem(SAVE_KEY); location.reload();
});

document.getElementById('ygg-toggle-btn').addEventListener('click',()=> setDisplayMode(!isColor));

document.getElementById('ygg-btn-dig').addEventListener('click',()=>{
  setPhase('digging'); addJournal('🤲 Tu creuses un trou dans la terre...');
});
document.getElementById('ygg-btn-plant').addEventListener('click',()=>{
  setPhase('covering'); plantA={active:true,time:0};
  addJournal('🌰 Tu déposes la graine au creux de la terre.');
});
document.getElementById('ygg-btn-cover').addEventListener('click',()=>{
  coverA={active:true,time:0};
  addJournal('🫳 Tu recouvres doucement la graine de terre.');
  setTimeout(()=>{
    setPhase('firstWater');
    showEco('🌱 Première étape','Ta graine est plantée. Elle a besoin de son premier arrosage pour germer.');
  },1800);
});
document.getElementById('ygg-btn-water').addEventListener('click', doWatering);
document.getElementById('ygg-btn-moral').addEventListener('click', showMoral);
document.getElementById('ygg-close-moral').addEventListener('click',()=>{ document.getElementById('ygg-moral').hidden=true; });

// Bouton i
const infoBtn=document.getElementById('ygg-info-btn');
const helpPanel=document.getElementById('ygg-help-panel');
infoBtn.addEventListener('click',()=>{
  const h=helpPanel.hidden; helpPanel.hidden=!h; infoBtn.setAttribute('aria-expanded',h?'true':'false');
});
document.getElementById('ygg-help-close').addEventListener('click',()=>{ helpPanel.hidden=true; infoBtn.setAttribute('aria-expanded','false'); });

// Orbite souris
canvas.addEventListener('pointerdown',e=>{drag=true;pmx=e.clientX;pmy=e.clientY;});
canvas.addEventListener('pointerup',()=>drag=false);
canvas.addEventListener('pointerleave',()=>drag=false);
canvas.addEventListener('pointermove',e=>{
  if(!drag) return;
  azimuth-=(e.clientX-pmx)*.005; polar=Math.max(.08,Math.min(Math.PI/2-.05,polar-(e.clientY-pmy)*.005));
  pmx=e.clientX; pmy=e.clientY; updateCam();
});

// Zoom molette
window.addEventListener('wheel',e=>{
  e.preventDefault(); camR=Math.max(4,Math.min(28,camR+e.deltaY*.01)); updateCam();
},{passive:false});

// Zoom tactile
let ltd=0;
canvas.addEventListener('touchstart',e=>{ if(e.touches.length===2) ltd=Math.hypot(e.touches[0].clientX-e.touches[1].clientX,e.touches[0].clientY-e.touches[1].clientY); });
canvas.addEventListener('touchmove',e=>{
  if(e.touches.length===2){
    e.preventDefault();
    const d=Math.hypot(e.touches[0].clientX-e.touches[1].clientX,e.touches[0].clientY-e.touches[1].clientY);
    camR=Math.max(4,Math.min(28,camR-(d-ltd)*.05)); ltd=d; updateCam();
  }
},{passive:false});

// Reset keyboard
window.addEventListener('keydown',e=>{ if(e.key==='r'&&e.ctrlKey&&e.shiftKey){localStorage.removeItem(SAVE_KEY);location.reload();} });

// Resize
window.addEventListener('resize',()=>{
  camera.aspect=window.innerWidth/window.innerHeight; camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth,window.innerHeight); updateCam();
});

/* ─── INIT ─── */
if(gs.phase!=='start') document.getElementById('ygg-start-btn').textContent='Retrouver mon arbre';
// Logo intro N&B par défaut (fond sombre → inversion)
document.getElementById('ygg-intro-logo').src=LOGO_STROKE;
document.getElementById('ygg-logo-img').src=LOGO_STROKE;
canvas.classList.add('ygg-nb');
initXR();

/* ─── BOUCLE ANIMATION ─── */
const clock=new THREE.Clock();

renderer.setAnimationLoop((timestamp, frame)=>{
  const dt=clock.getDelta(), t=clock.elapsedTime;

  /* Herbe */
  grassMat.uniforms.time.value=t;

  /* Lucioles */
  const fp=ffGeo.attributes.position;
  for(let i=0;i<ffN;i++){
    fp.setY(i,fp.getY(i)+Math.sin(t*.5+ffPha[i])*.002);
    fp.setX(i,fp.getX(i)+Math.cos(t*.3+ffPha[i])*.001);
  }
  fp.needsUpdate=true;
  ffMat.opacity=.3+Math.sin(t*2)*.24;

  /* Graine flottante */
  if(seed.visible&&gs.phase==='start'){
    seed.position.y=.5+Math.sin(t*2)*.05; seed.rotation.y=t*.5;
  }

  /* Phase : creuser */
  if(digA.active){
    digA.time+=dt*1.2; const p=Math.min(digA.time,1);
    if(p>=1){ digA.active=false; hole.visible=true; setPhase('planting'); showEco('🕳️ Trou creusé','Le sol est prêt. Plante ta graine !'); }
  }

  /* Phase : planter */
  if(plantA.active){
    plantA.time+=dt*1.2; const p=Math.min(plantA.time,1);
    seed.position.set(0,.5-p*.6,3-p*3); seed.scale.setScalar(1-p*.3);
    if(p>=1){ plantA.active=false; seed.visible=false; }
  }

  /* Phase : recouvrir */
  if(coverA.active){
    coverA.time+=dt*.9; const p=Math.min(coverA.time,1);
    hole.scale.y=1-p;
    if(p>=1){ coverA.active=false; hole.visible=false; hole.scale.y=1; }
  }

  /* Eau */
  if(waterA.active){
    waterA.time+=dt;
    const pos=waterP.geometry.attributes.position, vel=waterP.geometry.userData.velocity;
    for(let i=0;i<pos.count;i++){
      pos.setX(i,pos.getX(i)+vel[i*3]); pos.setY(i,pos.getY(i)+vel[i*3+1]); pos.setZ(i,pos.getZ(i)+vel[i*3+2]);
      vel[i*3+1]-=.001;
      if(pos.getY(i)<0){ pos.setXYZ(i,(Math.random()-.5)*1.5,3+Math.random(),(Math.random()-.5)*1.5); vel[i*3+1]=-.05-Math.random()*.08; }
    }
    pos.needsUpdate=true;
    if(waterA.time>3){ waterA.active=false; waterP.visible=false; }
  }

  /* Arrosoir */
  if(canGroup.visible){ canGroup.position.y=3+Math.sin(t*3)*.1; canGroup.rotation.z=-.3-Math.sin(t*2)*.1; }

  /* Canopées ondulantes */
  treeGroup.rotation.y=Math.sin(t*.28)*.018;
  treeObjs.forEach(o=>{ if(o.name?.startsWith('canopy')) o.position.x+=Math.sin(t*.8+o.id)*.0007; });
  const glow=treeGroup.getObjectByName('yggGlow');
  if(glow){ glow.rotation.y=t*.1; glow.material.opacity=.3+Math.sin(t)*.3; }

  /* ─── HAND TRACKING XR ─── */
  if(renderer.xr.isPresenting){
    updateHandViz(xrHand0,handMaps[0],false);
    updateHandViz(xrHand1,handMaps[1],true);

    const d0=pinchDist(xrHand0), isPinch0=d0<.03;
    if(isPinch0&&!pinchWas0){
      const now=Date.now();
      if(now-lastPinch>2000&&['firstWater','growing','yggdrasil'].includes(gs.phase)){ lastPinch=now; doWatering(); }
    }
    pinchWas0=isPinch0;

    const d1=pinchDist(xrHand1), isPinch1=d1<.03;
    if(isPinch1&&!pinchWas1){
      const now=Date.now();
      if(now-lastPinch>2000&&['firstWater','growing','yggdrasil'].includes(gs.phase)){ lastPinch=now; doWatering(); }
    }
    pinchWas1=isPinch1;

    if(dropCursor){
      dropCursor.material.opacity=(isPinch0||isPinch1)?1:.8;
      dropCursor.scale.setScalar((isPinch0||isPinch1)?1.4:1);
    }

    /* AR Hit Test */
    if(frame){
      const refSpace=renderer.xr.getReferenceSpace(), sess=renderer.xr.getSession();
      if(!hitSrcReq&&sess){
        sess.requestReferenceSpace('viewer').then(vs=>
          sess.requestHitTestSource({space:vs}).then(s=>{ hitSrc=s; })
        );
        sess.addEventListener('end',()=>{ hitSrcReq=false; hitSrc=null; reticle.visible=false; });
        hitSrcReq=true;
      }
      if(hitSrc){
        const res=frame.getHitTestResults(hitSrc);
        if(res.length>0){ reticle.visible=true; reticle.matrix.fromArray(res[0].getPose(refSpace).transform.matrix); }
        else { reticle.visible=false; }
      }
    }
  } else {
    handMaps.forEach(m=>Object.values(m).forEach(mesh=>mesh.visible=false));
    if(dropCursor) dropCursor.visible=false;
    updateCam();
  }

  renderer.render(scene,camera);
});
</script>
</body>
</html>
				</div>
				</div>
					</div>
				</div>
				</div>
		<p>Cet article <a href="https://presentcomposedesign.fr/yggdrasil/">Yggdrasil</a> est apparu en premier sur <a href="https://presentcomposedesign.fr">Présent Composé design</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>//*</title>
		<link>https://presentcomposedesign.fr/36446-2/</link>
		
		<dc:creator><![CDATA[admin]]></dc:creator>
		<pubDate>Sun, 19 Apr 2026 23:40:28 +0000</pubDate>
				<category><![CDATA[[ia]]]></category>
		<guid isPermaLink="false">https://presentcomposedesign.fr/?p=36446</guid>

					<description><![CDATA[<p>Préparation VR confortable WebGPU non disponible Ce microcosme interactif nécessite l’API WebGPU, qui n’est pas prise en charge par votre navigateur actuel. Essayez d’utiliser Google Chrome 113+ ou Microsoft Edge 113+ sur bureau. Sous macOS, essayez également Safari Technology Preview. ☰ ☾ &#8211;:&#8211; &#8212; AUTO Affiner &#8211;° · microclimat Vent &#8212; · humidité &#8212; Temp.&#8211;°C Vent&#8212; Humidité&#8212; Microclimat en observation Arrosage à évaluer Jours sans pluie : &#8212; ☾ Lune en calcul Phase · mouvement · type lunaire OK GPS ↻ Microclimat local &#8212; &#8212; &#8212; &#8212; &#8212; &#8212; &#8212; &#8212; &#8212; Organic Control Island Cycle vivant Jour 000/365 · graine en éveil Mémoire navigateur auto Arroser mon arbre Rituel volontaire Voir ma carte Carte vivante Déverrouiller la mémoire autonome Mémoire locale : la progression est automatiquement conservée dans ce navigateur. La mémoire HTML autonome est disponible avec l’add-on ou l’arbre à vie. Carte mémoire Microcosm Viewer collector dynamique · clic/tap pour retourner × Voir verso Microcosm Interactif 9 #&#8212;&#8212; Graine en éveil Mémoire vivante 0% Jour000/365 Temp.&#8211;°C Vent&#8212; km/h Humidité&#8211;% Nuages&#8211;% Synchro&#8212; Certificat de graine Certificat vivant Identité de l’arbre Seed public&#8212; Lieu récent&#8212; Générée&#8212; Activité&#8212; Carte mémoire autonome prête à être enregistrée. AR/XRpresentcomposedesign.fr/microcosm-ar Carte HTML autonome Carte mémoire Carte unique : le même HTML autonome sert au viewer et à la sauvegarde payante. Jour&#8212; Tier&#8212; Météo&#8212; Seed public&#8212; La carte flotte ici. En gratuit, elle reste consultable ; “Enregistrer” sert à déverrouiller la mémoire HTML autonome. Fermer options dans le HTML autonome Ouvrir AR/XR Enregistrer Jouer contre vents et marées débloquer l’expérience Ton arbre a accompli son premier cycle. 365 jours : une année complète de croissance. Tu peux conserver cet arbre, créer une card visuelle gratuite, repartir d’une nouvelle graine, ou garder une vraie carte mémoire premium pour lui redonner vie plus tard. Conserver l’arbrele garder comme page d’accueil Créer une card HTML vivantepage autonome partageable Couper et recommencernouvelle graine Continuer à vie · 19,99€carte mémoire illimitée + partage avancé Jouer contre vents et marées Deux modes de jeu existent : gratuit ou à vie. L’extension “vents &#038; marées” est un supplément séparé, activable aussi bien sur le mode gratuit que sur le mode à vie. Elle débloque des outils, artefacts, entretiens supplémentaires et le mode accueil plein écran sans HUD. Gratuit : 1 clic / jour pendant 365 jours À vie : arbre éternel + mémoire illimitée Add-on “vents &#038; marées” : 4,99€ sur gratuit ou à vie Débloque 5 clics/jour au départ + outils + artefacts + mode accueil sans HUD Cartes : 2D originales en gratuit · version glass collector pour les arbres à vie Mode accueil Plein écran sans HUD restaurant · accueil entreprise · sensibilisation “un clic par jour”</p>
<p>Cet article <a href="https://presentcomposedesign.fr/36446-2/">//*</a> est apparu en premier sur <a href="https://presentcomposedesign.fr">Présent Composé design</a>.</p>
]]></description>
										<content:encoded><![CDATA[		<div data-elementor-type="wp-post" data-elementor-id="36446" class="elementor elementor-36446">
				<div class="elementor-element elementor-element-cbb3427 e-con-full e-flex wpr-particle-no wpr-jarallax-no wpr-parallax-no wpr-sticky-section-no wpr-column-slider-no wpr-equal-height-no e-con e-parent" data-id="cbb3427" data-element_type="container" data-e-type="container">
				<div class="elementor-element elementor-element-d905f2b elementor-widget elementor-widget-html" data-id="d905f2b" data-element_type="widget" data-e-type="widget" data-widget_type="html.default">
				<div class="elementor-widget-container">
					<!-- MICROCOSM INTERACTIF 9 · WORDPRESS / ELEMENTOR CUSTOM HTML BLOCK · V8.27 MOBILE GROUND · CARD PARITY · ORGANIC SHORE
Base corrigée depuis la version standalone v8.25.
À coller dans un widget HTML Elementor sur une page Canvas / plein écran.
Patch orienté UX joueur : Organic Control Island, carte autonome en preview, gland au sol, rivage organique, sol mobile plus propre.
Croissance validée préservée : seul “Arroser mon arbre” déclenche la progression.
-->
<style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    :root { --microcosm-weather-stack-bottom: 64px; }

    /* WordPress / theme hard override: Microcosm doit occuper la page entière et ignorer les styles du site. */
    html body #microcosm-app-root,
    html body #microcosm-app-root canvas {
      max-width: none !important;
      max-height: none !important;
      min-width: 0 !important;
      min-height: 0 !important;
      margin: 0 !important;
      padding: 0 !important;
      border: 0 !important;
      box-shadow: none !important;
    }
    html body .elementor,
    html body .elementor-section,
    html body .elementor-container,
    html body .elementor-column,
    html body .elementor-widget,
    html body .elementor-widget-container,
    html body .entry-content,
    html body .site-content,
    html body .content-area,
    html body main.site-main {
      margin: 0 !important;
      padding: 0 !important;
      max-width: none !important;
      width: 100% !important;
      min-height: 0 !important;
      overflow: visible !important;
      background: transparent !important;
    }
    html body.microcosm-immersive-page {
      margin: 0 !important;
      padding: 0 !important;
      overflow: hidden !important;
      background: #0b0a06 !important;
    }

    html, body {
      width: 100% !important;
      height: 100% !important;
      min-height: 100% !important;
      margin: 0 !important;
      padding: 0 !important;
      overflow: hidden !important;
      background: linear-gradient(180deg, #8fd3ff 0%, #cdefff 55%, #ffe0a8 100%) !important;
    }
    body.elementor-page,
    body.page-template-elementor_canvas,
    body.page-template-elementor_header_footer {
      overflow: hidden !important;
    }
    .elementor-location-header,
    .elementor-location-footer,
    .site-header,
    .site-footer,
    header.site-header,
    footer.site-footer {
      display: none !important;
    }

    /* WordPress / Elementor hard override : la page Microcosm doit écraser le thème hôte. */
    html:has(#microcosm-app-root),
    body:has(#microcosm-app-root) {
      margin: 0 !important;
      padding: 0 !important;
      width: 100vw !important;
      min-width: 100vw !important;
      height: 100dvh !important;
      min-height: 100dvh !important;
      overflow: hidden !important;
      background: #090704 !important;
    }
    .elementor-widget-container:has(#microcosm-app-root),
    .elementor-widget-html:has(#microcosm-app-root),
    .entry-content:has(#microcosm-app-root),
    .site-main:has(#microcosm-app-root),
    .page-content:has(#microcosm-app-root) {
      margin: 0 !important;
      padding: 0 !important;
      width: 100vw !important;
      max-width: none !important;
      height: 100dvh !important;
      overflow: hidden !important;
      background: transparent !important;
    }

    #microcosm-app-root {
      position: fixed !important;
      inset: 0 !important;
      width: 100vw !important;
      height: 100dvh !important;
      overflow: hidden !important;
      z-index: 0 !important;
      background: #9edcff;
      isolation: isolate;
    }
    canvas {
      display: block;
      position: fixed !important;
      inset: 0 !important;
      width: 100vw !important;
      height: 100dvh !important;
      z-index: 1;
      touch-action: none;
    }
    /* panneaux d’interface superposés */
    #no-webgpu {
      display: none;
      position: fixed;
      inset: 0;
      background: #0a1628;
      color: #7ec8e3;
      font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
      z-index: 9999;
      justify-content: center;
      align-items: center;
      flex-direction: column;
      text-align: center;
      padding: 40px;
    }
    #no-webgpu h1 { font-size: 28px; margin-bottom: 16px; color: #fff; }
    #no-webgpu p { font-size: 16px; line-height: 1.7; max-width: 520px; opacity: 0.8; }
    #no-webgpu .hint { margin-top: 24px; padding: 16px 24px; background: rgba(126,200,227,0.08); border: 1px solid rgba(126,200,227,0.2); border-radius: 8px; font-size: 14px; }
    /* panneau UI océan */
    #ui-toggle {
      position: fixed;
      top: 20px;
      right: 20px;
      z-index: 1003;
      width: 42px;
      height: 42px;
      border: 1px solid rgba(216, 171, 97, 0.26);
      background: linear-gradient(145deg, rgba(27, 20, 12, 0.66), rgba(52, 39, 21, 0.42));
      box-shadow: 0 14px 34px rgba(7, 10, 5, 0.22), inset 0 1px 0 rgba(255, 238, 193, 0.14);
      backdrop-filter: blur(22px) saturate(128%);
      -webkit-backdrop-filter: blur(22px) saturate(128%);
      border-radius: 14px;
      cursor: pointer;
      display: flex;
      align-items: center;
      justify-content: center;
      transition: all 0.3s ease;
      color: rgba(255,255,255,0.86);
      font-size: 16px;
    }
    #ui-toggle:hover {
      background: linear-gradient(145deg, rgba(52, 39, 21, 0.72), rgba(79, 110, 38, 0.34));
      border-color: rgba(232, 190, 113, 0.34);
      color: rgba(255,255,255,0.98);
      transform: translateY(-1px);
    }
    #ui-toggle.active {
      border-color: rgba(232, 190, 113, 0.40);
      box-shadow: 0 14px 34px rgba(7, 10, 5, 0.24), inset 0 1px 0 rgba(255, 238, 193, 0.16), 0 0 0 1px rgba(210, 183, 111, 0.10);
    }
    #ui-panel {
      position: fixed;
      top: 20px;
      right: 70px;
      z-index: 1002;
      width: clamp(300px, 26vw, 360px);
      max-height: calc(100dvh - 40px);
      background:
        radial-gradient(circle at 12% 0%, rgba(238, 196, 116, 0.12), transparent 36%),
        linear-gradient(145deg, rgba(27, 20, 12, 0.78), rgba(52, 39, 21, 0.60));
      backdrop-filter: blur(28px) saturate(128%);
      -webkit-backdrop-filter: blur(28px) saturate(128%);
      border: 1px solid rgba(216, 171, 97, 0.22);
      box-shadow: 0 26px 58px rgba(7, 10, 5, 0.28), inset 0 1px 0 rgba(255, 238, 193, 0.14);
      border-radius: 20px;
      font-family: 'Inter', -apple-system, sans-serif;
      color: rgba(255, 248, 225, 0.88);
      overflow-y: auto;
      overflow-x: hidden;
      transform: translateX(12px) scale(0.985);
      opacity: 0;
      pointer-events: none;
      transition: width 0.28s ease, transform 0.35s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.35s ease, box-shadow 0.28s ease;
      scrollbar-width: thin;
      scrollbar-color: rgba(255,255,255,0.16) transparent;
    }
    #ui-panel::-webkit-scrollbar { width: 4px; }
    #ui-panel::-webkit-scrollbar-track { background: transparent; }
    #ui-panel::-webkit-scrollbar-thumb {
      background: rgba(255,255,255,0.12);
      border-radius: 4px;
    }
    #ui-panel::-webkit-scrollbar-thumb:hover {
      background: rgba(255,255,255,0.25);
    }
    #ui-panel.open {
      transform: translateX(0) scale(1);
      opacity: 1;
      pointer-events: all;
    }
    #ui-panel.open:hover,
    #ui-panel.open:focus-within {
      width: clamp(360px, 42vw, 560px);
      box-shadow: 0 32px 72px rgba(7, 10, 5, 0.34), inset 0 1px 0 rgba(255, 238, 193, 0.18);
    }
    #ui-panel.open:hover .ui-section-body,
    #ui-panel.open:focus-within .ui-section-body {
      display: grid;
      grid-template-columns: repeat(2, minmax(0, 1fr));
      gap: 4px 10px;
      align-items: start;
    }
    #ui-panel.open:hover .ui-section.dev-wide .ui-section-body,
    #ui-panel.open:focus-within .ui-section.dev-wide .ui-section-body {
      grid-template-columns: repeat(2, minmax(0, 1fr));
    }
    #ui-panel.open:hover .ui-section.product-section .ui-section-body,
    #ui-panel.open:focus-within .ui-section.product-section .ui-section-body {
      grid-template-columns: 1fr;
    }
    #ui-panel.open:hover .ui-row,
    #ui-panel.open:focus-within .ui-row {
      min-width: 0;
    }
    .ui-section {
      padding: 0 18px;
      border-bottom: 1px solid rgba(216, 171, 97, 0.10);
    }
    .ui-section:last-child { border-bottom: none; }
    .ui-section-header {
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 12px 0 8px;
      cursor: pointer;
      user-select: none;
    }
    .ui-section-title {
      font-size: 10px;
      font-weight: 700;
      letter-spacing: 1.25px;
      text-transform: uppercase;
      color: rgba(255, 248, 225, 0.88);
    }
    .ui-section-arrow {
      font-size: 8px;
      color: rgba(255,255,255,0.2);
      transition: transform 0.25s ease;
    }
    .ui-section.collapsed .ui-section-arrow { transform: rotate(-90deg); }
    .ui-section-body {
      overflow: hidden;
      max-height: 600px;
      transition: max-height 0.3s ease, opacity 0.25s ease;
      padding-bottom: 10px;
    }
    .ui-section.collapsed .ui-section-body {
      max-height: 0;
      opacity: 0;
      padding-bottom: 0;
    }
    .ui-row {
      display: flex;
      align-items: center;
      justify-content: space-between;
      height: 28px;
      margin-bottom: 2px;
    }
    .ui-label {
      font-size: 11px;
      font-weight: 500;
      color: rgba(226, 218, 188, 0.70);
      white-space: nowrap;
      flex-shrink: 0;
      margin-right: 10px;
    }
    .ui-slider-wrap {
      flex: 1;
      display: flex;
      align-items: center;
      gap: 6px;
      min-width: 0;
    }
    .ui-slider {
      flex: 1;
      -webkit-appearance: none;
      appearance: none;
      height: 4px;
      background: linear-gradient(90deg, rgba(216, 171, 97, 0.18), rgba(255, 248, 225, 0.08));
      border-radius: 2px;
      outline: none;
      cursor: pointer;
    }
    .ui-slider::-webkit-slider-thumb {
      -webkit-appearance: none;
      width: 14px;
      height: 14px;
      border-radius: 50%;
      background: linear-gradient(180deg, rgba(255, 248, 225, 0.98), rgba(206, 177, 110, 0.82));
      border: 1px solid rgba(255, 238, 193, 0.46);
      box-shadow: 0 4px 10px rgba(11,24,40,0.18);
      cursor: pointer;
      transition: transform 0.15s ease, background 0.15s ease;
    }
    .ui-slider::-webkit-slider-thumb:hover {
      transform: scale(1.3);
      background: #fff;
    }
    .ui-slider::-moz-range-thumb {
      width: 14px;
      height: 14px;
      border-radius: 50%;
      background: linear-gradient(180deg, rgba(255, 248, 225, 0.98), rgba(206, 177, 110, 0.82));
      border: 1px solid rgba(255, 238, 193, 0.46);
      box-shadow: 0 4px 10px rgba(11,24,40,0.18);
      cursor: pointer;
    }
    .ui-val {
      font-size: 10px;
      font-weight: 700;
      color: rgba(255,255,255,0.52);
      min-width: 28px;
      text-align: right;
      font-variant-numeric: tabular-nums;
    }
    .ui-color-wrap {
      position: relative;
      width: 20px;
      height: 20px;
      border-radius: 50%;
      overflow: hidden;
      border: 1px solid rgba(255,255,255,0.1);
      flex-shrink: 0;
    }
    .ui-color-input {
      position: absolute;
      inset: -4px;
      width: 28px;
      height: 28px;
      border: none;
      cursor: pointer;
      opacity: 0;
    }
    .ui-color-swatch {
      width: 100%;
      height: 100%;
      border-radius: 50%;
      pointer-events: none;
    }
    .ui-select {
      background: rgba(255,255,255,0.06);
      border: 1px solid rgba(255,255,255,0.08);
      border-radius: 6px;
      color: rgba(255,255,255,0.65);
      font-size: 10px;
      font-family: inherit;
      padding: 3px 6px;
      outline: none;
      cursor: pointer;
      max-width: 110px;
    }
    .ui-select:focus { border-color: rgba(255,255,255,0.2); }
    .ui-brand {
      padding: 14px 16px 6px;
      font-size: 11px;
      font-weight: 400;
      letter-spacing: 0.5px;
      color: rgba(255,255,255,0.25);
      text-align: center;
    }
    /* panneau herbe (contrôles) */
    .grass-panel {
      position: fixed;
      top: 20px;
      left: 20px;
      z-index: 1000;
      background: rgba(20, 30, 40, 0.7);
      backdrop-filter: blur(10px);
      border: 1px solid rgba(120, 160, 200, 0.2);
      border-radius: 12px;
      font-family: 'Inter', system-ui, -apple-system, sans-serif;
      color: rgba(200, 220, 240, 0.9);
      font-size: 13px;
      padding: 16px 20px;
      display: flex;
      flex-direction: column;
      gap: 12px;
      user-select: none;
    }
    .grass-panel .slider-row {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 12px;
    }
    .grass-panel .slider-row span {
      white-space: nowrap;
      font-weight: 500;
    }
    .grass-panel .slider-row input[type=range] {
      width: 120px;
      height: 4px;
      appearance: none;
      background: rgba(100, 140, 180, 0.3);
      border-radius: 2px;
      outline: none;
      cursor: pointer;
    }
    .grass-panel input[type=range]::-webkit-slider-thumb {
      -webkit-appearance: none;
      width: 14px;
      height: 14px;
      background: rgba(140, 180, 220, 0.9);
      border-radius: 50%;
      cursor: pointer;
      border: 2px solid rgba(200, 220, 240, 0.3);
    }


    /* Interface de croissance Fibonacci */
    .tree-growth-ui {
      position: fixed;
      bottom: 28px;
      left: 50%;
      transform: translateX(-50%);
      display: flex;
      flex-direction: column;
      align-items: center;
      gap: 12px;
      font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
      z-index: 1000;
      pointer-events: none;
      color: rgba(255, 248, 225, 0.92);
    }
    .tree-growth-card {
      min-width: 295px;
      padding: 14px 16px 15px;
      border: 1px solid rgba(198, 151, 80, 0.38);
      border-radius: 20px;
      background:
        radial-gradient(circle at 20% 0%, rgba(255, 217, 140, 0.18), transparent 45%),
        linear-gradient(145deg, rgba(27, 18, 10, 0.82), rgba(62, 41, 21, 0.64));
      box-shadow: 0 18px 45px rgba(0, 0, 0, 0.34), inset 0 1px 0 rgba(255, 255, 255, 0.12);
      backdrop-filter: blur(16px);
      -webkit-backdrop-filter: blur(16px);
      pointer-events: all;
    }
    .tree-growth-title {
      font-size: 11px;
      letter-spacing: 0.16em;
      text-transform: uppercase;
      color: rgba(255, 224, 166, 0.7);
      margin-bottom: 6px;
      text-align: center;
    }
    .tree-growth-status {
      font-size: 13px;
      color: rgba(255, 251, 232, 0.92);
      text-align: center;
      margin-bottom: 11px;
      font-variant-numeric: tabular-nums;
    }
    .tree-progress-track {
      width: 100%;
      height: 9px;
      border-radius: 999px;
      overflow: hidden;
      background: rgba(15, 10, 5, 0.55);
      border: 1px solid rgba(255, 226, 174, 0.16);
      box-shadow: inset 0 1px 6px rgba(0, 0, 0, 0.45);
    }
    .tree-progress-fill {
      width: 0%;
      height: 100%;
      border-radius: inherit;
      background: linear-gradient(90deg, #6e8f2e 0%, #c79d43 55%, #f4d58a 100%);
      box-shadow: 0 0 14px rgba(244, 213, 138, 0.55);
      transition: width 0.18s ease-out;
    }
    .tree-button-row {
      display: flex;
      gap: 10px;
      justify-content: center;
      margin-top: 13px;
      flex-wrap: wrap;
    }
    .tree-btn {
      border: 1px solid rgba(255, 222, 162, 0.28);
      color: rgba(255, 248, 225, 0.92);
      padding: 10px 16px;
      min-width: 132px;
      font-size: 12px;
      font-weight: 700;
      font-family: inherit;
      border-radius: 999px;
      cursor: pointer;
      letter-spacing: 0.03em;
      background: linear-gradient(145deg, rgba(79, 110, 38, 0.92), rgba(38, 69, 28, 0.88));
      box-shadow: 0 8px 18px rgba(0, 0, 0, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.18);
      transition: transform 0.18s ease, filter 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
    }
    .tree-btn:hover {
      transform: translateY(-2px) scale(1.02);
      filter: brightness(1.12);
      border-color: rgba(255, 235, 190, 0.55);
      box-shadow: 0 12px 24px rgba(0, 0, 0, 0.34), 0 0 18px rgba(205, 174, 92, 0.26);
    }
    .tree-btn:active { transform: translateY(0) scale(0.98); }
    .tree-btn.secondary {
      background: linear-gradient(145deg, rgba(85, 48, 28, 0.86), rgba(42, 26, 18, 0.88));
    }
    .tree-btn:disabled {
      cursor: default;
      opacity: 0.55;
      filter: grayscale(0.2);
      transform: none;
    }


    /* HUD météo allégé : valeurs essentielles uniquement, fond neutralisé. */
    #weather-hud.weather-lite {
      width: min(382px, calc(100vw - 86px));
      padding: 12px 13px;
      border-radius: 20px;
      background: linear-gradient(145deg, rgba(18, 14, 8, 0.42), rgba(37, 29, 16, 0.24));
      border: 1px solid rgba(216, 171, 97, 0.16);
      box-shadow: 0 14px 34px rgba(7, 10, 5, 0.16), inset 0 1px 0 rgba(255, 238, 193, 0.08);
      backdrop-filter: blur(14px) saturate(116%);
      -webkit-backdrop-filter: blur(14px) saturate(116%);
    }
    #weather-hud.weather-lite::after { opacity: 0.18; }
    .weather-lite-top {
      display: flex;
      align-items: flex-start;
      justify-content: space-between;
      gap: 10px;
      position: relative;
      z-index: 1;
      margin-bottom: 9px;
    }
    .weather-lite-clock {
      font-size: 18px;
      line-height: 1;
      font-weight: 900;
      letter-spacing: -0.02em;
      color: rgba(255, 248, 225, 0.96);
    }
    .weather-lite-date {
      margin-top: 4px;
      font-size: 10px;
      font-weight: 700;
      letter-spacing: 0.08em;
      text-transform: uppercase;
      color: rgba(226, 218, 188, 0.62);
    }
    .weather-lite-source {
      padding: 6px 9px;
      border-radius: 999px;
      border: 1px solid rgba(216, 171, 97, 0.14);
      background: rgba(79, 110, 38, 0.20);
      color: rgba(255, 248, 225, 0.82);
      font-size: 9px;
      font-weight: 850;
      letter-spacing: 0.12em;
      text-transform: uppercase;
      white-space: nowrap;
    }

    /* ─────────────────────────────────────────────────────────────
       CONFIGURABLE UI · météo allégée + module lunaire.
       Ces classes ne touchent pas à la croissance validée de l'arbre.
    ───────────────────────────────────────────────────────────── */
    .weather-lite-mainline {
      display: grid;
      grid-template-columns: 44px 1fr auto;
      align-items: start;
      gap: 10px;
      position: relative;
      z-index: 1;
      margin-bottom: 9px;
    }
    .weather-visual-icon {
      width: 42px;
      height: 42px;
      display: grid;
      place-items: center;
      border-radius: 16px;
      border: 1px solid rgba(216, 171, 97, 0.16);
      background: radial-gradient(circle at 35% 25%, rgba(255,248,225,0.18), rgba(79,110,38,0.10) 64%, rgba(15,10,5,0.34));
      color: rgba(255,248,225,0.96);
      font-size: 24px;
      line-height: 1;
      box-shadow: inset 0 1px 0 rgba(255,238,193,0.10), 0 8px 16px rgba(0,0,0,0.16);
    }
    .weather-lunar-strip {
      position: relative;
      z-index: 1;
      display: grid;
      grid-template-columns: 42px 1fr;
      gap: 10px;
      align-items: center;
      margin-top: 8px;
      padding: 9px 10px;
      border-radius: 16px;
      border: 1px solid rgba(216,171,97,0.10);
      background:
        radial-gradient(circle at 18% 0%, rgba(244,213,138,0.11), transparent 42%),
        rgba(15,10,5,0.22);
      box-shadow: inset 0 1px 0 rgba(255,238,193,0.06);
    }
    .weather-lunar-moon {
      width: 40px;
      height: 40px;
      border-radius: 999px;
      display: grid;
      place-items: center;
      color: rgba(255,248,225,0.96);
      font-size: 24px;
      background: radial-gradient(circle at 35% 34%, rgba(255,240,184,0.92), rgba(143,124,76,0.56) 55%, rgba(15,10,5,0.42) 100%);
      border: 1px solid rgba(255,248,225,0.16);
    }
    .weather-lunar-text strong {
      display: block;
      color: rgba(255,248,225,0.94);
      font-size: 11px;
      font-weight: 900;
      letter-spacing: 0.08em;
      text-transform: uppercase;
    }
    .weather-lunar-text span {
      display: block;
      margin-top: 2px;
      color: rgba(226,218,188,0.68);
      font-size: 10px;
      line-height: 1.32;
    }

    .weather-lite-values {
      display: grid;
      grid-template-columns: repeat(3, minmax(0, 1fr));
      gap: 7px;
      position: relative;
      z-index: 1;
    }
    .weather-lite-values > div {
      min-width: 0;
      padding: 8px 9px;
      border-radius: 14px;
      background: rgba(18, 13, 7, 0.26);
      border: 1px solid rgba(216, 171, 97, 0.09);
      box-shadow: inset 0 1px 0 rgba(255, 238, 193, 0.06);
    }
    .weather-lite-values small {
      display: block;
      color: rgba(226, 218, 188, 0.56);
      font-size: 9px;
      font-weight: 700;
      letter-spacing: 0.08em;
      text-transform: uppercase;
      white-space: nowrap;
    }
    .weather-lite-values b {
      display: block;
      margin-top: 2px;
      color: rgba(255, 248, 225, 0.94);
      font-size: 14px;
      font-weight: 900;
      white-space: nowrap;
    }
    .weather-lite-advice {
      position: relative;
      z-index: 1;
      margin-top: 8px;
      padding: 8px 10px;
      border-radius: 14px;
      border: 1px solid rgba(216, 171, 97, 0.10);
      background: rgba(79, 110, 38, 0.14);
      color: rgba(238, 230, 199, 0.76);
      font-size: 10.5px;
      line-height: 1.35;
    }
    .weather-lite-advice b {
      display: block;
      color: rgba(255, 248, 225, 0.94);
      margin-top: 2px;
      font-size: 11px;
      font-weight: 850;
    }
    .weather-rainline {
      position: relative;
      z-index: 1;
      margin-top: 7px;
      color: rgba(226, 218, 188, 0.62);
      font-size: 10px;
      font-weight: 700;
      letter-spacing: 0.04em;
    }
    .weather-rainline b { color: rgba(255, 248, 225, 0.92); }
    .weather-lite-advanced {
      position: relative;
      z-index: 1;
      display: grid;
      grid-template-columns: 1fr auto auto auto;
      gap: 7px;
      margin-top: 8px;
    }
    #weather-hud.compact .weather-lite-advanced { display: none; }
    .weather-lite-hidden { display: none !important; }


    /* HUD microclimat / météo réelle — direction organique glassmorphism */
    #weather-hud {
      position: fixed;
      left: 18px;
      top: 18px;
      z-index: 1001;
      width: min(376px, calc(100vw - 36px));
      color: rgba(255, 248, 225, 0.94);
      font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
      background:
        radial-gradient(circle at 18% 0%, rgba(238, 196, 116, 0.18), transparent 38%),
        radial-gradient(circle at 86% 10%, rgba(118, 145, 63, 0.12), transparent 34%),
        linear-gradient(145deg, rgba(27, 20, 12, 0.72), rgba(52, 39, 21, 0.58));
      border: 1px solid rgba(216, 171, 97, 0.30);
      border-radius: 28px;
      box-shadow:
        0 26px 58px rgba(7, 10, 5, 0.28),
        inset 0 1px 0 rgba(255, 238, 193, 0.16),
        inset 0 -1px 0 rgba(30, 20, 10, 0.28);
      backdrop-filter: blur(28px) saturate(128%);
      -webkit-backdrop-filter: blur(28px) saturate(128%);
      padding: 16px 16px 15px;
      user-select: none;
      pointer-events: auto;
      overflow: hidden;
      transition: opacity 0.25s ease, transform 0.25s ease, box-shadow 0.25s ease;
    }
    #weather-hud::after {
      content: '';
      position: absolute;
      inset: 0;
      border-radius: inherit;
      pointer-events: none;
      background:
        linear-gradient(180deg, rgba(255, 236, 191, 0.12), transparent 27%, transparent 74%, rgba(96, 62, 26, 0.08));
      opacity: 0.78;
    }
    #weather-hud:hover {
      box-shadow:
        0 30px 64px rgba(7, 10, 5, 0.34),
        inset 0 1px 0 rgba(255, 238, 193, 0.19),
        0 0 0 1px rgba(216, 171, 97, 0.08);
    }
    #weather-hud.compact .weather-grid,
    #weather-hud.compact .weather-compare,
    #weather-hud.compact .weather-location-tools {
      display: none;
    }
    .weather-topline,
    .weather-main,
    .weather-location-tools,
    .weather-grid,
    .weather-compare {
      position: relative;
      z-index: 1;
    }
    .weather-topline {
      display: flex;
      justify-content: space-between;
      align-items: center;
      gap: 8px;
      margin-bottom: 10px;
    }
    .weather-place {
      font-size: 15px;
      font-weight: 800;
      letter-spacing: 0.01em;
      color: rgba(255, 248, 225, 0.94);
    }
    .weather-state {
      font-size: 10px;
      text-transform: uppercase;
      letter-spacing: 0.14em;
      padding: 7px 10px;
      border-radius: 999px;
      background: linear-gradient(145deg, rgba(77, 90, 43, 0.54), rgba(32, 43, 20, 0.46));
      border: 1px solid rgba(185, 205, 102, 0.22);
      box-shadow: inset 0 1px 0 rgba(255, 246, 211, 0.13);
      color: rgba(241, 235, 205, 0.84);
      white-space: nowrap;
    }
    .weather-main {
      display: grid;
      grid-template-columns: 58px 1fr auto;
      align-items: center;
      gap: 12px;
      margin: 8px 0 12px;
      padding: 12px;
      border-radius: 22px;
      background: linear-gradient(145deg, rgba(23, 18, 11, 0.42), rgba(73, 54, 28, 0.28));
      border: 1px solid rgba(216, 171, 97, 0.18);
      box-shadow: inset 0 1px 0 rgba(255, 238, 193, 0.11);
    }
    .weather-sun {
      width: 48px;
      height: 48px;
      border-radius: 999px;
      background: radial-gradient(circle at 35% 30%, rgba(255, 238, 181, 0.95), rgba(176, 126, 54, 0.74) 58%, rgba(87, 62, 31, 0.52));
      border: 1px solid rgba(255, 226, 159, 0.26);
      box-shadow: inset 0 1px 0 rgba(255, 246, 218, 0.34), 0 8px 20px rgba(140, 98, 35, 0.16);
    }
    .weather-temp {
      font-size: 42px;
      line-height: 0.95;
      font-weight: 800;
      letter-spacing: -0.04em;
      color: rgba(255, 248, 225, 0.96);
      text-shadow: 0 1px 0 rgba(255, 232, 170, 0.08);
    }
    .weather-temp small {
      font-size: 18px;
      letter-spacing: 0;
      vertical-align: super;
      margin-left: 3px;
      opacity: 0.78;
    }
    .weather-desc {
      font-size: 17px;
      font-weight: 700;
      margin-top: 5px;
      color: rgba(235, 228, 199, 0.88);
    }
    .weather-refresh {
      border: 1px solid rgba(216, 171, 97, 0.22);
      color: rgba(255, 248, 225, 0.88);
      background: linear-gradient(145deg, rgba(74, 53, 30, 0.42), rgba(22, 17, 10, 0.36));
      box-shadow: inset 0 1px 0 rgba(255, 238, 193, 0.13);
      border-radius: 14px;
      min-width: 44px;
      height: 36px;
      cursor: pointer;
      font-weight: 700;
    }
    .weather-grid {
      display: grid;
      grid-template-columns: repeat(4, 1fr);
      gap: 10px 10px;
      margin-top: 8px;
    }
    .weather-grid > div,
    .weather-location-tools input,
    .weather-location-tools button,
    .save-share-dock .micro-btn,
    #premium-cta,
    #premium-panel {
      backdrop-filter: blur(22px) saturate(128%);
      -webkit-backdrop-filter: blur(22px) saturate(128%);
    }
    .weather-grid > div {
      padding: 10px 10px 9px;
      border-radius: 16px;
      background: linear-gradient(145deg, rgba(23, 18, 11, 0.34), rgba(77, 59, 31, 0.22));
      border: 1px solid rgba(216, 171, 97, 0.13);
      box-shadow: inset 0 1px 0 rgba(255, 238, 193, 0.08);
    }
    .weather-metric-label {
      font-size: 10px;
      font-weight: 600;
      color: rgba(221, 210, 174, 0.58);
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }
    .weather-metric-value {
      margin-top: 2px;
      font-size: 14px;
      font-weight: 800;
      white-space: nowrap;
      color: rgba(255, 248, 225, 0.92);
    }
    .weather-compare {
      margin-top: 12px;
      padding-top: 12px;
      border-top: 1px solid rgba(216, 171, 97, 0.12);
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 10px;
      font-size: 11px;
      color: rgba(226, 218, 188, 0.72);
    }
    .weather-compare > div:not(.weather-note) {
      padding: 10px 12px;
      border-radius: 16px;
      background: linear-gradient(145deg, rgba(22, 17, 10, 0.30), rgba(88, 66, 35, 0.16));
      border: 1px solid rgba(216, 171, 97, 0.10);
    }
    .weather-compare b {
      color: rgba(255, 248, 225, 0.94);
      font-weight: 800;
    }
    .weather-note {
      grid-column: 1 / -1;
      color: rgba(221, 210, 174, 0.56);
      font-size: 10px;
      line-height: 1.4;
      padding: 0 2px;
    }
    @media (max-width: 700px) {
      #weather-hud {
        left: 10px;
        top: 10px;
        width: min(330px, calc(100vw - 20px));
        padding: 12px;
        border-radius: 24px;
      }
      .weather-grid { grid-template-columns: repeat(2, 1fr); }
      .weather-temp { font-size: 34px; }
    }

    .weather-location-tools {
      display: grid;
      grid-template-columns: 1fr auto auto;
      gap: 8px;
      margin: 8px 0 10px;
    }
    .weather-location-tools input {
      min-width: 0;
      height: 34px;
      border: 1px solid rgba(216, 171, 97, 0.16);
      border-radius: 14px;
      padding: 0 12px;
      background: linear-gradient(145deg, rgba(23, 18, 11, 0.32), rgba(77, 59, 31, 0.18));
      color: rgba(255, 248, 225, 0.94);
      outline: none;
      font-family: inherit;
      font-size: 12px;
      box-shadow: inset 0 1px 0 rgba(255, 238, 193, 0.11);
    }
    .weather-location-tools input::placeholder {
      color: rgba(221, 210, 174, 0.44);
    }
    .weather-location-tools button,
    .micro-btn {
      height: 34px;
      border: 1px solid rgba(216, 171, 97, 0.18);
      border-radius: 14px;
      color: rgba(255, 248, 225, 0.90);
      background: linear-gradient(145deg, rgba(79, 110, 38, 0.50), rgba(38, 69, 28, 0.36));
      cursor: pointer;
      font-family: inherit;
      font-weight: 700;
      font-size: 11px;
      padding: 0 12px;
      box-shadow: inset 0 1px 0 rgba(255, 238, 193, 0.12);
    }
    .weather-location-tools button:hover,
    .micro-btn:hover {
      background: linear-gradient(145deg, rgba(91, 124, 47, 0.62), rgba(50, 78, 34, 0.44));
      border-color: rgba(232, 190, 113, 0.28);
    }
    #premium-cta {
      position: fixed;
      right: 18px;
      bottom: 22px;
      z-index: 1002;
      border: 1px solid rgba(216, 171, 97, 0.28);
      border-radius: 999px;
      padding: 12px 18px;
      color: rgba(255, 248, 225, 0.94);
      background:
        radial-gradient(circle at 15% 0%, rgba(238, 196, 116, 0.18), transparent 42%),
        linear-gradient(145deg, rgba(31, 23, 13, 0.70), rgba(74, 52, 27, 0.44));
      box-shadow: 0 18px 42px rgba(7, 10, 5, 0.24), inset 0 1px 0 rgba(255, 238, 193, 0.14);
      font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
      font-size: 13px;
      font-weight: 800;
      letter-spacing: 0.02em;
      cursor: pointer;
    }
    #premium-cta small {
      display: block;
      font-size: 9px;
      font-weight: 600;
      opacity: 0.64;
      letter-spacing: 0.10em;
      text-transform: uppercase;
      margin-top: 2px;
      color: rgba(221, 210, 174, 0.72);
    }
    #premium-panel {
      position: fixed;
      right: 18px;
      bottom: 82px;
      z-index: 1002;
      width: min(330px, calc(100vw - 36px));
      display: none;
      padding: 16px;
      border-radius: 24px;
      color: rgba(255, 248, 225, 0.92);
      background: linear-gradient(145deg, rgba(27, 20, 12, 0.76), rgba(52, 39, 21, 0.58));
      border: 1px solid rgba(216, 171, 97, 0.22);
      box-shadow: 0 24px 54px rgba(7, 10, 5, 0.28), inset 0 1px 0 rgba(255, 238, 193, 0.14);
      font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
    }
    #premium-panel.open { display: block; }
    .premium-title {
      font-weight: 850;
      font-size: 16px;
      margin-bottom: 6px;
      color: rgba(255, 248, 225, 0.96);
    }
    .premium-copy {
      font-size: 12px;
      color: rgba(226, 218, 188, 0.72);
      line-height: 1.45;
      margin-bottom: 10px;
    }
    .premium-list {
      display: grid;
      gap: 6px;
      margin-top: 8px;
      font-size: 12px;
      color: rgba(238, 230, 199, 0.82);
    }
    .premium-list span::before {
      content: '✦';
      color: rgba(210, 183, 111, 0.86);
      margin-right: 7px;
    }
    .premium-action-btn {
      width: 100%;
      margin-top: 13px;
      min-height: 48px;
      border: 1px solid rgba(216, 171, 97, 0.26);
      border-radius: 18px;
      color: rgba(255, 248, 225, 0.94);
      background:
        radial-gradient(circle at 16% 0%, rgba(238, 196, 116, 0.20), transparent 42%),
        linear-gradient(145deg, rgba(79, 110, 38, 0.58), rgba(42, 26, 18, 0.46));
      box-shadow: 0 16px 34px rgba(7, 10, 5, 0.22), inset 0 1px 0 rgba(255, 238, 193, 0.14);
      font-family: inherit;
      font-weight: 850;
      font-size: 13px;
      cursor: pointer;
      text-align: left;
      padding: 11px 13px;
    }
    .premium-action-btn:hover {
      background:
        radial-gradient(circle at 16% 0%, rgba(238, 196, 116, 0.28), transparent 42%),
        linear-gradient(145deg, rgba(91, 124, 47, 0.70), rgba(74, 53, 30, 0.54));
      border-color: rgba(232, 190, 113, 0.42);
    }
    .premium-action-btn small {
      display: block;
      margin-top: 3px;
      color: rgba(226, 218, 188, 0.68);
      font-size: 10px;
      font-weight: 650;
      line-height: 1.35;
    }

    .premium-action-group {
      margin-top: 12px;
      padding: 10px;
      border-radius: 20px;
      background: rgba(7, 6, 3, 0.28);
      border: 1px solid rgba(255, 248, 225, 0.08);
    }
    .premium-action-label {
      margin-bottom: 8px;
      color: rgba(226, 218, 188, 0.58);
      font-size: 9px;
      font-weight: 950;
      letter-spacing: 0.16em;
      text-transform: uppercase;
    }
    .save-share-dock {
      position: fixed;
      left: 18px;
      bottom: 22px;
      z-index: 1002;
      display: flex;
      gap: 8px;
      flex-wrap: wrap;
      max-width: min(420px, calc(100vw - 36px));
    }
    .save-share-dock .micro-btn {
      background: linear-gradient(145deg, rgba(27, 20, 12, 0.58), rgba(52, 39, 21, 0.36));
      box-shadow: 0 16px 34px rgba(7, 10, 5, 0.20), inset 0 1px 0 rgba(255, 238, 193, 0.12);
    }

    #storage-warning {
      position: fixed;
      left: 18px;
      bottom: 76px;
      z-index: 1002;
      max-width: min(460px, calc(100vw - 36px));
      padding: 11px 13px;
      border-radius: 16px;
      border: 1px solid rgba(216, 171, 97, 0.18);
      color: rgba(255, 248, 225, 0.86);
      background: linear-gradient(145deg, rgba(27, 20, 12, 0.62), rgba(52, 39, 21, 0.44));
      box-shadow: 0 14px 34px rgba(7, 10, 5, 0.18), inset 0 1px 0 rgba(255, 238, 193, 0.10);
      backdrop-filter: blur(18px) saturate(125%);
      -webkit-backdrop-filter: blur(18px) saturate(125%);
      font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
      font-size: 10.5px;
      line-height: 1.45;
      user-select: none;
    }
    #storage-warning strong {
      color: rgba(255, 248, 225, 0.96);
      font-weight: 850;
    }

    .ui-text-input {
      width: 100%;
      height: 30px;
      min-width: 0;
      border: 1px solid rgba(216, 171, 97, 0.16);
      border-radius: 10px;
      background: rgba(24, 18, 10, 0.28);
      color: rgba(255, 248, 225, 0.88);
      padding: 0 10px;
      font-size: 10px;
      outline: none;
      font-family: inherit;
      box-shadow: inset 0 1px 0 rgba(255, 238, 193, 0.10);
    }
    .ui-button {
      min-height: 28px;
      border: 1px solid rgba(216, 171, 97, 0.16);
      border-radius: 10px;
      background: linear-gradient(145deg, rgba(27, 20, 12, 0.44), rgba(79, 110, 38, 0.18));
      color: rgba(255, 248, 225, 0.90);
      font-size: 10px;
      font-family: inherit;
      cursor: pointer;
      padding: 4px 10px;
      box-shadow: inset 0 1px 0 rgba(255, 238, 193, 0.10);
    }
    .ui-button:hover {
      background: linear-gradient(145deg, rgba(42, 30, 16, 0.52), rgba(79, 110, 38, 0.28));
    }

    .microcosm-avatar-note {
      display: block;
      margin-top: 4px;
      color: rgba(226, 218, 188, 0.58);
      font-size: 9px;
      line-height: 1.25;
      letter-spacing: 0.02em;
    }

    /* Console de réglages responsive : compacte au repos, large au hover/focus pour dev/premium */
    .ui-section.product-section .ui-section-body {
      display: grid;
      gap: 8px;
    }
    .ui-info-card {
      width: 100%;
      padding: 10px 12px;
      border-radius: 14px;
      border: 1px solid rgba(216, 171, 97, 0.14);
      background: linear-gradient(145deg, rgba(23, 18, 11, 0.34), rgba(77, 59, 31, 0.18));
      color: rgba(226, 218, 188, 0.78);
      font-size: 10.5px;
      line-height: 1.42;
      box-shadow: inset 0 1px 0 rgba(255, 238, 193, 0.08);
    }
    .ui-info-card strong {
      color: rgba(255, 248, 225, 0.94);
      font-weight: 800;
    }
    .ui-pill-row {
      display: flex;
      flex-wrap: wrap;
      gap: 6px;
      margin-top: 7px;
    }
    .ui-pill {
      border-radius: 999px;
      padding: 5px 8px;
      border: 1px solid rgba(216, 171, 97, 0.14);
      background: rgba(79, 110, 38, 0.20);
      color: rgba(238, 230, 199, 0.78);
      font-size: 9.5px;
      letter-spacing: 0.02em;
    }
    #cycle-modal {
      position: fixed;
      inset: 0;
      display: none;
      z-index: 3000;
      align-items: center;
      justify-content: center;
      padding: 22px;
      background: rgba(5, 7, 4, 0.28);
      backdrop-filter: blur(14px) saturate(110%);
      -webkit-backdrop-filter: blur(14px) saturate(110%);
    }
    #cycle-modal.open { display: flex; }
    .cycle-card {
      width: min(560px, calc(100vw - 28px));
      border-radius: 30px;
      padding: 22px;
      color: rgba(255, 248, 225, 0.94);
      background:
        radial-gradient(circle at 20% 0%, rgba(238, 196, 116, 0.20), transparent 38%),
        linear-gradient(145deg, rgba(27, 20, 12, 0.86), rgba(52, 39, 21, 0.70));
      border: 1px solid rgba(216, 171, 97, 0.32);
      box-shadow: 0 28px 70px rgba(5, 7, 4, 0.38), inset 0 1px 0 rgba(255, 238, 193, 0.16);
      font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
    }
    .cycle-card h2 {
      margin: 0 0 8px;
      font-size: 22px;
      line-height: 1.15;
      letter-spacing: -0.02em;
    }
    .cycle-card p {
      margin: 0 0 14px;
      color: rgba(226, 218, 188, 0.76);
      line-height: 1.5;
      font-size: 13px;
    }
    .cycle-actions {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 10px;
      margin-top: 14px;
    }
    .cycle-actions button {
      min-height: 42px;
      border: 1px solid rgba(216, 171, 97, 0.20);
      border-radius: 16px;
      background: linear-gradient(145deg, rgba(79, 110, 38, 0.44), rgba(38, 69, 28, 0.30));
      color: rgba(255, 248, 225, 0.92);
      font-weight: 800;
      font-family: inherit;
      cursor: pointer;
      padding: 10px 12px;
      text-align: left;
    }
    .cycle-actions button.secondary {
      background: linear-gradient(145deg, rgba(74, 53, 30, 0.46), rgba(22, 17, 10, 0.36));
    }
    .cycle-actions button.premium {
      grid-column: 1 / -1;
      background:
        radial-gradient(circle at 12% 0%, rgba(238, 196, 116, 0.18), transparent 42%),
        linear-gradient(145deg, rgba(79, 110, 38, 0.54), rgba(74, 53, 30, 0.42));
    }
    @media (max-width: 900px) {
      #ui-toggle {
        top: 12px;
        right: 12px;
      }
      #ui-panel,
      #ui-panel.open,
      #ui-panel.open:hover,
      #ui-panel.open:focus-within {
        top: var(--microcosm-weather-stack-bottom, 64px);
        right: 10px;
        left: 10px;
        width: auto;
        max-height: calc(100dvh - var(--microcosm-weather-stack-bottom, 64px) - 10px);
        border-radius: 22px;
      }
      #ui-panel.open:hover .ui-section-body,
      #ui-panel.open:focus-within .ui-section-body {
        grid-template-columns: 1fr;
      }
      #weather-hud {
        left: 10px;
        top: 10px;
        width: min(340px, calc(100vw - 74px));
      }
      .save-share-dock {
        left: 10px;
        bottom: 12px;
      }

      #storage-warning {
        left: 10px;
        right: 10px;
        bottom: 64px;
        max-width: none;
      }

      #premium-cta {
        right: 10px;
        bottom: 12px;
      }
    }


    #weather-hud.weather-lite .weather-lite-advanced.weather-location-tools {
      display: grid;
      grid-template-columns: 1fr auto auto auto;
      gap: 7px;
      margin: 8px 0 0;
    }
    #weather-hud.weather-lite.compact .weather-lite-advanced.weather-location-tools { display: none; }
    #weather-hud.weather-lite .weather-lite-advanced input { height: 30px; font-size: 11px; }
    #weather-hud.weather-lite .weather-lite-advanced button { height: 30px; min-width: 34px; padding: 0 9px; }

    /* Empêche les CTA / HUD de se superposer sur les écrans courants. */
    .save-share-dock {
      left: 18px;
      bottom: 16px;
      max-width: min(560px, calc(100vw - 236px));
      padding-right: 0;
    }
    #premium-cta {
      right: 18px;
      bottom: 16px;
      max-width: 210px;
      text-align: center;
    }
    .tree-growth-ui {
      bottom: 86px;
    }
    #storage-warning {
      left: 18px;
      bottom: 62px;
      max-width: min(560px, calc(100vw - 236px));
      opacity: 0.72;
      transform-origin: left bottom;
      transition: opacity 0.2s ease, transform 0.2s ease;
    }
    #storage-warning:hover {
      opacity: 1;
      transform: translateY(-1px);
    }
    @media (max-width: 980px) {
      .save-share-dock {
        left: 10px;
        right: 10px;
        bottom: 72px;
        max-width: none;
      }
      #premium-cta {
        left: 10px;
        right: 10px;
        bottom: 10px;
        max-width: none;
      }
      #storage-warning {
        left: 10px;
        right: 10px;
        bottom: 128px;
        max-width: none;
      }
      .tree-growth-ui { bottom: 160px; }
    }
    @media (max-width: 560px) {
      #weather-hud.weather-lite {
        top: 62px;
        left: 10px;
        width: calc(100vw - 20px);
      }
      .save-share-dock {
        display: grid;
        grid-template-columns: 1fr 1fr;
        bottom: 72px;
      }
      .save-share-dock .micro-btn { width: 100%; }
      #storage-warning { display: none; }
      .tree-growth-ui { bottom: 150px; width: calc(100vw - 20px); }
      .tree-growth-card { min-width: 0; width: 100%; }
    }

    @media (max-width: 520px) {
      #weather-hud {
        width: calc(100vw - 20px);
        top: 62px;
      }
      .save-share-dock {
        display: none;
      }
      #premium-cta {
        left: 10px;
        right: 10px;
        bottom: 10px;
        text-align: center;
      }
      .cycle-actions {
        grid-template-columns: 1fr;
      }
      .cycle-actions button.premium {
        grid-column: auto;
      }
    }

    /* V8.14 CTA UX CLEANUP — actions regroupées par intention.
       1) sauvegarde sûre, 2) outils secondaires, 3) premium séparé. */
    .save-share-dock.cta-clean {
      left: 18px !important;
      bottom: 16px !important;
      width: min(390px, calc(100vw - 236px)) !important;
      max-width: min(390px, calc(100vw - 236px)) !important;
      display: flex !important;
      flex-direction: column !important;
      gap: 10px !important;
      padding: 12px !important;
      border-radius: 24px !important;
      border: 1px solid rgba(216, 171, 97, 0.20) !important;
      background:
        radial-gradient(circle at 12% 0%, rgba(238, 196, 116, 0.14), transparent 42%),
        linear-gradient(145deg, rgba(27, 20, 12, 0.72), rgba(52, 39, 21, 0.48)) !important;
      box-shadow: 0 22px 52px rgba(7, 10, 5, 0.28), inset 0 1px 0 rgba(255, 238, 193, 0.12) !important;
      backdrop-filter: blur(22px) saturate(128%) !important;
      -webkit-backdrop-filter: blur(22px) saturate(128%) !important;
    }
    .dock-header {
      display: flex;
      align-items: flex-start;
      justify-content: space-between;
      gap: 12px;
      padding: 0 2px 2px;
      color: rgba(255, 248, 225, 0.92);
      font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
    }
    .dock-header span {
      font-size: 11px;
      font-weight: 950;
      letter-spacing: 0.13em;
      text-transform: uppercase;
      white-space: nowrap;
    }
    .dock-header small {
      color: rgba(226, 218, 188, 0.62);
      font-size: 10px;
      font-weight: 750;
      text-align: right;
      line-height: 1.25;
    }
    .save-share-dock.cta-clean .micro-btn {
      width: 100%;
      min-height: 38px;
      height: auto;
      display: inline-flex;
      align-items: center;
      justify-content: center;
      text-align: center;
      border-radius: 16px;
      padding: 10px 12px;
      white-space: normal;
      line-height: 1.15;
    }
    .save-share-dock.cta-clean .micro-btn small {
      display: block;
      margin-top: 4px;
      color: rgba(226, 218, 188, 0.66);
      font-size: 10px;
      font-weight: 650;
      line-height: 1.25;
    }
    .save-share-dock.cta-clean .micro-btn-primary {
      min-height: 58px;
      flex-direction: column;
      border-color: rgba(244, 213, 138, 0.36);
      background:
        radial-gradient(circle at 14% 0%, rgba(244, 213, 138, 0.20), transparent 46%),
        linear-gradient(145deg, rgba(79, 110, 38, 0.72), rgba(42, 26, 18, 0.58));
      box-shadow: 0 16px 34px rgba(7, 10, 5, 0.22), inset 0 1px 0 rgba(255, 238, 193, 0.16);
      font-size: 12px;
      font-weight: 900;
    }
    .dock-more {
      border-top: 1px solid rgba(255, 248, 225, 0.08);
      padding-top: 8px;
    }
    .dock-more summary {
      list-style: none;
      cursor: pointer;
      color: rgba(255, 248, 225, 0.78);
      font: 850 10px/1.2 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
      letter-spacing: 0.12em;
      text-transform: uppercase;
      display: flex;
      align-items: center;
      justify-content: space-between;
      user-select: none;
    }
    .dock-more summary::-webkit-details-marker { display: none; }
    .dock-more summary::after {
      content: '›';
      transform: rotate(90deg);
      transition: transform 0.18s ease;
      color: rgba(244, 213, 138, 0.70);
      font-size: 16px;
      line-height: 1;
    }
    .dock-more[open] summary::after { transform: rotate(-90deg); }
    .dock-secondary-grid {
      display: grid;
      grid-template-columns: repeat(3, minmax(0, 1fr));
      gap: 8px;
      margin-top: 9px;
    }
    .dock-secondary-grid .micro-btn {
      font-size: 10px;
      padding: 9px 8px;
      background: linear-gradient(145deg, rgba(27, 20, 12, 0.58), rgba(52, 39, 21, 0.36));
    }
    #storage-warning {
      bottom: 184px !important;
      max-width: min(390px, calc(100vw - 236px)) !important;
      font-size: 10.5px !important;
      line-height: 1.35 !important;
      opacity: 0.76;
    }
    .tree-growth-ui { bottom: 118px !important; }
    @media (max-width: 980px) {
      .save-share-dock.cta-clean {
        left: 10px !important;
        right: 10px !important;
        bottom: 74px !important;
        width: auto !important;
        max-width: none !important;
      }
      #storage-warning {
        left: 10px !important;
        right: 10px !important;
        bottom: 250px !important;
        max-width: none !important;
      }
      .tree-growth-ui { bottom: 232px !important; }
      #premium-cta { bottom: 10px !important; }
    }
    @media (max-width: 560px) {
      .save-share-dock.cta-clean {
        display: flex !important;
        bottom: 72px !important;
        padding: 10px !important;
      }
      .dock-header { display: none; }
      .save-share-dock.cta-clean .micro-btn-primary { min-height: 48px; }
      .save-share-dock.cta-clean .micro-btn-primary small { display: none; }
      .dock-secondary-grid { grid-template-columns: 1fr; }
      #storage-warning { display: none !important; }
      .tree-growth-ui { bottom: 182px !important; }
    }
    @media (max-width: 520px) {
      .save-share-dock.cta-clean { display: flex !important; }
    }

    /* =====================================================
       MICROCOSM V8.15 STABLE PROD — HUD responsive compact
       Objectif : limiter les chevauchements entre météo, mémoire,
       croissance, premium et console dev, tout en gardant les CTA lisibles.
       ===================================================== */
    .save-share-dock.cta-clean {
      width: min(340px, calc(100vw - 248px)) !important;
      max-width: min(340px, calc(100vw - 248px)) !important;
      gap: 8px !important;
      padding: 10px !important;
    }
    .dock-header small { font-size: 9px; }
    .save-share-dock.cta-clean .micro-btn-primary { min-height: 50px; }
    .save-share-dock.cta-clean .micro-btn[disabled],
    .save-share-dock.cta-clean .micro-btn.is-disabled {
      cursor: not-allowed;
      opacity: 0.48;
      filter: grayscale(0.28) saturate(0.72);
      background: linear-gradient(145deg, rgba(45, 37, 23, 0.64), rgba(24, 19, 12, 0.56)) !important;
      border-color: rgba(226, 218, 188, 0.12) !important;
      box-shadow: inset 0 1px 0 rgba(255, 248, 225, 0.06) !important;
    }
    .dock-more summary {
      color: rgba(226, 218, 188, 0.56);
      font-size: 9px;
      letter-spacing: 0.10em;
      text-transform: lowercase;
    }
    .dock-secondary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
    .dock-secondary-grid .micro-btn { min-height: 34px; }
    #weather-hud.weather-lite {
      width: min(330px, calc(100vw - 86px));
      padding: 10px 11px;
    }
    .weather-lite-values { gap: 5px; }
    .weather-lite-values > div { padding: 7px 8px; }
    #premium-cta {
      max-width: min(300px, calc(100vw - 40px));
      text-align: center;
    }
    #storage-warning {
      bottom: 154px !important;
      max-width: min(340px, calc(100vw - 248px)) !important;
    }
    /* Popup glass de carte mémoire : pas d'iframe, pas de seconde fenêtre imbriquée.
       Le microcosme reste visible en fond, avec un blur doux. */
    /* V8.19 · CARD_VIEWER_FLOW_CLEANUP
       La carte doit rester le sujet principal : le décor est encore lisible,
       les HUD deviennent fantômes et ne capturent plus les interactions. */
    body.microcosm-memory-card-open #microcosm-app-root,
    body.microcosm-memory-card-open canvas {
      filter: blur(5px) saturate(0.92) brightness(0.78);
      transform: translateZ(0);
      transition: filter 0.28s ease;
    }
    body.microcosm-memory-card-open #weather-hud,
    body.microcosm-memory-card-open #ui-toggle,
    body.microcosm-memory-card-open #ui-panel,
    body.microcosm-memory-card-open .tree-growth-ui,
    body.microcosm-memory-card-open .save-share-dock,
    body.microcosm-memory-card-open #premium-cta,
    body.microcosm-memory-card-open #premium-panel,
    body.microcosm-memory-card-open #storage-warning {
      opacity: 0.16;
      filter: blur(3px) saturate(0.72) brightness(0.72);
      pointer-events: none;
      transform: translateZ(0);
      transition: opacity 0.22s ease, filter 0.22s ease;
    }
    .memory-card-modal {
      position: fixed;
      inset: 0;
      z-index: 6000;
      display: none;
      align-items: center;
      justify-content: center;
      padding: clamp(16px, 4vw, 56px);
      background:
        radial-gradient(circle at 50% 18%, rgba(244,213,138,0.10), transparent 34%),
        radial-gradient(circle at 18% 82%, rgba(79,110,38,0.12), transparent 38%),
        rgba(5, 7, 4, 0.28);
      backdrop-filter: blur(8px) saturate(112%) brightness(0.92);
      -webkit-backdrop-filter: blur(8px) saturate(112%) brightness(0.92);
    }
    .memory-card-modal.open { display: flex; }
    .memory-card-modal::before {
      content: '';
      position: absolute;
      inset: 0;
      pointer-events: none;
      background: linear-gradient(180deg, rgba(255,248,225,0.05), transparent 34%, rgba(0,0,0,0.10));
    }
    .memory-card-shell {
      position: relative;
      z-index: 1;
      width: min(940px, calc(100vw - 32px));
      max-height: min(720px, calc(100dvh - 32px));
      display: grid;
      grid-template-rows: auto minmax(0, 1fr) auto;
      overflow: hidden;
      border-radius: 30px;
      border: 1px solid rgba(216,171,97,0.30);
      background:
        radial-gradient(circle at 12% 0%, rgba(244,213,138,0.13), transparent 38%),
        radial-gradient(circle at 88% 18%, rgba(79,110,38,0.14), transparent 36%),
        linear-gradient(145deg, rgba(22,17,10,0.78), rgba(8,8,5,0.74));
      box-shadow:
        0 38px 92px rgba(0,0,0,0.44),
        0 0 0 1px rgba(255,248,225,0.04),
        inset 0 1px 0 rgba(255,248,225,0.14);
      backdrop-filter: blur(24px) saturate(122%);
      -webkit-backdrop-filter: blur(24px) saturate(122%);
      color: rgba(255,248,225,0.92);
      font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
    }
    .memory-card-modal-bar {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 12px;
      padding: 14px 16px 10px 18px;
      border-bottom: 1px solid rgba(216,171,97,0.12);
      background: linear-gradient(180deg, rgba(7,6,3,0.36), rgba(7,6,3,0.12));
    }
    .memory-card-modal-title {
      min-width: 0;
      display: grid;
      gap: 2px;
      font-weight: 950;
      font-size: 12px;
      letter-spacing: 0.10em;
      text-transform: uppercase;
    }
    .memory-card-modal-title small {
      color: rgba(226,218,188,0.64);
      font-size: 10px;
      font-weight: 650;
      letter-spacing: 0;
      text-transform: none;
    }
    .memory-card-close-x {
      width: 34px;
      height: 34px;
      border-radius: 999px;
      border: 1px solid rgba(244,213,138,0.20);
      color: rgba(255,248,225,0.88);
      background: rgba(20,15,9,0.52);
      font: 900 18px/1 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
      cursor: pointer;
    }
    .memory-card-popup-body {
      min-height: 0;
      display: grid;
      grid-template-columns: minmax(260px, 410px) minmax(260px, 1fr);
      gap: 18px;
      padding: 18px;
      overflow: auto;
      scrollbar-width: thin;
      scrollbar-color: rgba(244,213,138,0.24) transparent;
    }
    .memory-card-popup-body::-webkit-scrollbar { width: 6px; }
    .memory-card-popup-body::-webkit-scrollbar-thumb { background: rgba(244,213,138,0.20); border-radius: 999px; }
    .memory-card-preview-panel {
      display: grid;
      place-items: center;
      min-height: 0;
      border-radius: 24px;
      border: 1px solid rgba(216,171,97,0.13);
      background:
        radial-gradient(circle at 50% 0%, rgba(244,213,138,0.09), transparent 34%),
        rgba(10,9,5,0.24);
      overflow: hidden;
      padding: clamp(10px, 2vw, 18px);
    }
    .memory-card-preview-panel img {
      display: block;
      width: min(100%, 360px);
      max-height: min(52dvh, 520px);
      aspect-ratio: 1080 / 1600;
      object-fit: contain;
      border-radius: 22px;
      box-shadow: 0 24px 54px rgba(0,0,0,0.34), 0 0 0 1px rgba(255,248,225,0.08);
      background: rgba(8,7,4,0.52);
    }
    .memory-card-info-panel {
      min-width: 0;
      display: grid;
      align-content: start;
      gap: 12px;
      padding: 4px 2px;
    }
    .memory-card-kicker {
      color: rgba(226,218,188,0.72);
      font-size: 12px;
      line-height: 1.45;
    }
    .memory-card-info-panel h2 {
      margin: 0;
      color: rgba(255,248,225,0.96);
      font-size: clamp(24px, 3.5vw, 42px);
      line-height: .94;
      letter-spacing: -0.03em;
      text-transform: uppercase;
    }
    .memory-card-info-grid {
      display: grid;
      grid-template-columns: repeat(2, minmax(0, 1fr));
      gap: 8px;
    }
    .memory-card-info-grid div {
      min-width: 0;
      padding: 10px;
      border-radius: 16px;
      border: 1px solid rgba(216,171,97,0.12);
      background: rgba(7,6,3,0.28);
    }
    .memory-card-info-grid small {
      display: block;
      color: rgba(226,218,188,0.54);
      font-size: 8px;
      font-weight: 900;
      letter-spacing: .13em;
      text-transform: uppercase;
    }
    .memory-card-info-grid b {
      display: block;
      margin-top: 4px;
      color: rgba(255,248,225,0.94);
      font-size: 13px;
      line-height: 1.1;
      overflow-wrap: anywhere;
    }
    .memory-card-popup-note {
      color: rgba(226,218,188,0.62);
      font-size: 11px;
      line-height: 1.45;
    }
    .memory-card-modal-footer {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 10px;
      padding: 12px 16px 16px;
      border-top: 1px solid rgba(216,171,97,0.12);
      background: rgba(7,6,3,0.20);
    }
    .memory-card-modal-footer button,
    .memory-card-inline-actions button {
      min-height: 38px;
      border: 1px solid rgba(244,213,138,0.22);
      border-radius: 999px;
      padding: 9px 14px;
      color: rgba(255,248,225,0.92);
      background: linear-gradient(145deg, rgba(79,110,38,0.54), rgba(42,26,18,0.42));
      font: 900 12px/1 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
      cursor: pointer;
    }
    .memory-card-modal-footer button.secondary,
    .memory-card-inline-actions button.secondary {
      background: linear-gradient(145deg, rgba(27,20,12,0.58), rgba(52,39,21,0.36));
    }
    .memory-card-modal-footer button[disabled] {
      opacity: .48;
      cursor: not-allowed;
      filter: grayscale(.25);
    }
    .memory-card-inline-actions {
      display: flex;
      flex-wrap: wrap;
      gap: 8px;
      align-items: center;
    }
    .memory-card-more-options-note {
      color: rgba(226,218,188,0.42);
      font-size: 10px;
      font-weight: 750;
      letter-spacing: .03em;
      white-space: nowrap;
    }

    /* DYNAMIC_MEMORY_VIEWER_V8_17
       La popup reste une vraie couche glass native, mais la carte n'est plus une image figée :
       elle flotte dans un viewer 3D CSS avec recto/verso, reflet holo et tilt au pointeur. */
    .memory-card-preview-panel {
      overflow: visible;
      perspective: 1200px;
      min-height: min(62dvh, 580px);
      padding: clamp(14px, 2.4vw, 24px);
    }
    .memory-card-viewer {
      --tilt-x: 0deg;
      --tilt-y: 0deg;
      --shine-x: 45%;
      --shine-y: 18%;
      position: relative;
      width: min(100%, 380px);
      display: grid;
      place-items: center;
      transform-style: preserve-3d;
      isolation: isolate;
    }
    .memory-card-flip-btn {
      position: absolute;
      top: -10px;
      right: -6px;
      z-index: 6;
      min-height: 32px;
      border: 1px solid rgba(244,213,138,0.22);
      border-radius: 999px;
      padding: 7px 11px;
      color: rgba(255,248,225,0.88);
      background: linear-gradient(145deg, rgba(30,22,12,0.72), rgba(79,110,38,0.34));
      box-shadow: 0 10px 24px rgba(0,0,0,0.26), inset 0 1px 0 rgba(255,248,225,0.10);
      font: 900 10px/1 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
      letter-spacing: .08em;
      text-transform: uppercase;
      cursor: pointer;
    }
    .memory-card-tilt {
      width: min(100%, 370px);
      aspect-ratio: 1080 / 1600;
      transform-style: preserve-3d;
      transform: rotateX(var(--tilt-x)) rotateY(var(--tilt-y));
      animation: memoryCardFloat 5.4s ease-in-out infinite;
      transition: transform 0.18s ease-out;
      filter: drop-shadow(0 34px 38px rgba(0,0,0,0.46));
    }
    .memory-card-flipper {
      position: relative;
      width: 100%;
      height: 100%;
      transform-style: preserve-3d;
      transition: transform 0.72s cubic-bezier(.16,1,.3,1);
    }
    .memory-card-viewer.is-flipped .memory-card-flipper { transform: rotateY(180deg); }
    .memory-card-face {
      position: absolute;
      inset: 0;
      display: grid;
      grid-template-rows: auto auto auto minmax(0, 1fr) auto auto;
      gap: 2.3%;
      padding: 8.2% 7.4%;
      border-radius: 7.2%;
      overflow: hidden;
      backface-visibility: hidden;
      transform: translateZ(0.1px);
      border: 1px solid rgba(244,213,138,0.24);
      color: rgba(255,248,225,0.94);
      background:
        radial-gradient(circle at var(--shine-x) var(--shine-y), rgba(255,248,225,0.20), transparent 20%),
        radial-gradient(circle at 18% 5%, rgba(244,213,138,0.15), transparent 33%),
        linear-gradient(145deg, rgba(43,37,21,0.94), rgba(9,10,5,0.98) 58%, rgba(20,15,9,0.96));
      box-shadow: inset 0 0 0 10px rgba(255,248,225,0.035), inset 0 0 0 12px rgba(216,171,97,0.08);
    }
    .memory-card-face::before {
      content: '';
      position: absolute;
      inset: -18%;
      pointer-events: none;
      opacity: .34;
      mix-blend-mode: screen;
      background:
        linear-gradient(115deg, transparent 0 28%, rgba(156,255,196,.14) 36%, rgba(244,213,138,.20) 45%, rgba(194,174,255,.12) 54%, transparent 66% 100%),
        repeating-linear-gradient(35deg, rgba(255,255,255,.035) 0 1px, transparent 1px 9px);
      transform: translate3d(calc((var(--shine-x) - 50%) * .08), calc((var(--shine-y) - 50%) * .08), 1px) rotate(2deg);
      animation: memoryCardHoloSweep 6.8s linear infinite;
    }
    .memory-card-back { transform: rotateY(180deg) translateZ(0.1px); }
    .memory-card-face-header {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 10px;
      position: relative;
      z-index: 1;
      color: rgba(255,248,225,0.76);
      font-size: clamp(8px, 1.15vw, 11px);
      font-weight: 950;
      letter-spacing: .20em;
      text-transform: uppercase;
    }
    .memory-card-face-header b {
      border-radius: 999px;
      padding: .42em .78em;
      background: rgba(8,7,4,0.55);
      border: 1px solid rgba(244,213,138,0.14);
      letter-spacing: .10em;
    }
    .memory-card-face h3 {
      position: relative;
      z-index: 1;
      margin: 0;
      font-size: clamp(30px, 4.5vw, 54px);
      line-height: .84;
      letter-spacing: .04em;
      text-transform: uppercase;
      color: rgba(255,248,225,0.97);
      text-shadow: 0 2px 0 rgba(0,0,0,0.22);
    }
    .memory-card-face p {
      position: relative;
      z-index: 1;
      margin: 0;
      color: rgba(244,213,138,0.82);
      font-size: clamp(9px, 1.35vw, 12px);
      font-weight: 950;
      letter-spacing: .18em;
      text-transform: uppercase;
    }
    .memory-card-view-shot {
      position: relative;
      z-index: 1;
      min-height: 0;
      border-radius: 5.8%;
      overflow: hidden;
      background:
        linear-gradient(180deg, rgba(255,248,225,0.10), rgba(0,0,0,0.12)),
        var(--memory-card-shot, radial-gradient(circle at 50% 55%, rgba(79,110,38,0.55), rgba(8,7,4,0.90)));
      background-size: cover;
      background-position: center;
      border: 1px solid rgba(244,213,138,0.16);
      box-shadow: inset 0 1px 0 rgba(255,248,225,0.10);
    }
    .memory-card-view-progress {
      position: absolute;
      left: 7%;
      right: 7%;
      bottom: 9%;
      height: 8.5%;
      border-radius: 999px;
      overflow: hidden;
      background: rgba(8,7,4,0.82);
      border: 1px solid rgba(244,213,138,0.14);
      display: flex;
      align-items: center;
      justify-content: flex-end;
      padding-right: 4%;
      color: rgba(255,248,225,0.96);
      font-size: clamp(10px, 1.7vw, 15px);
      font-weight: 950;
    }
    .memory-card-view-progress i {
      position: absolute;
      inset: 0 auto 0 0;
      width: var(--memory-card-progress, 0%);
      border-radius: inherit;
      background: linear-gradient(90deg, rgba(118,145,63,0.95), rgba(244,213,138,0.88));
      box-shadow: 0 0 20px rgba(244,213,138,0.22);
    }
    .memory-card-view-progress span { position: relative; z-index: 1; }
    .memory-card-view-stats,
    .memory-card-back-grid {
      position: relative;
      z-index: 1;
      display: grid;
      grid-template-columns: repeat(3, minmax(0, 1fr));
      gap: 2.2%;
    }
    .memory-card-view-stats div,
    .memory-card-back-grid div {
      min-width: 0;
      border-radius: 14px;
      padding: 9% 10%;
      background: rgba(3,4,2,0.46);
      border: 1px solid rgba(244,213,138,0.10);
      box-shadow: inset 0 1px 0 rgba(255,248,225,0.05);
    }
    .memory-card-view-stats small,
    .memory-card-back-grid small {
      display: block;
      color: rgba(226,218,188,0.58);
      font-size: clamp(6px, .9vw, 8px);
      font-weight: 950;
      letter-spacing: .16em;
      text-transform: uppercase;
    }
    .memory-card-view-stats b,
    .memory-card-back-grid b {
      display: block;
      margin-top: .35em;
      color: rgba(255,248,225,0.95);
      font-size: clamp(10px, 1.5vw, 15px);
      line-height: 1.08;
      overflow-wrap: anywhere;
    }
    .memory-card-view-cert,
    .memory-card-back-ar {
      position: relative;
      z-index: 1;
      display: grid;
      grid-template-columns: auto 1fr;
      gap: 4%;
      align-items: center;
      min-height: 0;
      padding: 3.2%;
      border-radius: 18px;
      background: rgba(5,5,2,0.42);
      border: 1px solid rgba(244,213,138,0.12);
      color: rgba(255,248,225,0.90);
      font-weight: 950;
      font-size: clamp(10px, 1.3vw, 13px);
      letter-spacing: .08em;
      text-transform: uppercase;
    }
    .memory-card-view-cert img,
    .memory-card-back-ar img {
      width: clamp(42px, 6.2vw, 64px);
      aspect-ratio: 1;
      border-radius: 10px;
      background: rgba(255,248,225,0.92);
      padding: 4px;
      object-fit: contain;
    }
    .memory-card-holo-mask {
      position: absolute;
      right: -10%;
      top: 16%;
      width: 42%;
      aspect-ratio: 1;
      border-radius: 999px;
      opacity: .12;
      filter: blur(.2px);
      background:
        radial-gradient(circle at 42% 26%, rgba(255,248,225,.75), transparent 8%),
        conic-gradient(from 20deg, rgba(194,174,255,.3), rgba(156,255,196,.36), rgba(244,213,138,.42), rgba(194,174,255,.3));
      mask-image: var(--yggdrasil-logo-mask, radial-gradient(circle, #000 0 66%, transparent 67%));
      -webkit-mask-image: var(--yggdrasil-logo-mask, radial-gradient(circle, #000 0 66%, transparent 67%));
      mask-size: contain;
      -webkit-mask-size: contain;
      mask-repeat: no-repeat;
      -webkit-mask-repeat: no-repeat;
      mask-position: center;
      -webkit-mask-position: center;
      transform: translate3d(calc((var(--shine-x) - 50%) * .04), calc((var(--shine-y) - 50%) * .04), 2px);
    }
    .memory-card-holo-mask.large {
      inset: auto -8% 9% auto;
      width: 66%;
      opacity: .16;
    }
    .memory-card-back-kicker {
      position: relative;
      z-index: 1;
      color: rgba(244,213,138,0.74);
      font-size: clamp(8px, 1vw, 10px);
      font-weight: 950;
      letter-spacing: .22em;
      text-transform: uppercase;
    }
    .memory-card-back-grid {
      grid-template-columns: repeat(2, minmax(0, 1fr));
      align-self: start;
    }
    .memory-card-back #memory-card-back-note {
      align-self: start;
      color: rgba(226,218,188,0.70);
      font-size: clamp(10px, 1.4vw, 13px);
      line-height: 1.42;
      letter-spacing: 0;
      text-transform: none;
      font-weight: 650;
    }
    .memory-card-back-ar small {
      display: block;
      margin-top: 2px;
      color: rgba(226,218,188,0.62);
      font-size: clamp(7px, 1vw, 9px);
      line-height: 1.2;
      letter-spacing: .02em;
      text-transform: none;
      overflow-wrap: anywhere;
    }
    @keyframes memoryCardFloat {
      0%, 100% { translate: 0 0; }
      50% { translate: 0 -10px; }
    }
    @keyframes memoryCardHoloSweep {
      0% { translate: -8% -2%; }
      50% { translate: 8% 2%; }
      100% { translate: -8% -2%; }
    }
    /* V8.18 · Ajustement popup carte : la carte reste dynamique, mais ne déborde plus.
       Les longs stades comme “chêne centenaire accompli” sont cassés proprement dans le cadre. */
    .memory-card-shell {
      width: min(980px, calc(100vw - 44px));
      max-height: min(700px, calc(100dvh - 44px));
    }
    .memory-card-popup-body {
      grid-template-columns: minmax(300px, 390px) minmax(260px, 1fr);
      overflow-x: hidden;
    }
    .memory-card-preview-panel {
      overflow: hidden;
      min-height: min(60dvh, 560px);
    }
    .memory-card-viewer { width: min(100%, 330px); }
    .memory-card-tilt { width: min(100%, 318px); }
    .memory-card-face { gap: 1.85%; padding: 7.4% 7%; }
    .memory-card-face h3 {
      font-size: clamp(24px, 3.15vw, 40px);
      line-height: .88;
      letter-spacing: .035em;
      overflow-wrap: anywhere;
      word-break: break-word;
      hyphens: auto;
    }
    .memory-card-face p { font-size: clamp(8px, 1.05vw, 10px); }
    .memory-card-view-stats div,
    .memory-card-back-grid div { padding: 7.5% 8%; }
    .memory-card-view-cert,
    .memory-card-back-ar { border-radius: 14px; padding: 2.8%; }

    /* V8.19 · Popup = glass viewer, pas fenêtre bureautique.
       Le panneau d'informations devient secondaire, la carte respire et la rotation
       est possible par clic/tap sur la carte ou dans la zone autour. */
    .memory-card-modal {
      padding: clamp(18px, 5vw, 68px);
      background:
        radial-gradient(circle at 48% 20%, rgba(244,213,138,0.08), transparent 34%),
        radial-gradient(circle at 24% 82%, rgba(79,110,38,0.10), transparent 40%),
        rgba(4, 6, 3, 0.20);
      backdrop-filter: blur(10px) saturate(0.96) brightness(0.92);
      -webkit-backdrop-filter: blur(10px) saturate(0.96) brightness(0.92);
    }
    .memory-card-shell {
      width: min(1120px, calc(100vw - 52px));
      height: min(720px, calc(100dvh - 52px));
      max-height: none;
      border-radius: 32px;
      background:
        radial-gradient(circle at 24% 10%, rgba(244,213,138,0.08), transparent 36%),
        radial-gradient(circle at 76% 22%, rgba(79,110,38,0.08), transparent 42%),
        linear-gradient(145deg, rgba(18,15,8,0.54), rgba(6,7,4,0.42));
      border-color: rgba(244,213,138,0.18);
      box-shadow:
        0 42px 92px rgba(0,0,0,0.46),
        0 0 0 1px rgba(255,248,225,0.03),
        inset 0 1px 0 rgba(255,248,225,0.08);
    }
    .memory-card-modal-bar {
      min-height: 48px;
      padding: 12px 16px 8px 18px;
      background: linear-gradient(180deg, rgba(7,6,3,0.18), transparent);
      border-bottom-color: rgba(216,171,97,0.07);
    }
    .memory-card-popup-body {
      grid-template-columns: minmax(400px, 1.04fr) minmax(280px, .70fr);
      gap: clamp(20px, 3vw, 38px);
      padding: clamp(16px, 2.6vw, 30px);
      overflow: hidden;
      scrollbar-width: none;
    }
    .memory-card-popup-body::-webkit-scrollbar { display: none; }
    .memory-card-preview-panel {
      min-height: 0;
      overflow: visible;
      padding: clamp(10px, 2vw, 24px);
      border-color: rgba(216,171,97,0.06);
      background:
        radial-gradient(circle at 50% 22%, rgba(244,213,138,0.08), transparent 42%),
        radial-gradient(circle at 50% 100%, rgba(79,110,38,0.10), transparent 48%);
      cursor: pointer;
    }
    .memory-card-preview-panel::after {
      content: 'Cliquer / toucher pour retourner';
      position: absolute;
      left: 50%;
      bottom: 10px;
      transform: translateX(-50%);
      color: rgba(226,218,188,0.38);
      font-size: 10px;
      font-weight: 800;
      letter-spacing: .10em;
      text-transform: uppercase;
      pointer-events: none;
      white-space: nowrap;
    }
    .memory-card-viewer {
      width: min(100%, 390px);
      cursor: pointer;
    }
    .memory-card-tilt {
      width: min(100%, 382px);
      filter: drop-shadow(0 38px 44px rgba(0,0,0,0.48));
    }
    .memory-card-flip-btn {
      opacity: .68;
      top: -6px;
      right: -4px;
      background: linear-gradient(145deg, rgba(20,15,9,0.62), rgba(79,110,38,0.22));
    }
    .memory-card-flip-btn:hover { opacity: .96; }
    .memory-card-info-panel {
      align-self: center;
      gap: 10px;
      max-width: 420px;
      opacity: .88;
    }
    .memory-card-info-panel h2 {
      font-size: clamp(24px, 3.1vw, 40px);
      line-height: .96;
      max-width: 12ch;
    }
    .memory-card-popup-note {
      max-width: 48ch;
      color: rgba(226,218,188,0.54);
    }
    .memory-card-modal-footer {
      padding: 10px 16px 16px;
      border-top-color: rgba(216,171,97,0.07);
      background: linear-gradient(180deg, transparent, rgba(7,6,3,0.16));
    }
    .memory-card-modal-footer button,
    .memory-card-inline-actions button {
      min-height: 36px;
      padding: 8px 14px;
    }
    .memory-card-face h3 {
      max-width: 100%;
      text-wrap: balance;
      overflow: visible;
      word-break: normal;
      overflow-wrap: anywhere;
      hyphens: none;
    }
    .memory-card-view-shot { min-height: 145px; }
    .memory-card-back #memory-card-back-note {
      display: -webkit-box;
      -webkit-line-clamp: 4;
      -webkit-box-orient: vertical;
      overflow: hidden;
    }

    @media (prefers-reduced-motion: reduce) {
      .memory-card-tilt,
      .memory-card-face::before { animation: none; }
      .memory-card-tilt { transition: none; }
    }

    @media (max-width: 840px) {
      .save-share-dock.cta-clean { width: auto !important; max-width: none !important; }
      #storage-warning { bottom: 236px !important; }
      .memory-card-modal { padding: 12px; }
      .memory-card-shell { width: calc(100vw - 24px); max-height: calc(100dvh - 24px); border-radius: 24px; }
      .memory-card-popup-body { grid-template-columns: 1fr; gap: 12px; padding: 12px; }
      .memory-card-tilt { width: min(100%, 286px); }
    }
    @media (max-width: 540px), (max-height: 560px) {
      #weather-hud.weather-lite { width: min(300px, calc(100vw - 72px)); }
      .tree-growth-card { transform: scale(0.92); transform-origin: center bottom; }
      .save-share-dock.cta-clean { bottom: 66px !important; }
      .memory-card-modal { padding: 8px; align-items: end; }
      .memory-card-shell { width: calc(100vw - 16px); max-height: calc(100dvh - 16px); border-radius: 20px; }
      .memory-card-modal-bar { padding: 10px 12px 8px; }
      .memory-card-modal-title small { display: none; }
      .memory-card-popup-body { padding: 10px; }
      .memory-card-tilt { width: min(100%, 232px); }
      .memory-card-info-grid { grid-template-columns: 1fr; }
      .memory-card-modal-footer { flex-direction: column-reverse; align-items: stretch; padding: 10px 12px 12px; }
      .memory-card-modal-footer button { width: 100%; }
      .memory-card-inline-actions { width: 100%; }
      .memory-card-inline-actions button { flex: 1 1 140px; }
    }
    @media (display-mode: fullscreen) {
      .memory-card-modal { padding: clamp(10px, 3vw, 42px); }
    }
    @media (max-width: 840px) {
      .memory-card-shell {
        width: calc(100vw - 22px);
        height: calc(100dvh - 22px);
      }
      .memory-card-popup-body {
        grid-template-columns: 1fr;
        grid-template-rows: minmax(0, 1fr) auto;
        gap: 12px;
        overflow: auto;
        scrollbar-width: none;
      }
      .memory-card-popup-body::-webkit-scrollbar { display: none; }
      .memory-card-preview-panel { min-height: min(58dvh, 500px); }
      .memory-card-viewer { width: min(100%, 320px); }
      .memory-card-tilt { width: min(100%, 312px); }
      .memory-card-info-panel { align-self: stretch; max-width: none; }
      .memory-card-info-panel h2 { max-width: none; font-size: clamp(22px, 7vw, 30px); }
      .memory-card-info-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
    }
    @media (max-width: 540px), (max-height: 560px) {
      .memory-card-modal { align-items: center; padding: 8px; }
      .memory-card-shell { width: calc(100vw - 16px); height: calc(100dvh - 16px); }
      .memory-card-preview-panel { min-height: min(52dvh, 390px); }
      .memory-card-viewer { width: min(100%, 260px); }
      .memory-card-tilt { width: min(100%, 254px); }
      .memory-card-face { gap: 1.45%; padding: 7% 6.6%; }
      .memory-card-view-shot { min-height: 96px; }
      .memory-card-info-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
      .memory-card-popup-summary,
      .memory-card-popup-note { display: none; }
      .memory-card-preview-panel::after { display: none; }
    }



    /* Mode accueil premium : plein écran sans HUD pour restaurant, entreprise ou exposition.
       L'utilisateur sort avec Échap, ou avec la touche H si le navigateur ne quitte pas le fullscreen. */
    body.microcosm-kiosk-active #weather-hud,
    body.microcosm-kiosk-active #ui-panel,
    body.microcosm-kiosk-active #ui-toggle,
    body.microcosm-kiosk-active .save-share-dock,
    body.microcosm-kiosk-active #premium-cta,
    body.microcosm-kiosk-active #premium-panel,
    body.microcosm-kiosk-active #storage-warning,
    body.microcosm-kiosk-active #cycle-modal,
    body.microcosm-kiosk-active #vr-status-chip,
    body.microcosm-kiosk-active .tree-growth-ui {
      display: none !important;
      opacity: 0 !important;
      pointer-events: none !important;
    }
    body.microcosm-kiosk-active canvas {
      cursor: none;
    }

    /* Mode VR : les HUD DOM restent consultables hors VR, mais disparaissent en immersion.
       Le rendu VR doit rester confortable, sans panneau collé au visage. */
    body.microcosm-xr-active #weather-hud,
    body.microcosm-xr-active #ui-panel,
    body.microcosm-xr-active #ui-toggle,
    body.microcosm-xr-active .save-share-dock,
    body.microcosm-xr-active #premium-cta,
    body.microcosm-xr-active #premium-panel,
    body.microcosm-xr-active #storage-warning,
    body.microcosm-xr-active #cycle-modal {
      display: none !important;
      pointer-events: none !important;
    }
    body.microcosm-xr-active .tree-growth-ui {
      opacity: 0 !important;
      pointer-events: none !important;
      transform: translateX(-50%) translateY(12px) scale(0.94);
    }
    #vr-status-chip {
      position: fixed;
      left: 50%;
      top: 18px;
      transform: translateX(-50%);
      z-index: 1003;
      display: none;
      padding: 9px 14px;
      border-radius: 999px;
      border: 1px solid rgba(216, 171, 97, 0.22);
      background:
        radial-gradient(circle at 20% 0%, rgba(238, 196, 116, 0.16), transparent 42%),
        linear-gradient(145deg, rgba(27, 20, 12, 0.66), rgba(52, 39, 21, 0.44));
      color: rgba(255, 248, 225, 0.88);
      font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
      font-size: 11px;
      font-weight: 800;
      letter-spacing: 0.08em;
      text-transform: uppercase;
      box-shadow: 0 16px 34px rgba(7, 10, 5, 0.22), inset 0 1px 0 rgba(255, 238, 193, 0.13);
      backdrop-filter: blur(20px) saturate(128%);
      -webkit-backdrop-filter: blur(20px) saturate(128%);
      pointer-events: none;
    }
    body.microcosm-xr-preparing #vr-status-chip {
      display: block;
    }

  

    /* =====================================================
       V8.20 · GAMEPLAY BOTTOM BAR
       Interface joueur : une barre centrale basse, pensée comme un rituel de jeu.
       Le menu dev reste séparé. Les exports restent dans la carte HTML autonome.
    ===================================================== */
    .save-share-dock.cta-clean.gameplay-bar {
      left: 50% !important;
      right: auto !important;
      bottom: max(14px, env(safe-area-inset-bottom)) !important;
      transform: translateX(-50%) !important;
      width: min(740px, calc(100vw - 32px)) !important;
      max-width: min(740px, calc(100vw - 32px)) !important;
      display: grid !important;
      grid-template-columns: 1fr !important;
      gap: 8px !important;
      padding: 10px 12px !important;
      border-radius: 28px !important;
      background:
        radial-gradient(circle at 50% -30%, rgba(244, 213, 138, 0.18), transparent 48%),
        linear-gradient(145deg, rgba(20, 15, 8, 0.64), rgba(41, 32, 18, 0.42)) !important;
      border-color: rgba(244, 213, 138, 0.22) !important;
      box-shadow:
        0 24px 56px rgba(7, 10, 5, 0.30),
        inset 0 1px 0 rgba(255, 248, 225, 0.12) !important;
      backdrop-filter: blur(24px) saturate(126%) !important;
      -webkit-backdrop-filter: blur(24px) saturate(126%) !important;
    }
    .gameplay-bar .dock-header {
      align-items: center;
      justify-content: center;
      padding: 0 10px;
      gap: 10px;
      min-height: 18px;
      text-align: center;
    }
    .gameplay-bar .dock-header span {
      font-size: 10px;
      opacity: 0.78;
    }
    .gameplay-bar .dock-header small {
      text-align: center;
      font-size: 10px;
      max-width: 420px;
      opacity: 0.70;
    }
    .gameplay-main-actions {
      display: grid;
      grid-template-columns: repeat(2, minmax(0, 1fr));
      gap: 10px;
      align-items: stretch;
    }
    .save-share-dock.cta-clean.gameplay-bar .micro-btn {
      min-height: 58px;
      border-radius: 20px;
      font-size: 13px;
      letter-spacing: 0.02em;
    }
    .save-share-dock.cta-clean.gameplay-bar .micro-btn small {
      font-size: 10px;
      line-height: 1.18;
      opacity: 0.72;
    }
    .save-share-dock.cta-clean.gameplay-bar .micro-btn-primary {
      min-height: 64px;
    }
    #water-tree-btn {
      background:
        radial-gradient(circle at 18% 0%, rgba(244, 213, 138, 0.22), transparent 44%),
        linear-gradient(145deg, rgba(91, 124, 47, 0.82), rgba(37, 69, 28, 0.70)) !important;
      border-color: rgba(244, 213, 138, 0.36) !important;
    }
    #save-tree-btn {
      background:
        radial-gradient(circle at 18% 0%, rgba(255, 248, 225, 0.17), transparent 44%),
        linear-gradient(145deg, rgba(68, 54, 28, 0.84), rgba(79, 110, 38, 0.56)) !important;
    }
    .gameplay-bar .dock-more {
      padding-top: 4px;
      border-top-color: rgba(255, 248, 225, 0.06);
    }
    .gameplay-bar .dock-more summary {
      justify-content: center;
      gap: 8px;
      opacity: 0.62;
    }
    .gameplay-bar .dock-secondary-grid {
      grid-template-columns: repeat(3, minmax(0, 1fr));
    }
    .tree-growth-ui {
      bottom: 112px !important;
      width: min(520px, calc(100vw - 32px));
      pointer-events: none !important;
    }
    .tree-growth-card {
      min-width: 0 !important;
      width: 100% !important;
      padding: 10px 14px 11px !important;
      border-radius: 18px !important;
      background:
        radial-gradient(circle at 18% 0%, rgba(244, 213, 138, 0.12), transparent 42%),
        linear-gradient(145deg, rgba(22, 16, 9, 0.54), rgba(52, 39, 21, 0.34)) !important;
      box-shadow: 0 12px 30px rgba(7, 10, 5, 0.18), inset 0 1px 0 rgba(255, 248, 225, 0.10) !important;
      backdrop-filter: blur(16px) saturate(112%) !important;
      -webkit-backdrop-filter: blur(16px) saturate(112%) !important;
    }
    .tree-growth-title {
      margin-bottom: 4px !important;
      font-size: 9px !important;
      opacity: 0.72;
    }
    .tree-growth-status {
      margin-bottom: 8px !important;
      font-size: 11px !important;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }
    .tree-button-row {
      display: none !important;
    }
    @media (max-width: 760px) {
      .save-share-dock.cta-clean.gameplay-bar {
        width: calc(100vw - 18px) !important;
        max-width: calc(100vw - 18px) !important;
        padding: 9px !important;
        border-radius: 22px !important;
      }
      .gameplay-main-actions { gap: 8px; }
      .save-share-dock.cta-clean.gameplay-bar .micro-btn {
        min-height: 56px;
        padding: 9px 8px;
        font-size: 12px;
      }
      .save-share-dock.cta-clean.gameplay-bar .micro-btn small { display: none; }
      .gameplay-bar .dock-header small { display: none; }
      .tree-growth-ui { bottom: 98px !important; width: calc(100vw - 22px); }
      .tree-growth-title { display: none; }
      .tree-growth-status { font-size: 10px !important; }
    }
    @media (max-width: 420px) {
      .gameplay-main-actions { grid-template-columns: 1fr 1fr; }
      .save-share-dock.cta-clean.gameplay-bar .micro-btn { font-size: 11px; }
      .gameplay-bar .dock-more summary { font-size: 9px; }
    }



    /* =====================================================
       V8.21 · SIMPLIFIED GAME UI + WEATHER FOCUS + PERF
       Joueur : 2 actions claires. Météo : utile d'abord, détails à la demande.
       Objectif : réduire la charge visuelle et les coûts GPU/CSS hors menu dev.
    ===================================================== */
    #weather-hud.weather-lite {
      width: min(314px, calc(100vw - 74px)) !important;
      padding: 10px 11px !important;
      border-radius: 18px !important;
      background:
        radial-gradient(circle at 18% 0%, rgba(238,196,116,0.10), transparent 36%),
        linear-gradient(145deg, rgba(18,14,8,0.36), rgba(37,29,16,0.20)) !important;
      box-shadow: 0 12px 28px rgba(7,10,5,0.13), inset 0 1px 0 rgba(255,238,193,0.07) !important;
      border-color: rgba(216,171,97,0.13) !important;
    }
    .weather-lite-mainline {
      grid-template-columns: 38px minmax(0, 1fr) auto !important;
      gap: 8px !important;
      margin-bottom: 7px !important;
    }
    .weather-visual-icon {
      width: 36px !important;
      height: 36px !important;
      border-radius: 14px !important;
      font-size: 20px !important;
    }
    .weather-lite-clock { font-size: 16px !important; }
    .weather-lite-date { font-size: 9px !important; margin-top: 2px !important; }
    .weather-mini-actions {
      display: grid;
      gap: 4px;
      justify-items: end;
      align-items: center;
    }
    .weather-lite-source {
      padding: 4px 7px !important;
      font-size: 8px !important;
      opacity: 0.72;
    }
    .weather-tune-btn {
      border: 1px solid rgba(216,171,97,0.12);
      border-radius: 999px;
      padding: 4px 8px;
      background: rgba(15,10,5,0.18);
      color: rgba(255,248,225,0.72);
      font-family: inherit;
      font-size: 8px;
      font-weight: 850;
      letter-spacing: 0.10em;
      text-transform: uppercase;
      cursor: pointer;
    }
    .weather-focus-line {
      position: relative;
      z-index: 1;
      display: grid;
      grid-template-columns: 1fr;
      gap: 2px;
      padding: 8px 10px;
      border-radius: 14px;
      border: 1px solid rgba(216,171,97,0.09);
      background: rgba(79,110,38,0.12);
      color: rgba(255,248,225,0.90);
    }
    .weather-focus-line strong {
      font-size: 12px;
      line-height: 1.18;
      font-weight: 900;
      letter-spacing: 0.01em;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }
    .weather-focus-line span {
      font-size: 9.5px;
      line-height: 1.2;
      color: rgba(226,218,188,0.62);
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }
    #weather-hud.compact .weather-lite-values,
    #weather-hud.compact .weather-lite-advice,
    #weather-hud.compact .weather-rainline {
      display: none !important;
    }
    #weather-hud:not(.compact) .weather-focus-line { margin-bottom: 7px; }
    #weather-hud:not(.compact) .weather-lite-values {
      grid-template-columns: repeat(3, minmax(0, 1fr));
    }
    #weather-hud:not(.compact) .weather-rainline {
      opacity: 0.72;
      font-size: 9px;
    }
    .save-share-dock.cta-clean.gameplay-bar {
      width: min(620px, calc(100vw - 28px)) !important;
      max-width: min(620px, calc(100vw - 28px)) !important;
      padding: 8px !important;
      gap: 6px !important;
      border-radius: 24px !important;
      background:
        radial-gradient(circle at 50% -25%, rgba(244,213,138,0.12), transparent 44%),
        linear-gradient(145deg, rgba(20,15,8,0.52), rgba(41,32,18,0.34)) !important;
      box-shadow: 0 18px 42px rgba(7,10,5,0.22), inset 0 1px 0 rgba(255,248,225,0.10) !important;
      backdrop-filter: blur(18px) saturate(114%) !important;
      -webkit-backdrop-filter: blur(18px) saturate(114%) !important;
    }
    .gameplay-bar .dock-header {
      min-height: 0 !important;
      padding: 0 6px 1px !important;
    }
    .gameplay-bar .dock-header span {
      font-size: 8.5px !important;
      letter-spacing: 0.15em;
      opacity: 0.52 !important;
    }
    .gameplay-bar .dock-header small { display: none !important; }
    .gameplay-main-actions { gap: 8px !important; }
    .save-share-dock.cta-clean.gameplay-bar .micro-btn {
      min-height: 50px !important;
      border-radius: 18px !important;
      font-size: 12px !important;
      padding: 9px 10px !important;
    }
    .save-share-dock.cta-clean.gameplay-bar .micro-btn small {
      font-size: 9px !important;
      opacity: 0.58 !important;
      margin-top: 2px;
    }
    .save-share-dock.cta-clean.gameplay-bar .micro-btn-primary { min-height: 54px !important; }
    .gameplay-bar .dock-more { padding-top: 0 !important; border-top: 0 !important; }
    .gameplay-bar .dock-more summary {
      min-height: 18px;
      font-size: 8.5px !important;
      opacity: 0.48 !important;
    }
    .gameplay-bar .dock-secondary-grid {
      grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
      gap: 6px !important;
      padding-top: 5px;
    }
    .gameplay-bar .dock-secondary-grid .micro-btn {
      min-height: 34px !important;
      font-size: 9px !important;
      border-radius: 13px !important;
    }
    .tree-growth-ui { bottom: 94px !important; width: min(420px, calc(100vw - 30px)) !important; }
    .tree-growth-card {
      padding: 8px 12px !important;
      border-radius: 16px !important;
      background: linear-gradient(145deg, rgba(22,16,9,0.42), rgba(52,39,21,0.24)) !important;
      box-shadow: 0 10px 24px rgba(7,10,5,0.14), inset 0 1px 0 rgba(255,248,225,0.08) !important;
      backdrop-filter: blur(12px) saturate(106%) !important;
      -webkit-backdrop-filter: blur(12px) saturate(106%) !important;
    }
    .tree-growth-title { display: none !important; }
    .tree-growth-status { margin-bottom: 6px !important; font-size: 10.5px !important; opacity: 0.84; }
    .tree-progress-track { height: 7px !important; }
    .memory-card-shell {
      background:
        radial-gradient(circle at 28% 18%, rgba(244,213,138,0.07), transparent 34%),
        linear-gradient(145deg, rgba(18,15,8,0.50), rgba(6,7,4,0.36)) !important;
      backdrop-filter: blur(22px) saturate(112%) !important;
      -webkit-backdrop-filter: blur(22px) saturate(112%) !important;
    }
    .memory-card-popup-summary { opacity: 0.78; }

    @media (max-width: 760px) {
      #weather-hud.weather-lite { width: min(286px, calc(100vw - 66px)) !important; }
      .weather-focus-line strong { font-size: 11px; }
      .weather-focus-line span { display: none; }
      .save-share-dock.cta-clean.gameplay-bar {
        width: calc(100vw - 16px) !important;
        max-width: calc(100vw - 16px) !important;
        padding: 7px !important;
        border-radius: 20px !important;
      }
      .save-share-dock.cta-clean.gameplay-bar .micro-btn {
        min-height: 48px !important;
        font-size: 11px !important;
      }
      .save-share-dock.cta-clean.gameplay-bar .micro-btn small { display: none !important; }
      .tree-growth-ui { bottom: 86px !important; }
    }
    @media (max-width: 420px) {
      #weather-hud.weather-lite { width: min(258px, calc(100vw - 58px)) !important; }
      .weather-tune-btn { display: none; }
      .save-share-dock.cta-clean.gameplay-bar .micro-btn { padding-inline: 7px !important; }
    }
    @media (prefers-reduced-motion: reduce) {
      .memory-card-tilt,
      .memory-card-viewer,
      .memory-card-face,
      .save-share-dock,
      .tree-btn,
      .micro-btn {
        animation: none !important;
        transition-duration: 0.01ms !important;
      }
    }



    /* =====================================================
       V8.22 · ORGANIC CONTROL ISLAND
       Benchmark appliqué : spatial UI légère + bento contextuel + disclosure.
       Toutes les informations joueur utiles sont réunies au même endroit :
       progression, microclimat, rituel, carte, mémoire automatique.
    ===================================================== */
    #premium-cta,
    #storage-warning {
      display: none !important;
    }

    .microcosm-ritual-dock {
      position: fixed !important;
      left: 50% !important;
      right: auto !important;
      bottom: max(14px, env(safe-area-inset-bottom)) !important;
      transform: translateX(-50%) !important;
      width: min(680px, calc(100vw - 28px)) !important;
      max-width: min(680px, calc(100vw - 28px)) !important;
      display: grid !important;
      grid-template-columns: minmax(0, 1fr) !important;
      gap: 8px !important;
      padding: 10px !important;
      border-radius: 28px !important;
      border: 1px solid rgba(244, 213, 138, 0.18) !important;
      background:
        radial-gradient(circle at 50% -35%, rgba(244, 213, 138, 0.16), transparent 42%),
        radial-gradient(circle at 12% 100%, rgba(96, 122, 48, 0.13), transparent 44%),
        linear-gradient(145deg, rgba(18, 14, 8, 0.62), rgba(31, 26, 15, 0.44)) !important;
      box-shadow:
        0 18px 44px rgba(0, 0, 0, 0.28),
        0 0 0 1px rgba(255, 248, 225, 0.035),
        inset 0 1px 0 rgba(255, 248, 225, 0.11) !important;
      backdrop-filter: blur(12px) saturate(112%) !important;
      -webkit-backdrop-filter: blur(12px) saturate(112%) !important;
      contain: layout style;
      z-index: 1004 !important;
    }

    .ritual-topline {
      display: grid;
      grid-template-columns: minmax(0, 1fr) auto;
      gap: 8px;
      align-items: center;
      padding: 0 2px;
      font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
    }
    .ritual-stage-pill,
    .ritual-memory-pill {
      min-width: 0;
      min-height: 32px;
      display: flex;
      align-items: center;
      gap: 8px;
      border: 1px solid rgba(216, 171, 97, 0.10);
      border-radius: 999px;
      background: rgba(8, 7, 4, 0.24);
      color: rgba(255, 248, 225, 0.82);
      padding: 7px 10px;
      box-shadow: inset 0 1px 0 rgba(255, 248, 225, 0.045);
    }
    .ritual-stage-pill span {
      color: rgba(226, 218, 188, 0.52);
      font-size: 8.5px;
      font-weight: 900;
      letter-spacing: 0.14em;
      text-transform: uppercase;
      white-space: nowrap;
    }
    .ritual-stage-pill b {
      min-width: 0;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
      font-size: 11px;
      font-weight: 850;
      letter-spacing: 0.01em;
    }
    .ritual-memory-pill {
      justify-content: center;
      color: rgba(226, 218, 188, 0.64);
      font-size: 9.5px;
      font-weight: 800;
      letter-spacing: 0.04em;
      white-space: nowrap;
    }
    .ritual-memory-dot {
      width: 6px;
      height: 6px;
      border-radius: 999px;
      background: rgba(168, 205, 98, 0.92);
      box-shadow: 0 0 10px rgba(168, 205, 98, 0.58);
      flex: 0 0 auto;
    }

    .ritual-progress-anchor .tree-growth-ui {
      position: static !important;
      inset: auto !important;
      transform: none !important;
      width: 100% !important;
      pointer-events: none !important;
      display: block !important;
    }
    .ritual-progress-anchor .tree-growth-card {
      width: 100% !important;
      min-width: 0 !important;
      padding: 0 !important;
      border: 0 !important;
      border-radius: 0 !important;
      background: transparent !important;
      box-shadow: none !important;
      backdrop-filter: none !important;
      -webkit-backdrop-filter: none !important;
    }
    .ritual-progress-anchor .tree-growth-title,
    .ritual-progress-anchor .tree-growth-status,
    .ritual-progress-anchor .tree-button-row {
      display: none !important;
    }
    .ritual-progress-anchor .tree-progress-track {
      height: 7px !important;
      border-radius: 999px !important;
      border: 1px solid rgba(255, 248, 225, 0.09) !important;
      background: rgba(7, 6, 3, 0.38) !important;
      box-shadow: inset 0 1px 5px rgba(0, 0, 0, 0.32) !important;
    }

    .ritual-weather-anchor #weather-hud.weather-lite {
      position: static !important;
      inset: auto !important;
      width: 100% !important;
      max-width: none !important;
      padding: 8px !important;
      border-radius: 20px !important;
      background:
        radial-gradient(circle at 0% 0%, rgba(118, 145, 63, 0.12), transparent 38%),
        rgba(7, 6, 3, 0.20) !important;
      border: 1px solid rgba(216, 171, 97, 0.10) !important;
      box-shadow: inset 0 1px 0 rgba(255, 248, 225, 0.055) !important;
      backdrop-filter: none !important;
      -webkit-backdrop-filter: none !important;
      cursor: pointer;
      contain: layout style;
    }
    .ritual-weather-anchor #weather-hud.weather-lite::after { display: none !important; }
    .ritual-weather-anchor .weather-lite-mainline {
      grid-template-columns: 34px minmax(0, 1fr) auto !important;
      gap: 8px !important;
      margin-bottom: 6px !important;
      align-items: center !important;
    }
    .ritual-weather-anchor .weather-visual-icon {
      width: 34px !important;
      height: 34px !important;
      border-radius: 14px !important;
      font-size: 18px !important;
      background: rgba(255, 248, 225, 0.06) !important;
      box-shadow: none !important;
    }
    .ritual-weather-anchor .weather-lite-clock { font-size: 15px !important; }
    .ritual-weather-anchor .weather-lite-date { font-size: 8.5px !important; opacity: 0.72; }
    .ritual-weather-anchor .weather-lite-source {
      padding: 4px 7px !important;
      font-size: 7.5px !important;
      opacity: 0.68;
    }
    .ritual-weather-anchor .weather-tune-btn {
      padding: 4px 7px !important;
      font-size: 7.5px !important;
      opacity: 0.68;
      background: rgba(15, 10, 5, 0.18);
    }
    .ritual-weather-anchor .weather-focus-line {
      padding: 0 !important;
      border: 0 !important;
      border-radius: 0 !important;
      background: transparent !important;
    }
    .ritual-weather-anchor .weather-focus-line strong {
      font-size: 12px !important;
      line-height: 1.2 !important;
      color: rgba(255, 248, 225, 0.92);
    }
    .ritual-weather-anchor .weather-focus-line span {
      font-size: 9px !important;
      color: rgba(226, 218, 188, 0.58);
    }
    .ritual-weather-anchor #weather-hud.compact .weather-lite-values,
    .ritual-weather-anchor #weather-hud.compact .weather-lite-advice,
    .ritual-weather-anchor #weather-hud.compact .weather-rainline,
    .ritual-weather-anchor #weather-hud.compact .weather-lunar-strip,
    .ritual-weather-anchor #weather-hud.compact .weather-location-tools {
      display: none !important;
    }
    .ritual-weather-anchor #weather-hud:not(.compact) .weather-lite-values {
      grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
      gap: 6px !important;
      margin-top: 8px !important;
    }
    .ritual-weather-anchor #weather-hud:not(.compact) .weather-lite-values > div {
      padding: 7px 8px !important;
      border-radius: 12px !important;
      background: rgba(8, 7, 4, 0.24) !important;
    }
    .ritual-weather-anchor #weather-hud:not(.compact) .weather-lite-advice,
    .ritual-weather-anchor #weather-hud:not(.compact) .weather-rainline {
      margin-top: 6px !important;
      font-size: 9.5px !important;
    }

    .ritual-action-row {
      display: grid !important;
      grid-template-columns: 1fr 1fr !important;
      gap: 8px !important;
      margin-top: 0 !important;
    }
    .microcosm-ritual-dock .micro-btn {
      min-height: 58px !important;
      border-radius: 20px !important;
      border: 1px solid rgba(244, 213, 138, 0.18) !important;
      color: rgba(255, 248, 225, 0.94) !important;
      background:
        radial-gradient(circle at 50% 0%, rgba(244, 213, 138, 0.16), transparent 52%),
        linear-gradient(145deg, rgba(80, 106, 42, 0.72), rgba(36, 48, 24, 0.58)) !important;
      box-shadow: 0 9px 20px rgba(0,0,0,0.18), inset 0 1px 0 rgba(255,248,225,0.12) !important;
      font-size: 13px !important;
      font-weight: 950 !important;
      letter-spacing: 0.01em !important;
      transition: transform 0.16s ease, filter 0.16s ease, border-color 0.16s ease !important;
    }
    .microcosm-ritual-dock .micro-btn:hover {
      transform: translateY(-1px);
      filter: brightness(1.06);
      border-color: rgba(244, 213, 138, 0.30) !important;
    }
    .microcosm-ritual-dock .card-btn {
      background:
        radial-gradient(circle at 50% 0%, rgba(244, 213, 138, 0.14), transparent 54%),
        linear-gradient(145deg, rgba(54, 42, 24, 0.72), rgba(26, 22, 14, 0.58)) !important;
    }
    .microcosm-ritual-dock .micro-btn small {
      display: block !important;
      margin-top: 3px !important;
      color: rgba(226, 218, 188, 0.56) !important;
      font-size: 9px !important;
      font-weight: 700 !important;
      letter-spacing: 0.02em !important;
    }
    .ritual-unlock-link {
      justify-self: center;
      border: 0;
      background: transparent;
      color: rgba(226, 218, 188, 0.52);
      font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
      font-size: 9.5px;
      font-weight: 850;
      letter-spacing: 0.08em;
      text-transform: uppercase;
      cursor: pointer;
      padding: 2px 8px 0;
    }
    .ritual-unlock-link:hover { color: rgba(255, 248, 225, 0.82); }

    body.microcosm-memory-card-open .microcosm-ritual-dock {
      opacity: 0.14 !important;
      filter: blur(2px) saturate(0.72) !important;
      pointer-events: none !important;
    }
    body:not(.microcosm-memory-card-open) .memory-card-tilt,
    body:not(.microcosm-memory-card-open) .memory-card-face::before {
      animation-play-state: paused !important;
    }

    @media (max-width: 740px) {
      .microcosm-ritual-dock {
        width: calc(100vw - 16px) !important;
        max-width: calc(100vw - 16px) !important;
        gap: 7px !important;
        padding: 8px !important;
        border-radius: 22px !important;
      }
      .ritual-topline {
        grid-template-columns: 1fr;
        gap: 5px;
      }
      .ritual-memory-pill {
        min-height: 26px;
        justify-content: flex-start;
        font-size: 8.5px;
      }
      .ritual-weather-anchor .weather-lite-mainline {
        grid-template-columns: 32px minmax(0,1fr) auto !important;
      }
      .ritual-weather-anchor .weather-focus-line span { display: none !important; }
      .microcosm-ritual-dock .micro-btn {
        min-height: 54px !important;
        font-size: 12px !important;
        padding-inline: 8px !important;
      }
    }
    @media (max-width: 440px) {
      .ritual-stage-pill span { display: none; }
      .ritual-action-row { gap: 6px !important; }
      .microcosm-ritual-dock .micro-btn small { display: none !important; }
      .microcosm-ritual-dock .micro-btn { min-height: 50px !important; font-size: 11.5px !important; }
      .ritual-unlock-link { font-size: 8px; }
    }


    /* =====================================================
       V8.23 · PROD FLOW POLISH
       Patch de finition uniquement : hiérarchie joueur, météo utile,
       carte flottante, responsive et coût visuel plus bas.
       La croissance Fibonacci et les seeds ne sont pas modifiés.
    ===================================================== */
    :root {
      --mic-cream: rgba(255, 248, 225, 0.94);
      --mic-cream-soft: rgba(226, 218, 188, 0.66);
      --mic-earth: rgba(18, 14, 8, 0.58);
      --mic-earth-soft: rgba(31, 25, 14, 0.42);
      --mic-olive: rgba(82, 110, 43, 0.70);
      --mic-gold: rgba(244, 213, 138, 0.28);
    }

    .microcosm-ritual-dock {
      width: min(720px, calc(100vw - 26px)) !important;
      max-width: min(720px, calc(100vw - 26px)) !important;
      gap: 7px !important;
      padding: 11px 12px 10px !important;
      border-radius: 30px !important;
      background:
        radial-gradient(circle at 50% -45%, rgba(244, 213, 138, 0.13), transparent 44%),
        radial-gradient(circle at 9% 118%, rgba(96, 122, 48, 0.12), transparent 42%),
        linear-gradient(145deg, rgba(17, 13, 7, 0.56), rgba(30, 25, 14, 0.38)) !important;
      border-color: rgba(244, 213, 138, 0.15) !important;
      box-shadow:
        0 16px 36px rgba(0, 0, 0, 0.24),
        0 0 0 1px rgba(255, 248, 225, 0.028),
        inset 0 1px 0 rgba(255, 248, 225, 0.10) !important;
      backdrop-filter: blur(10px) saturate(108%) !important;
      -webkit-backdrop-filter: blur(10px) saturate(108%) !important;
    }

    .ritual-island-label {
      justify-self: center;
      margin: -1px 0 -2px;
      color: rgba(255, 248, 225, 0.44);
      font: 950 8.5px/1.1 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
      letter-spacing: 0.20em;
      text-transform: uppercase;
      pointer-events: none;
    }

    .ritual-topline {
      grid-template-columns: minmax(0, 1fr) auto !important;
      gap: 7px !important;
    }

    .ritual-stage-pill,
    .ritual-memory-pill {
      min-height: 29px !important;
      padding: 6px 9px !important;
      background: rgba(7, 6, 3, 0.19) !important;
      border-color: rgba(216, 171, 97, 0.075) !important;
    }

    .ritual-stage-pill span { font-size: 8px !important; opacity: 0.82; }
    .ritual-stage-pill b { font-size: 10.5px !important; }
    .ritual-memory-pill { font-size: 8.8px !important; color: rgba(226, 218, 188, 0.58) !important; }

    .ritual-progress-anchor .tree-progress-track {
      height: 6px !important;
      background: rgba(5, 5, 3, 0.32) !important;
      border-color: rgba(255, 248, 225, 0.07) !important;
    }
    .ritual-progress-anchor .tree-progress-fill {
      background: linear-gradient(90deg, rgba(103, 135, 54, 0.92), rgba(193, 156, 72, 0.94), rgba(244, 213, 138, 0.96)) !important;
      box-shadow: 0 0 11px rgba(244, 213, 138, 0.34) !important;
    }

    .ritual-weather-anchor #weather-hud.weather-lite {
      padding: 8px 9px !important;
      border-radius: 19px !important;
      background:
        radial-gradient(circle at 0% 0%, rgba(118, 145, 63, 0.10), transparent 38%),
        rgba(7, 6, 3, 0.16) !important;
      transition: background 0.18s ease, border-color 0.18s ease;
    }
    .ritual-weather-anchor #weather-hud.weather-lite:not(.compact) {
      background:
        radial-gradient(circle at 0% 0%, rgba(118, 145, 63, 0.14), transparent 38%),
        rgba(7, 6, 3, 0.24) !important;
      border-color: rgba(216, 171, 97, 0.13) !important;
    }
    .weather-mini-actions {
      display: inline-grid;
      grid-template-columns: auto auto;
      gap: 5px;
      align-items: center;
    }
    .weather-tune-btn {
      border: 1px solid rgba(216, 171, 97, 0.12);
      border-radius: 999px;
      color: rgba(255, 248, 225, 0.70);
      background: rgba(9, 7, 4, 0.22);
      font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
      font-weight: 900;
      letter-spacing: 0.10em;
      text-transform: uppercase;
      cursor: pointer;
    }
    .weather-tune-btn[aria-expanded="true"] {
      color: rgba(255, 248, 225, 0.92);
      border-color: rgba(244, 213, 138, 0.22);
      background: rgba(80, 106, 42, 0.24);
    }
    .ritual-weather-anchor .weather-focus-line strong {
      font-size: 12.4px !important;
      letter-spacing: -0.01em;
    }
    .ritual-weather-anchor .weather-focus-line span {
      display: block;
      max-width: 100%;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }

    .microcosm-ritual-dock .micro-btn {
      min-height: 56px !important;
      border-radius: 19px !important;
      box-shadow: 0 8px 18px rgba(0,0,0,0.16), inset 0 1px 0 rgba(255,248,225,0.11) !important;
    }
    #water-tree-btn {
      border-color: rgba(244, 213, 138, 0.24) !important;
      background:
        radial-gradient(circle at 50% 0%, rgba(244, 213, 138, 0.18), transparent 52%),
        linear-gradient(145deg, rgba(88, 119, 46, 0.76), rgba(38, 53, 25, 0.62)) !important;
    }
    #save-tree-btn {
      background:
        radial-gradient(circle at 50% 0%, rgba(244, 213, 138, 0.13), transparent 52%),
        linear-gradient(145deg, rgba(58, 45, 25, 0.66), rgba(25, 22, 14, 0.54)) !important;
    }
    .ritual-unlock-link {
      opacity: 0.82;
      padding-top: 0 !important;
    }

    .memory-card-modal {
      background:
        radial-gradient(circle at 50% 18%, rgba(244,213,138,0.075), transparent 34%),
        radial-gradient(circle at 18% 82%, rgba(79,110,38,0.085), transparent 38%),
        rgba(4, 6, 3, 0.18) !important;
      backdrop-filter: blur(8px) saturate(0.98) brightness(0.92) !important;
      -webkit-backdrop-filter: blur(8px) saturate(0.98) brightness(0.92) !important;
    }
    .memory-card-shell {
      width: min(1080px, calc(100vw - 44px)) !important;
      height: min(690px, calc(100dvh - 44px)) !important;
      background:
        radial-gradient(circle at 26% 10%, rgba(244,213,138,0.075), transparent 36%),
        radial-gradient(circle at 80% 24%, rgba(79,110,38,0.075), transparent 42%),
        linear-gradient(145deg, rgba(16,13,7,0.48), rgba(6,7,4,0.38)) !important;
      backdrop-filter: blur(18px) saturate(112%) !important;
      -webkit-backdrop-filter: blur(18px) saturate(112%) !important;
    }
    .memory-card-popup-body { gap: clamp(16px, 2.4vw, 30px) !important; }
    .memory-card-info-panel { opacity: 0.80 !important; }
    .memory-card-modal-footer button.is-soft-locked,
    .memory-card-modal-footer button[aria-disabled="true"] {
      opacity: 0.82;
      filter: none;
      cursor: pointer;
      background:
        radial-gradient(circle at 16% 0%, rgba(244, 213, 138, 0.12), transparent 44%),
        linear-gradient(145deg, rgba(74, 53, 30, 0.50), rgba(22, 17, 10, 0.42)) !important;
    }

    body.microcosm-memory-card-open .microcosm-ritual-dock {
      opacity: 0.10 !important;
      filter: blur(2px) saturate(0.70) brightness(0.82) !important;
    }

    @media (max-width: 740px) {
      .microcosm-ritual-dock {
        width: calc(100vw - 14px) !important;
        max-width: calc(100vw - 14px) !important;
        padding: 8px !important;
        gap: 6px !important;
        border-radius: 22px !important;
      }
      .ritual-island-label { display: none; }
      .ritual-topline { grid-template-columns: 1fr !important; }
      .ritual-stage-pill,
      .ritual-memory-pill { min-height: 25px !important; }
      .ritual-memory-pill { display: none !important; }
      .ritual-weather-anchor .weather-lite-mainline {
        grid-template-columns: 30px minmax(0, 1fr) auto !important;
        gap: 7px !important;
      }
      .ritual-weather-anchor .weather-lite-clock { font-size: 13px !important; }
      .ritual-weather-anchor .weather-lite-date { display: none !important; }
      .weather-mini-actions { grid-template-columns: auto; }
      .weather-lite-source { display: none !important; }
      .ritual-action-row { gap: 7px !important; }
      .microcosm-ritual-dock .micro-btn { min-height: 51px !important; font-size: 12px !important; }
      .microcosm-ritual-dock .micro-btn small { font-size: 8px !important; }
      .memory-card-shell {
        width: calc(100vw - 16px) !important;
        height: calc(100dvh - 16px) !important;
      }
    }

    @media (max-width: 440px) {
      .ritual-stage-pill b { font-size: 9.8px !important; }
      .ritual-weather-anchor .weather-focus-line strong { font-size: 11.3px !important; }
      .ritual-weather-anchor .weather-focus-line span { display: none !important; }
      .microcosm-ritual-dock .micro-btn { min-height: 48px !important; font-size: 11px !important; }
      .ritual-unlock-link { font-size: 7.7px !important; letter-spacing: .06em !important; }
    }

    @media (max-height: 680px) and (min-width: 741px) {
      .microcosm-ritual-dock {
        width: min(760px, calc(100vw - 24px)) !important;
        grid-template-columns: minmax(180px, .85fr) minmax(240px, 1.1fr) minmax(260px, 1fr) !important;
        align-items: center !important;
      }
      .ritual-island-label { grid-column: 1 / -1; margin-bottom: -4px; }
      .ritual-topline { grid-column: 1; grid-template-columns: 1fr !important; }
      .ritual-progress-anchor { grid-column: 1; }
      .ritual-weather-anchor { grid-column: 2; grid-row: 2 / span 2; }
      .ritual-action-row { grid-column: 3; grid-row: 2; }
      .ritual-unlock-link { grid-column: 3; grid-row: 3; }
      .ritual-memory-pill { display: none !important; }
    }


    /* =====================================================
       V8.27 · MOBILE GROUND / CARD PARITY / ORGANIC SHORE
       - Terre visible autour du gland plus subtile.
       - Ancien proxy géosphère réellement invisible sans ombre.
       - Voir ma carte affiche la texture de carte autonome exportée.
       - Le liseré de rivage suit le tracé organique de l'îlot.
       ===================================================== */
    .memory-card-face.memory-card-front.export-card-front {
      grid-template-rows: 1fr !important;
      gap: 0 !important;
      padding: 0 !important;
      background:
        var(--memory-card-export-texture),
        radial-gradient(circle at 50% 55%, rgba(79,110,38,0.55), rgba(8,7,4,0.90)) !important;
      background-size: cover !important;
      background-position: center !important;
      background-repeat: no-repeat !important;
      border-color: rgba(244,213,138,0.34) !important;
      box-shadow:
        inset 0 0 0 8px rgba(255,248,225,0.025),
        inset 0 0 0 11px rgba(216,171,97,0.08) !important;
    }
    .memory-card-face.memory-card-front.export-card-front > * {
      display: none !important;
    }
    .memory-card-face.memory-card-front.export-card-front::before {
      opacity: .26 !important;
    }
    .memory-card-face.memory-card-front.export-card-front::after {
      content: 'texture autonome' !important;
      position: absolute !important;
      right: 8.4% !important;
      bottom: 6.4% !important;
      z-index: 4 !important;
      padding: .55em .8em !important;
      border-radius: 999px !important;
      border: 1px solid rgba(244,213,138,.22) !important;
      background: rgba(5,5,3,.42) !important;
      color: rgba(255,248,225,.72) !important;
      font: 950 clamp(7px, .95vw, 10px)/1 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif !important;
      letter-spacing: .14em !important;
      text-transform: uppercase !important;
      pointer-events: none !important;
    }
    .memory-card-face.memory-card-front.export-card-front + .memory-card-back {
      border-color: rgba(244,213,138,0.28) !important;
    }
    .memory-card-preview-panel::after {
      content: 'Même texture que la carte HTML autonome · toucher pour retourner' !important;
    }
    .memory-card-modal .memory-card-popup-note {
      max-width: 52ch;
    }

  </style>
<script async src="https://cdn.jsdelivr.net/npm/es-module-shims@1.7.1/dist/es-module-shims.min.js"></script>
<script type="importmap">
  {
    "imports": {
      "three": "https://cdn.jsdelivr.net/npm/three@0.183.2/build/three.webgpu.js",
      "three/webgpu": "https://cdn.jsdelivr.net/npm/three@0.183.2/build/three.webgpu.js",
      "three/tsl": "https://cdn.jsdelivr.net/npm/three@0.183.2/build/three.tsl.js",
      "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.183.2/examples/jsm/",
      "stats-gl": "https://cdn.jsdelivr.net/npm/stats-gl@2.4.2/dist/main.js"
    }
  }
  </script>
<div id="microcosm-app-root" aria-label="Microcosm immersive WebGPU canvas"></div>
  <div id="vr-status-chip">Préparation VR confortable</div>

  <!-- Panneau d’erreur WebGPU -->
  <div id="no-webgpu">
    <h1>WebGPU non disponible</h1>
    <p>Ce microcosme interactif nécessite l’API WebGPU, qui n’est pas prise en charge par votre navigateur actuel.</p>
    <div class="hint">
      Essayez d’utiliser <strong>Google Chrome 113+</strong> ou <strong>Microsoft Edge 113+</strong> sur bureau.<br>
      Sous macOS, essayez également <strong>Safari Technology Preview</strong>.
    </div>
  </div>
  <!-- Bouton et panneau UI de l’océan -->
  <button id="ui-toggle" title="Paramètres">☰</button>
  <div id="ui-panel"></div>
  <!-- Panneau d’herbe (contrôles) -->
  <!-- Removed separate grass panel; grass controls are integrated into the main UI -->

  <div id="weather-hud" class="weather-lite compact" title="Microclimat utile à la croissance · bouton Affiner pour les détails">
    <div class="weather-lite-mainline">
      <div class="weather-visual-icon" id="weather-visual-icon" aria-label="Icône météo">☾</div>
      <div>
        <div class="weather-lite-clock" id="weather-time">--:--</div>
        <div class="weather-lite-date" id="weather-date">--</div>
      </div>
      <div class="weather-mini-actions">
        <div class="weather-lite-source" id="weather-source">AUTO</div>
        <button class="weather-tune-btn" type="button" aria-label="Affiner la météo" aria-expanded="false">Affiner</button>
      </div>
    </div>
    <div class="weather-focus-line" aria-live="polite">
      <strong id="weather-focus-main">--° · microclimat</strong>
      <span id="weather-focus-sub">Vent -- · humidité --</span>
    </div>
    <div class="weather-lite-values">
      <div><small>Temp.</small><b><span id="weather-temp">--</span>°C</b></div>
      <div><small>Vent</small><b id="weather-wind">--</b></div>
      <div><small>Humidité</small><b id="weather-humidity">--</b></div>
    </div>
    <div class="weather-lite-advice">
      <span id="weather-desc">Microclimat en observation</span>
      <b id="weather-advice">Arrosage à évaluer</b>
    </div>
    <div class="weather-rainline">Jours sans pluie : <b id="weather-rainless-days">--</b></div>
    <div class="weather-lunar-strip weather-lite-advanced">
      <div class="weather-lunar-moon" id="weather-lunar-icon">☾</div>
      <div class="weather-lunar-text">
        <strong id="weather-lunar-phase">Lune en calcul</strong>
        <span id="weather-lunar-detail">Phase · mouvement · type lunaire</span>
      </div>
    </div>
    <div class="weather-lite-advanced weather-location-tools">
      <input id="weather-location-input" type="text" placeholder="Adresse ou ville">
      <button id="weather-location-search" title="Chercher cette météo">OK</button>
      <button id="weather-location-gps" title="Revenir à ma position GPS">GPS</button>
      <button id="weather-refresh" title="Forcer un relevé météo live">↻</button>
    </div>
    <div class="weather-lite-hidden">
      <div id="weather-place">Microclimat local</div>
      <div id="weather-icon"></div>
      <div id="weather-feels">--</div>
      <div id="weather-pressure">--</div>
      <div id="weather-clouds">--</div>
      <div id="weather-rain">--</div>
      <div id="weather-daylight">--</div>
      <div id="weather-sync">--</div>
      <b id="weather-compare-api">--</b>
      <b id="weather-compare-world">--</b>
      <div id="weather-note">--</div>
    </div>
  </div>

  <div class="save-share-dock cta-clean gameplay-bar microcosm-ritual-dock" aria-label="Organic Control Island · rituel principal Microcosm">
    <div class="ritual-island-label" aria-hidden="true">Organic Control Island</div>
    <div class="ritual-topline" aria-live="polite">
      <div class="ritual-stage-pill">
        <span>Cycle vivant</span>
        <b id="save-tree-state-label">Jour 000/365 · graine en éveil</b>
      </div>
      <div class="ritual-memory-pill" title="La progression est automatiquement conservée dans ce navigateur.">
        <span class="ritual-memory-dot" aria-hidden="true"></span>
        Mémoire navigateur auto
      </div>
    </div>
    <div class="ritual-progress-anchor" aria-label="Progression de l’arbre"></div>
    <div class="ritual-weather-anchor" aria-label="Microclimat utile à la croissance"></div>
    <div class="gameplay-main-actions ritual-action-row">
      <button class="micro-btn micro-btn-primary ritual-btn" id="water-tree-btn" type="button">
        Arroser mon arbre
        <small id="water-tree-btn-hint">Rituel volontaire</small>
      </button>
      <button class="micro-btn micro-btn-primary card-btn" id="save-tree-btn" type="button">
        Voir ma carte
        <small id="save-tree-btn-hint">Carte vivante</small>
      </button>
    </div>
    <button class="ritual-unlock-link" id="open-premium-options-btn" type="button">Déverrouiller la mémoire autonome</button>
    <input id="import-settings-file" type="file" accept="application/json,.json" style="display:none">
  </div>
  <div id="storage-warning" hidden><strong>Mémoire locale :</strong> la progression est automatiquement conservée dans ce navigateur. La mémoire HTML autonome est disponible avec l’add-on ou l’arbre à vie.</div>


  <div id="memory-card-modal" class="memory-card-modal" aria-hidden="true" role="dialog" aria-label="Carte mémoire Microcosm flottante" hidden>
    <div class="memory-card-shell" role="document">
      <div class="memory-card-modal-bar">
        <div class="memory-card-modal-title">
          Carte mémoire Microcosm
          <small id="memory-card-modal-subtitle">Viewer collector dynamique · clic/tap pour retourner</small>
        </div>
        <button type="button" class="memory-card-close-x" id="memory-card-close-x" aria-label="Fermer la carte mémoire">×</button>
      </div>
      <div class="memory-card-popup-body">
        <div class="memory-card-preview-panel" id="memory-card-preview-panel" aria-label="Viewer 3D de la carte mémoire">
          <div class="memory-card-viewer" id="memory-card-viewer">
            <button type="button" class="memory-card-flip-btn" id="memory-card-flip-btn">Voir verso</button>
            <div class="memory-card-tilt" id="memory-card-tilt">
              <div class="memory-card-flipper" id="memory-card-flipper">
                <article class="memory-card-face memory-card-front" aria-label="Recto de la carte mémoire">
                  <div class="memory-card-holo-mask" aria-hidden="true"></div>
                  <header class="memory-card-face-header">
                    <span>Microcosm Interactif 9</span>
                    <b id="memory-card-view-edition">#------</b>
                  </header>
                  <h3 id="memory-card-view-title">Graine en éveil</h3>
                  <p id="memory-card-view-rarity">Mémoire vivante</p>
                  <div class="memory-card-view-shot" id="memory-card-view-shot">
                    <div class="memory-card-view-progress">
                      <i id="memory-card-view-progress-bar"></i>
                      <span id="memory-card-view-progress-label">0%</span>
                    </div>
                  </div>
                  <div class="memory-card-view-stats">
                    <div><small>Jour</small><b id="memory-card-view-day">000/365</b></div>
                    <div><small>Temp.</small><b id="memory-card-view-temp">--°C</b></div>
                    <div><small>Vent</small><b id="memory-card-view-wind">-- km/h</b></div>
                    <div><small>Humidité</small><b id="memory-card-view-humidity">--%</b></div>
                    <div><small>Nuages</small><b id="memory-card-view-clouds">--%</b></div>
                    <div><small>Synchro</small><b id="memory-card-view-sync">--</b></div>
                  </div>
                  <footer class="memory-card-view-cert">
                    <img id="memory-card-view-qr" alt="QR AR/XR de la carte mémoire">
                    <span id="memory-card-view-cert">Certificat de graine</span>
                  </footer>
                </article>
                <article class="memory-card-face memory-card-back" aria-label="Verso certificat de la carte mémoire">
                  <div class="memory-card-holo-mask large" aria-hidden="true"></div>
                  <span class="memory-card-back-kicker">Certificat vivant</span>
                  <h3 id="memory-card-back-title">Identité de l’arbre</h3>
                  <div class="memory-card-back-grid">
                    <div><small>Seed public</small><b id="memory-card-back-seed">--</b></div>
                    <div><small>Lieu récent</small><b id="memory-card-back-place">--</b></div>
                    <div><small>Générée</small><b id="memory-card-back-generated">--</b></div>
                    <div><small>Activité</small><b id="memory-card-back-activity">--</b></div>
                  </div>
                  <p id="memory-card-back-note">Carte mémoire autonome prête à être enregistrée.</p>
                  <div class="memory-card-back-ar">
                    <img id="memory-card-back-qr" alt="QR AR/XR de la carte mémoire">
                    <span>AR/XR<br><small id="memory-card-back-url">presentcomposedesign.fr/microcosm-ar</small></span>
                  </div>
                </article>
              </div>
            </div>
          </div>
        </div>
        <div class="memory-card-info-panel">
          <div class="memory-card-kicker" id="memory-card-edition-meta">Carte HTML autonome</div>
          <h2 id="memory-card-title-line">Carte mémoire</h2>
          <p class="memory-card-kicker" id="memory-card-popup-summary">Carte unique : le même HTML autonome sert au viewer et à la sauvegarde payante.</p>
          <div class="memory-card-info-grid" aria-label="Résumé de la carte mémoire">
            <div><small>Jour</small><b id="memory-card-popup-day">--</b></div>
            <div><small>Tier</small><b id="memory-card-popup-tier">--</b></div>
            <div><small>Météo</small><b id="memory-card-popup-weather">--</b></div>
            <div><small>Seed public</small><b id="memory-card-popup-seed">--</b></div>
          </div>
          <p class="memory-card-popup-note" id="memory-card-popup-note">La carte flotte ici. En gratuit, elle reste consultable ; “Enregistrer” sert à déverrouiller la mémoire HTML autonome.</p>
        </div>
      </div>
      <div class="memory-card-modal-footer">
        <button type="button" class="secondary" id="memory-card-close-btn">Fermer</button>
        <div class="memory-card-inline-actions">
          <span id="memory-card-more-options-note" class="memory-card-more-options-note">options dans le HTML autonome</span>
          <button type="button" class="secondary" id="memory-card-open-ar-btn">Ouvrir AR/XR</button>
          <button type="button" id="memory-card-download-btn">Enregistrer</button>
        </div>
      </div>
    </div>
  </div>

  <button id="premium-cta">
    Jouer contre vents et marées
    <small>débloquer l’expérience</small>
  </button>
  <div id="cycle-modal" aria-hidden="true">
    <div class="cycle-card">
      <h2>Ton arbre a accompli son premier cycle.</h2>
      <p>
        365 jours : une année complète de croissance. Tu peux conserver cet arbre,
        créer une card visuelle gratuite, repartir d’une nouvelle graine, ou garder une vraie
        carte mémoire premium pour lui redonner vie plus tard.
      </p>
      <div class="cycle-actions">
        <button id="cycle-keep-btn">Conserver l’arbre<br><small>le garder comme page d’accueil</small></button>
        <button id="cycle-card-btn" class="secondary">Créer une card HTML vivante<br><small>page autonome partageable</small></button>
        <button id="cycle-cut-btn" class="secondary">Couper et recommencer<br><small>nouvelle graine</small></button>
        <button id="cycle-premium-btn" class="premium">Continuer à vie · 19,99€<br><small>carte mémoire illimitée + partage avancé</small></button>
      </div>
    </div>
  </div>


  <div id="premium-panel">
    <div class="premium-title">Jouer contre vents et marées</div>
    <div class="premium-copy" id="premium-copy-main">
      Deux modes de jeu existent : gratuit ou à vie. L’extension “vents & marées” est un supplément séparé,
      activable aussi bien sur le mode gratuit que sur le mode à vie. Elle débloque des outils, artefacts,
      entretiens supplémentaires et le mode accueil plein écran sans HUD.
    </div>
    <div class="premium-list">
      <span id="premium-line-free">Gratuit : 1 clic / jour pendant 365 jours</span>
      <span id="premium-line-life">À vie : arbre éternel + mémoire illimitée</span>
      <span id="premium-line-addon">Add-on “vents & marées” : 4,99€ sur gratuit ou à vie</span>
      <span id="premium-line-addon-features">Débloque 5 clics/jour au départ + outils + artefacts + mode accueil sans HUD</span>
      <span id="premium-line-cards">Cartes : 2D originales en gratuit · version glass collector pour les arbres à vie</span>
    </div>
    <div class="premium-action-group" aria-label="Action premium disponible">
      <div class="premium-action-label">Mode accueil</div>
      <button class="premium-action-btn" id="presentation-fullscreen-btn">
        Plein écran sans HUD
        <small>restaurant · accueil entreprise · sensibilisation “un clic par jour”</small>
      </button>
    </div>
  </div>


  <script type="module">
    import * as THREE from 'three/webgpu';
    import {
      Fn, uniform, float, int, vec3, vec4,
      instancedArray, instanceIndex, positionGeometry, positionWorld,
      // Import additional TSL helpers used in this scene
      uv,
      sin, cos, pow, sqrt, abs, floor, fract, max, max as tslMax, exp,
      smoothstep, mix, select, dot, normalize, time,
      Loop, cameraPosition, hash, atan, clamp, vec2,
      mx_noise_float, deltaTime, PI
    } from 'three/tsl';
    import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
    import { VRButton } from 'three/addons/webxr/VRButton.js';
    import { PLYLoader } from 'three/addons/loaders/PLYLoader.js';
    import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
    import { SkyMesh } from 'three/addons/objects/SkyMesh.js';
    import Stats from 'stats-gl';

    document.body.classList.add('microcosm-immersive-page');

    // Vérifier le support WebGPU
    if (!navigator.gpu) {
      document.getElementById('no-webgpu').style.display = 'flex';
      throw new Error('WebGPU not supported');
    }

    // ───── Budget de performance runtime ─────
    // V8.25 : la densité herbe peut réellement monter jusqu'à ×10 sur desktop.
    // Sur mobile, on garde le slider de dev mais le pool GPU est plafonné pour éviter un gel immédiat.
    const MICRO_GRASS_BASE_BLADE_INSTANCES = 65536;
    const microcosmMobileAutoBudget = /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
    const microcosmGrassMaxMultiplier = microcosmMobileAutoBudget ? 3 : 10;
    const MICROCOSM_RUNTIME_PERF = {
      maxPixelRatio: 1.35,
      vrComfortPixelRatio: 1.0,
      grassBaseBladeInstances: MICRO_GRASS_BASE_BLADE_INSTANCES,
      grassMaxDensityMultiplier: microcosmGrassMaxMultiplier,
      grassBladeInstances: MICRO_GRASS_BASE_BLADE_INSTANCES * microcosmGrassMaxMultiplier,
      grassGrid: Math.ceil(Math.sqrt(MICRO_GRASS_BASE_BLADE_INSTANCES * microcosmGrassMaxMultiplier)),
      grassUpdateEvery: microcosmGrassMaxMultiplier >= 10 ? 3 : 2,
      oceanFineUpdateEvery: 2,
      statsUpdateEvery: 30,
      hudUpdateMs: 1400,
      mobileAutoBudget: microcosmMobileAutoBudget
    };
    if (MICROCOSM_RUNTIME_PERF.mobileAutoBudget) {
      MICROCOSM_RUNTIME_PERF.maxPixelRatio = 1.0;
      MICROCOSM_RUNTIME_PERF.grassUpdateEvery = 4;
      MICROCOSM_RUNTIME_PERF.oceanFineUpdateEvery = 3;
      MICROCOSM_RUNTIME_PERF.hudUpdateMs = 1800;
    }

    // ───── Initialisation de la scène, de la caméra et du renderer ─────
    const microcosmMount = document.getElementById('microcosm-app-root') || document.body;
    const scene = new THREE.Scene();
    scene.background = new THREE.Color('#9edcff');
    // Increase the field of view for a wider initial perspective
    const camera = new THREE.PerspectiveCamera(78, window.innerWidth / window.innerHeight, 0.01, 1000000);
    camera.position.set(0, 4.5, 14.0);
    const renderer = new THREE.WebGPURenderer({ antialias: true, sampleCount: 2, alpha: false });
    renderer.shadowMap.enabled = true;
    renderer.shadowMap.type = THREE.PCFSoftShadowMap;
    renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, MICROCOSM_RUNTIME_PERF.maxPixelRatio));
    renderer.setSize(window.innerWidth, window.innerHeight);
    renderer.toneMapping = THREE.ACESFilmicToneMapping;
    renderer.toneMappingExposure = 0.8;
    renderer.setClearColor(0x9edcff, 1);
    renderer.domElement.style.background = '#9edcff';
    microcosmMount.appendChild(renderer.domElement);
    await renderer.init();
    renderer.xr.enabled = true;
    if ('xr' in navigator) {
      microcosmMount.appendChild(VRButton.createButton(renderer));
    }

    function setMicrocosmHudVisibilityForVR(active) {
      document.body.classList.toggle('microcosm-xr-active', active && vrRuntimeState.hideHudInVR);
      document.body.classList.toggle('microcosm-xr-preparing', false);
    }

    function applyVRRuntimeMode(active) {
      vrRuntimeState.active = active;
      setMicrocosmHudVisibilityForVR(active);

      if (active) {
        vrRuntimeState.saved.grassDensity = grassDensity?.value ?? null;
        vrRuntimeState.saved.grassDensityMultiplier = grassDensityMultiplier?.value ?? null;
        vrRuntimeState.saved.shoreOpacity = shoreFadeState.opacity;
        vrRuntimeState.saved.foamIntensity = foamIntensity?.value ?? null;
        vrRuntimeState.saved.heightScale = heightScale?.value ?? null;
        vrRuntimeState.saved.choppiness = choppiness?.value ?? null;

        renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, vrRuntimeState.comfortPixelRatio));

        if (vrRuntimeState.reduceParticles) {
          grassDensity.value = Math.min(grassDensity.value, 1.05);
          grassDensityMultiplier.value = Math.min(grassDensityMultiplier.value, MICROCOSM_RUNTIME_PERF.mobileAutoBudget ? 1.15 : 1.75);
          shoreFadeState.opacity *= 0.68;
          if (shoreFadePoints) shoreFadePoints.material.opacity = shoreFadeState.enabled ? shoreFadeState.opacity : 0;
        }
        if (vrRuntimeState.reduceOceanWhileVR) {
          heightScale.value = Math.min(heightScale.value, 0.62);
          choppiness.value = Math.min(choppiness.value, 0.82);
          foamIntensity.value = Math.min(foamIntensity.value, 1.05);
        }
      } else {
        renderer.setPixelRatio(vrRuntimeState.normalPixelRatio);

        if (vrRuntimeState.saved.grassDensity !== null) grassDensity.value = vrRuntimeState.saved.grassDensity;
        if (vrRuntimeState.saved.grassDensityMultiplier !== null) grassDensityMultiplier.value = vrRuntimeState.saved.grassDensityMultiplier;
        if (vrRuntimeState.saved.shoreOpacity !== null) {
          shoreFadeState.opacity = vrRuntimeState.saved.shoreOpacity;
          if (shoreFadePoints) shoreFadePoints.material.opacity = shoreFadeState.enabled ? shoreFadeState.opacity : 0;
        }
        if (vrRuntimeState.saved.foamIntensity !== null) foamIntensity.value = vrRuntimeState.saved.foamIntensity;
        if (vrRuntimeState.saved.heightScale !== null) heightScale.value = vrRuntimeState.saved.heightScale;
        if (vrRuntimeState.saved.choppiness !== null) choppiness.value = vrRuntimeState.saved.choppiness;
        vrRuntimeState.saved.grassDensity = null;
        vrRuntimeState.saved.grassDensityMultiplier = null;
        vrRuntimeState.saved.shoreOpacity = null;
        vrRuntimeState.saved.foamIntensity = null;
        vrRuntimeState.saved.heightScale = null;
        vrRuntimeState.saved.choppiness = null;
      }
    }

    renderer.xr.addEventListener('sessionstart', () => {
      document.body.classList.add('microcosm-xr-preparing');
      setTimeout(() => applyVRRuntimeMode(true), 120);
    });
    renderer.xr.addEventListener('sessionend', () => {
      applyVRRuntimeMode(false);
    });

    // ───── Contrôles orbitaux unifiés ─────
    const controls = new OrbitControls(camera, renderer.domElement);
    controls.target.set(0, 0.9, 0);
    camera.lookAt(controls.target);

    // Caméra libre : plus de verrouillage d'angle, de distance ou d'azimut.
    // L'auto-follow de l'icosaèdre se désactive dès que l'utilisateur manipule la caméra.
    controls.enableDamping = true;
    controls.dampingFactor = 0.055;
    controls.enableZoom = true;
    controls.enablePan = true;
    controls.enableRotate = true;
    controls.minDistance = 0.01;
    controls.maxDistance = Infinity;
    controls.minPolarAngle = 0;
    controls.maxPolarAngle = Math.PI;
    controls.minAzimuthAngle = -Infinity;
    controls.maxAzimuthAngle = Infinity;
    controls.zoomSpeed = 1.35;
    controls.rotateSpeed = 0.85;
    controls.panSpeed = 0.9;
    controls.screenSpacePanning = true;
    if ('zoomToCursor' in controls) controls.zoomToCursor = true;

    let cameraAutoFollow = true;
    // Dès que l'utilisateur zoome/pivote/pan, la caméra devient volontairement manuelle.
    // Un clic de croissance ne doit plus jamais provoquer de dézoom/recentrage automatique.
    let userCameraLocked = false;
    let openingGrassSplashActive = true;
    const disableCameraAutoFollow = () => {
      openingGrassSplashActive = false;
      cameraAutoFollow = false;
      userCameraLocked = true;
    };
    const restoreCameraAutoFollow = ({ clearUserLock = false } = {}) => {
      if (clearUserLock) userCameraLocked = false;
      if (!userCameraLocked) cameraAutoFollow = true;
    };

    // Splashscreen d'ouverture uniquement : caméra basse dans l'herbe, un peu moins zoomée
    // que la version précédente. Dès le premier clic, on reprend la caméra de croissance validée.
    const openingGrassCameraPos = new THREE.Vector3(0.0, 0.88, 11.5);
    const openingGrassCameraLook = new THREE.Vector3(0.0, 0.96, -48.0);
    function setOpeningGrassSplashCamera() {
      camera.position.copy(openingGrassCameraPos);
      controls.target.copy(openingGrassCameraLook);
      camera.lookAt(openingGrassCameraLook);
      camera.updateProjectionMatrix();
      controls.update();
    }
    function exitOpeningGrassSplash() {
      if (!openingGrassSplashActive) return;
      openingGrassSplashActive = false;
      restoreCameraAutoFollow({ clearUserLock: true });
    }
    const disableCameraAutoFollowAfterSplash = () => {
      if (openingGrassSplashActive) return;
      disableCameraAutoFollow();
    };
    controls.addEventListener('start', disableCameraAutoFollow);
    renderer.domElement.addEventListener('wheel', disableCameraAutoFollow, { passive: true });
    renderer.domElement.addEventListener('pointerdown', disableCameraAutoFollowAfterSplash, { passive: true });
    renderer.domElement.addEventListener('touchstart', disableCameraAutoFollowAfterSplash, { passive: true });
    window.addEventListener('keydown', (e) => {
      if (e.code === 'KeyC') {
        openingGrassSplashActive = false;
        userCameraLocked = cameraAutoFollow;
        cameraAutoFollow = !cameraAutoFollow;
      }
    });

    // ───── Statistiques GPU/CPU (optionnel) ─────
    const stats = new Stats({ trackGPU: false });
    stats.init(renderer);
    // document.body.appendChild(stats.dom);

    // ───── Éclairage global ─────
    const hemiLight = new THREE.HemisphereLight(0x87ceeb, 0x1a3a5c, 0.3);
    scene.add(hemiLight);
    const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
    scene.add(ambientLight);
    // Soleil pour l’océan et le ciel
    const sunLight = new THREE.DirectionalLight(0xffecd0, 4.5);
    scene.add(sunLight);
    // Lumière supplémentaire pour l’arbre
    const treeDirLight = new THREE.DirectionalLight(0xfff8f0, 1.8);
    treeDirLight.position.set(10, 20, 8);
    treeDirLight.castShadow = true;
    treeDirLight.shadow.mapSize.set(2048, 2048);
    treeDirLight.shadow.camera.left = -20;
    treeDirLight.shadow.camera.right = 20;
    treeDirLight.shadow.camera.top = 20;
    treeDirLight.shadow.camera.bottom = -20;
    treeDirLight.shadow.camera.near = 1;
    treeDirLight.shadow.camera.far = 50;
    treeDirLight.shadow.bias = -0.001;
    scene.add(treeDirLight);
    const rimLight = new THREE.DirectionalLight(0xffd4c4, 0.5);
    rimLight.position.set(-8, 10, -10);
    scene.add(rimLight);
    const fillLight = new THREE.DirectionalLight(0xffe8e0, 0.3);
    fillLight.position.set(-5, 5, 10);
    scene.add(fillLight);
    const pointLight = new THREE.PointLight(0xffccbb, 0.4, 30);
    pointLight.position.set(5, 3, 5);
    scene.add(pointLight);

    // ───── Sky and Clouds (océan) ─────
    const sunDir = uniform(new THREE.Vector3());

    // Ciel principal robuste WebGPU + WebXR.
    // Correction réelle du noir : le dôme précédent avait un rayon trop grand / matériau node fragile.
    // Ici : sphère complète, rayon inférieur au camera.far, MeshBasicMaterial + vertex colors.
    const skyDomeGeo = new THREE.SphereGeometry(18000, 96, 48);
    const skyColors = [];
    const skyPosAttr = skyDomeGeo.getAttribute('position');
    const cHorizon = new THREE.Color(0xffdfa3);
    const cLow = new THREE.Color(0xcff2ff);
    const cMid = new THREE.Color(0x9edcff);
    const cHigh = new THREE.Color(0x74bff2);
    const cTmp = new THREE.Color();
    for (let i = 0; i < skyPosAttr.count; i++) {
      const y = skyPosAttr.getY(i) / 18000;
      const t = THREE.MathUtils.clamp((y + 0.18) / 1.18, 0, 1);
      if (t < 0.32) {
        cTmp.copy(cHorizon).lerp(cLow, t / 0.32);
      } else if (t < 0.72) {
        cTmp.copy(cLow).lerp(cMid, (t - 0.32) / 0.40);
      } else {
        cTmp.copy(cMid).lerp(cHigh, (t - 0.72) / 0.28);
      }
      skyColors.push(cTmp.r, cTmp.g, cTmp.b);
    }
    skyDomeGeo.setAttribute('color', new THREE.Float32BufferAttribute(skyColors, 3));
    const skyDomeMat = new THREE.MeshBasicMaterial({
      side: THREE.DoubleSide,
      vertexColors: true,
      depthWrite: false,
      depthTest: false,
      fog: false,
      toneMapped: false
    });
    const skyDome = new THREE.Mesh(skyDomeGeo, skyDomeMat);
    skyDome.renderOrder = -1000000;
    skyDome.frustumCulled = false;
    const _skyFollowPos = new THREE.Vector3();
    skyDome.onBeforeRender = (_renderer, _scene, activeCamera) => {
      activeCamera.getWorldPosition(_skyFollowPos);
      skyDome.position.copy(_skyFollowPos);
      skyDome.updateMatrixWorld(true);
    };
    scene.add(skyDome);

    // Skybox PLY externe : activable depuis les réglages, mais désactivée automatiquement en XR.
    const PLY_SKYBOX_URL = 'https://presentcomposedesign.fr/wp-content/uploads/2026/05/Reflective_Sky_and_Water.compressed.ply';
    let plySkybox = null;
    let plySkyboxWanted = false;
    const plySkyboxState = { loaded: false, error: false, loading: true };
    function applyPlySkyboxVisibility() {
      if (!plySkybox) return;
      const inXR = renderer.xr.isPresenting === true;
      plySkybox.visible = plySkyboxWanted && !inXR;
      skyDome.visible = !plySkybox.visible;
    }
    renderer.xr.addEventListener('sessionstart', applyPlySkyboxVisibility);
    renderer.xr.addEventListener('sessionend', applyPlySkyboxVisibility);

    const plyLoader = new PLYLoader();
    plyLoader.load(PLY_SKYBOX_URL, (geometry) => {
      geometry.computeBoundingBox();
      geometry.computeVertexNormals?.();
      const box = geometry.boundingBox;
      const center = new THREE.Vector3();
      const size = new THREE.Vector3();
      box.getCenter(center);
      box.getSize(size);
      geometry.translate(-center.x, -center.y, -center.z);

      const hasVertexColors = !!geometry.getAttribute('color');
      const skyMat = new THREE.MeshBasicMaterial({
        side: THREE.DoubleSide,
        vertexColors: hasVertexColors,
        color: hasVertexColors ? 0xffffff : 0x9edcff,
        depthWrite: false,
        depthTest: false,
        fog: false,
        toneMapped: false
      });
      plySkybox = new THREE.Mesh(geometry, skyMat);
      const maxDim = Math.max(size.x, size.y, size.z) || 1;
      plySkybox.scale.setScalar(26000 / maxDim);
      plySkybox.renderOrder = -999999;
      plySkybox.frustumCulled = false;
      plySkybox.onBeforeRender = (_renderer, _scene, activeCamera) => {
        activeCamera.getWorldPosition(_skyFollowPos);
        plySkybox.position.copy(_skyFollowPos);
        plySkybox.updateMatrixWorld(true);
      };
      scene.add(plySkybox);
      plySkyboxState.loaded = true;
      plySkyboxState.loading = false;
      plySkyboxWanted = false;
      applyPlySkyboxVisibility();
    }, undefined, (err) => {
      console.warn('Impossible de charger la skybox PLY :', err);
      plySkyboxState.error = true;
      plySkyboxState.loading = false;
      skyDome.visible = true;
    });

    // Fallback non-XR : même si un navigateur ignore le dôme, le clear + fond CSS ne redeviennent jamais noirs.
    // SkyMesh conservé seulement comme référence de paramètres, mais jamais ajouté à la scène.
    const sky = new SkyMesh();
    sky.visible = false;

    // Couche de nuages
    const cloudCoverage = uniform(0.45);
    const cloudDensity = uniform(0.8);
    const cloudSpeed = uniform(0.006);
    function createCloudLayer(radius, yPos, scaleUV, alpha, geo) {
      const mat = new THREE.MeshBasicNodeMaterial({ side: THREE.BackSide, transparent: true, depthWrite: false, depthTest: false });
      mat.colorNode = Fn(() => {
        const wPos = positionWorld;
        const uv = vec2(wPos.x, wPos.z).mul(scaleUV);
        const t = time.mul(cloudSpeed);
        const warpN = mx_noise_float(uv.mul(0.7).add(vec2(t.mul(0.5), t.mul(-0.3))));
        const warpedUV = uv.add(vec2(warpN.mul(0.15), warpN.mul(0.12)));
        const n1 = mx_noise_float(warpedUV.mul(1.0).add(vec2(t, t.mul(0.3))));
        const n2 = mx_noise_float(warpedUV.mul(2.2).add(vec2(t.mul(-0.6), t.mul(0.8))));
        const n3 = mx_noise_float(warpedUV.mul(5.0).add(vec2(t.mul(0.9), t.mul(-0.5))));
        const n4 = mx_noise_float(warpedUV.mul(10.0).add(vec2(t.mul(-0.4), t.mul(1.1))));
        const n5 = mx_noise_float(warpedUV.mul(20.0).add(vec2(t.mul(1.2), t.mul(0.4))));
        const cloud = n1.mul(0.4).add(n2.mul(0.25)).add(n3.mul(0.18)).add(n4.mul(0.1)).add(n5.mul(0.07));
        const coverageThresh = float(1.0).sub(cloudCoverage);
        const cloudMask = smoothstep(coverageThresh, coverageThresh.add(0.2), cloud);
        const densityMask = pow(cloudMask, float(0.6)).mul(cloudDensity);
        const L = normalize(sunDir);
        const cloudNormalApprox = normalize(vec3(
          mx_noise_float(warpedUV.add(vec2(0.001, 0.0))).sub(n1),
          float(0.08),
          mx_noise_float(warpedUV.add(vec2(0.0, 0.001))).sub(n1)
        ));
        const NdotL = max(dot(cloudNormalApprox, L), float(0.0));
        const sunColorWarm = vec3(1.0, 0.92, 0.8);
        const sunColorCool = vec3(0.95, 0.97, 1.0);
        const sunTint = mix(sunColorWarm, sunColorCool, smoothstep(float(0.0), float(30.0), float(params.sunElevation)));
        const brightColor = sunTint;
        const midColor = vec3(0.78, 0.8, 0.85);
        const shadowColor = vec3(0.4, 0.42, 0.52);
        const darkEdge = vec3(0.28, 0.3, 0.38);
        const lightMix = NdotL.mul(0.6).add(cloud.mul(0.4));
        const col1 = mix(darkEdge, shadowColor, smoothstep(float(0.0), float(0.3), lightMix));
        const col2 = mix(col1, midColor, smoothstep(float(0.3), float(0.55), lightMix));
        const cloudCol = mix(col2, brightColor, smoothstep(float(0.55), float(0.85), lightMix));
        const edgeMask = smoothstep(float(0.0), float(0.15), cloudMask).mul(smoothstep(float(0.5), float(0.15), cloudMask));
        const silverLining = edgeMask.mul(0.35).mul(max(NdotL, float(0.2)));
        const finalCol = cloudCol.add(vec3(silverLining));
        const horizonFade = smoothstep(float(0.0), float(0.15), normalize(wPos).y);
        return vec4(finalCol, densityMask.mul(alpha).mul(horizonFade));
      })();
      const mesh = new THREE.Mesh(geo, mat);
      mesh.position.y = yPos;
      mesh.renderOrder = -999000;
      mesh.frustumCulled = false;
      return mesh;
    }
    const cloudGeo1 = new THREE.SphereGeometry(13000, 48, 24, 0, Math.PI * 2, 0, Math.PI * 0.38);
    const cloudLayer1 = createCloudLayer(13000, 0, 0.00008, 0.75, cloudGeo1);
    // Désactivé : ces calottes transparentes WebGPU rendaient parfois une grande zone noire.
    cloudLayer1.visible = false;
    const cloudGeo2 = new THREE.SphereGeometry(13500, 40, 20, 0, Math.PI * 2, 0, Math.PI * 0.32);
    const cloudLayer2 = createCloudLayer(13500, 200, 0.00005, 0.35, cloudGeo2);
    // Désactivé pour garder un ciel jour complet en desktop et en XR.
    cloudLayer2.visible = false;

    // ───── Paramètres globaux pour l’océan ─────
    const params = {
      windSpeed: 9.5,
      choppiness: 0.62,
      heightScale: 0.58,
  // Lighten the base water color so the ocean is visible by default
  waterColor: '#042c4e',
      sssColor: '#0a5c5a',
      sssIntensity: 0.9,
      roughness: 0.08,
      fresnelPower: 4.5,
      fresnelStrength: 0.75,
      skyColor: '#4a6d8c',
      sunElevation: 2,
      sunAzimuth: 187,
      foamColor: '#ffffff',
      foamThreshold: 0.72,
      foamPower: 1.75,
      foamIntensity: 0.92,
      fbmNoiseFreq: 0.8,
      fbmNoiseAmp: 0.35,
      depthBlend: 0.55,
      reflectionStrength: 0.46,
      reflectionDistortion: 0.04,
      bumpStrength: 0.135,
      bumpFrequency: 0.42,
      fogDensity: 0.5,
      fogNear: 100,
      fogFar: 1800,
      fogColor: '#47616d'
    };
    // Uniforms océan
    const windSpeedU = uniform(params.windSpeed);
    const windDir = uniform(Math.PI * 0.25);
    const choppiness = uniform(params.choppiness);
    const heightScale = uniform(params.heightScale);
    const waterColor = uniform(new THREE.Color(params.waterColor));
    const sssColor = uniform(new THREE.Color(params.sssColor));
    const sssIntensity = uniform(params.sssIntensity);
    const roughness_u = uniform(params.roughness);
    const fresnelPower = uniform(params.fresnelPower);
    const fresnelStrength = uniform(params.fresnelStrength);
    const skyColor = uniform(new THREE.Color(params.skyColor));
    const foamColorU = uniform(new THREE.Color(params.foamColor));
    const foamThreshold = uniform(params.foamThreshold);
    const foamPower = uniform(params.foamPower);
    const foamIntensity = uniform(params.foamIntensity);
    // V8.27 · Le rivage et la mousse suivent enfin le même path organique que la terre.
    // On projette la distance océan sur une distance radiale déformée par la même signature de forme.
    const oceanIslandShapePhaseA = uniform(0.0);
    const oceanIslandShapePhaseB = uniform(0.0);
    const oceanIslandShapePhaseC = uniform(0.0);
    const oceanIslandShapeRoundness = uniform(0.16);
    const fbmNoiseFreqU = uniform(params.fbmNoiseFreq);
    const fbmNoiseAmpU = uniform(params.fbmNoiseAmp);
    const depthBlend = uniform(params.depthBlend);
    const reflectionStrength = uniform(params.reflectionStrength);
    const reflectionDistortion = uniform(params.reflectionDistortion);
    const bumpStrengthU = uniform(params.bumpStrength);
    const bumpNoiseType = uniform(5);
    const bumpFrequencyU = uniform(params.bumpFrequency);
    const fogDensity = uniform(params.fogDensity);
    const fogNear = uniform(params.fogNear);
    const fogFar = uniform(params.fogFar);
    const fogColorU = uniform(new THREE.Color(params.fogColor));
    // Courbure commune kokedama : même logique pour eau, terre et herbe.
    // L'eau est courbée dans le shader pour éviter un horizon plat, mais reste dissociée du sol fixe.
    const oceanPlanetCurveStrength = uniform(0.00024);
    const oceanPlanetCurveLimit = uniform(-38.0);
    // L'océan Alejandro est directement sphérisé : la courbure est appliquée au maillage dynamique,
    // pas à une sphère décorative ajoutée sous la scène.
    const oceanCurveAmount = uniform(1.0);
    const oceanSphereRadiusU = uniform(4800.0);
    const oceanIslandCutRadius = uniform(14.0);
    const oceanIslandCutFeather = uniform(2.2);
    // Zone de rivage : elle neutralise physiquement la houle près de l'îlot,
    // afin que les vagues ne traversent pas la terre/herbe.
    const oceanShoreCalmDistance = uniform(6.2);
    const oceanShoreMinimumWave = uniform(0.035);
    const oceanShoreFoamStrength = uniform(0.68);
    const oceanMatchedCurveStrength = uniform(0.0026);
    const foamNoiseType = uniform(5);
    const heightColorLow = uniform(new THREE.Color('#021a35'));
    const heightColorMid = uniform(new THREE.Color('#064273'));
    const heightColorHigh = uniform(new THREE.Color('#1a8a7d'));
    const heightColorPeak = uniform(new THREE.Color('#5ec4b0'));
    const heightColorIntensity = uniform(0.6);
    // Ocean compute constants
    const G = 9.81;
    const FFT_SIZE = 256;
    const FFT_SIZE_SQ = FFT_SIZE * FFT_SIZE;
    const GRID_K = 16;
    const WAVES_PER_CASCADE = GRID_K * GRID_K;
    const TOTAL_WAVES = WAVES_PER_CASCADE * 2;
    const PATCH_COARSE = 250;
    const PATCH_FINE = 37;
    // GPU buffers for ocean
    const waveData = instancedArray(TOTAL_WAVES, 'vec4');
    const waveExtra = instancedArray(TOTAL_WAVES, 'vec4');
    const dispGridCoarse = instancedArray(FFT_SIZE_SQ, 'vec4');
    const dispGridFine = instancedArray(FFT_SIZE_SQ, 'vec4');
    const slopeGridCoarse = instancedArray(FFT_SIZE_SQ, 'vec4');
    const slopeGridFine = instancedArray(FFT_SIZE_SQ, 'vec4');
    // Init JONSWAP spectrum
    const TWO_PI = float(Math.PI * 2);
    const initWaves = Fn(() => {
      const i = instanceIndex;
      const cascadeIdx = i.div(WAVES_PER_CASCADE);
      const localIdx = i.mod(WAVES_PER_CASCADE);
      const nx = localIdx.mod(GRID_K).sub(GRID_K / 2).toFloat();
      const nz = localIdx.div(GRID_K).sub(GRID_K / 2).toFloat();
      const patchSize = select(cascadeIdx.equal(0), float(PATCH_COARSE), float(PATCH_FINE));
      const dk = TWO_PI.div(patchSize);
      const kx = nx.mul(dk);
      const kz = nz.mul(dk);
      const k = sqrt(kx.mul(kx).add(kz.mul(kz))).max(0.0001);
      const omega = sqrt(float(G).mul(k));
      const dirX = kx.div(k);
      const dirZ = kz.div(k);
      const wSpeed = tslMax(windSpeedU, float(0.5));
      const L = wSpeed.mul(wSpeed).div(float(G));
      const kL = k.mul(L);
      const k4 = k.mul(k).mul(k).mul(k);
      const phillips = float(0.01).div(k4).mul(exp(float(-1.0).div(kL.mul(kL))));
      const omegaP = float(G).mul(0.87).div(wSpeed);
      const sigma = select(omega.lessThanEqual(omegaP), float(0.07), float(0.09));
      const rArg = omega.sub(omegaP).mul(omega.sub(omegaP)).negate().div(float(2.0).mul(sigma).mul(sigma).mul(omegaP).mul(omegaP));
      const jonswap = pow(float(3.3), exp(rArg));
      const waveAngle = atan(kz, kx);
      const cosA = cos(waveAngle.sub(windDir));
      const directional = pow(tslMax(cosA, float(0)), 2);
      const suppress = exp(k.mul(k).mul(-0.0001));
      const S = phillips.mul(jonswap).mul(directional).mul(suppress);
      const isCenter = abs(nx).lessThan(0.5).and(abs(nz).lessThan(0.5));
      const amp = select(isCenter, float(0), sqrt(S).mul(dk).mul(heightScale));
      const phase = hash(i.add(12345)).mul(TWO_PI);
      waveData.element(i).assign(vec4(dirX, dirZ, omega, amp));
      waveExtra.element(i).assign(vec4(k, phase, float(0), float(0)));
    })().compute(TOTAL_WAVES);
    // Compute ocean displacement per cascade
    function makeOceanCompute(dispGrid, slopeGrid, waveStart, waveCount, patchSizeVal) {
      const ps = float(patchSizeVal);
      return Fn(() => {
        const idx = instanceIndex;
        const gx = idx.mod(FFT_SIZE);
        const gz = idx.div(FFT_SIZE);
        const wx = gx.toFloat().div(float(FFT_SIZE)).mul(ps);
        const wz = gz.toFloat().div(float(FFT_SIZE)).mul(ps);
        const dy = float(0).toVar();
        const dx = float(0).toVar();
        const dz = float(0).toVar();
        const slopeX = float(0).toVar();
        const slopeZ = float(0).toVar();
        const Jxx = float(0).toVar();
        const Jzz = float(0).toVar();
        const Jxz = float(0).toVar();
        Loop(waveCount, ({ i }) => {
          const wi = i.add(waveStart);
          const wd = waveData.element(wi);
          const we = waveExtra.element(wi);
          const dX = wd.x;
          const dZ = wd.y;
          const omega = wd.z;
          const amp_w = wd.w;
          const k = we.x;
          const phase = we.y;
          const theta = k.mul(dX.mul(wx).add(dZ.mul(wz))).sub(omega.mul(time)).add(phase);
          const c = cos(theta);
          const s = sin(theta);
          dy.addAssign(amp_w.mul(c));
          dx.subAssign(amp_w.mul(dX).mul(s));
          dz.subAssign(amp_w.mul(dZ).mul(s));
          slopeX.subAssign(amp_w.mul(k).mul(dX).mul(s));
          slopeZ.subAssign(amp_w.mul(k).mul(dZ).mul(s));
          Jxx.subAssign(amp_w.mul(k).mul(dX).mul(dX).mul(c));
          Jzz.subAssign(amp_w.mul(k).mul(dZ).mul(dZ).mul(c));
          Jxz.subAssign(amp_w.mul(k).mul(dX).mul(dZ).mul(c));
        });
        dispGrid.element(idx).assign(vec4(dx.mul(choppiness), dy, dz.mul(choppiness), Jxz.mul(choppiness)));
        slopeGrid.element(idx).assign(vec4(slopeX, slopeZ, Jxx.mul(choppiness), Jzz.mul(choppiness)));
      })().compute(FFT_SIZE_SQ);
    }
    const computeOceanCoarse = makeOceanCompute(dispGridCoarse, slopeGridCoarse, 0, WAVES_PER_CASCADE, PATCH_COARSE);
    const computeOceanFine = makeOceanCompute(dispGridFine, slopeGridFine, WAVES_PER_CASCADE, WAVES_PER_CASCADE, PATCH_FINE);
    function sampleGrid(grid, wx, wz, patchSizeVal) {
      const ps = float(patchSizeVal);
      const u = fract(wx.div(ps)).mul(float(FFT_SIZE));
      const v = fract(wz.div(ps)).mul(float(FFT_SIZE));
      const ix = floor(u).toInt();
      const iz = floor(v).toInt();
      const fx = fract(u);
      const fz = fract(v);
      const N = int(FFT_SIZE);
      const i00 = iz.mul(N).add(ix);
      const i10 = iz.mul(N).add(ix.add(1).mod(N));
      const i01 = iz.add(1).mod(N).mul(N).add(ix);
      const i11 = iz.add(1).mod(N).mul(N).add(ix.add(1).mod(N));
      return mix(
        mix(grid.element(i00), grid.element(i10), fx),
        mix(grid.element(i01), grid.element(i11), fx),
        fz
      );
    }
    // Gerstner swells
    const SWELLS = [
      { dx: 0.9, dz: 0.44, wavelength: 120, steepness: 0.18 },
      { dx: -0.3, dz: 0.95, wavelength: 80, steepness: 0.13 },
      { dx: 0.6, dz: -0.8, wavelength: 200, steepness: 0.10 },
      { dx: 0.7, dz: 0.7, wavelength: 400, steepness: 0.06 },
      { dx: -0.5, dz: 0.86, wavelength: 600, steepness: 0.04 },
      { dx: 0.4, dz: 0.92, wavelength: 55, steepness: 0.12 }
    ];
    const SWELL_PARAMS = SWELLS.map((sw) => {
      const len = Math.sqrt(sw.dx * sw.dx + sw.dz * sw.dz);
      const ndx = sw.dx / len;
      const ndz = sw.dz / len;
      const k = (2 * Math.PI) / sw.wavelength;
      const omega = Math.sqrt(G * k);
      const amp = sw.steepness / k;
      return { ndx, ndz, k, omega, amp };
    });
    function buildGerstnerSwells(wx, wz) {
      let dy = float(0);
      let dx = float(0);
      let dz = float(0);
      for (const p of SWELL_PARAMS) {
        const theta = float(p.k).mul(float(p.ndx).mul(wx).add(float(p.ndz).mul(wz))).sub(float(p.omega).mul(time));
        const c = cos(theta);
        const s = sin(theta);
        dy = dy.add(float(p.amp).mul(c));
        dx = dx.sub(float(p.amp * p.ndx).mul(s));
        dz = dz.sub(float(p.amp * p.ndz).mul(s));
      }
      return { dx, dy, dz };
    }
    function buildGerstnerSwellJacobian(wx, wz) {
      let Jxx = float(0);
      let Jzz = float(0);
      let Jxz = float(0);
      for (const p of SWELL_PARAMS) {
        const theta = float(p.k).mul(float(p.ndx).mul(wx).add(float(p.ndz).mul(wz))).sub(float(p.omega).mul(time));
        const c = cos(theta);
        Jxx = Jxx.sub(float(p.amp * p.k * p.ndx * p.ndx).mul(c));
        Jzz = Jzz.sub(float(p.amp * p.k * p.ndz * p.ndz).mul(c));
        Jxz = Jxz.sub(float(p.amp * p.k * p.ndx * p.ndz).mul(c));
      }
      return { Jxx, Jzz, Jxz };
    }
    // Géométrie réelle de l'océan : disque radial, puis sphérisation dans le shader.
    // On n'utilise plus de plaque carrée ni de sphère décorative ajoutée.
    // Le shader Alejandro reste appliqué sur ce maillage circulaire et courbé.
    const OCEAN_DISC_RADIUS = 1350.0;
    const OCEAN_EDGE_FADE_START = 1080.0;
    const OCEAN_EDGE_FADE_END = 1340.0;
    function buildSphericalOceanDiscGeometry(radius = OCEAN_DISC_RADIUS, radialSteps = 192, segments = 512) {
      const positions = [];
      const normals = [];
      const uvs = [];
      const indices = [];
      for (let r = 0; r <= radialSteps; r++) {
        const rn = r / radialSteps;
        const rr = radius * Math.pow(rn, 0.92);
        for (let s = 0; s < segments; s++) {
          const a = (s / segments) * Math.PI * 2;
          const x = Math.cos(a) * rr;
          const z = Math.sin(a) * rr;
          positions.push(x, 0, z);
          normals.push(0, 1, 0);
          uvs.push(0.5 + (x / radius) * 0.5, 0.5 + (z / radius) * 0.5);
        }
      }
      for (let r = 0; r < radialSteps; r++) {
        for (let s = 0; s < segments; s++) {
          const a = r * segments + s;
          const b = r * segments + ((s + 1) % segments);
          const c = (r + 1) * segments + s;
          const d = (r + 1) * segments + ((s + 1) % segments);
          indices.push(a, c, b, b, c, d);
        }
      }
      const geo = new THREE.BufferGeometry();
      geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
      geo.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
      geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
      geo.setIndex(indices);
      geo.computeVertexNormals();
      return geo;
    }
    const oceanGeo = buildSphericalOceanDiscGeometry();
    const oceanMat = new THREE.MeshBasicNodeMaterial({ side: THREE.DoubleSide });
    // Node for position
    oceanMat.positionNode = Fn(() => {
      const wx = positionGeometry.x;
      const wz = positionGeometry.z;
      const dCoarse = sampleGrid(dispGridCoarse, wx, wz, PATCH_COARSE);
      const dFine = sampleGrid(dispGridFine, wx, wz, PATCH_FINE);
      const swell = buildGerstnerSwells(wx, wz);
      const baseDist = sqrt(wx.mul(wx).add(wz.mul(wz)));
      const shoreAngle = atan(wz, wx);
      const shoreLobeA = sin(shoreAngle.mul(3.0).add(oceanIslandShapePhaseA));
      const shoreLobeB = sin(shoreAngle.mul(5.0).add(oceanIslandShapePhaseB)).mul(0.55);
      const shoreLobeC = sin(shoreAngle.mul(7.0).add(oceanIslandShapePhaseC)).mul(0.20);
      const shoreShapeScale = tslMax(float(0.72), float(1.0).add(shoreLobeA.add(shoreLobeB).mul(oceanIslandShapeRoundness)).add(shoreLobeC.mul(0.07)));
      const islandOrganicDist = baseDist.div(shoreShapeScale);
      // Collision visuelle : la houle est étouffée au plus près de la vraie silhouette organique de l'îlot.
      // La zone calme n'est donc plus une simple couronne circulaire.
      const shoreEnergy = mix(
        oceanShoreMinimumWave,
        float(1.0),
        smoothstep(oceanIslandCutRadius, oceanIslandCutRadius.add(oceanShoreCalmDistance), islandOrganicDist)
      );
      const totalDx = dCoarse.x.add(dFine.x).add(swell.dx).mul(shoreEnergy);
      const totalDy = dCoarse.y.add(dFine.y).add(swell.dy).mul(shoreEnergy);
      const totalDz = dCoarse.z.add(dFine.z).add(swell.dz).mul(shoreEnergy);
      const oceanX = wx.add(totalDx);
      const oceanZ = wz.add(totalDz);
      const oceanR2 = oceanX.mul(oceanX).add(oceanZ.mul(oceanZ));
      // Courbure par défaut accordée à celle de l'îlot : même coefficient local près du micro-monde.
      // Le clamp évite que la calotte tombe trop bas aux très grandes distances.
      const matchedIslandSag = oceanR2.mul(oceanMatchedCurveStrength).negate().max(oceanPlanetCurveLimit);
      const sphericalCapSag = sqrt(oceanSphereRadiusU.mul(oceanSphereRadiusU).sub(oceanR2).max(float(1.0)))
        .sub(oceanSphereRadiusU)
        .max(oceanPlanetCurveLimit);
      const sphericalSag = mix(sphericalCapSag, matchedIslandSag, float(0.92)).mul(oceanCurveAmount);
      return vec3(oceanX, totalDy.add(sphericalSag), oceanZ);
    })();
    oceanMat.colorNode = Fn(() => {
      const wx = positionGeometry.x;
      const wz = positionGeometry.z;

      const slCoarse = sampleGrid(slopeGridCoarse, wx, wz, PATCH_COARSE);
      const slFine = sampleGrid(slopeGridFine, wx, wz, PATCH_FINE);
      const dCoarse = sampleGrid(dispGridCoarse, wx, wz, PATCH_COARSE);
      const dFine = sampleGrid(dispGridFine, wx, wz, PATCH_FINE);

      const gridJxx = slCoarse.z.add(slFine.z);
      const gridJzz = slCoarse.w.add(slFine.w);
      const gridJxz = dCoarse.w.add(dFine.w);

      const swellJ = buildGerstnerSwellJacobian(wx, wz);
      const totalJxx = gridJxx.add(swellJ.Jxx);
      const totalJzz = gridJzz.add(swellJ.Jzz);
      const totalJxz = gridJxz.add(swellJ.Jxz);

      const J = float(1).add(totalJxx).mul(float(1).add(totalJzz)).sub(totalJxz.mul(totalJxz));

      const foamEdgeUV = vec2(wx, wz);
      const ft = time.mul(0.15);

      // === Shared noise samples at multiple octaves ===
      // Shared noise samples (3 octaves instead of 5 for perf)
      const warpScale = fbmNoiseFreqU.mul(0.3);
      const warpX = mx_noise_float(foamEdgeUV.mul(warpScale).add(vec2(ft.mul(0.2), float(0)))).mul(1.8);
      const warpZ = mx_noise_float(foamEdgeUV.mul(warpScale).add(vec2(float(0), ft.mul(-0.15)))).mul(1.8);
      const warpedUV = foamEdgeUV.add(vec2(warpX, warpZ));

      const n1f = mx_noise_float(warpedUV.mul(fbmNoiseFreqU).add(vec2(ft.mul(0.35), ft.mul(-0.2))));
      const n2f = mx_noise_float(warpedUV.mul(fbmNoiseFreqU.mul(2.3)).add(vec2(ft.mul(-0.25), ft.mul(0.4))));
      const n3f = mx_noise_float(warpedUV.mul(fbmNoiseFreqU.mul(5.7)).add(vec2(ft.mul(0.5), ft.mul(0.15))));

      // Unified 3-octave FBM used as base for all foam noise types
      const baseFbm = n1f.mul(0.5).add(n2f.mul(0.3)).add(n3f.mul(0.2));

      // Derive all noise variants from shared samples (no extra texture fetches)
      // --- Type 0: Domain Warp ---
      const ridgedDW = float(1.0).sub(abs(baseFbm.mul(2.0).sub(1.0)));
      const noiseDomainWarp = mix(baseFbm, ridgedDW, float(0.4));

      // --- Type 1: Ridged Multifractal ---
      const r1 = float(1.0).sub(abs(n1f.mul(2.0).sub(1.0)));
      const r2 = float(1.0).sub(abs(n2f.mul(2.0).sub(1.0)));
      const r3 = float(1.0).sub(abs(n3f.mul(2.0).sub(1.0)));
      const noiseRidged = r1.mul(0.5).add(r2.mul(r1).mul(0.3)).add(r3.mul(r2).mul(0.2));

      // --- Type 2: Cellular / Voronoi-like (2 offset samples instead of 4) ---
      const cUV = foamEdgeUV.mul(fbmNoiseFreqU.mul(0.8)).add(vec2(ft.mul(0.2), ft.mul(-0.15)));
      const c1 = mx_noise_float(cUV);
      const c2 = mx_noise_float(cUV.add(vec2(0.33, 0.77)));
      const noiseCellular = float(1.0).sub(abs(c1.sub(c2))).mul(1.5);

      // --- Type 3: Billow ---
      const b1 = abs(n1f.mul(2.0).sub(1.0));
      const b2 = abs(n2f.mul(2.0).sub(1.0));
      const b3 = abs(n3f.mul(2.0).sub(1.0));
      const noiseBillow = b1.mul(0.5).add(b2.mul(0.3)).add(b3.mul(0.2));

      // --- Type 4: Swiss (2 octaves instead of 3) ---
      const sw1 = float(1.0).sub(abs(n1f.mul(2.0).sub(1.0)));
      const sw1sq = sw1.mul(sw1);
      const swWarp = warpedUV.add(vec2(sw1.mul(0.3), sw1.mul(-0.25)));
      const sw2raw = mx_noise_float(swWarp.mul(fbmNoiseFreqU.mul(2.5)).add(vec2(ft.mul(-0.3), ft.mul(0.2))));
      const sw2 = float(1.0).sub(abs(sw2raw.mul(2.0).sub(1.0))).mul(sw1sq);
      const noiseSwiss = sw1.mul(0.6).add(sw2.mul(0.4));

      // --- Type 5: Turbulent (3 octaves instead of 4) ---
      const t1 = abs(n1f.mul(2.0).sub(1.0));
      const t2 = abs(n2f.mul(2.0).sub(1.0));
      const t3 = abs(n3f.mul(2.0).sub(1.0));
      const noiseTurbulent = float(1.0).sub(t1.mul(0.45).add(t2.mul(0.35)).add(t3.mul(0.2)));

      // Select noise type via step functions
      const isType0 = smoothstep(float(-0.5), float(0.5), foamNoiseType.negate());
      const isType1 = smoothstep(float(0.5), float(1.5), foamNoiseType).mul(smoothstep(float(1.5), float(0.5), foamNoiseType));
      const isType2 = smoothstep(float(1.5), float(2.5), foamNoiseType).mul(smoothstep(float(2.5), float(1.5), foamNoiseType));
      const isType3 = smoothstep(float(2.5), float(3.5), foamNoiseType).mul(smoothstep(float(3.5), float(2.5), foamNoiseType));
      const isType4 = smoothstep(float(3.5), float(4.5), foamNoiseType).mul(smoothstep(float(4.5), float(3.5), foamNoiseType));
      const isType5 = smoothstep(float(4.5), float(5.5), foamNoiseType);

      const foamNoise = noiseDomainWarp.mul(isType0)
        .add(noiseRidged.mul(isType1))
        .add(noiseCellular.mul(isType2))
        .add(noiseBillow.mul(isType3))
        .add(noiseSwiss.mul(isType4))
        .add(noiseTurbulent.mul(isType5))
        .mul(fbmNoiseAmpU);
  
      const foamEdge = foamThreshold.add(foamNoise);
      const foamRaw = pow(smoothstep(foamEdge, foamEdge.sub(0.6), J), foamPower);
      const islandDistForFoamBase = sqrt(wx.mul(wx).add(wz.mul(wz)));
      const shoreAngle = atan(wz, wx);
      const shoreLobeA = sin(shoreAngle.mul(3.0).add(oceanIslandShapePhaseA));
      const shoreLobeB = sin(shoreAngle.mul(5.0).add(oceanIslandShapePhaseB)).mul(0.55);
      const shoreLobeC = sin(shoreAngle.mul(7.0).add(oceanIslandShapePhaseC)).mul(0.20);
      const shoreShapeScale = tslMax(float(0.72), float(1.0).add(shoreLobeA.add(shoreLobeB).mul(oceanIslandShapeRoundness)).add(shoreLobeC.mul(0.07)));
      const islandDistForFoam = islandDistForFoamBase.div(shoreShapeScale);
      const shoreEnergyColor = mix(
        oceanShoreMinimumWave,
        float(1.0),
        smoothstep(oceanIslandCutRadius, oceanIslandCutRadius.add(oceanShoreCalmDistance), islandDistForFoam)
      );
      const shoreInner = smoothstep(oceanIslandCutRadius.sub(oceanIslandCutFeather.mul(0.42)), oceanIslandCutRadius, islandDistForFoam);
      const shoreOuter = float(1.0).sub(smoothstep(oceanIslandCutRadius, oceanIslandCutRadius.add(oceanIslandCutFeather.mul(1.55)), islandDistForFoam));
      const shoreFoam = shoreInner.mul(shoreOuter).mul(oceanShoreFoamStrength);
      // La mousse de pleine mer est calmée près de l'île ; seul le liseré de rivage reste marqué.
      const foamMask = tslMax(clamp(foamRaw.mul(foamIntensity).mul(shoreEnergyColor), 0, 1), shoreFoam);

      const sX = slCoarse.x.add(slFine.x).mul(shoreEnergyColor);
      const sZ = slCoarse.y.add(slFine.y).mul(shoreEnergyColor);

      // Procedural normal-map detail via multi-octave noise derivatives
      const detailUV = vec2(wx, wz);
      const t = time.mul(0.3);
      const eps = float(0.01);
      const epsF = float(0.005);
      const epsVF = float(0.002);

      // ── Bump noise: 2 octaves with distance LOD ──
      const nFreq1 = float(0.8).mul(bumpFrequencyU);
      const nFreq2 = float(3.2).mul(bumpFrequencyU);
      const tOff1 = vec2(t.mul(0.4), t.mul(0.2));
      const tOff2 = vec2(t.mul(-0.6), t.mul(0.35));

      // Shared base noise samples (reused across all bump types)
      const bn1 = mx_noise_float(detailUV.mul(nFreq1).add(tOff1));
      const bn1px = mx_noise_float(detailUV.mul(nFreq1).add(vec2(eps, float(0))).add(tOff1));
      const bn1pz = mx_noise_float(detailUV.mul(nFreq1).add(vec2(float(0), eps)).add(tOff1));
      const bn2 = mx_noise_float(detailUV.mul(nFreq2).add(tOff2));
      const bn2px = mx_noise_float(detailUV.mul(nFreq2).add(vec2(epsF, float(0))).add(tOff2));
      const bn2pz = mx_noise_float(detailUV.mul(nFreq2).add(vec2(float(0), epsF)).add(tOff2));

      // Finite-difference derivatives from shared samples
      const d1x = bn1px.sub(bn1).mul(100.0);
      const d1z = bn1pz.sub(bn1).mul(100.0);
      const d2x = bn2px.sub(bn2).mul(200.0);
      const d2z = bn2pz.sub(bn2).mul(200.0);

      // === Type 0: Standard Perlin ===
      const perlinBumpX = d1x.mul(0.6).add(d2x.mul(0.4));
      const perlinBumpZ = d1z.mul(0.6).add(d2z.mul(0.4));

      // === Type 1: Ridged ===
      const rbn1 = float(1.0).sub(abs(bn1.mul(2.0).sub(1.0)));
      const rbn1px = float(1.0).sub(abs(bn1px.mul(2.0).sub(1.0)));
      const rbn1pz = float(1.0).sub(abs(bn1pz.mul(2.0).sub(1.0)));
      const rbn2 = float(1.0).sub(abs(bn2.mul(2.0).sub(1.0)));
      const rbn2px = float(1.0).sub(abs(bn2px.mul(2.0).sub(1.0)));
      const rbn2pz = float(1.0).sub(abs(bn2pz.mul(2.0).sub(1.0)));
      const ridgedBumpX = rbn1px.sub(rbn1).mul(100.0).mul(0.6).add(rbn2px.sub(rbn2).mul(200.0).mul(0.4));
      const ridgedBumpZ = rbn1pz.sub(rbn1).mul(100.0).mul(0.6).add(rbn2pz.sub(rbn2).mul(200.0).mul(0.4));

      // === Type 2: Domain Warped ===
      const dwWarpN = mx_noise_float(detailUV.mul(0.4).add(vec2(t.mul(0.15), t.mul(-0.1))));
      const dwOff = vec2(dwWarpN.mul(0.3), dwWarpN.mul(-0.25));
      const dwUV = detailUV.add(dwOff);
      const dw1 = mx_noise_float(dwUV.mul(nFreq1).add(tOff1));
      const dw1dx = mx_noise_float(dwUV.mul(nFreq1).add(vec2(eps, float(0))).add(tOff1)).sub(dw1).mul(100.0);
      const dw1dz = mx_noise_float(dwUV.mul(nFreq1).add(vec2(float(0), eps)).add(tOff1)).sub(dw1).mul(100.0);
      const dwBumpX = dw1dx;
      const dwBumpZ = dw1dz;

      // === Type 3: Billow ===
      const bbn1 = abs(bn1.mul(2.0).sub(1.0));
      const bbn1px = abs(bn1px.mul(2.0).sub(1.0));
      const bbn1pz = abs(bn1pz.mul(2.0).sub(1.0));
      const bbn2 = abs(bn2.mul(2.0).sub(1.0));
      const bbn2px = abs(bn2px.mul(2.0).sub(1.0));
      const bbn2pz = abs(bn2pz.mul(2.0).sub(1.0));
      const billowBumpX = bbn1px.sub(bbn1).mul(100.0).mul(0.6).add(bbn2px.sub(bbn2).mul(200.0).mul(0.4));
      const billowBumpZ = bbn1pz.sub(bbn1).mul(100.0).mul(0.6).add(bbn2pz.sub(bbn2).mul(200.0).mul(0.4));

      // === Type 4: Crosshatch ===
      const chFreq1 = nFreq1.mul(1.2);
      const chA1 = mx_noise_float(vec2(detailUV.x.mul(chFreq1).add(detailUV.y.mul(0.3)), detailUV.y.mul(chFreq1).sub(detailUV.x.mul(0.3))).add(tOff1));
      const chB1 = mx_noise_float(vec2(detailUV.x.mul(chFreq1).sub(detailUV.y.mul(0.5)), detailUV.y.mul(chFreq1).add(detailUV.x.mul(0.5))).add(tOff1.mul(-0.7)));
      const ch1 = chA1.mul(0.5).add(chB1.mul(0.5));
      const chA1px = mx_noise_float(vec2(detailUV.x.add(eps).mul(chFreq1).add(detailUV.y.mul(0.3)), detailUV.y.mul(chFreq1).sub(detailUV.x.add(eps).mul(0.3))).add(tOff1));
      const chB1px = mx_noise_float(vec2(detailUV.x.add(eps).mul(chFreq1).sub(detailUV.y.mul(0.5)), detailUV.y.mul(chFreq1).add(detailUV.x.add(eps).mul(0.5))).add(tOff1.mul(-0.7)));
      const ch1px = chA1px.mul(0.5).add(chB1px.mul(0.5));
      const chA1pz = mx_noise_float(vec2(detailUV.x.mul(chFreq1).add(detailUV.y.add(eps).mul(0.3)), detailUV.y.add(eps).mul(chFreq1).sub(detailUV.x.mul(0.3))).add(tOff1));
      const chB1pz = mx_noise_float(vec2(detailUV.x.mul(chFreq1).sub(detailUV.y.add(eps).mul(0.5)), detailUV.y.add(eps).mul(chFreq1).add(detailUV.x.mul(0.5))).add(tOff1.mul(-0.7)));
      const ch1pz = chA1pz.mul(0.5).add(chB1pz.mul(0.5));
      const crossBumpX = ch1px.sub(ch1).mul(100.0);
      const crossBumpZ = ch1pz.sub(ch1).mul(100.0);

      // === Type 5: FBM (4 octaves, distance-culled) ===
      const fbmFreqs = [nFreq1, nFreq1.mul(2.0), nFreq1.mul(4.0), nFreq1.mul(8.0)];
      const fbmWeights = [0.4, 0.3, 0.2, 0.1];
      const fbmTimeOffsets = [tOff1, tOff2, vec2(t.mul(0.55), t.mul(-0.3)), vec2(t.mul(-0.25), t.mul(0.6))];
      const fbmEpsArr = [eps, epsF, epsF, epsVF];
      const fbmDerScale = [100.0, 200.0, 200.0, 500.0];

      let fbmBumpXAcc = float(0);
      let fbmBumpZAcc = float(0);
      for (let oi = 0; oi < 4; oi++) {
        const fn0 = mx_noise_float(detailUV.mul(fbmFreqs[oi]).add(fbmTimeOffsets[oi]));
        const fnpx = mx_noise_float(detailUV.mul(fbmFreqs[oi]).add(vec2(fbmEpsArr[oi], float(0))).add(fbmTimeOffsets[oi]));
        const fnpz = mx_noise_float(detailUV.mul(fbmFreqs[oi]).add(vec2(float(0), fbmEpsArr[oi])).add(fbmTimeOffsets[oi]));
        fbmBumpXAcc = fbmBumpXAcc.add(fnpx.sub(fn0).mul(fbmDerScale[oi]).mul(fbmWeights[oi]));
        fbmBumpZAcc = fbmBumpZAcc.add(fnpz.sub(fn0).mul(fbmDerScale[oi]).mul(fbmWeights[oi]));
      }
      const fbmBumpX = fbmBumpXAcc;
      const fbmBumpZ = fbmBumpZAcc;

      // Select bump noise type
      const bIsT0 = smoothstep(float(-0.5), float(0.5), bumpNoiseType.negate());
      const bIsT1 = smoothstep(float(0.5), float(1.5), bumpNoiseType).mul(smoothstep(float(1.5), float(0.5), bumpNoiseType));
      const bIsT2 = smoothstep(float(1.5), float(2.5), bumpNoiseType).mul(smoothstep(float(2.5), float(1.5), bumpNoiseType));
      const bIsT3 = smoothstep(float(2.5), float(3.5), bumpNoiseType).mul(smoothstep(float(3.5), float(2.5), bumpNoiseType));
      const bIsT4 = smoothstep(float(3.5), float(4.5), bumpNoiseType).mul(smoothstep(float(4.5), float(3.5), bumpNoiseType));
      const bIsT5 = smoothstep(float(4.5), float(5.5), bumpNoiseType);

      const selectedBumpX = perlinBumpX.mul(bIsT0)
        .add(ridgedBumpX.mul(bIsT1))
        .add(dwBumpX.mul(bIsT2))
        .add(billowBumpX.mul(bIsT3))
        .add(crossBumpX.mul(bIsT4))
        .add(fbmBumpX.mul(bIsT5));
      const selectedBumpZ = perlinBumpZ.mul(bIsT0)
        .add(ridgedBumpZ.mul(bIsT1))
        .add(dwBumpZ.mul(bIsT2))
        .add(billowBumpZ.mul(bIsT3))
        .add(crossBumpZ.mul(bIsT4))
        .add(fbmBumpZ.mul(bIsT5));

      // Blend noise normals - attenuate with distance for performance perception  
      const distFade = smoothstep(float(800.0), float(50.0), positionWorld.sub(cameraPosition).length());
      const bumpStrength = bumpStrengthU.mul(distFade).mul(shoreEnergyColor);
      const bumpX = selectedBumpX.mul(bumpStrength);
      const bumpZ = selectedBumpZ.mul(bumpStrength);

      const N_vec = normalize(vec3(sX.negate().add(bumpX), float(1), sZ.negate().add(bumpZ)));

      const waveHeight = dCoarse.y.add(dFine.y);

      const V = normalize(cameraPosition.sub(positionWorld));
      const L = normalize(sunDir);
      const NdotL = abs(dot(N_vec, L)).max(float(0.15));
      const NdotV = abs(dot(N_vec, V)).max(float(0.05));

      const F = pow(float(1.0).sub(NdotV), fresnelPower)
        .mul(fresnelStrength)
        .mul(float(1).sub(foamMask.mul(0.8)));

      const deepColor = vec3(0.0, 0.03, 0.12);
      const shallowColor = vec3(0.0, 0.08, 0.18);
      const elevationMask = smoothstep(float(-4.0), float(6.0), waveHeight);
      const baseAlbedo = mix(deepColor, waterColor, elevationMask);
      const depthTint = mix(shallowColor, baseAlbedo, smoothstep(float(-2.0), float(3.0), waveHeight));

      // Height-based color gradient (4-stop)
      const hNorm = smoothstep(float(-5.0), float(8.0), waveHeight);
      const hColor1 = mix(heightColorLow, heightColorMid, smoothstep(float(0.0), float(0.33), hNorm));
      const hColor2 = mix(hColor1, heightColorHigh, smoothstep(float(0.33), float(0.66), hNorm));
      const hColor3 = mix(hColor2, heightColorPeak, smoothstep(float(0.66), float(1.0), hNorm));
      const albedo = mix(depthTint, hColor3, heightColorIntensity);

      const H = normalize(L.add(V));
      const NdotH = tslMax(dot(N_vec, H), float(0));
      const shininess = mix(float(800), float(4), roughness_u);
      const spec = pow(NdotH, shininess).mul(1.2).mul(float(1).sub(foamMask));

      // Sun disc reflection on water
      const R = normalize(L.sub(N_vec.mul(dot(L, N_vec).mul(2.0))));
      const sunReflect = pow(tslMax(dot(V, R), float(0)), float(512)).mul(3.0).mul(float(1).sub(foamMask));

      // ── Advanced Subsurface Scattering ──
      // 1. Forward-scatter through wave crests (backlit translucency)
      const sssForwardDir = normalize(L.add(N_vec.mul(0.4)));
      const sssForward = pow(tslMax(dot(V, sssForwardDir.negate()), float(0)), float(5)).mul(0.8);
  
      // 2. View-dependent SSS at grazing angles (subtle)
      const sssViewGraze = pow(float(1.0).sub(NdotV), float(4)).mul(0.2);
  
      // 3. Height-based translucency - thin wave crests scatter more light
      const sssCrestMask = smoothstep(float(0.0), float(6.0), waveHeight);
      const sssThickness = smoothstep(float(2.0), float(7.0), waveHeight).mul(0.5);
  
      // 4. Ambient SSS - very subtle volume contribution
      const sssAmbient = float(0.06).mul(sssCrestMask);
  
      // 5. Backlit SSS - reduced for realism
      const sssBacklit = pow(tslMax(dot(V, L.negate()), float(0)), float(4)).mul(0.5);
  
      // 6. Wave-curvature enhanced SSS - tighter range
      const sssCurvature = smoothstep(float(0.4), float(-0.3), J).mul(0.3);
  
      // Combine all SSS components with reduced overall contribution
      const sssTotal = sssForward.add(sssViewGraze).add(sssBacklit).add(sssAmbient).add(sssCurvature);
      const sssModulated = sssTotal.mul(sssCrestMask.mul(0.7).add(sssThickness)).mul(sssIntensity);
  
      // SSS color: more blue-teal, less vivid green; deeper blue dominates
      const sssShallowTint = sssColor;
      const sssDeepColor = vec3(0.01, 0.04, 0.14);
      const sssDepthBlend = smoothstep(float(-1.0), float(5.0), waveHeight);
      const sssMixedColor = mix(sssDeepColor, sssShallowTint, sssDepthBlend.mul(depthBlend));
      const sssFinalColor = sssMixedColor.mul(sssModulated);
  
      const sss = sssFinalColor;

      // Atmospheric fog / distance fade
      const distToCamera = positionWorld.sub(cameraPosition).length();
      const fogFactor = smoothstep(fogNear, fogFar, distToCamera).mul(fogDensity);
      const fogColor = fogColorU;

      const foamLit = foamColorU.mul(NdotL.mul(0.4).add(0.7));
      const foamDetailNoise = mx_noise_float(vec2(wx, wz).mul(8.0)).mul(0.1).add(0.9);
      const foamFinal = foamLit.mul(foamDetailNoise);

      // ── PBR Reflection via procedural sky ──
      // Reflect view vector around perturbed normal
      const reflectDir = V.negate().add(N_vec.mul(dot(V, N_vec).mul(2.0)));
      // Add bump-based distortion for wavy reflections
      const distortedReflect = normalize(reflectDir.add(
        vec3(bumpX.mul(reflectionDistortion.mul(10.0)), float(0), bumpZ.mul(reflectionDistortion.mul(10.0)))
      ));

      // Procedural sky reflection matching the atmospheric scattering sky
      const reflY = distortedReflect.y;
      const reflYClamped = tslMax(reflY, float(0.0));

      // Rayleigh-like sky gradient for reflection
      const sunDotRefl = tslMax(dot(distortedReflect, L), float(0));

      // Sky zenith to horizon gradient
      const skyZenith = vec3(0.15, 0.35, 0.75);  // deep blue zenith
      const skyHorizon = vec3(0.55, 0.70, 0.90);  // pale blue horizon
      const skyDawn = vec3(0.85, 0.55, 0.35);     // warm horizon at low sun

      // Sun elevation factor (low sun = warmer horizon)
      const sunElFactor = smoothstep(float(0.0), float(0.25), L.y);
      const horizonColor = mix(skyDawn, skyHorizon, sunElFactor);

      // Base sky color based on reflection elevation
      const skyGrad = mix(horizonColor, skyZenith, smoothstep(float(0.0), float(0.6), reflYClamped));

      // Mie-like sun glow around sun direction
      const mieGlow = pow(sunDotRefl, float(64)).mul(1.2);
      const mieHalo = pow(sunDotRefl, float(8)).mul(0.25);
      const sunWarmColor = vec3(1.0, 0.85, 0.55);
      const sunWhiteColor = vec3(1.0, 0.95, 0.85);
      const mieContrib = sunWarmColor.mul(mieHalo).add(sunWhiteColor.mul(mieGlow));

      // Sun disc specular (sharp, bright)
      const sunDiscGlow = pow(sunDotRefl, float(512)).mul(5.0).mul(float(1).sub(roughness_u));
      const sunDiscHalo = pow(sunDotRefl, float(128)).mul(1.5).mul(float(1).sub(roughness_u.mul(0.5)));
      const sunReflColor = vec3(1.0, 0.92, 0.75);

      // Below-horizon: use deep water color instead of near-black
      const belowHorizonColor = mix(
        vec3(0.04, 0.08, 0.18),
        vec3(0.08, 0.15, 0.28),
        smoothstep(float(-0.5), float(0.0), reflY)
      );
      // Wider, softer horizon transition to avoid harsh black band
      const horizonBlend = smoothstep(float(-0.25), float(0.12), reflY);

      // Combine sky reflection components
      const skyReflection = mix(belowHorizonColor, skyGrad.add(mieContrib), horizonBlend);

      // Cloud reflection: single noise sample for approximate cloud reflections
      const cloudReflUV = vec2(distortedReflect.x, distortedReflect.z).mul(0.5).div(tslMax(reflYClamped, float(0.05)));
      const cloudReflScale = float(0.00015);
      const crt = time.mul(cloudSpeed);
      const cloudReflNoise = mx_noise_float(cloudReflUV.mul(cloudReflScale.mul(10000.0)).add(vec2(crt, crt.mul(0.3))));
      const cloudReflThresh = float(1.0).sub(cloudCoverage);
      const cloudReflMask = smoothstep(cloudReflThresh, cloudReflThresh.add(0.2), cloudReflNoise).mul(0.35);
      const cloudReflColor = vec3(0.85, 0.88, 0.92);

      // Mix cloud reflection into sky reflection (only above horizon)
      const envReflection = mix(skyReflection, cloudReflColor, cloudReflMask.mul(horizonBlend))
        .add(sunReflColor.mul(sunDiscGlow.add(sunDiscHalo)));

      // PBR roughness-based reflection fade - ensure minimum reflection brightness
      const roughFade = float(1.0).sub(roughness_u.mul(0.5));
      // Add a small ambient reflection floor to prevent pure black
      const ambientReflection = mix(
        vec3(0.03, 0.06, 0.12),
        vec3(0.06, 0.10, 0.18),
        smoothstep(float(-0.3), float(0.2), reflY)
      );
      const finalReflection = tslMax(envReflection.mul(roughFade), ambientReflection);

      // Schlick Fresnel with F0 for water (IOR ~1.33 -> F0 ~0.02)
      // Slightly higher F0 to prevent dark patches at steep angles
      const f0 = float(0.04);
      const fresnelSchlick = f0.add(float(1.0).sub(f0).mul(pow(float(1.0).sub(NdotV), float(5.0))));
      const reflectionMix = fresnelSchlick.mul(reflectionStrength).mul(float(1).sub(foamMask.mul(0.7)));
  
      // PBR energy conservation: reduce diffuse as reflections increase
      const diffuseWeight = float(1.0).sub(reflectionMix);
      const diffuseColor = albedo.mul(NdotL.mul(0.8).add(0.2)).mul(diffuseWeight);
  
      // Combine diffuse + reflection + specular + SSS
      const oceanColor = diffuseColor
        .add(finalReflection.mul(reflectionMix))
        .add(vec3(spec).mul(float(1).sub(roughness_u)))
        .add(sss);

      const litOcean = mix(oceanColor, foamFinal, foamMask);
      return mix(litOcean, fogColor, fogFactor);
    })();


    // Découpe douce sous l'îlot : l'océan ne traverse plus visuellement la terre/herbe.
    // Le liseré de mousse est ajouté dans colorNode autour de cette zone.
    oceanMat.opacityNode = Fn(() => {
      const wx = positionGeometry.x;
      const wz = positionGeometry.z;
      const d = sqrt(wx.mul(wx).add(wz.mul(wz)));
      const shoreAngle = atan(wz, wx);
      const shoreLobeA = sin(shoreAngle.mul(3.0).add(oceanIslandShapePhaseA));
      const shoreLobeB = sin(shoreAngle.mul(5.0).add(oceanIslandShapePhaseB)).mul(0.55);
      const shoreLobeC = sin(shoreAngle.mul(7.0).add(oceanIslandShapePhaseC)).mul(0.20);
      const shoreShapeScale = tslMax(float(0.72), float(1.0).add(shoreLobeA.add(shoreLobeB).mul(oceanIslandShapeRoundness)).add(shoreLobeC.mul(0.07)));
      const organicDist = d.div(shoreShapeScale);
      const islandMask = smoothstep(oceanIslandCutRadius.sub(oceanIslandCutFeather.mul(0.42)), oceanIslandCutRadius.add(oceanIslandCutFeather.mul(0.95)), organicDist);
      // Fait disparaître progressivement le bord sphérisé tout en suivant le path organique du sol.
      const edgeFade = float(1.0).sub(smoothstep(float(OCEAN_EDGE_FADE_START), float(OCEAN_EDGE_FADE_END), d));
      return islandMask.mul(edgeFade);
    })();
    oceanMat.transparent = true;
    oceanMat.depthWrite = false;
    oceanMat.depthTest = true;
    const oceanMesh = new THREE.Mesh(oceanGeo, oceanMat);
    oceanMesh.frustumCulled = false;
    // Eau rapprochée de la terre : l'écart vertical est réduit sans toucher à la croissance.
    oceanMesh.position.y = -0.92;
    scene.add(oceanMesh);
    // Ancien fond sphérique supprimé : l'océan est maintenant le maillage Alejandro lui-même,
    // sphérisé en shader. Aucun volume bleu supplémentaire n'est ajouté sous la scène.
    const oceanPlanet = new THREE.Group();
    oceanPlanet.visible = false;
    oceanPlanet.frustumCulled = false;
    const oceanPlanetMat = { color: { set: () => {} } };

    // Flat floor optionnel : remplace la grande couche noire/"autre océan" par un sol plat contrôlable.
    // Il reste sous l'île et sous l'eau pour ne pas masquer les couches courbées.
    const flatFloorState = { enabled: false, color: '#02050c', y: -12.0, size: 3600 };
    const flatFloorGeo = new THREE.PlaneGeometry(flatFloorState.size, flatFloorState.size, 1, 1);
    flatFloorGeo.rotateX(-Math.PI / 2);
    const flatFloorMat = new THREE.MeshBasicMaterial({
      color: new THREE.Color(flatFloorState.color),
      side: THREE.DoubleSide,
      depthWrite: false,
      depthTest: true,
      toneMapped: false
    });
    const flatFloor = new THREE.Mesh(flatFloorGeo, flatFloorMat);
    flatFloor.position.y = flatFloorState.y;
    flatFloor.visible = flatFloorState.enabled;
    flatFloor.renderOrder = -20;
    flatFloor.frustumCulled = false;
    scene.add(flatFloor);

    // Cycles naturels : marée douce pilotée par le temps + progression Fibonacci,
    // et synchronisée à l'énergie du vent. Elle anime le niveau d'eau sans toucher à la croissance de l'arbre.
    const PHI = (1 + Math.sqrt(5)) / 2;
    const oceanCycleState = { tideEnabled: true, baseLevel: oceanMesh.position.y, tideAmplitude: 0.045, tideSpeed: 0.24, manualTideControls: false };
    const oceanManualOverrides = {
      tideAmplitude: false,
      shoreCalmDistance: false,
      shoreFoamStrength: false,
      waterLevel: false,
      shoreFade: false
    };

    // ───── Microclimat global ─────
    // Une seule source de vérité pour le vent, l'air, la lumière, la marée,
    // l'océan, l'herbe, les feuilles et les particules de rivage.
    // Par défaut, le microclimat suit l'horloge locale du navigateur.
    const microClimate = {
      autoBrowserClock: true,
      liveWeatherEnabled: false,
      liveWeatherStatus: 'horloge navigateur',
      liveWeatherError: '',
      liveWeatherTime: '',
      liveWeatherCode: null,
      liveWeatherApparent: null,
      liveWeatherIsDay: null,
      locationLabel: '',
      latitude: null,
      longitude: null,
      windSpeed: params.windSpeed,
      windDirection: Math.PI * 0.25,
      windForce: 0.22,
      airTemperature: 18,
      humidity: 62,
      pressure: 1013,
      cloudCover: 0.45,
      rain: 0.0,
      daylight: 1.0,
      season01: 0.0,
      time01: 0.5,
      tide01: 0.0,
      lastSpectrumUpdate: 0,
      lastLiveWeatherFetch: 0
    };

    const vrPlayableState = {
      // Désactivé par défaut sur desktop, mobile et tablette.
      // La limite réelle 18 m² reste disponible pour une vraie session VR.
      enabled: false,
      visible: false,
      radius: Math.sqrt(18 / Math.PI)
    };

    const vrRuntimeState = {
      active: false,
      hideHudInVR: true,
      viewMode: '3e personne',
      comfortPixelRatio: MICROCOSM_RUNTIME_PERF.vrComfortPixelRatio,
      normalPixelRatio: Math.min(window.devicePixelRatio || 1, MICROCOSM_RUNTIME_PERF.maxPixelRatio),
      vrScale: 1.0,
      reduceParticles: true,
      reduceOceanWhileVR: true,
      saved: {
        grassDensity: null,
        grassDensityMultiplier: null,
        shoreOpacity: null,
        foamIntensity: null,
        heightScale: null,
        choppiness: null
      }
    };

    const shoreFadeState = {
      enabled: true,
      density: 1650,
      width: 1.18,
      opacity: 0.46,
      color: '#e9f0d2'
    };

    const premiumProductState = {
      localTestingOverride: false,
      maxFreeClicksPerDay: 1,
      windsUnlocked: false,
      lifetimeUnlocked: false,
      windsClicksPerDay: 5
    };

    // Racine vivante de l'îlot : terre + herbe + arbre + graine + petites interactions.
    // La croissance de l'arbre reste inchangée ; on anime uniquement le micro-monde qui le porte.
    const islandRoot = new THREE.Group();
    islandRoot.name = 'MicrocosmFloatingIslandRoot';
    scene.add(islandRoot);
    const islandMotionState = {
      // Désactivé par défaut : plus de flottement séparé qui donne l'impression
      // que l'îlot dérive hors du système. Si on l'active, il utilise le microclimat global.
      enabled: false,
      bobAmplitude: 0.0,
      tiltAmplitude: 0.0,
      driftAmplitude: 0.0,
      vrComfort: true,
      lastTide: 0
    };
    function updateFloatingIslandMotion(elapsed, windRT) {
      if (!islandMotionState.enabled) {
        islandRoot.position.set(0, 0, 0);
        islandRoot.rotation.set(0, 0, 0);
        islandRoot.updateMatrixWorld(true);
        return;
      }
      const p = THREE.MathUtils.clamp(growthProgress, 0, 1);
      const dayPhase = (growthClicksTree / FIB_GROWTH_TOTAL_CLICKS_TREE) * Math.PI * 2 * PHI;
      const wind01 = THREE.MathUtils.clamp(globalWind.speed / 50, 0, 1);
      const comfort = islandMotionState.vrComfort || renderer.xr.isPresenting ? 0.42 : 1.0;
      const ageInertia = THREE.MathUtils.lerp(0.72, 1.0, p);
      const phaseA = elapsed * oceanCycleState.tideSpeed * PHI + dayPhase;
      const phaseB = elapsed * oceanCycleState.tideSpeed * (PHI * 0.63) + dayPhase * 0.37;
      const waveEnergy = (0.70 + wind01 * 0.55) * ageInertia;

      const bob = Math.sin(phaseA + 0.55) * islandMotionState.bobAmplitude * waveEnergy * comfort
        + islandMotionState.lastTide * 0.22;
      islandRoot.position.y = bob;
      islandRoot.position.x = Math.sin(phaseB) * islandMotionState.driftAmplitude * windRT.dirX * comfort;
      islandRoot.position.z = Math.cos(phaseB * 0.91) * islandMotionState.driftAmplitude * windRT.dirZ * comfort;
      islandRoot.rotation.x = Math.sin(phaseB + 1.1) * islandMotionState.tiltAmplitude * (0.7 + wind01) * comfort;
      islandRoot.rotation.z = Math.cos(phaseB * 0.87 - 0.4) * islandMotionState.tiltAmplitude * (0.7 + wind01) * comfort;
      islandRoot.rotation.y = Math.sin(phaseA * 0.21) * islandMotionState.tiltAmplitude * 0.30 * comfort;
      islandRoot.updateMatrixWorld(true);
    }

    // ───── Herbe (grass) ─────
    // Herbe unique par utilisateur/nouvelle partie : on garde un nombre d'instances raisonnable
    // pour la fluidité, mais on agrandit vraiment la zone et sa forme visuelle.
    const BLADE_COUNT = MICROCOSM_RUNTIME_PERF.grassBladeInstances;
    const GRASS_GRID = MICROCOSM_RUNTIME_PERF.grassGrid;
    const FIELD_SIZE = 58;
    // Dark blue background to allow the ocean and horizon to show through
    const BACKGROUND_HEX = '#0a1628';
    // Dark navy ground to match the ocean’s palette
    const GROUND_HEX = '#041e42';
    const BLADE_BASE_HEX = '#252d0b';
    const BLADE_TIP_HEX = '#97d638';
    // Scene background for grass is already integrated in global scene background

    const GRASS_USER_SEED_KEY = 'microcosm-grass-user-seed-v1';
    const GRASS_GAME_SEED_KEY = 'microcosm-grass-game-seed-v1';
    function createGrassSeedString(prefix = 'grass') {
      const bytes = new Uint32Array(4);
      if (window.crypto?.getRandomValues) {
        window.crypto.getRandomValues(bytes);
      } else {
        const now = Date.now();
        for (let i = 0; i < bytes.length; i++) bytes[i] = ((now + i * 2654435761) ^ Math.floor(Math.random() * 0xffffffff)) >>> 0;
      }
      return `${prefix}-${Date.now().toString(36)}-${Array.from(bytes, n => n.toString(36)).join('-')}`;
    }
    function readGrassStorageSeed(key, prefix) {
      try {
        let value = localStorage.getItem(key);
        if (!value) {
          value = createGrassSeedString(prefix);
          localStorage.setItem(key, value);
        }
        return value;
      } catch (_) {
        return createGrassSeedString(prefix);
      }
    }
    function hashGrassSeedString(str) {
      let h = 2166136261 >>> 0;
      for (let i = 0; i < str.length; i++) {
        h ^= str.charCodeAt(i);
        h = Math.imul(h, 16777619) >>> 0;
      }
      return h >>> 0;
    }
    function grassSeeded01(a, b = 0) {
      let h = (grassSeedBase ^ Math.imul((a * 1000003) | 0, 0x9e3779b1) ^ Math.imul((b * 9176) | 0, 0x85ebca6b)) >>> 0;
      h ^= h >>> 16;
      h = Math.imul(h, 0x7feb352d) >>> 0;
      h ^= h >>> 15;
      h = Math.imul(h, 0x846ca68b) >>> 0;
      h ^= h >>> 16;
      return (h >>> 0) / 4294967296;
    }
    const grassUserSeedString = readGrassStorageSeed(GRASS_USER_SEED_KEY, 'grass-user');
    let grassGameSeedString = readGrassStorageSeed(GRASS_GAME_SEED_KEY, 'grass-game');
    let grassSeedBase = hashGrassSeedString(`${grassUserSeedString}|${grassGameSeedString}`);
    function newGrassGameSeed() {
      grassGameSeedString = createGrassSeedString('grass-game');
      try { localStorage.setItem(GRASS_GAME_SEED_KEY, grassGameSeedString); } catch (_) {}
      grassSeedBase = hashGrassSeedString(`${grassUserSeedString}|${grassGameSeedString}`);
      applyGrassSeedUniforms();
      if (typeof groundRadiusGrass !== 'undefined' && typeof rebuildIslandEarth === 'function') rebuildIslandEarth(groundRadiusGrass.value);
    }

    const grassSeedConfig = {
      x: grassSeeded01(11, 3) * 1000,
      z: grassSeeded01(17, 5) * 1000,
      phaseA: grassSeeded01(23, 7) * Math.PI * 2,
      phaseB: grassSeeded01(29, 9) * Math.PI * 2,
      size: 0.92 + grassSeeded01(31, 11) * 0.20,
      roundness: 0.11 + grassSeeded01(37, 13) * 0.13,
    };
    // GPU storage buffers for grass
    const bladeData = instancedArray(BLADE_COUNT, 'vec4');
    const bendState = instancedArray(BLADE_COUNT, 'vec4');
    const bladeBound = instancedArray(BLADE_COUNT, 'float');
    // Uniforms for grass
    const mouseWorld = uniform(new THREE.Vector3(99999, 0, 99999));
    const mouseRadius = uniform(2.2);
    const mouseStrength = uniform(1.8);
    const icoWorld = uniform(new THREE.Vector3(99999, 0, 99999));
    const icoRadius = uniform(2.5);
    const icoStrengthU = uniform(2.0);
    // V8.18 · herbe plus dense par défaut.
    // V8.25 · le slider développeur “Densité générée ×” multiplie vraiment le pool visible.
    // Desktop : pool GPU ×10. Mobile : pool plafonné pour éviter le gel instantané.
    const grassDensity = uniform(1.86);
    const grassDensityMultiplier = uniform(1.0);
    const grassMaxDensityMultiplier = uniform(MICROCOSM_RUNTIME_PERF.grassMaxDensityMultiplier);
    let grassDensityManualOverride = false;
    const windAmplitude = uniform(0.22);
    const grassSeedX = uniform(grassSeedConfig.x);
    const grassSeedZ = uniform(grassSeedConfig.z);
    const grassShapePhaseA = uniform(grassSeedConfig.phaseA);
    const grassShapePhaseB = uniform(grassSeedConfig.phaseB);
    const grassShapeRoundness = uniform(grassSeedConfig.roundness);
    const grassSizeFactor = uniform(grassSeedConfig.size);
    const grassVisibleRadius = uniform(15.0 * grassSeedConfig.size);
    const grassMaxRadius = uniform(34.0);
    oceanIslandShapePhaseA.value = grassSeedConfig.phaseA;
    oceanIslandShapePhaseB.value = grassSeedConfig.phaseB;
    oceanIslandShapePhaseC.value = grassSeedConfig.x * 0.013;
    oceanIslandShapeRoundness.value = grassSeedConfig.roundness;
    // Même uniform que l'océan : la terre et l'herbe gardent le même angle de courbure.
    const islandCurveStrength = uniform(0.0032);
    function applyGrassSeedUniforms() {
      grassSeedConfig.x = grassSeeded01(11, 3) * 1000;
      grassSeedConfig.z = grassSeeded01(17, 5) * 1000;
      grassSeedConfig.phaseA = grassSeeded01(23, 7) * Math.PI * 2;
      grassSeedConfig.phaseB = grassSeeded01(29, 9) * Math.PI * 2;
      grassSeedConfig.size = 0.92 + grassSeeded01(31, 11) * 0.20;
      grassSeedConfig.roundness = 0.11 + grassSeeded01(37, 13) * 0.13;
      grassSeedX.value = grassSeedConfig.x;
      grassSeedZ.value = grassSeedConfig.z;
      grassShapePhaseA.value = grassSeedConfig.phaseA;
      grassShapePhaseB.value = grassSeedConfig.phaseB;
      grassShapeRoundness.value = grassSeedConfig.roundness;
      oceanIslandShapePhaseA.value = grassSeedConfig.phaseA;
      oceanIslandShapePhaseB.value = grassSeedConfig.phaseB;
      oceanIslandShapePhaseC.value = grassSeedConfig.x * 0.013;
      oceanIslandShapeRoundness.value = grassSeedConfig.roundness;
      grassSizeFactor.value = grassSeedConfig.size;
    }

    // Vent global synchronisé : océan, nuages, herbe, branches fines, feuilles, fleurs et feuilles tombantes.
    const globalWind = {
      speed: params.windSpeed,
      direction: Math.PI * 0.25,
      force: windAmplitude.value,
    };
    function syncGlobalWind({ speed, direction, force, rebuildOcean = false, fromMicroClimate = false } = {}) {
      if (Number.isFinite(speed)) {
        globalWind.speed = speed;
        params.windSpeed = speed;
        windSpeedU.value = speed;
        if (!fromMicroClimate) microClimate.windSpeed = speed;
      }
      if (Number.isFinite(direction)) {
        globalWind.direction = direction;
        windDir.value = direction;
        if (!fromMicroClimate) microClimate.windDirection = direction;
      }
      if (Number.isFinite(force)) {
        globalWind.force = force;
        windAmplitude.value = force;
        if (!fromMicroClimate) microClimate.windForce = force;
      }
      const speed01 = THREE.MathUtils.clamp(globalWind.speed / 50, 0, 1);
      // Les nuages, l'herbe, les feuilles, l'océan et les particules utilisent cette même énergie.
      cloudSpeed.value = THREE.MathUtils.lerp(0.0025, 0.024, speed01);
      if (rebuildOcean) renderer.compute(initWaves);
    }
    function getWindRuntime() {
      const speed01 = THREE.MathUtils.clamp(globalWind.speed / 50, 0, 1);
      const force = THREE.MathUtils.clamp(globalWind.force, 0, 1);
      return {
        speedMul: 0.45 + speed01 * 2.75,
        branchAmp: 0.35 + force * 2.6,
        leafAmp: 0.35 + force * 2.35,
        fallAmp: 0.25 + force * 2.15,
        dirX: Math.cos(globalWind.direction),
        dirZ: Math.sin(globalWind.direction),
      };
    }
    syncGlobalWind();

    function dayOfYearFromDate(date) {
      const start = new Date(date.getFullYear(), 0, 0);
      return Math.floor((date - start) / 86400000);
    }

    function smoothPulse01(x, a, b, c, d) {
      return THREE.MathUtils.smoothstep(x, a, b) * (1.0 - THREE.MathUtils.smoothstep(x, c, d));
    }

    function updateMicroClimateFromBrowserClock(now = new Date()) {
      if (!microClimate.autoBrowserClock || microClimate.liveWeatherEnabled) return;

      const hour = now.getHours() + now.getMinutes() / 60 + now.getSeconds() / 3600;
      const time01 = hour / 24;
      const day = dayOfYearFromDate(now);
      const season01 = day / 365;
      const yearPhase = season01 * Math.PI * 2;
      const dayPhase = time01 * Math.PI * 2;

      const daylight = smoothPulse01(hour, 5.2, 8.1, 18.2, 22.1);
      const seasonWarmth = Math.sin(yearPhase - Math.PI * 0.48) * 0.5 + 0.5;
      const diurnalWarmth = Math.sin(dayPhase - Math.PI * 0.55) * 0.5 + 0.5;
      const slowWeather = Math.sin(yearPhase * 7.0 + Math.cos(dayPhase * 0.5)) * 0.5 + 0.5;

      microClimate.time01 = time01;
      microClimate.season01 = season01;
      microClimate.daylight = daylight;
      microClimate.airTemperature = THREE.MathUtils.lerp(7, 25, seasonWarmth) + THREE.MathUtils.lerp(-2.5, 3.8, diurnalWarmth);
      microClimate.humidity = THREE.MathUtils.clamp(72 - (microClimate.airTemperature - 12) * 1.3 + slowWeather * 24, 30, 96);
      microClimate.pressure = 1004 + (1.0 - slowWeather) * 18;
      microClimate.cloudCover = THREE.MathUtils.clamp(0.18 + slowWeather * 0.68 + (microClimate.humidity - 55) * 0.006, 0.08, 0.96);
      microClimate.rain = THREE.MathUtils.clamp((microClimate.cloudCover - 0.62) * 1.55 + (microClimate.humidity - 78) * 0.018, 0, 1);
      microClimate.windSpeed = THREE.MathUtils.clamp(5.2 + slowWeather * 13.0 + microClimate.rain * 4.0, 2.0, 28.0);
      microClimate.windDirection = (Math.PI * 0.25 + yearPhase * 0.35 + Math.sin(dayPhase * 0.7) * 0.55) % (Math.PI * 2);
      microClimate.windForce = THREE.MathUtils.clamp(0.13 + microClimate.windSpeed / 45 + microClimate.rain * 0.12, 0.05, 0.86);
      microClimate.tide01 = Math.sin((now.getTime() / 1000) * 0.00018 * PHI + season01 * Math.PI * 4.0);
      microClimate.liveWeatherStatus = 'horloge navigateur';
    }

    function applyMicroClimateToWorld(elapsed) {
      syncMicrocosmRealtimeLight(new Date());
      applyRealtimeSkyPalette();
      const wind01 = THREE.MathUtils.clamp(microClimate.windSpeed / 35, 0, 1);
      const humidity01 = THREE.MathUtils.clamp(microClimate.humidity / 100, 0, 1);
      const cloud01 = THREE.MathUtils.clamp(microClimate.cloudCover, 0, 1);
      const rain01 = THREE.MathUtils.clamp(microClimate.rain, 0, 1);
      const temp01 = THREE.MathUtils.clamp((microClimate.airTemperature + 5) / 35, 0, 1);

      syncGlobalWind({
        speed: microClimate.windSpeed,
        direction: microClimate.windDirection,
        force: microClimate.windForce,
        fromMicroClimate: true
      });

      // Air / lumière
      cloudCoverage.value = cloud01;
      cloudDensity.value = THREE.MathUtils.lerp(0.38, 1.12, cloud01);
      params.sunElevation = THREE.MathUtils.lerp(-0.8, 27.0, microClimate.daylight);
      params.sunAzimuth = (microClimate.time01 * 360 + 105) % 360;
      updateSun(params.sunElevation, params.sunAzimuth);

      // Eau : les réglages océaniques restent dérivés du même vent et de la même humidité.
      const nextChop = THREE.MathUtils.lerp(0.34, 1.05, wind01) + rain01 * 0.10;
      const nextHeight = THREE.MathUtils.lerp(0.32, 0.82, wind01) + rain01 * 0.12;
      const nextBump = THREE.MathUtils.lerp(0.075, 0.17, wind01) + rain01 * 0.035;
      choppiness.value = THREE.MathUtils.lerp(choppiness.value, nextChop, 0.018);
      heightScale.value = THREE.MathUtils.lerp(heightScale.value, nextHeight, 0.012);
      bumpStrengthU.value = THREE.MathUtils.lerp(bumpStrengthU.value, nextBump, 0.018);
      foamIntensity.value = THREE.MathUtils.lerp(foamIntensity.value, THREE.MathUtils.lerp(0.62, 1.45, wind01 + rain01 * 0.35), 0.018);
      if (!oceanCycleState.manualTideControls && !oceanManualOverrides.tideAmplitude) {
        oceanCycleState.tideAmplitude = THREE.MathUtils.lerp(0.018, 0.075, Math.abs(microClimate.tide01) * 0.6 + wind01 * 0.4);
        oceanCycleState.tideSpeed = THREE.MathUtils.lerp(0.16, 0.34, wind01);
      }

      // Végétation : le même air règle le mouvement et la vigueur visuelle.
      windAmplitude.value = THREE.MathUtils.lerp(0.09, 0.34, wind01) + rain01 * 0.04;
      if (!grassDensityManualOverride) {
        grassDensity.value = THREE.MathUtils.clamp(THREE.MathUtils.lerp(1.58, 1.92, humidity01) - rain01 * 0.04, 1.35, 1.98);
      }

      // Rivage : le fondu particulaire suit l'état de l'eau, sauf si l'utilisateur a repris la main.
      if (!oceanManualOverrides.shoreFade) {
        shoreFadeState.opacity = THREE.MathUtils.lerp(0.30, 0.58, cloud01 * 0.45 + wind01 * 0.55);
        shoreFadeState.width = THREE.MathUtils.lerp(1.15, 2.20, wind01 + rain01 * 0.25);
      }
      if (!oceanManualOverrides.shoreFoamStrength) {
        oceanShoreFoamStrength.value = THREE.MathUtils.lerp(0.52, 0.95, wind01 + rain01 * 0.25);
      }
      if (!oceanManualOverrides.shoreCalmDistance) {
        oceanShoreMinimumWave.value = THREE.MathUtils.lerp(0.018, 0.055, wind01);
      }

      // Le spectre de l'océan est recalculé par palier, pas à chaque frame.
      if (elapsed - microClimate.lastSpectrumUpdate > 5.0) {
        microClimate.lastSpectrumUpdate = elapsed;
        renderer.compute(initWaves);
      }

      // Une température basse ralentit très légèrement la respiration végétale, sans toucher à la croissance validée.
      cloudSpeed.value = THREE.MathUtils.lerp(0.003, 0.020, wind01) * THREE.MathUtils.lerp(0.82, 1.12, temp01);
    }

    function setManualMicroClimate(field, value) {
      microClimate.autoBrowserClock = false;
      microClimate.liveWeatherEnabled = false;
      microClimate[field] = value;
      applyMicroClimateToWorld(clock?.elapsedTime || 0);
    }

    function weatherCodeLabel(code) {
      const c = Number(code);
      if (c === 0) return 'Ensoleillé';
      if ([1, 2].includes(c)) return 'Partiellement nuageux';
      if (c === 3) return 'Nuageux';
      if ([45, 48].includes(c)) return 'Brouillard';
      if ([51, 53, 55, 56, 57].includes(c)) return 'Bruine';
      if ([61, 63, 65, 66, 67, 80, 81, 82].includes(c)) return 'Pluie';
      if ([71, 73, 75, 77, 85, 86].includes(c)) return 'Neige';
      if ([95, 96, 99].includes(c)) return 'Orage';
      return microClimate.liveWeatherEnabled ? 'Météo live' : 'Microclimat simulé';
    }

    function kmDistanceApprox(lat1, lon1, lat2, lon2) {
      const R = 6371;
      const dLat = THREE.MathUtils.degToRad(lat2 - lat1);
      const dLon = THREE.MathUtils.degToRad(lon2 - lon1);
      const a = Math.sin(dLat / 2) ** 2
        + Math.cos(THREE.MathUtils.degToRad(lat1)) * Math.cos(THREE.MathUtils.degToRad(lat2))
        * Math.sin(dLon / 2) ** 2;
      return 2 * R * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
    }

    function weatherPlaceLabel() {
      if (microClimate.locationLabel) return microClimate.locationLabel;
      if (!Number.isFinite(microClimate.latitude) || !Number.isFinite(microClimate.longitude)) {
        return microClimate.liveWeatherEnabled ? 'Position météo' : 'Microclimat local';
      }
      const distToulouse = kmDistanceApprox(microClimate.latitude, microClimate.longitude, 43.6045, 1.4440);
      if (distToulouse < 35) return 'Toulouse, Occitanie';
      return `${microClimate.latitude.toFixed(3)}, ${microClimate.longitude.toFixed(3)}`;
    }



    // =====================================================
    // MICROCOSM CONFIG — zones modifiables sans toucher au cœur
    // Version stable prod : v8.21_simplified_game_ui_perf_weather_focus
    // La croissance validée de l'arbre n'est pas configurée ici.
    // =====================================================
    const MICROCOSM_CONFIG = {
      versionLabel: 'v8.21_simplified_game_ui_perf_weather_focus',
      urls: {
        yggdrasilLogo: 'https://presentcomposedesign.fr/wp-content/uploads/2026/05/YGGDRASIL-LOGO_ROUND-WHITE_PCdlab_2026.svg',
        arBaseUrl: 'https://presentcomposedesign.fr/microcosm-ar/'
      },

      // Compatibilité avec le code existant : ne pas supprimer ces deux alias.
      arExperienceBaseUrl: 'https://presentcomposedesign.fr/microcosm-ar/',
      authenticityLogoUrl: 'https://presentcomposedesign.fr/wp-content/uploads/2026/05/YGGDRASIL-LOGO_ROUND-WHITE_PCdlab_2026.svg',

      cards: {
        qrSize: 220,
        contributionDays: 371,
        maxLocationChips: 8,
        enableQr: true,
        enableSvgTexture: true,
        enablePngTexture: true,
        singleHtmlExportOnly: true,
        memoryCardFilenamePrefix: 'microcosm-memory-card'
      },

      weather: {
        compactByDefault: true,
        showLunarInfo: true,
        showWateringAdvice: true,
        lunarReference: {
          source: 'configuration locale + météo live si disponible',
          exampleDate: '2026-05-09',
          phase: 'Dernier quartier',
          movement: 'Lune montante',
          gardenType: 'Racines'
        },
        lunarOverrides: {
          '2026-05-09': {
            phaseLabel: 'Dernier quartier',
            gardenType: 'Racines',
            motion: 'Lune montante',
            illumination: 'décroissante',
            moonrise: '',
            moonset: ''
          }
        }
      },

      island: {
        grassInsetRatio: 0.985,
        grassToEarthRatio: 0.985,
        earthEdgeMargin: 0.28,
        shoreMargin: 0.42,
        bevelAngleDeg: 5,
        bevelDraftDegrees: 5,
        soilDepth: 1.4,
        edgeLip: 0.18
      },

      ocean: {
        preserveManualTideOverrides: true
      },

      product: {
        freeClicksPerDay: 1,
        windsAddonClicksPerDay: 5,
        lifetimeEnabled: false
      },

      performance: {
        maxPixelRatio: MICROCOSM_RUNTIME_PERF.maxPixelRatio,
        grassBladeInstances: MICROCOSM_RUNTIME_PERF.grassBladeInstances,
        grassMaxDensityMultiplier: MICROCOSM_RUNTIME_PERF.grassMaxDensityMultiplier,
        grassUpdateEvery: MICROCOSM_RUNTIME_PERF.grassUpdateEvery,
        oceanFineUpdateEvery: MICROCOSM_RUNTIME_PERF.oceanFineUpdateEvery,
        hudUpdateMs: MICROCOSM_RUNTIME_PERF.hudUpdateMs
      }
    };

    const YGGDRASIL_AUTH_STAMP_URL = MICROCOSM_CONFIG.authenticityLogoUrl;

    function getMicrocosmArLaunchUrl(payload = {}) {
      const base = MICROCOSM_CONFIG.arExperienceBaseUrl;
      const qs = new URLSearchParams({
        edition: payload.edition || 'preview',
        seed: payload.seedPublic || 'microcosm',
        day: String(payload.day || growthClicksTree || 0)
      });
      return `${base}?${qs.toString()}`;
    }

    function getQrImageUrl(data, size = MICROCOSM_CONFIG.cards.qrSize) {
      return `https://api.qrserver.com/v1/create-qr-code/?size=${size}x${size}&margin=12&format=svg&data=${encodeURIComponent(data)}`;
    }

    function syncMicrocosmRealtimeLight(now = new Date()) {
      const hour = now.getHours() + now.getMinutes() / 60 + now.getSeconds() / 3600;
      const time01 = hour / 24;
      const browserDaylight = smoothPulse01(hour, 5.2, 8.1, 18.2, 22.1);
      microClimate.time01 = time01;
      if (microClimate.liveWeatherEnabled && Number.isFinite(microClimate.liveWeatherIsDay)) {
        microClimate.daylight = microClimate.liveWeatherIsDay ? Math.max(browserDaylight, 0.76) : Math.min(browserDaylight, 0.08);
      } else if (!Number.isFinite(microClimate.daylight)) {
        microClimate.daylight = browserDaylight;
      } else if (microClimate.autoBrowserClock) {
        microClimate.daylight = browserDaylight;
      }
      return microClimate.daylight;
    }

    function applyRealtimeSkyPalette() {
      const d = THREE.MathUtils.clamp(microClimate.daylight ?? 1, 0, 1);
      const night = 1 - d;
      const rain = THREE.MathUtils.clamp(microClimate.rain ?? 0, 0, 1);
      const cloud = THREE.MathUtils.clamp(microClimate.cloudCover ?? 0, 0, 1);
      const skyDay = new THREE.Color(0x9edcff);
      const skyNight = new THREE.Color(0x070b12);
      const skyStorm = new THREE.Color(0x31404a);
      const bg = skyNight.clone().lerp(skyDay, d).lerp(skyStorm, Math.min(0.45, rain * 0.32 + cloud * 0.10));
      scene.background.copy(bg);
      renderer.setClearColor(bg, 1);
      if (skyDomeMat?.color) skyDomeMat.color.copy(new THREE.Color(0x3c4d5a).lerp(new THREE.Color(0xffffff), d));
      ambientLight.intensity = THREE.MathUtils.lerp(0.12, 0.52, d) * THREE.MathUtils.lerp(1.0, 0.76, rain);
      hemiLight.intensity = THREE.MathUtils.lerp(0.10, 0.34, d);
      sunLight.intensity = THREE.MathUtils.lerp(0.20, 4.5, d) * THREE.MathUtils.lerp(1.0, 0.72, cloud);
      treeDirLight.intensity = THREE.MathUtils.lerp(0.42, 1.8, d) * THREE.MathUtils.lerp(1.0, 0.82, rain);
      rimLight.intensity = THREE.MathUtils.lerp(0.14, 0.50, d);
      pointLight.color.set(night > 0.45 ? 0xd7c38a : 0xffccbb);
    }

    const rainMemoryState = (() => {
      try {
        return JSON.parse(localStorage.getItem('microcosm-rain-memory-v1') || '{}') || {};
      } catch (_) {
        return {};
      }
    })();

    function updateRainMemoryFromMicroClimate(now = new Date()) {
      const key = now.toISOString().slice(0, 10);
      const isRainy = (microClimate.rain ?? 0) > 0.08;
      if (!rainMemoryState.lastDate) {
        rainMemoryState.lastDate = key;
        rainMemoryState.daysWithoutRain = isRainy ? 0 : 1;
      } else if (rainMemoryState.lastDate !== key) {
        rainMemoryState.daysWithoutRain = isRainy ? 0 : Math.max(0, Number(rainMemoryState.daysWithoutRain || 0)) + 1;
        rainMemoryState.lastDate = key;
      } else if (isRainy) {
        rainMemoryState.daysWithoutRain = 0;
      }
      try { localStorage.setItem('microcosm-rain-memory-v1', JSON.stringify(rainMemoryState)); } catch (_) {}
      return Math.max(0, Number(rainMemoryState.daysWithoutRain || 0));
    }

    function computeMicrocosmWeatherInfo(daysWithoutRain) {
      const temp = Number(microClimate.airTemperature || 0);
      const humidity = Number(microClimate.humidity || 0);
      const wind = Number(microClimate.windSpeed || 0);
      const rain = Number(microClimate.rain || 0);
      const isNight = (microClimate.daylight ?? 1) < 0.16;
      if (rain > 0.08) return 'Pluie détectée · l’île se recharge naturellement.';
      if (isNight) return 'Nuit locale · croissance calme, respiration lente.';
      if (wind > 26) return 'Vent fort · feuilles et rivage plus actifs.';
      if (temp > 28 && humidity < 42) return 'Air chaud et sec · surveille la terre.';
      if (daysWithoutRain >= 3) return 'Séquence sèche · humidité à surveiller.';
      return 'Microclimat stable · observation quotidienne.';
    }

    function computeMicrocosmWateringAdvice(daysWithoutRain) {
      const temp = Number(microClimate.airTemperature || 0);
      const humidity = Number(microClimate.humidity || 0);
      const rain = Number(microClimate.rain || 0);
      if (rain > 0.08 || humidity >= 74) return 'Arrosage non nécessaire';
      if (daysWithoutRain >= 3 || (temp >= 25 && humidity <= 45)) return 'Arroser pourrait être utile aujourd’hui';
      return 'Arrosage léger seulement si la mousse est sèche';
    }



    function localDateKey(date = new Date()) {
      const y = date.getFullYear();
      const m = String(date.getMonth() + 1).padStart(2, '0');
      const d = String(date.getDate()).padStart(2, '0');
      return `${y}-${m}-${d}`;
    }

    const LUNAR_CALENDAR_OVERRIDES = {
      // 09/05/2026 : référence demandée pour l'écran d'inspiration.
      // Les données d'influence lunaire viennent du calendrier lunaire consulté.
      '2026-05-08': { phase: 'Décroissante', movement: 'Montante', gardenType: 'Racines', illumination: 63.5, riseSet: '02:48 · 11:05 Paris' },
      '2026-05-09': { phase: 'Dernier quartier', movement: 'Montante', gardenType: 'Racines', illumination: 53.8, riseSet: '03:12 · 12:17 Paris' }
    };

    function positiveModulo(value, modulo) {
      return ((value % modulo) + modulo) % modulo;
    }

    function computeLunarMicroInfo(date = new Date()) {
      const key = localDateKey(date);
      const override = LUNAR_CALENDAR_OVERRIDES[key];
      if (override) {
        return {
          ...override,
          icon: override.icon || '◑',
          source: MICROCOSM_CONFIG.weather.lunarReference.source,
          label: `${override.phase} · ${override.movement}`,
          subline: `${override.gardenType} · ${Math.round(override.illumination)}% · ${override.riseSet}`
        };
      }

      // Approximation astronomique suffisante pour l'UI locale :
      // nouvelle lune de référence 2000-01-06 18:14 UTC, cycle synodique moyen.
      const synodic = 29.53058867;
      const ref = Date.UTC(2000, 0, 6, 18, 14, 0);
      const days = (date.getTime() - ref) / 86400000;
      const age = positiveModulo(days, synodic);
      const phase01 = age / synodic;
      const illumination = Math.round((1 - Math.cos(2 * Math.PI * phase01)) * 50);
      let phase = 'Nouvelle lune';
      let icon = '●';
      if (phase01 >= 0.03 && phase01 < 0.22) { phase = 'Croissante'; icon = '☽'; }
      else if (phase01 >= 0.22 && phase01 < 0.28) { phase = 'Premier quartier'; icon = '◐'; }
      else if (phase01 >= 0.28 && phase01 < 0.47) { phase = 'Gibbeuse croissante'; icon = '◖'; }
      else if (phase01 >= 0.47 && phase01 < 0.53) { phase = 'Pleine lune'; icon = '○'; }
      else if (phase01 >= 0.53 && phase01 < 0.72) { phase = 'Gibbeuse décroissante'; icon = '◗'; }
      else if (phase01 >= 0.72 && phase01 < 0.78) { phase = 'Dernier quartier'; icon = '◑'; }
      else if (phase01 >= 0.78) { phase = 'Décroissante'; icon = '☾'; }
      const sidereal = 27.321661;
      const movement = Math.sin((positiveModulo(days, sidereal) / sidereal) * Math.PI * 2) >= 0 ? 'Montante' : 'Descendante';
      const gardenTypes = ['Racines', 'Fleurs', 'Feuilles', 'Fruits'];
      const gardenType = gardenTypes[Math.floor(positiveModulo(days + 1, 4))];
      return {
        phase,
        movement,
        gardenType,
        illumination,
        icon,
        source: 'estimation locale',
        label: `${phase} · ${movement}`,
        subline: `${gardenType} · ${illumination}% · ${date.toLocaleDateString('fr-FR', { day: '2-digit', month: 'short' })}`
      };
    }

    function getWeatherVisualIcon(lunarInfo = computeLunarMicroInfo()) {
      const isNight = microClimate.liveWeatherIsDay === 0 || (microClimate.daylight ?? 1) < 0.16;
      if (isNight) return { icon: lunarInfo.icon || '☾', label: 'Nuit locale' };
      if ((microClimate.rain ?? 0) > 0.08) return { icon: '🌧️', label: 'Pluie' };
      if ((microClimate.cloudCover ?? 0) > 0.72) return { icon: '☁️', label: 'Nuageux' };
      if ((microClimate.cloudCover ?? 0) > 0.38) return { icon: '⛅', label: 'Variable' };
      return { icon: '☀️', label: 'Soleil' };
    }

    const MICRO_ACTIVITY_KEY = 'microcosm-tree-activity-v2';
    const microActivityState = (() => {
      try {
        const parsed = JSON.parse(localStorage.getItem(MICRO_ACTIVITY_KEY) || '{}') || {};
        return {
          daily: parsed.daily && typeof parsed.daily === 'object' ? parsed.daily : {},
          locations: Array.isArray(parsed.locations) ? parsed.locations.slice(0, 40) : []
        };
      } catch (_) {
        return { daily: {}, locations: [] };
      }
    })();

    function saveMicroActivityState() {
      try { localStorage.setItem(MICRO_ACTIVITY_KEY, JSON.stringify(microActivityState)); } catch (_) {}
    }

    const microActivitySessionSeen = new Set();

    function recordMicrocosmVisit(date = new Date(), options = {}) {
      const key = localDateKey(date);
      const place = weatherPlaceLabel();
      const sessionKey = `${key}|${place || 'local'}`;
      if (!options.force && microActivitySessionSeen.has(sessionKey)) return;
      microActivitySessionSeen.add(sessionKey);
      const row = microActivityState.daily[key] || { visits: 0, clicks: 0, places: [] };
      row.visits = Math.min(99, Number(row.visits || 0) + 1);
      if (place && !row.places.includes(place)) row.places.push(place);
      microActivityState.daily[key] = row;
      if (place && !microActivityState.locations.some(item => item.place === place)) {
        microActivityState.locations.unshift({ place, firstSeen: date.toISOString(), day: growthClicksTree });
        microActivityState.locations = microActivityState.locations.slice(0, 40);
      }
      saveMicroActivityState();
    }

    function recordMicrocosmTreeClick(date = new Date()) {
      const key = localDateKey(date);
      const row = microActivityState.daily[key] || { visits: 0, clicks: 0, places: [] };
      row.clicks = Math.min(99, Number(row.clicks || 0) + 1);
      const place = weatherPlaceLabel();
      if (place && !row.places.includes(place)) row.places.push(place);
      microActivityState.daily[key] = row;
      saveMicroActivityState();
    }

    function getMicrocosmActivitySnapshot(days = MICROCOSM_CONFIG.cards.contributionDays) {
      const out = [];
      const today = new Date();
      for (let i = days - 1; i >= 0; i--) {
        const d = new Date(today);
        d.setDate(today.getDate() - i);
        const key = localDateKey(d);
        const row = microActivityState.daily[key] || { visits: 0, clicks: 0, places: [] };
        const score = Number(row.visits || 0) + Number(row.clicks || 0) * 2;
        const level = score <= 0 ? 0 : score === 1 ? 1 : score <= 3 ? 2 : score <= 6 ? 3 : 4;
        out.push({
          date: key,
          visits: Number(row.visits || 0),
          clicks: Number(row.clicks || 0),
          level,
          places: Array.isArray(row.places) ? row.places.slice(0, 3) : []
        });
      }
      return {
        days: out,
        locations: microActivityState.locations.slice(0, MICROCOSM_CONFIG.cards.maxLocationChips),
        totalVisits: out.reduce((sum, item) => sum + item.visits, 0),
        totalClicks: out.reduce((sum, item) => sum + item.clicks, 0)
      };
    }

    const weatherHudEls = {
      root: document.getElementById('weather-hud'),
      place: document.getElementById('weather-place'),
      time: document.getElementById('weather-time'),
      date: document.getElementById('weather-date'),
      visualIcon: document.getElementById('weather-visual-icon'),
      lunarIcon: document.getElementById('weather-lunar-icon'),
      lunarPhase: document.getElementById('weather-lunar-phase'),
      lunarDetail: document.getElementById('weather-lunar-detail'),
      advice: document.getElementById('weather-advice'),
      rainlessDays: document.getElementById('weather-rainless-days'),
      source: document.getElementById('weather-source'),
      icon: document.getElementById('weather-icon'),
      temp: document.getElementById('weather-temp'),
      desc: document.getElementById('weather-desc'),
      wind: document.getElementById('weather-wind'),
      humidity: document.getElementById('weather-humidity'),
      feels: document.getElementById('weather-feels'),
      pressure: document.getElementById('weather-pressure'),
      clouds: document.getElementById('weather-clouds'),
      rain: document.getElementById('weather-rain'),
      daylight: document.getElementById('weather-daylight'),
      sync: document.getElementById('weather-sync'),
      compareApi: document.getElementById('weather-compare-api'),
      compareWorld: document.getElementById('weather-compare-world'),
      note: document.getElementById('weather-note'),
      refresh: document.getElementById('weather-refresh'),
      locationInput: document.getElementById('weather-location-input'),
      locationSearch: document.getElementById('weather-location-search'),
      locationGps: document.getElementById('weather-location-gps'),
      tune: document.querySelector('.weather-tune-btn'),
      focusMain: document.getElementById('weather-focus-main'),
      focusSub: document.getElementById('weather-focus-sub')
    };
    let lastHudUpdate = 0;


    function mountPlayerHudIntoRitualDock() {
      const dock = document.querySelector('.microcosm-ritual-dock');
      const weatherAnchor = dock?.querySelector('.ritual-weather-anchor');
      const progressAnchor = dock?.querySelector('.ritual-progress-anchor');
      if (weatherAnchor && weatherHudEls.root && weatherHudEls.root.parentElement !== weatherAnchor) {
        weatherAnchor.appendChild(weatherHudEls.root);
      }
      const growthUi = document.querySelector('.tree-growth-ui');
      if (progressAnchor && growthUi && growthUi.parentElement !== progressAnchor) {
        progressAnchor.appendChild(growthUi);
      }
    }
    requestAnimationFrame(mountPlayerHudIntoRitualDock);


    const hudOverlayEls = {
      toggle: document.getElementById('ui-toggle'),
      panel: document.getElementById('ui-panel')
    };

    function updateHudStackLayout() {
      const weatherRoot = weatherHudEls.root;
      const panelRoot = hudOverlayEls.panel;
      if (!weatherRoot || !panelRoot) return;
      if (!window.matchMedia('(max-width: 900px)').matches) {
        document.documentElement.style.setProperty('--microcosm-weather-stack-bottom', '64px');
        return;
      }
      const gap = 10;
      const stackedTop = Math.round(weatherRoot.offsetTop + weatherRoot.offsetHeight + gap);
      document.documentElement.style.setProperty('--microcosm-weather-stack-bottom', `${stackedTop}px`);
    }

    if ('ResizeObserver' in window && weatherHudEls.root) {
      const hudStackObserver = new ResizeObserver(() => {
        updateHudStackLayout();
      });
      hudStackObserver.observe(weatherHudEls.root);
    }

    function compactWateringAdviceLabel(advice = '') {
      const value = String(advice || '').toLowerCase();
      if (value.includes('non nécessaire') || value.includes('pas nécessaire')) return 'Arrosage non nécessaire';
      if (value.includes('léger')) return 'Arrosage léger';
      if (value.includes('utile') || value.includes('arroser')) return 'Arrosage utile aujourd’hui';
      if (value.includes('sèche') || value.includes('sec')) return 'Vérifier la mousse';
      return 'Observer le sol';
    }

    function usefulMicroclimateLine(daysWithoutRain = 0) {
      const humidity = Math.round(microClimate.humidity);
      const rain = Math.round((microClimate.rain || 0) * 100);
      const wind = Math.round(microClimate.windSpeed);
      const isNight = microClimate.liveWeatherIsDay === 0 || (microClimate.daylight ?? 1) < 0.16;
      if (rain > 8) return `pluie ${rain}% · l’île se recharge`;
      if (humidity < 45) return `humidité ${humidity}% · mousse à surveiller`;
      if (humidity >= 74) return `humidité ${humidity}% · sol déjà frais`;
      if (daysWithoutRain >= 3) return `${daysWithoutRain} jours sans pluie · surveiller la terre`;
      if (isNight) return `nuit locale · croissance calme`;
      if (wind > 26) return `vent ${wind} km/h · feuillage actif`;
      return `humidité ${humidity}% · microclimat stable`;
    }

    function updateWeatherHUD(force = false) {
      const nowMs = performance.now();
      if (!force && nowMs - lastHudUpdate < MICROCOSM_RUNTIME_PERF.hudUpdateMs) return;
      lastHudUpdate = nowMs;

      const live = microClimate.liveWeatherEnabled && !!microClimate.lastLiveWeatherFetch;
      const status = live ? 'LIVE' : (microClimate.autoBrowserClock ? 'AUTO' : 'MANUEL');
      const desc = live ? weatherCodeLabel(microClimate.liveWeatherCode) : 'Horloge navigateur';
      const feels = Number.isFinite(microClimate.liveWeatherApparent) ? microClimate.liveWeatherApparent : microClimate.airTemperature;
      const windDeg = (THREE.MathUtils.radToDeg(microClimate.windDirection) + 360) % 360;
      const apiDiffT = live ? Math.abs(microClimate.airTemperature - (Number.isFinite(microClimate.liveWeatherApparent) ? microClimate.liveWeatherApparent : microClimate.airTemperature)) : 0;
      const syncOk = live ? 'OK live' : (microClimate.autoBrowserClock ? 'simulé' : 'manuel');
      const localNow = new Date();
      const timeText = localNow.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
      const dateText = localNow.toLocaleDateString('fr-FR', { weekday: 'short', day: '2-digit', month: 'short' });
      const daysWithoutRain = updateRainMemoryFromMicroClimate(localNow);
      const weatherInfo = computeMicrocosmWeatherInfo(daysWithoutRain);
      const wateringAdvice = computeMicrocosmWateringAdvice(daysWithoutRain);
      const lunarInfo = computeLunarMicroInfo(localNow);
      const visual = getWeatherVisualIcon(lunarInfo);

      weatherHudEls.place.textContent = weatherPlaceLabel();
      if (weatherHudEls.locationInput && !document.activeElement?.isSameNode(weatherHudEls.locationInput)) {
        weatherHudEls.locationInput.value = microClimate.locationLabel || '';
      }
      weatherHudEls.time.textContent = timeText;
      if (weatherHudEls.date) weatherHudEls.date.textContent = dateText;
      weatherHudEls.source.textContent = status;
      if (weatherHudEls.visualIcon) {
        weatherHudEls.visualIcon.textContent = visual.icon;
        weatherHudEls.visualIcon.setAttribute('aria-label', visual.label);
      }
      if (weatherHudEls.lunarIcon) weatherHudEls.lunarIcon.textContent = lunarInfo.icon || '☾';
      if (weatherHudEls.lunarPhase) weatherHudEls.lunarPhase.textContent = lunarInfo.label;
      if (weatherHudEls.lunarDetail) weatherHudEls.lunarDetail.textContent = lunarInfo.subline + ' · ' + lunarInfo.source;
      weatherHudEls.temp.textContent = Math.round(microClimate.airTemperature);
      weatherHudEls.desc.textContent = weatherInfo;
      if (weatherHudEls.advice) weatherHudEls.advice.textContent = wateringAdvice;
      if (weatherHudEls.rainlessDays) weatherHudEls.rainlessDays.textContent = String(daysWithoutRain);
      if (weatherHudEls.focusMain) weatherHudEls.focusMain.textContent = `${compactWateringAdviceLabel(wateringAdvice)} · ${Math.round(microClimate.airTemperature)}°`;
      if (weatherHudEls.focusSub) weatherHudEls.focusSub.textContent = usefulMicroclimateLine(daysWithoutRain);
      weatherHudEls.wind.textContent = `${Math.round(microClimate.windSpeed)} km/h`;
      weatherHudEls.humidity.textContent = `${Math.round(microClimate.humidity)}%`;
      weatherHudEls.feels.textContent = `${Math.round(feels)}°`;
      weatherHudEls.pressure.textContent = `${Math.round(microClimate.pressure)} hPa`;
      weatherHudEls.clouds.textContent = `${Math.round(microClimate.cloudCover * 100)}%`;
      weatherHudEls.rain.textContent = `${Math.round(microClimate.rain * 100)}%`;
      weatherHudEls.daylight.textContent = `${Math.round(microClimate.daylight * 100)}%`;
      weatherHudEls.sync.textContent = syncOk;

      const iconHue = microClimate.liveWeatherIsDay === 0 || microClimate.daylight < 0.15 ? 'radial-gradient(circle at 35% 30%, rgba(224, 219, 184, 0.92), rgba(65, 71, 48, 0.70) 62%, rgba(22, 18, 12, 0.62))' :
        microClimate.rain > 0.25 ? 'radial-gradient(circle at 35% 30%, rgba(207, 220, 196, 0.92), rgba(82, 106, 87, 0.70) 62%, rgba(31, 38, 27, 0.58))' :
        microClimate.cloudCover > 0.65 ? 'radial-gradient(circle at 35% 30%, rgba(231, 226, 199, 0.92), rgba(142, 133, 102, 0.72) 62%, rgba(53, 43, 28, 0.58))' :
        'radial-gradient(circle at 35% 30%, rgba(255, 238, 181, 0.95), rgba(176, 126, 54, 0.74) 58%, rgba(87, 62, 31, 0.52))';
      weatherHudEls.icon.style.background = iconHue;

      weatherHudEls.compareApi.textContent = live
        ? `T ${Math.round(microClimate.airTemperature)}° · vent ${Math.round(microClimate.windSpeed)} · dir ${Math.round(windDeg)}°`
        : 'pas de relevé live';
      weatherHudEls.compareWorld.textContent =
        `houle ${heightScale.value.toFixed(2)} · herbe ${windAmplitude.value.toFixed(2)}`;
      weatherHudEls.note.textContent = live
        ? `Données appliquées au microcosme. Si ton app météo affiche ~${Math.round(microClimate.airTemperature)}°C, ${Math.round(microClimate.windSpeed)} km/h et ${Math.round(microClimate.humidity)}% d’humidité, la synchro fonctionne.`
        : (microClimate.liveWeatherError || 'Active “Météo live” puis autorise la position pour vérifier avec les données réelles.');
      recordMicrocosmVisit(localNow);
      requestAnimationFrame(updateHudStackLayout);
    }

    weatherHudEls.refresh?.addEventListener('click', (event) => {
      event.stopPropagation();
      microClimate.liveWeatherEnabled = true;
      microClimate.autoBrowserClock = false;
      requestLiveMicroWeather();
      updateWeatherHUD(true);
    });
    weatherHudEls.locationSearch?.addEventListener('click', (event) => {
      event.stopPropagation();
      searchWeatherLocation(weatherHudEls.locationInput?.value || '');
    });
    weatherHudEls.locationGps?.addEventListener('click', (event) => {
      event.stopPropagation();
      microClimate.locationLabel = '';
      microClimate.liveWeatherEnabled = true;
      microClimate.autoBrowserClock = false;
      requestLiveMicroWeather();
      updateWeatherHUD(true);
    });
    weatherHudEls.locationInput?.addEventListener('keydown', (event) => {
      if (event.key === 'Enter') {
        event.preventDefault();
        event.stopPropagation();
        searchWeatherLocation(weatherHudEls.locationInput.value);
      }
    });
    function setWeatherAdvancedOpen(open) {
      if (!weatherHudEls.root) return;
      weatherHudEls.root.classList.toggle('compact', !open);
      weatherHudEls.tune?.setAttribute('aria-expanded', open ? 'true' : 'false');
      weatherHudEls.tune && (weatherHudEls.tune.textContent = open ? 'Réduire' : 'Affiner');
      requestAnimationFrame(updateHudStackLayout);
    }

    weatherHudEls.tune?.addEventListener('click', (event) => {
      event.stopPropagation();
      setWeatherAdvancedOpen(weatherHudEls.root?.classList.contains('compact'));
    });

    async function fetchLiveWeatherAt(lat, lon, label = '') {
      microClimate.liveWeatherStatus = 'météo live...';
      microClimate.liveWeatherError = '';
      microClimate.liveWeatherEnabled = true;
      microClimate.autoBrowserClock = false;
      microClimate.latitude = lat;
      microClimate.longitude = lon;
      if (label) microClimate.locationLabel = label;
      try {
        const url = `https://api.open-meteo.com/v1/forecast?latitude=${microClimate.latitude}&longitude=${microClimate.longitude}&current=temperature_2m,relative_humidity_2m,apparent_temperature,pressure_msl,wind_speed_10m,wind_direction_10m,cloud_cover,precipitation,weather_code,is_day&timezone=auto`;
        const res = await fetch(url);
        const data = await res.json();
        const cur = data.current || {};
        if (typeof cur.time === 'string') microClimate.liveWeatherTime = cur.time;
        if (Number.isFinite(cur.temperature_2m)) microClimate.airTemperature = cur.temperature_2m;
        if (Number.isFinite(cur.apparent_temperature)) microClimate.liveWeatherApparent = cur.apparent_temperature;
        if (Number.isFinite(cur.relative_humidity_2m)) microClimate.humidity = cur.relative_humidity_2m;
        if (Number.isFinite(cur.pressure_msl)) microClimate.pressure = cur.pressure_msl;
        if (Number.isFinite(cur.wind_speed_10m)) microClimate.windSpeed = THREE.MathUtils.clamp(cur.wind_speed_10m, 0, 45);
        if (Number.isFinite(cur.wind_direction_10m)) microClimate.windDirection = THREE.MathUtils.degToRad(cur.wind_direction_10m);
        if (Number.isFinite(cur.cloud_cover)) microClimate.cloudCover = THREE.MathUtils.clamp(cur.cloud_cover / 100, 0, 1);
        if (Number.isFinite(cur.precipitation)) microClimate.rain = THREE.MathUtils.clamp(cur.precipitation / 4, 0, 1);
        if (Number.isFinite(cur.weather_code)) microClimate.liveWeatherCode = cur.weather_code;
        if (Number.isFinite(cur.is_day)) microClimate.liveWeatherIsDay = cur.is_day;
        syncMicrocosmRealtimeLight(new Date());
        microClimate.windForce = THREE.MathUtils.clamp(0.12 + microClimate.windSpeed / 48 + microClimate.rain * 0.12, 0.05, 0.9);
        microClimate.liveWeatherStatus = 'météo live';
        microClimate.liveWeatherError = '';
        microClimate.lastLiveWeatherFetch = Date.now();
        applyMicroClimateToWorld(clock?.elapsedTime || 0);
        updateWeatherHUD(true);
      } catch (err) {
        console.warn('Météo live indisponible :', err);
        microClimate.liveWeatherError = 'Météo live indisponible : vérifie la connexion ou réessaie.';
        updateWeatherHUD(true);
      }
    }

    async function searchWeatherLocation(query) {
      const q = String(query || '').trim();
      if (!q) return;
      microClimate.liveWeatherStatus = 'recherche lieu...';
      microClimate.liveWeatherError = '';
      updateWeatherHUD(true);

      const gpsMatch = q.match(/^\s*(-?\d+(?:[\.,]\d+)?)\s*[,; ]\s*(-?\d+(?:[\.,]\d+)?)\s*$/);
      if (gpsMatch) {
        const lat = parseFloat(gpsMatch[1].replace(',', '.'));
        const lon = parseFloat(gpsMatch[2].replace(',', '.'));
        if (Number.isFinite(lat) && Number.isFinite(lon)) {
          await fetchLiveWeatherAt(lat, lon, `${lat.toFixed(3)}, ${lon.toFixed(3)}`);
        }
        return;
      }

      try {
        const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(q)}&count=1&language=fr&format=json`;
        const res = await fetch(url);
        const data = await res.json();
        const place = data.results?.[0];
        if (!place) {
          microClimate.liveWeatherError = `Adresse introuvable : “${q}”. Essaie une ville ou lat,lon.`;
          updateWeatherHUD(true);
          return;
        }
        const label = [place.name, place.admin1, place.country].filter(Boolean).join(', ');
        await fetchLiveWeatherAt(place.latitude, place.longitude, label);
      } catch (err) {
        console.warn('Recherche météo impossible :', err);
        microClimate.liveWeatherError = 'Recherche météo impossible : connexion ou API indisponible.';
        updateWeatherHUD(true);
      }
    }

    async function requestLiveMicroWeather() {
      if (!navigator.geolocation) {
        microClimate.liveWeatherStatus = 'géolocalisation indisponible';
        return;
      }
      microClimate.liveWeatherStatus = 'géolocalisation...';
      navigator.geolocation.getCurrentPosition(async (pos) => {
        const lat = pos.coords.latitude;
        const lon = pos.coords.longitude;
        const distToulouse = kmDistanceApprox(lat, lon, 43.6045, 1.4440);
        const label = distToulouse < 35 ? 'Toulouse, Occitanie' : `${lat.toFixed(3)}, ${lon.toFixed(3)}`;
        await fetchLiveWeatherAt(lat, lon, label);
      }, () => {
        microClimate.liveWeatherEnabled = false;
        microClimate.autoBrowserClock = true;
        microClimate.liveWeatherStatus = 'localisation refusée';
        microClimate.liveWeatherError = 'Localisation refusée : impossible de vérifier Toulouse automatiquement.';
        updateWeatherHUD(true);
      }, { enableHighAccuracy: false, timeout: 8000, maximumAge: 20 * 60 * 1000 });
    }

    const bladeWidth = uniform(1.4);
    const bladeHeight = uniform(0.7);
    let grassBladeWidthUser = 1.0;
    let grassBladeLengthUser = 1.0;
    const bladeLean = uniform(1.3);
    const noiseAmplitude = uniform(1.85);
    const noiseFrequency = uniform(0.3);
    const noise2Amplitude = uniform(0.25);
    const noise2Frequency = uniform(15);
    const bladeColorVariation = uniform(0.97);
    const bladeGradientFalloff = uniform(1.7);
    const groundRadiusGrass = uniform(15.0 * grassSeedConfig.size);
    const groundFalloff = uniform(2.8);
    const bladeBaseColorU = uniform(new THREE.Color(BLADE_BASE_HEX));
    const bladeTipColorU = uniform(new THREE.Color(BLADE_TIP_HEX));
    const backgroundColorU = uniform(new THREE.Color(BACKGROUND_HEX));
    const groundColorU = uniform(new THREE.Color(GROUND_HEX));
    // 2D noise
    const noise2D = Fn(([x, z]) => {
      return mx_noise_float(vec3(x, float(0), z)).mul(0.5).add(0.7);
    });
    const grassBoundaryMask = Fn(([wx, wz, radius]) => {
      const dist = sqrt(wx.mul(wx).add(wz.mul(wz)));
      const ang = atan(wz, wx);
      const lobeA = sin(ang.mul(3.0).add(grassShapePhaseA));
      const lobeB = sin(ang.mul(5.0).add(grassShapePhaseB)).mul(0.55);
      const edgeNoise = mx_noise_float(vec3(wx.mul(0.075).add(grassSeedX), float(0), wz.mul(0.075).add(grassSeedZ))).sub(0.5);
      const shape = float(1.0).add(lobeA.add(lobeB).mul(grassShapeRoundness)).add(edgeNoise.mul(0.16));
      const shapedRadius = radius.mul(shape).max(3.0);
      return float(1).sub(smoothstep(shapedRadius.sub(groundFalloff), shapedRadius, dist));
    });
    // Compute init blades
    const computeInitGrass = Fn(() => {
      const blade = bladeData.element(instanceIndex);
      const col = instanceIndex.mod(GRASS_GRID);
      const row = instanceIndex.div(GRASS_GRID);
      const jx = hash(instanceIndex).sub(0.5);
      const jz = hash(instanceIndex.add(7919)).sub(0.5);
      const wx = col.toFloat().add(jx).div(float(GRASS_GRID)).sub(0.5).mul(FIELD_SIZE);
      const wz = row.toFloat().add(jz).div(float(GRASS_GRID)).sub(0.5).mul(FIELD_SIZE);
      blade.x.assign(wx);
      blade.y.assign(wz);
      blade.z.assign(hash(instanceIndex.add(1337)).mul(PI.mul(2)));
      const n1 = noise2D(wx.mul(noiseFrequency), wz.mul(noiseFrequency));
      const n2 = noise2D(
        wx.mul(noiseFrequency.mul(noise2Frequency)).add(50),
        wz.mul(noiseFrequency.mul(noise2Frequency)).add(50)
      );
      const clump = n1.mul(noiseAmplitude).sub(noise2Amplitude).add(n2.mul(noise2Amplitude).mul(2)).max(0);
      blade.w.assign(clump);
      const boundary = grassBoundaryMask(wx, wz, grassMaxRadius);
      bladeBound.element(instanceIndex).assign(select(boundary.lessThan(0.035), float(0), boundary));
    })().compute(BLADE_COUNT);
    // Compute update grass
    const computeUpdateGrass = Fn(() => {
      const blade = bladeData.element(instanceIndex);
      const bend = bendState.element(instanceIndex);
      const bx = blade.x;
      const bz = blade.y;
      // Use the global wind speed uniform (windSpeedU) to drive grass movement
      const w1 = sin(bx.mul(0.35).add(bz.mul(0.12)).add(time.mul(windSpeedU)));
      const w2 = sin(bx.mul(0.28).add(bz.mul(0.28)).add(time.mul(windSpeedU.mul(0.67))).add(1.7));
      const windX = w1.add(w2).mul(windAmplitude);
      const windZ = w1.sub(w2).mul(windAmplitude.mul(0.55));
      const lw = deltaTime.mul(4.0).saturate();
      bend.x.assign(mix(bend.x, windX, lw));
      bend.y.assign(mix(bend.y, windZ, lw));
      const dx = bx.sub(mouseWorld.x);
      const dz = bz.sub(mouseWorld.z);
      const dist = sqrt(dx.mul(dx).add(dz.mul(dz))).add(0.0001);
      const falloff = float(1).sub(dist.div(mouseRadius).saturate());
      const influence = falloff.mul(falloff).mul(mouseStrength);
      const pushX = dx.div(dist).mul(influence);
      const pushZ = dz.div(dist).mul(influence);
      const idx2 = bx.sub(icoWorld.x);
      const idz2 = bz.sub(icoWorld.z);
      const idist2 = sqrt(idx2.mul(idx2).add(idz2.mul(idz2))).add(0.0001);
      const ifalloff2 = float(1).sub(idist2.div(icoRadius).saturate());
      const iinfluence2 = ifalloff2.mul(ifalloff2).mul(icoStrengthU);
      const ipushX = idx2.div(idist2).mul(iinfluence2);
      const ipushZ = idz2.div(idist2).mul(iinfluence2);
      const totalPushX = pushX.add(ipushX);
      const totalPushZ = pushZ.add(ipushZ);
      const targetMag = sqrt(totalPushX.mul(totalPushX).add(totalPushZ.mul(totalPushZ)));
      const currentMag = sqrt(bend.z.mul(bend.z).add(bend.w.mul(bend.w)));
      const lm = select(targetMag.greaterThan(currentMag), deltaTime.mul(12.0), deltaTime.mul(1)).saturate();
      bend.z.assign(mix(bend.z, totalPushX, lm));
      bend.w.assign(mix(bend.w, totalPushZ, lm));
    })().compute(BLADE_COUNT);
    // Grass geometry
    function createBladeGeometry() {
      const segs = 5;
      const W = 0.055;
      const H = 1.0;
      const verts = [], norms = [], uvArr = [], idx = [];
      for (let i = 0; i <= segs; i++) {
        const t = i / segs;
        const y = t * H;
        const hw = W * 0.5 * (1.0 - t * 0.82);
        verts.push(-hw, y, 0, hw, y, 0);
        norms.push(0, 0, 1, 0, 0, 1);
        uvArr.push(0, t, 1, t);
      }
      for (let i = 0; i < segs; i++) {
        const b = i * 2;
        idx.push(b, b + 1, b + 2, b + 1, b + 3, b + 2);
      }
      const geo = new THREE.BufferGeometry();
      geo.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3));
      geo.setAttribute('normal', new THREE.Float32BufferAttribute(norms, 3));
      geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvArr, 2));
      geo.setIndex(idx);
      return geo;
    }
    const grassMat = new THREE.MeshBasicNodeMaterial({ side: THREE.DoubleSide });
    grassMat.positionNode = Fn(() => {
      const blade = bladeData.element(instanceIndex);
      const bend = bendState.element(instanceIndex);
      const worldX = blade.x;
      const worldZ = blade.y;
      const rotY = blade.z;
      const boundary = bladeBound.element(instanceIndex).mul(grassBoundaryMask(worldX, worldZ, grassVisibleRadius));
      // Pool d'herbe surdimensionné : à ×1 on garde le rendu v8.24, à ×10 on libère vraiment ~10× plus de brins.
      const densityThreshold = clamp(grassDensity.mul(0.5).mul(grassDensityMultiplier).div(grassMaxDensityMultiplier), float(0.0), float(1.0));
      const rawVisible = select(hash(instanceIndex.add(9999)).lessThan(densityThreshold), float(1), float(0));
      // Fenêtres de terre : autour du gland jouable et du centre, la terre reste visible même sur mobile.
      const dxPlayable = worldX.sub(icoWorld.x);
      const dzPlayable = worldZ.sub(icoWorld.z);
      const distPlayable = sqrt(dxPlayable.mul(dxPlayable).add(dzPlayable.mul(dzPlayable)));
      const playableEarthWindow = smoothstep(float(0.34), float(0.88), distPlayable);
      const distCenter = sqrt(worldX.mul(worldX).add(worldZ.mul(worldZ)));
      const centerEarthWindow = smoothstep(float(0.24), float(0.76), distCenter);
      const visible = rawVisible.mul(playableEarthWindow).mul(centerEarthWindow);
      const heightScale = float(0.25).add(blade.w).mul(boundary).mul(visible);
      const lx = positionGeometry.x.mul(bladeWidth).mul(heightScale.sign());
      const ly = positionGeometry.y.mul(heightScale).mul(bladeHeight);
      const cY = cos(rotY);
      const sY = sin(rotY);
      const rx = lx.mul(cY);
      const rz = lx.mul(sY);
      const t = uv().y;
      const bendFactor = pow(t, 1.8);
      const staticBendX = hash(instanceIndex.add(7777)).sub(0.5).mul(bladeLean);
      const staticBendZ = hash(instanceIndex.add(8888)).sub(0.5).mul(bladeLean);
      const bendX = staticBendX.add(bend.x).add(bend.z);
      const bendZ = staticBendZ.add(bend.y).add(bend.w);
      const relX = rx.add(bendX.mul(bendFactor).mul(bladeHeight));
      const relY = ly;
      const relZ = rz.add(bendZ.mul(bendFactor).mul(bladeHeight));
      const origLen = sqrt(rx.mul(rx).add(ly.mul(ly)).add(rz.mul(rz)));
      const newLen = sqrt(relX.mul(relX).add(relY.mul(relY)).add(relZ.mul(relZ)));
      const scale = origLen.div(newLen.max(0.0001));
      const islandDist = sqrt(worldX.mul(worldX).add(worldZ.mul(worldZ)));
      const islandCurveY = islandDist.mul(islandDist).mul(islandCurveStrength).negate();
      return vec3(worldX.add(relX.mul(scale)), relY.mul(scale).add(islandCurveY), worldZ.add(relZ.mul(scale)));
    })();
    grassMat.colorNode = Fn(() => {
      const t = uv().y;
      const clump = bladeData.element(instanceIndex).w.saturate();
      const gradient = pow(t, bladeGradientFalloff);
      const tipMix = float(1).sub(bladeColorVariation).add(clump.mul(bladeColorVariation));
      const variedTip = mix(bladeBaseColorU, bladeTipColorU, tipMix);
      return mix(bladeBaseColorU, variedTip, gradient);
    })();
    grassMat.opacityNode = smoothstep(float(0.0), float(0.1), uv().y);
    grassMat.transparent = true;
    const bladeGeo = createBladeGeometry();
    const grass = new THREE.InstancedMesh(bladeGeo, grassMat, BLADE_COUNT);
    grass.frustumCulled = false;
    islandRoot.add(grass);
    const dummyMatrix = new THREE.Object3D();
    for (let i = 0; i < BLADE_COUNT; i++) grass.setMatrixAt(i, dummyMatrix.matrix);
    grass.instanceMatrix.needsUpdate = true;
    // Icosahedron
    const icoGeo = new THREE.IcosahedronGeometry(0.33, 1);
    icoGeo.computeVertexNormals();
    const icoMat = new THREE.MeshStandardNodeMaterial({ color: new THREE.Color('#ffffff'), roughness: 0.95, metalness: 0.05, flatShading: false });
    const icosahedron = new THREE.Mesh(icoGeo, icoMat);
    icosahedron.position.set(0, 0.45, 0);
    // Proxy physique invisible par défaut : aucune ombre ni masque hérité de l'ancienne géosphère.
    icosahedron.castShadow = false;
    icosahedron.receiveShadow = false;
    islandRoot.add(icosahedron);
    const icoVisualModelHost = new THREE.Group();
    icoVisualModelHost.name = 'PlayableAcornModelHost';
    icosahedron.add(icoVisualModelHost);
    const icoVel = new THREE.Vector3();
    const DEFAULT_INTERACTIVE_AVATAR_MODEL_URL = 'https://presentcomposedesign.fr/wp-content/uploads/2026/04/Oak-seed-hyper3dai_basic_pbr_PCdlab.glb';
    const DEFAULT_INTERACTIVE_AVATAR_LABEL = 'Gland de chêne';
    const interactiveSphereState = {
      speed: 65.0,
      damping: 0.92,
      gravity: 22.0,
      jumpForce: 10.0,
      modelUrl: DEFAULT_INTERACTIVE_AVATAR_MODEL_URL,
      modelLabel: DEFAULT_INTERACTIVE_AVATAR_LABEL,
      modelObject: null,
      modelIsFallback: false
    };
    const keysPressed = {};
    // Le gland est un élément jouable posé sur la terre, pas un avatar flottant.
    const playableAcornRadius = 0.35;
    const playableAcornContactInset = 0.018;
    let groundY = playableAcornRadius;
    function getPlayableGroundYAt(x, z) {
      try {
        if (typeof islandCurveYAtXZ === 'function') return islandCurveYAtXZ(x, z) - 0.035;
      } catch (_) {}
      return 0;
    }
    function getPlayableAcornCenterY(x, z) {
      return getPlayableGroundYAt(x, z) + playableAcornRadius - playableAcornContactInset;
    }
    function alignPlayableAcornBottomToGround(object) {
      if (!object) return;
      object.updateMatrixWorld(true);
      const box = new THREE.Box3().setFromObject(object);
      if (!Number.isFinite(box.min.y)) return;
      object.position.y += (-playableAcornRadius) - box.min.y;
      object.updateMatrixWorld(true);
    }
    const interactiveAvatarLoader = new GLTFLoader();
    interactiveAvatarLoader.setCrossOrigin?.('anonymous');
    let interactiveAvatarLoadToken = 0;

    function setPhysicsProxyVisible(visible) {
      icoMat.transparent = !visible;
      icoMat.opacity = visible ? 1 : 0;
      icosahedron.visible = true;
      icosahedron.castShadow = !!visible;
      icosahedron.receiveShadow = !!visible;
      icoMat.depthWrite = !!visible;
      icoMat.needsUpdate = true;
    }

    function disposeInteractiveAvatarHost() {
      while (icoVisualModelHost.children.length) {
        const child = icoVisualModelHost.children[0];
        icoVisualModelHost.remove(child);
        child.traverse?.((node) => {
          if (node.geometry?.dispose) node.geometry.dispose();
          const mats = Array.isArray(node.material) ? node.material : (node.material ? [node.material] : []);
          mats.forEach(mat => mat?.dispose?.());
        });
      }
    }

    function createProceduralAcornAvatar() {
      const acorn = new THREE.Group();
      acorn.name = 'FallbackProceduralPlayableOakSeed';
      const bodyMat = new THREE.MeshStandardMaterial({
        color: new THREE.Color('#8a5a2b'),
        roughness: 0.88,
        metalness: 0.02
      });
      const capMat = new THREE.MeshStandardMaterial({
        color: new THREE.Color('#3f2b18'),
        roughness: 0.94,
        metalness: 0.02
      });
      const stemMat = new THREE.MeshStandardMaterial({
        color: new THREE.Color('#2c1d10'),
        roughness: 0.96,
        metalness: 0.0
      });
      const body = new THREE.Mesh(new THREE.SphereGeometry(0.28, 24, 18), bodyMat);
      body.name = 'OakSeedBody';
      body.scale.set(0.82, 1.18, 0.82);
      body.position.y = 0.03;
      const cap = new THREE.Mesh(new THREE.SphereGeometry(0.30, 24, 12, 0, Math.PI * 2, 0, Math.PI * 0.56), capMat);
      cap.name = 'OakSeedCap';
      cap.scale.set(0.98, 0.48, 0.98);
      cap.position.y = 0.31;
      const stem = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.055, 0.18, 10), stemMat);
      stem.name = 'OakSeedStem';
      stem.position.y = 0.54;
      stem.rotation.z = -0.28;
      [body, cap, stem].forEach((part) => {
        part.castShadow = true;
        part.receiveShadow = true;
        acorn.add(part);
      });
      acorn.rotation.z = -0.10;
      alignPlayableAcornBottomToGround(acorn);
      return acorn;
    }

    function mountProceduralAcornAvatar(reason = 'fallback') {
      disposeInteractiveAvatarHost();
      const acorn = createProceduralAcornAvatar();
      icoVisualModelHost.add(acorn);
      interactiveSphereState.modelObject = acorn;
      interactiveSphereState.modelLabel = reason === 'default' ? DEFAULT_INTERACTIVE_AVATAR_LABEL : 'Gland de chêne · fallback local';
      interactiveSphereState.modelIsFallback = reason !== 'loaded';
      setPhysicsProxyVisible(false);
      return acorn;
    }

    function clearInteractiveAvatarModel({ showPhysicsProxy = false } = {}) {
      disposeInteractiveAvatarHost();
      interactiveSphereState.modelObject = null;
      interactiveSphereState.modelUrl = '';
      interactiveSphereState.modelLabel = showPhysicsProxy ? 'Proxy physique blanc' : DEFAULT_INTERACTIVE_AVATAR_LABEL;
      interactiveSphereState.modelIsFallback = false;
      setPhysicsProxyVisible(showPhysicsProxy);
    }

    async function loadInteractiveAvatarModel(url, options = {}) {
      const cleanUrl = String(url || '').trim();
      const fallbackToAcorn = options.fallbackToAcorn !== false;
      if (!cleanUrl) {
        resetInteractiveAvatarToDefault();
        return;
      }
      const token = ++interactiveAvatarLoadToken;
      interactiveSphereState.modelUrl = cleanUrl;
      interactiveSphereState.modelLabel = cleanUrl === DEFAULT_INTERACTIVE_AVATAR_MODEL_URL ? DEFAULT_INTERACTIVE_AVATAR_LABEL : 'Modèle 3D personnalisé';
      try {
        const gltf = await interactiveAvatarLoader.loadAsync(cleanUrl);
        if (token !== interactiveAvatarLoadToken) return;
        const model = gltf.scene || gltf.scenes?.[0];
        if (!model) throw new Error('Modèle 3D vide');
        const box = new THREE.Box3().setFromObject(model);
        const size = new THREE.Vector3();
        const center = new THREE.Vector3();
        box.getSize(size);
        box.getCenter(center);
        const maxAxis = Math.max(size.x, size.y, size.z, 0.001);
        const scale = 0.78 / maxAxis;
        disposeInteractiveAvatarHost();
        model.position.sub(center.multiplyScalar(scale));
        model.scale.setScalar(scale);
        alignPlayableAcornBottomToGround(model);
        model.traverse((node) => {
          if (node.isMesh) {
            node.castShadow = true;
            node.receiveShadow = true;
          }
        });
        icoVisualModelHost.add(model);
        interactiveSphereState.modelObject = model;
        interactiveSphereState.modelIsFallback = false;
        setPhysicsProxyVisible(false);
      } catch (err) {
        if (token !== interactiveAvatarLoadToken) return;
        console.warn('[Microcosm] Modèle 3D du gland jouable non chargé, fallback gland local utilisé :', err);
        if (fallbackToAcorn) {
          mountProceduralAcornAvatar(cleanUrl === DEFAULT_INTERACTIVE_AVATAR_MODEL_URL ? 'default' : 'fallback');
          interactiveSphereState.modelUrl = cleanUrl;
        } else {
          clearInteractiveAvatarModel({ showPhysicsProxy: true });
        }
      }
    }

    function resetInteractiveAvatarToDefault() {
      interactiveAvatarLoadToken++;
      interactiveSphereState.modelUrl = DEFAULT_INTERACTIVE_AVATAR_MODEL_URL;
      mountProceduralAcornAvatar('default');
      loadInteractiveAvatarModel(DEFAULT_INTERACTIVE_AVATAR_MODEL_URL, { fallbackToAcorn: true });
    }

    resetInteractiveAvatarToDefault();

    let icoYVel = 0;
    let isGrounded = true;
    window.addEventListener('keydown', (e) => {
      keysPressed[e.key.toLowerCase()] = true;
      if (e.code === 'Space') {
        e.preventDefault();
        if (isGrounded) {
          icoYVel = interactiveSphereState.jumpForce;
          isGrounded = false;
        }
      }
    });
    window.addEventListener('keyup', (e) => {
      keysPressed[e.key.toLowerCase()] = false;
    });
    const cameraOffset = new THREE.Vector3(0, 22, 54);
    const cameraLerpSpeed = 1.6;
    const cameraTargetPos = new THREE.Vector3();
    const cameraTargetLook = new THREE.Vector3();
    function updateIcosahedron(dt) {
      const accel = new THREE.Vector3();
      if (keysPressed['z']) accel.z -= 1;
      if (keysPressed['s']) accel.z += 1;
      if (keysPressed['q']) accel.x -= 1;
      if (keysPressed['d']) accel.x += 1;
      const isMoving = accel.length() > 0.5;
      if (isMoving) {
        accel.normalize().multiplyScalar(interactiveSphereState.speed * dt);
        icoVel.add(accel);
      }
      icoVel.multiplyScalar(interactiveSphereState.damping);
      icosahedron.position.x += icoVel.x * dt;
      icosahedron.position.z += icoVel.z * dt;
      icoYVel += -Math.abs(interactiveSphereState.gravity) * dt;
      icosahedron.position.y += icoYVel * dt;
      groundY = getPlayableAcornCenterY(icosahedron.position.x, icosahedron.position.z);
      if (icosahedron.position.y <= groundY) {
        icosahedron.position.y = groundY;
        icoYVel = 0;
        isGrounded = true;
      }
      const halfField = FIELD_SIZE * 50;
      icosahedron.position.x = Math.max(-halfField, Math.min(halfField, icosahedron.position.x));
      icosahedron.position.z = Math.max(-halfField, Math.min(halfField, icosahedron.position.z));
      if (vrPlayableState.enabled) {
        const planarLen = Math.hypot(icosahedron.position.x, icosahedron.position.z);
        if (planarLen > vrPlayableState.radius) {
          const k = vrPlayableState.radius / Math.max(planarLen, 0.0001);
          icosahedron.position.x *= k;
          icosahedron.position.z *= k;
          icoVel.x *= 0.25;
          icoVel.z *= 0.25;
        }
      }
      if (icoVel.length() > 0.01) {
        const rotAxis = new THREE.Vector3(icoVel.z, 0, -icoVel.x).normalize();
        const rotAmount = icoVel.length() * dt * 5.0;
        icosahedron.rotateOnWorldAxis(rotAxis, rotAmount);
      }
      const heightAboveGround = icosahedron.position.y - groundY;
      const airFactor = Math.max(0, 1 - heightAboveGround * 1.5);
      icoWorld.value.set(
        icosahedron.position.x * airFactor + 99999 * (1 - airFactor),
        0,
        icosahedron.position.z * airFactor + 99999 * (1 - airFactor)
      );
      cameraTargetPos.set(
        icosahedron.position.x + cameraOffset.x,
        cameraOffset.y,
        icosahedron.position.z + cameraOffset.z
      );
      cameraTargetLook.set(
        icosahedron.position.x,
        icosahedron.position.y - 0.75,
        icosahedron.position.z
      );
      // La caméra automatique est maintenant cadrée sur la croissance de l'arbre
      // dans animate(), pas sur l'icosaèdre, afin d'éviter les cadrages vides.
    }
    // Ancien masque de sol/île. Il reste calculable mais invisible :
    // il créait une plaque carrée sombre autour de la graine et recouvrait la terre.
    const groundMatGrass = new THREE.MeshBasicNodeMaterial();
    groundMatGrass.colorNode = Fn(() => {
      const wx = positionWorld.x;
      const wz = positionWorld.z;
      const mask = grassBoundaryMask(wx, wz, groundRadiusGrass);
      return mix(backgroundColorU, groundColorU, mask);
    })();
    // Sol de masque courbé : il suit la même courbure que l'herbe, au lieu de rester plat.
    function buildCurvedGrassGroundGeometry() {
      const geo = new THREE.PlaneGeometry(FIELD_SIZE * 50, FIELD_SIZE * 50, 112, 112);
      geo.rotateX(-Math.PI / 2);
      const pos = geo.getAttribute('position');
      for (let i = 0; i < pos.count; i++) {
        const x = pos.getX(i);
        const z = pos.getZ(i);
        const sag = -(x * x + z * z) * islandCurveStrength.value;
        pos.setY(i, Math.max(sag, oceanPlanetCurveLimit.value + 1.5));
      }
      pos.needsUpdate = true;
      geo.computeVertexNormals();
      return geo;
    }
    const grassGround = new THREE.Mesh(buildCurvedGrassGroundGeometry(), groundMatGrass);
    grassGround.receiveShadow = false;
    grassGround.visible = false;
    islandRoot.add(grassGround);

    // Couche de terre stable sous l'herbe : île fixe, organique et légèrement courbée.
    // Elle ne dépend pas du déplacement de l'océan : utile pour le confort VR.
    const islandEarthMat = new THREE.MeshStandardMaterial({
      color: 0x5a3821,
      roughness: 0.98,
      metalness: 0.0,
      emissive: 0x160c05,
      emissiveIntensity: 0.16,
      side: THREE.DoubleSide
    });
    const islandEdgeMat = new THREE.MeshStandardMaterial({
      color: 0x342014,
      roughness: 1.0,
      metalness: 0.0,
      emissive: 0x100804,
      emissiveIntensity: 0.10,
      side: THREE.DoubleSide
    });
    const islandEarthGroup = new THREE.Group();
    islandRoot.add(islandEarthGroup);
    let islandEarthMesh = null;
    let islandEdgeMesh = null;

    function islandShapeRadiusAtAngle(angle, radius) {
      const lobeA = Math.sin(angle * 3.0 + grassSeedConfig.phaseA);
      const lobeB = Math.sin(angle * 5.0 + grassSeedConfig.phaseB) * 0.55;
      const lobeC = Math.sin(angle * 7.0 + grassSeedConfig.x * 0.013) * 0.20;
      return radius * Math.max(0.72, 1.0 + (lobeA + lobeB) * grassSeedConfig.roundness + lobeC * 0.07);
    }
    function islandCurveYAtXZ(x, z) {
      return Math.max(-(x * x + z * z) * islandCurveStrength.value, oceanPlanetCurveLimit.value + 1.5);
    }

    function buildIslandEarthGeometry(radius) {
      const radial = 26;
      const segments = 144;
      const positions = [];
      const normals = [];
      const uvs = [];
      const indices = [];
      for (let r = 0; r <= radial; r++) {
        const rn = r / radial;
        for (let s = 0; s < segments; s++) {
          const a = (s / segments) * Math.PI * 2;
          const edgeR = islandShapeRadiusAtAngle(a, radius + MICROCOSM_CONFIG.island.earthEdgeMargin);
          const rr = edgeR * rn;
          const x = Math.cos(a) * rr;
          const z = Math.sin(a) * rr;
          // Même courbure que l'herbe : la terre devient un vrai kokedama/îlot,
          // et non plus un disque plat posé derrière.
          const dome = islandCurveYAtXZ(x, z) - 0.018 - rn * rn * 0.040;
          positions.push(x, dome, z);
          normals.push(0, 1, 0);
          uvs.push(rn, s / segments);
        }
      }
      for (let r = 0; r < radial; r++) {
        for (let s = 0; s < segments; s++) {
          const a = r * segments + s;
          const b = r * segments + ((s + 1) % segments);
          const c = (r + 1) * segments + s;
          const d = (r + 1) * segments + ((s + 1) % segments);
          indices.push(a, c, b, b, c, d);
        }
      }
      const geo = new THREE.BufferGeometry();
      geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
      geo.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
      geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
      geo.setIndex(indices);
      geo.computeVertexNormals();
      return geo;
    }

    function buildIslandEdgeGeometry(radius) {
      const segments = 144;
      const positions = [];
      const normals = [];
      const uvs = [];
      const indices = [];
      for (let s = 0; s < segments; s++) {
        const a = (s / segments) * Math.PI * 2;
        const edgeR = islandShapeRadiusAtAngle(a, radius + MICROCOSM_CONFIG.island.earthEdgeMargin);
        const x = Math.cos(a) * edgeR;
        const z = Math.sin(a) * edgeR;
        const curvedTop = islandCurveYAtXZ(x, z) - 0.026;
        const thickness = 0.46 + radius * 0.007;
        const draftInset = thickness * Math.tan(THREE.MathUtils.degToRad(MICROCOSM_CONFIG.island.bevelDraftDegrees));
        const bottomR = Math.max(0.1, edgeR - draftInset);
        const xb = Math.cos(a) * bottomR;
        const zb = Math.sin(a) * bottomR;
        const yTop = curvedTop;
        const yBottom = curvedTop - thickness;
        positions.push(x, yTop, z, xb, yBottom, zb);
        normals.push(Math.cos(a), 0.15, Math.sin(a), Math.cos(a), -0.2, Math.sin(a));
        uvs.push(s / segments, 0, s / segments, 1);
      }
      for (let s = 0; s < segments; s++) {
        const a = s * 2;
        const b = ((s + 1) % segments) * 2;
        indices.push(a, a + 1, b, b, a + 1, b + 1);
      }
      const geo = new THREE.BufferGeometry();
      geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
      geo.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
      geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
      geo.setIndex(indices);
      geo.computeVertexNormals();
      return geo;
    }

    function rebuildIslandEarth(radius) {
      if (islandEarthMesh) {
        islandEarthGroup.remove(islandEarthMesh);
        islandEarthMesh.geometry.dispose();
      }
      if (islandEdgeMesh) {
        islandEarthGroup.remove(islandEdgeMesh);
        islandEdgeMesh.geometry.dispose();
      }
      islandEarthMesh = new THREE.Mesh(buildIslandEarthGeometry(radius), islandEarthMat);
      islandEdgeMesh = new THREE.Mesh(buildIslandEdgeGeometry(radius), islandEdgeMat);
      islandEarthMesh.receiveShadow = true;
      islandEdgeMesh.receiveShadow = true;
      islandEarthMesh.renderOrder = -2;
      islandEdgeMesh.renderOrder = -3;
      islandEarthGroup.add(islandEarthMesh, islandEdgeMesh);
      if (window.__microcosmShoreFadeReady && typeof rebuildShoreBlueFadeParticles === 'function') rebuildShoreBlueFadeParticles(radius);
    }
    rebuildIslandEarth(groundRadiusGrass.value);

    const vrPlayableRingGeo = new THREE.RingGeometry(vrPlayableState.radius - 0.025, vrPlayableState.radius + 0.025, 128);
    vrPlayableRingGeo.rotateX(-Math.PI / 2);
    const vrPlayableRingMat = new THREE.MeshBasicMaterial({
      color: 0x7ee7ff,
      transparent: true,
      opacity: 0.34,
      side: THREE.DoubleSide,
      depthWrite: false
    });
    const vrPlayableRing = new THREE.Mesh(vrPlayableRingGeo, vrPlayableRingMat);
    vrPlayableRing.position.y = 0.022;
    vrPlayableRing.visible = vrPlayableState.visible;
    vrPlayableRing.renderOrder = 20;
    islandRoot.add(vrPlayableRing);

    window.__microcosmShoreFadeReady = true;
    let shoreFadePoints = null;
    function rebuildShoreOrganicFoamParticles(radius) {
      if (shoreFadePoints) {
        islandRoot.remove(shoreFadePoints);
        shoreFadePoints.geometry.dispose();
        shoreFadePoints.material.dispose();
        shoreFadePoints = null;
      }
      const count = Math.max(80, Math.floor(shoreFadeState.density));
      const positions = new Float32Array(count * 3);
      const colors = new Float32Array(count * 3);
      const baseColor = new THREE.Color(shoreFadeState.color);
      for (let i = 0; i < count; i++) {
        const a = (i / count) * Math.PI * 2 + (Math.random() - 0.5) * 0.035;
        const edgeR = islandShapeRadiusAtAngle(a, radius + MICROCOSM_CONFIG.island.earthEdgeMargin + MICROCOSM_CONFIG.island.shoreMargin);
        // Décalage principalement extérieur : la mousse épouse le tracé de l'îlot sans rentrer sous la terre.
        const radialJitter = (0.16 + Math.random() * 0.84) * shoreFadeState.width;
        const rr = edgeR + radialJitter;
        const x = Math.cos(a) * rr;
        const z = Math.sin(a) * rr;
        const y = islandCurveYAtXZ(x, z) - 0.012 + Math.random() * 0.055;
        positions[i * 3 + 0] = x;
        positions[i * 3 + 1] = y;
        positions[i * 3 + 2] = z;
        const c = baseColor.clone().lerp(new THREE.Color(0xffffff), 0.30 + Math.random() * 0.34);
        colors[i * 3 + 0] = c.r;
        colors[i * 3 + 1] = c.g;
        colors[i * 3 + 2] = c.b;
      }
      const geo = new THREE.BufferGeometry();
      geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
      geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
      const mat = new THREE.PointsMaterial({
        size: 0.092,
        vertexColors: true,
        transparent: true,
        opacity: shoreFadeState.enabled ? shoreFadeState.opacity : 0,
        depthWrite: false,
        sizeAttenuation: true
      });
      shoreFadePoints = new THREE.Points(geo, mat);
      shoreFadePoints.renderOrder = 18;
      shoreFadePoints.frustumCulled = false;
      islandRoot.add(shoreFadePoints);
    }
    rebuildShoreOrganicFoamParticles(groundRadiusGrass.value);

    // ───── Arbre (tree) ─────
    const treeGroup = new THREE.Group();
    islandRoot.add(treeGroup);
    const saplingGroupTree = new THREE.Group();
    islandRoot.add(saplingGroupTree);
    // Start the tree with a small sprout (10% grown)
    let growthProgress = 0;
    let isGrowing = false;
    let targetGrowth = 0;
    let growthSpeedTree = 0.065;
    const FIB_GROWTH_TOTAL_CLICKS_TREE = 365;
    const FIB_GROWTH_MILESTONES_TREE = [0, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 365];
    let growthClicksTree = 0;
    let manualGrowthModeTree = false;
    let seasonModeTree = true;
    const treeSeasonState = {
      name: 'hiver',
      leafVisibility: 0.28,
      leafScale: 0.48,
      leafHue: new THREE.Color(0x6b8f2a),
      fallingLeaves: 0.15
    };
    const fibStageNamesTree = [
      'graine dormante',
      'premier germe',
      'racines profondes',
      'jeune pousse',
      'tige robuste',
      'premier rameau',
      'jeune arbre',
      'charpente naissante',
      'couronne en formation',
      'chêne adulte',
      'grand chêne',
      'vieux chêne',
      'chêne centenaire'
    ];
    function smoothStepTree(t) {
      t = Math.max(0, Math.min(1, t));
      return t * t * (3 - 2 * t);
    }
    function getFibonacciStageIndexTree(clicks) {
      for (let i = 0; i < FIB_GROWTH_MILESTONES_TREE.length - 1; i++) {
        if (clicks <= FIB_GROWTH_MILESTONES_TREE[i + 1]) return i;
      }
      return FIB_GROWTH_MILESTONES_TREE.length - 2;
    }
    function fibonacciGrowthCurveTree(clicks) {
      const c = Math.max(0, Math.min(FIB_GROWTH_TOTAL_CLICKS_TREE, clicks));
      if (c <= 0) return 0;
      if (c >= FIB_GROWTH_TOTAL_CLICKS_TREE) return 1;
      const stageIndex = getFibonacciStageIndexTree(c);
      const a = FIB_GROWTH_MILESTONES_TREE[stageIndex];
      const b = FIB_GROWTH_MILESTONES_TREE[stageIndex + 1];
      const localT = smoothStepTree((c - a) / Math.max(1, b - a));
      const fibStageProgress = (stageIndex + localT) / (FIB_GROWTH_MILESTONES_TREE.length - 2);
      const dayProgress = c / FIB_GROWTH_TOTAL_CLICKS_TREE;
      // Progression continue : les jalons Fibonacci rythment les stades,
      // mais la taille reste dominée par le nombre réel de jours/clics pour éviter les sauts visibles.
      const blended = dayProgress * 0.84 + fibStageProgress * 0.16;
      return Math.min(1, Math.pow(blended, 1.38));
    }
    function getFibonacciStageNameTree(clicks) {
      if (clicks >= FIB_GROWTH_TOTAL_CLICKS_TREE) return 'chêne centenaire accompli';
      const idx = getFibonacciStageIndexTree(Math.max(1, clicks));
      return fibStageNamesTree[Math.min(idx, fibStageNamesTree.length - 1)];
    }
    function getSeasonFromDayTree(day) {
      // Rythme saisonnier français sur 365 jours.
      // La densité des feuilles varie de façon continue, pas par ruptures :
      // printemps = bourgeonnement / repousse, été = pleine densité,
      // automne = jaunissement + perte, hiver = repos végétatif.
      const d = ((Math.round(day) - 1) % 365 + 365) % 365 + 1;

      if (d >= 60 && d <= 151) {
        const t = (d - 60) / 91;
        const e = smoothStepTree(t);
        return {
          name: 'printemps',
          leafVisibility: THREE.MathUtils.lerp(0.42, 0.92, e),
          leafScale: THREE.MathUtils.lerp(0.56, 0.92, e),
          leafHue: new THREE.Color(0x72b83f),
          fallingLeaves: 0.01
        };
      }

      if (d >= 152 && d <= 243) {
        const t = (d - 152) / 91;
        return {
          name: 'été',
          leafVisibility: 0.96,
          leafScale: 1.0,
          leafHue: new THREE.Color(0x2f7d32),
          fallingLeaves: 0.006
        };
      }

      if (d >= 244 && d <= 334) {
        const t = (d - 244) / 90;
        const e = smoothStepTree(t);
        return {
          name: 'automne',
          leafVisibility: THREE.MathUtils.lerp(0.86, 0.32, e),
          leafScale: THREE.MathUtils.lerp(0.86, 0.58, e),
          leafHue: new THREE.Color(0xa58a32),
          fallingLeaves: THREE.MathUtils.lerp(0.16, 0.55, e)
        };
      }

      // Hiver : l'arbre peut garder quelques bourgeons/feuilles terminales,
      // mais pas des rubans verts complets sur toutes les branches.
      return {
        name: 'hiver',
        leafVisibility: 0.16,
        leafScale: 0.40,
        leafHue: new THREE.Color(0x5f6f28),
        fallingLeaves: 0.05
      };
    }
    function setTreeGrowthByPercent(percent, updateClicks = true) {
      manualGrowthModeTree = true;
      const pct = THREE.MathUtils.clamp(percent, 0, 100);
      const p = pct / 100;
      growthProgress = p;
      targetGrowth = p;
      isGrowing = false;
      if (updateClicks) growthClicksTree = Math.round(p * FIB_GROWTH_TOTAL_CLICKS_TREE);
      restoreCameraAutoFollow({ clearUserLock: true });
      updateTreeCameraFrame(0.016, true);
      updateGrowthUITree?.();
      if (growthSliderTree) growthSliderTree.value = pct;
    }
    function updateTreeCameraFrame(dt, force = false) {
      if ((!cameraAutoFollow && !force) || renderer.xr.isPresenting) return;
      if (vrRuntimeState.viewMode === '1re personne' && typeof icosahedron !== 'undefined') {
        const lerpFactor = force ? 1 : 1.0 - Math.exp(-3.2 * dt);
        const forwardZ = keysPressed?.['z'] ? -1 : (keysPressed?.['s'] ? 1 : -1);
        cameraTargetPos.set(icosahedron.position.x, icosahedron.position.y + 1.35, icosahedron.position.z + 1.65 * forwardZ);
        cameraTargetLook.set(icosahedron.position.x, icosahedron.position.y + 1.05, icosahedron.position.z - 6.0 * forwardZ);
        cameraTargetPos.y += islandRoot.position.y;
        cameraTargetLook.y += islandRoot.position.y;
        camera.position.lerp(cameraTargetPos, lerpFactor);
        controls.target.lerp(cameraTargetLook, lerpFactor);
        if (force) {
          camera.lookAt(cameraTargetLook);
          camera.updateProjectionMatrix();
          controls.update();
        }
        return;
      }
      const p = THREE.MathUtils.clamp(growthProgress, 0, 1);
      // À partir de 69 % de croissance, on bloque le calcul de cadrage.
      // L'arbre continue à pousser, mais la caméra ne recule/dézoome plus automatiquement.
      const cameraP = Math.min(p, 0.69);
      const early = THREE.MathUtils.smoothstep(cameraP, 0.0, 0.18);
      const mid = THREE.MathUtils.smoothstep(cameraP, 0.16, 0.62);
      const late = THREE.MathUtils.smoothstep(cameraP, 0.55, 1.0);

      // Cadrage 1 an : mêmes étapes qu'avant, mais environ 2× plus zoomées.
      // Le cadrage est plafonné à 69 % pour éviter tout dézoom après ce seuil.
      const closePos = new THREE.Vector3(0, 2.6, 7.0);
      const midPos = new THREE.Vector3(0, 8.2, 18.0);
      const farPos = new THREE.Vector3(-4.0, 20.5, 42.0);
      const closeLook = new THREE.Vector3(0, 0.75, 0);
      const midLook = new THREE.Vector3(0, 5.2, 0);
      const farLook = new THREE.Vector3(0, 13.5, 0);

      cameraTargetPos.copy(closePos).lerp(midPos, early).lerp(farPos, late);
      cameraTargetLook.copy(closeLook).lerp(midLook, mid).lerp(farLook, late);
      // L'auto-cadrage suit le micro-monde flottant sans provoquer de dézoom.
      cameraTargetPos.y += islandRoot.position.y;
      cameraTargetLook.y += islandRoot.position.y;
      const lerpFactor = force ? 1 : 1.0 - Math.exp(-1.55 * dt);
      camera.position.lerp(cameraTargetPos, lerpFactor);
      controls.target.lerp(cameraTargetLook, lerpFactor);
      if (force) {
        camera.lookAt(cameraTargetLook);
        camera.updateProjectionMatrix();
        controls.update();
      }
    }

    function matureOakScaleTree(progress) {
      // Croissance organique continue : le chêne part très petit,
      // puis augmente sans palier visible. Le tronc adulte n’est jamais affiché
      // directement à sa taille finale.
      const p = Math.max(0, Math.min(1, progress));
      // Le modèle adulte commence vraiment après les premiers jours : avant cela,
      // on affiche une pousse dédiée, fine et feuillue, pour éviter un tronc adulte miniature.
      // Le modèle adulte ne prend le relais qu'après une vraie phase de jeune pousse lisible.
      // Avant ~J55, on laisse un brin unique/tige élégante porter l'expérience visuelle.
      const start = fibonacciGrowthCurveTree(55);
      const q = THREE.MathUtils.clamp((p - start) / (1 - start), 0, 1);
      const eased = q * q * (3 - 2 * q);
      return q <= 0 ? 0.0001 : 0.06 + eased * 0.94;
    }
    const branches = [];
    const leavesArr = [];
    const canopyPatchesArr = [];
    const flowersArr = [];
    const fallingLeavesArr = [];
    const barkMat = new THREE.MeshStandardMaterial({ color: 0x5f4938, roughness: 0.96, metalness: 0.01 });
    const darkBarkMat = new THREE.MeshStandardMaterial({ color: 0x3f3026, roughness: 0.98, metalness: 0.01 });
    // Graine déterministe : chaque navigateur/utilisateur garde son arbre,
    // et le bouton « Nouvelle graine » crée une nouvelle partie réellement unique.
    const TREE_USER_SEED_KEY = 'microcosm-tree-user-seed-v1';
    const TREE_GAME_SEED_KEY = 'microcosm-tree-game-seed-v1';
    function createTreeSeedStringTree(prefix = 'tree') {
      const bytes = new Uint32Array(4);
      if (window.crypto?.getRandomValues) {
        window.crypto.getRandomValues(bytes);
      } else {
        const now = Date.now();
        for (let i = 0; i < bytes.length; i++) bytes[i] = ((now + i * 2654435761) ^ Math.floor(Math.random() * 0xffffffff)) >>> 0;
      }
      return `${prefix}-${Date.now().toString(36)}-${Array.from(bytes, n => n.toString(36)).join('-')}`;
    }
    function readTreeStorageSeedTree(key, prefix) {
      try {
        let value = localStorage.getItem(key);
        if (!value) {
          value = createTreeSeedStringTree(prefix);
          localStorage.setItem(key, value);
        }
        return value;
      } catch (_) {
        return createTreeSeedStringTree(prefix);
      }
    }
    function hashTreeSeedStringTree(str) {
      let h = 2166136261 >>> 0;
      for (let i = 0; i < str.length; i++) {
        h ^= str.charCodeAt(i);
        h = Math.imul(h, 16777619) >>> 0;
      }
      return h >>> 0;
    }
    const treeUserSeedString = readTreeStorageSeedTree(TREE_USER_SEED_KEY, 'user');
    let treeGameSeedString = readTreeStorageSeedTree(TREE_GAME_SEED_KEY, 'game');
    let treeSeedBase = hashTreeSeedStringTree(`${treeUserSeedString}|${treeGameSeedString}`);
    let treeSeedState = treeSeedBase || 0x9e3779b9;
    function resetTreeRandomStreamTree() {
      treeSeedBase = hashTreeSeedStringTree(`${treeUserSeedString}|${treeGameSeedString}`);
      treeSeedState = treeSeedBase || 0x9e3779b9;
    }
    function treeRandom() {
      treeSeedState = (treeSeedState + 0x6D2B79F5) >>> 0;
      let t = treeSeedState;
      t = Math.imul(t ^ (t >>> 15), t | 1);
      t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
      return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
    }
    function newTreeGameSeedTree() {
      treeGameSeedString = createTreeSeedStringTree('game');
      try { localStorage.setItem(TREE_GAME_SEED_KEY, treeGameSeedString); } catch (_) {}
      resetTreeRandomStreamTree();
    }
    resetTreeRandomStreamTree();
    // Use green hues for tree leaves instead of pink cherry blossoms
    const leafColorsArr = [0x1f5f2a, 0x2f7d32, 0x3d8f3f, 0x5f9f3a, 0x6b8f2a, 0x8aa13a];
    const flowerColorsArr = [0xffb7c5, 0xffc1cc, 0xffd4dc, 0xff9eb5, 0xffffff, 0xffe0e8];
    function createTaperedTubeTree(curve, segments, radiusStart, radiusEnd, radialSegments) {
      const frames = curve.computeFrenetFrames(segments, false);
      const vertices = [];
      const normals = [];
      const uvs = [];
      const indices = [];
      for (let i = 0; i <= segments; i++) {
        const t = i / segments;
        const r = radiusStart * (1 - t) + radiusEnd * t;
        const P = curve.getPointAt(t);
        const N = frames.normals[i];
        const B = frames.binormals[i];
        for (let j = 0; j <= radialSegments; j++) {
          const v = (j / radialSegments) * Math.PI * 2;
          const sinV = Math.sin(v);
          const cosV = -Math.cos(v);
          const nx = cosV * N.x + sinV * B.x;
          const ny = cosV * N.y + sinV * B.y;
          const nz = cosV * N.z + sinV * B.z;
          vertices.push(P.x + r * nx, P.y + r * ny, P.z + r * nz);
          normals.push(nx, ny, nz);
          uvs.push(j / radialSegments, t);
        }
      }
      for (let i = 0; i < segments; i++) {
        for (let j = 0; j < radialSegments; j++) {
          const a = i * (radialSegments + 1) + j;
          const b = a + radialSegments + 1;
          indices.push(a, b, a + 1);
          indices.push(b, b + 1, a + 1);
        }
      }
      const geo = new THREE.BufferGeometry();
      geo.setIndex(indices);
      geo.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
      geo.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
      geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
      return geo;
    }
    function createBranchTree(startPos, direction, length, radius, depth, parentGrowth) {
      // Ramification plus proche d'un chêne : charpentières solides,
      // puis nombreuses petites branches fines. Les diamètres diminuent
      // fortement pour éviter les “tuyaux” disproportionnés.
      const wobble = depth === 0 ? 0.025 : 0.045 + depth * 0.014;
      const gravityDroop = depth >= 2 ? -0.045 * depth : 0;
      const pts = [];
      const numPts = depth < 2 ? 8 : 6;
      for (let i = 0; i <= numPts; i++) {
        const t = i / numPts;
        const p = startPos.clone().add(direction.clone().multiplyScalar(length * t));
        if (i > 0 && i < numPts) {
          p.x += (treeRandom() - 0.5) * length * wobble;
          p.y += (treeRandom() - 0.35) * length * wobble * 0.35 + gravityDroop * t;
          p.z += (treeRandom() - 0.5) * length * wobble;
        }
        pts.push(p);
      }
      const curve = new THREE.CatmullRomCurve3(pts);
      const segments = Math.max(18, Math.floor(length * 8.0));
      const radialSegments = depth === 0 ? 14 : depth < 3 ? 9 : 6;
      const radiusBase = depth === 0 ? radius : radius * 1.03;
      // Évite l'effet de tronc/cime sectionné : même le tronc principal se termine
      // en vraie pointe organique, puis la cime est reprise par des branches terminales.
      const radiusEnd = radius * (depth === 0 ? 0.22 : depth < 2 ? 0.34 : depth < 4 ? 0.22 : 0.14);
      const geo = createTaperedTubeTree(curve, segments, radiusBase, radiusEnd, radialSegments);
      const mesh = new THREE.Mesh(geo, depth < 2 ? barkMat : darkBarkMat);
      mesh.castShadow = true;
      mesh.receiveShadow = true;
      const endPos = curve.getPointAt(1);
      const tangentEnd = curve.getTangentAt(1).normalize();
      const baseWindow = 0.16;
      const depthBonus = depth * 0.045;
      const lengthFactor = length / 10;
      const growthWindow = Math.min(0.48, (baseWindow + depthBonus) * (0.82 + lengthFactor * 0.18));
      const branchData = {
        mesh,
        curve,
        depth,
        growthStart: parentGrowth,
        growthEnd: parentGrowth + growthWindow,
        parentStartT: 0,
        endPos,
        endDirection: tangentEnd,
        length,
        radius,
        radiusEnd,
        segments,
        radialSegments,
        children: [],
        leavesAdded: false,
        fullGeo: geo,
        _lastVisibleSeg: -1,
        startPos: startPos.clone(),
        parentBranch: null,
      };
      geo.setDrawRange(0, 0);
      mesh.visible = false;
      mesh.frustumCulled = false;
      treeGroup.add(mesh);
      branches.push(branchData);

      if (depth < 5) {
        const numChildren = depth === 0 ? 5 + Math.floor(treeRandom() * 2) :
                            depth === 1 ? 4 + Math.floor(treeRandom() * 2) :
                            depth === 2 ? 3 + Math.floor(treeRandom() * 2) :
                            depth === 3 ? 2 + Math.floor(treeRandom() * 2) :
                            treeRandom() > 0.42 ? 1 : 0;
        for (let i = 0; i < numChildren; i++) {
          const childStartT = depth === 0 ? 0.36 + treeRandom() * 0.60 : 0.26 + treeRandom() * 0.68;
          const childStart = curve.getPointAt(childStartT);
          const parentTangent = curve.getTangentAt(childStartT).normalize();
          const connectionOverlap = radius * 0.18;
          childStart.addScaledVector(parentTangent, -connectionOverlap);
          const spreadAngle = depth === 0 ? 1.05 + treeRandom() * 0.55 : depth < 3 ? 0.85 + treeRandom() * 0.55 : 0.65 + treeRandom() * 0.45;
          const upwardBias = depth === 0 ? 0.42 : depth < 3 ? 0.26 : 0.14;
          const randomPerp = new THREE.Vector3((treeRandom() - 0.5), 0, (treeRandom() - 0.5));
          if (randomPerp.lengthSq() < 0.001) randomPerp.set(1, 0, 0);
          randomPerp.normalize();
          const childDir = new THREE.Vector3()
            .addScaledVector(parentTangent, depth === 0 ? 0.44 : 0.50)
            .addScaledVector(randomPerp, spreadAngle)
            .add(new THREE.Vector3(0, upwardBias, 0))
            .normalize();
          const lengthMult = depth === 0 ? 0.58 + treeRandom() * 0.18 :
                             depth === 1 ? 0.52 + treeRandom() * 0.16 :
                             depth === 2 ? 0.42 + treeRandom() * 0.15 :
                             depth === 3 ? 0.34 + treeRandom() * 0.13 :
                             0.28 + treeRandom() * 0.10;
          const childLength = length * lengthMult;
          const radiusMult = depth === 0 ? 0.42 + treeRandom() * 0.08 :
                             depth === 1 ? 0.38 + treeRandom() * 0.08 :
                             depth === 2 ? 0.32 + treeRandom() * 0.07 :
                             0.26 + treeRandom() * 0.06;
          const childRadius = Math.max(0.025, radius * radiusMult);
          const childGrowthStart = branchData.growthStart + (branchData.growthEnd - branchData.growthStart) * childStartT;
          const childBranch = createBranchTree(childStart, childDir, childLength, childRadius, depth + 1, childGrowthStart);
          childBranch.parentBranch = branchData;
          childBranch.parentStartT = childStartT;
          branchData.children.push(childBranch);
        }
      }
      return branchData;
    }
    // Leaves and flowers instanced meshes for tree
    const MAX_LEAVES_TREE = 16000; // budget optimisé : assez dense, mais beaucoup moins coûteux GPU que 30000
    const leafInstanceGeoTree = new THREE.SphereGeometry(0.42, 7, 5);
    leafInstanceGeoTree.scale(1.45, 0.22, 0.82);
    const leafInstanceMatsTree = leafColorsArr.map(c => new THREE.MeshStandardMaterial({ color: c, roughness: 0.5, metalness: 0.02, side: THREE.DoubleSide, emissive: new THREE.Color(c).multiplyScalar(0.08) }));
    const leafInstancedMeshesTree = [];
    const leafInstancesPerMeshTree = Math.ceil(MAX_LEAVES_TREE / leafColorsArr.length);
    for (let i = 0; i < leafColorsArr.length; i++) {
      const im = new THREE.InstancedMesh(leafInstanceGeoTree, leafInstanceMatsTree[i], leafInstancesPerMeshTree);
      im.count = 0;
      im.castShadow = true;
      im.frustumCulled = false;
      treeGroup.add(im);
      leafInstancedMeshesTree.push(im);
    }
    const flowerPetalGeoTree = new THREE.SphereGeometry(0.1, 5, 4);
    flowerPetalGeoTree.scale(1.8, 0.25, 1.2);
    const flowerPetalMatsTree = flowerColorsArr.map(c => new THREE.MeshStandardMaterial({ color: c, roughness: 0.4, metalness: 0.02, emissive: new THREE.Color(c).multiplyScalar(0.06), transparent: true, opacity: 0.92 }));
    const flowerInstancedMeshesTree = [];
    const petalsPerColorMeshTree = Math.ceil((MAX_LEAVES_TREE / 5) / flowerColorsArr.length);
    for (let i = 0; i < flowerColorsArr.length; i++) {
      const im = new THREE.InstancedMesh(flowerPetalGeoTree, flowerPetalMatsTree[i], petalsPerColorMeshTree);
      im.count = 0;
      im.castShadow = true;
      im.frustumCulled = false;
      treeGroup.add(im);
      flowerInstancedMeshesTree.push(im);
    }
    const flowerCenterGeoTree = new THREE.SphereGeometry(0.04, 6, 6);
    const flowerCenterMatTree = new THREE.MeshStandardMaterial({ color: 0xf5d76e, emissive: 0x4a3520, roughness: 0.6 });
    const flowerCenterIMTree = new THREE.InstancedMesh(flowerCenterGeoTree, flowerCenterMatTree, 300);
    flowerCenterIMTree.count = 0;
    flowerCenterIMTree.castShadow = true;
    flowerCenterIMTree.frustumCulled = false;
    treeGroup.add(flowerCenterIMTree);
    // helpers for leaves and flowers
    const _leafDummyTree = new THREE.Object3D();
    const _leafMatrixTree = new THREE.Matrix4();
    const _tempVecTree = new THREE.Vector3();
    const _tempQuatTree = new THREE.Quaternion();
    const _tempScaleTree = new THREE.Vector3();
    const _tempEulerTree = new THREE.Euler();
    const _leafTmpATree = new THREE.Vector3();
    const _leafTmpBTree = new THREE.Vector3();
    const _leafTmpCTree = new THREE.Vector3();
    function seeded01Tree(a, b = 0) {
      const seedOffset = (treeSeedBase % 1000003) * 0.000137;
      const s = Math.sin((a + seedOffset) * 127.1 + (b + seedOffset * 0.73) * 311.7) * 43758.5453123;
      return s - Math.floor(s);
    }
    function makeBranchSurfaceOffsetTree(branch, branchT, ringAngle, looseness = 1) {
      const tangent = branch.curve.getTangentAt(branchT).clone().normalize();
      const upRef = Math.abs(tangent.y) > 0.82 ? _leafTmpATree.set(1, 0, 0) : _leafTmpATree.set(0, 1, 0);
      const normal = _leafTmpBTree.copy(tangent).cross(upRef).normalize();
      if (normal.lengthSq() < 1e-6) normal.set(1, 0, 0);
      const binormal = _leafTmpCTree.copy(tangent).cross(normal).normalize();
      const branchRadiusAtT = THREE.MathUtils.lerp(branch.radius, branch.radiusEnd, branchT);
      const attachRadius = Math.max(0.025, branchRadiusAtT * (0.78 + 0.18 * looseness));
      const n = normal.clone().multiplyScalar(Math.cos(ringAngle) * attachRadius);
      const b = binormal.clone().multiplyScalar(Math.sin(ringAngle) * attachRadius);
      const along = tangent.multiplyScalar((seeded01Tree(branch.depth + branchT * 17.0, ringAngle) - 0.5) * attachRadius * 0.25);
      return n.add(b).add(along);
    }
    function createCanopyPatchTree(branch, branchT, ringAngle, options = {}) {
      // Désactivé : les patchs foliaires en Mesh séparés créaient trop de draw calls
      // et donnaient un feuillage incohérent en rubans. On utilise désormais
      // uniquement des feuilles instanciées optimisées.
      return null;
    }
    function createLeafTree(position, options = {}) {
      // Répartition équilibrée entre les meshes de couleurs : évite qu'une couleur
      // soit saturée trop tôt et que les feuilles générées plus tard, notamment
      // celles de la cime, soient simplement ignorées.
      let colorIdx = options.colorIdx ?? ((leavesArr.length + Math.floor(treeRandom() * leafColorsArr.length)) % leafColorsArr.length);
      let im = leafInstancedMeshesTree[colorIdx];
      let idx = im.count;
      if (idx >= leafInstancesPerMeshTree) {
        let found = false;
        for (let ci = 0; ci < leafInstancedMeshesTree.length; ci++) {
          const testIdx = (colorIdx + ci + 1) % leafInstancedMeshesTree.length;
          if (leafInstancedMeshesTree[testIdx].count < leafInstancesPerMeshTree) {
            colorIdx = testIdx;
            im = leafInstancedMeshesTree[colorIdx];
            idx = im.count;
            found = true;
            break;
          }
        }
        if (!found) return;
      }
      im.count = idx + 1;

      const branch = options.branch || null;
      const branchT = options.branchT ?? 1;
      const ringAngle = options.ringAngle ?? treeRandom() * Math.PI * 2;
      const jitterScale = options.jitterScale ?? (branch ? Math.max(0.22, 0.9 - branch.depth * 0.07) : 1.0);
      const localOffset = options.offset ? options.offset.clone() : branch
        ? makeBranchSurfaceOffsetTree(branch, branchT, ringAngle, jitterScale)
        : new THREE.Vector3(
            (treeRandom() - 0.5) * 0.35 * jitterScale,
            (treeRandom() - 0.25) * 0.20 * jitterScale,
            (treeRandom() - 0.5) * 0.35 * jitterScale
          );
      const basePos = branch ? branch.curve.getPointAt(branchT).add(localOffset) : position.clone().add(localOffset);
      const px = basePos.x;
      const py = basePos.y;
      const pz = basePos.z;
      const rx = treeRandom() * Math.PI;
      const ry = treeRandom() * Math.PI;
      const rz = treeRandom() * Math.PI;
      _leafDummyTree.position.set(px, py, pz);
      _leafDummyTree.rotation.set(rx, ry, rz);
      _leafDummyTree.scale.set(0.001, 0.001, 0.001);
      _leafDummyTree.updateMatrix();
      im.setMatrixAt(idx, _leafDummyTree.matrix);
      im.instanceMatrix.needsUpdate = true;

      const branchGrowthAtLeaf = branch
        ? branch.growthStart + (branch.growthEnd - branch.growthStart) * branchT
        : 0.02;
      // Les feuilles démarrent presque en même temps que le segment qui les porte.
      // Elles ne doivent plus attendre la moitié de la croissance globale.
      const earlyBias = branch ? (branch.depth <= 1 ? -0.095 : -0.065) : -0.015;
      const growthStart = THREE.MathUtils.clamp(
        options.growthStart ?? (branchGrowthAtLeaf + earlyBias + treeRandom() * 0.028),
        0.004,
        0.88
      );

      const leafData = {
        instancedMesh: im,
        instanceIdx: idx,
        colorIdx,
        branch,
        branchT,
        localOffset,
        growthStart,
        targetScale: options.targetScale ?? (0.54 + treeRandom() * 0.46),
        seasonSeed: options.seasonSeed ?? treeRandom(),
        terminalFollower: options.terminalFollower === true,
        tipPriority: options.tipPriority ?? 0,
        swayOffset: treeRandom() * Math.PI * 2,
        swaySpeed: 0.5 + treeRandom() * 1.5,
        originalPos: new THREE.Vector3(px, py, pz),
        baseRotX: rx,
        baseRotY: ry,
        baseRotZ: rz,
        currentScale: 0,
      };
      leavesArr.push(leafData);
    }
    const _flowerDummyTree = new THREE.Object3D();
    function createFlowerTree(position) {
      const colorIdx = Math.floor(treeRandom() * flowerColorsArr.length);
      const im = flowerInstancedMeshesTree[colorIdx];
      const baseRotX = treeRandom() * 0.5;
      const baseRotY = treeRandom() * Math.PI * 2;
      const baseRotZ = treeRandom() * 0.5;
      const targetScale = 0.8 + treeRandom() * 0.6;
      const petalIndices = [];
      const numPetals = 5;
      for (let i = 0; i < numPetals; i++) {
        const idx = im.count;
        if (idx >= petalsPerColorMeshTree) return;
        im.count = idx + 1;
        _flowerDummyTree.position.set(0, 0, 0);
        _flowerDummyTree.scale.set(0.001, 0.001, 0.001);
        _flowerDummyTree.updateMatrix();
        im.setMatrixAt(idx, _flowerDummyTree.matrix);
        petalIndices.push(idx);
      }
      im.instanceMatrix.needsUpdate = true;
      const centerIdx = flowerCenterIMTree.count;
      if (centerIdx >= 300) return;
      flowerCenterIMTree.count = centerIdx + 1;
      _flowerDummyTree.position.set(0, 0, 0);
      _flowerDummyTree.scale.set(0.001, 0.001, 0.001);
      _flowerDummyTree.updateMatrix();
      flowerCenterIMTree.setMatrixAt(centerIdx, _flowerDummyTree.matrix);
      flowerCenterIMTree.instanceMatrix.needsUpdate = true;
      const flowerData = {
        instancedMesh: im,
        petalIndices,
        centerIdx,
        colorIdx,
        position: position.clone(),
        growthStart: 0.7 + treeRandom() * 0.25,
        targetScale,
        swayOffset: treeRandom() * Math.PI * 2,
        baseRotX,
        baseRotY,
        baseRotZ,
        currentScale: 0,
      };
      flowersArr.push(flowerData);
    }
    // Falling leaves pool for tree
    const FALLING_LEAF_POOL_SIZE_TREE = 80;
    const fallingLeafPoolTree = [];
    let fallingLeafNextIdxTree = 0;
    const fallingLeafGeoTree = new THREE.PlaneGeometry(0.36, 0.46);
    const fallingLeafMatTree = new THREE.MeshStandardMaterial({ roughness: 0.45, side: THREE.DoubleSide, transparent: true, opacity: 1.0 });
    const fallingLeafIMTree = new THREE.InstancedMesh(fallingLeafGeoTree, fallingLeafMatTree, FALLING_LEAF_POOL_SIZE_TREE);
    fallingLeafIMTree.count = FALLING_LEAF_POOL_SIZE_TREE;
    fallingLeafIMTree.castShadow = true;
    fallingLeafIMTree.frustumCulled = false;
    treeGroup.add(fallingLeafIMTree);
    const _flDummyTree = new THREE.Object3D();
    for (let i = 0; i < FALLING_LEAF_POOL_SIZE_TREE; i++) {
      _flDummyTree.position.set(0, -100, 0);
      _flDummyTree.scale.set(0.001, 0.001, 0.001);
      _flDummyTree.updateMatrix();
      fallingLeafIMTree.setMatrixAt(i, _flDummyTree.matrix);
      const colorIdx = Math.floor(treeRandom() * leafColorsArr.length);
      const c = new THREE.Color(leafColorsArr[colorIdx]);
      fallingLeafIMTree.setColorAt(i, c);
      fallingLeafPoolTree.push({
        idx: i,
        position: new THREE.Vector3(0, -100, 0),
        rotation: new THREE.Euler(),
        scale: 1,
        velocity: new THREE.Vector3(),
        rotSpeed: new THREE.Vector3(),
        swayOffset: 0,
        life: 0,
        opacity: 0,
        active: false,
      });
    }
    fallingLeafIMTree.instanceMatrix.needsUpdate = true;
    fallingLeafIMTree.instanceColor.needsUpdate = true;
    function spawnFallingLeafTree() {
      const entry = fallingLeafPoolTree[fallingLeafNextIdxTree];
      fallingLeafNextIdxTree = (fallingLeafNextIdxTree + 1) % FALLING_LEAF_POOL_SIZE_TREE;
      const startAngle = treeRandom() * Math.PI * 2;
      const startRadius = 2 + treeRandom() * 13;
      const s = 0.7 + treeRandom() * 0.8;
      entry.position.set(Math.cos(startAngle) * startRadius, 16 + treeRandom() * 18, Math.sin(startAngle) * startRadius);
      entry.rotation.set(treeRandom() * Math.PI, treeRandom() * Math.PI, treeRandom() * Math.PI);
      entry.scale = s;
      entry.opacity = 0.85;
      const windRT = getWindRuntime();
      const fallPush = 0.006 * windRT.fallAmp * windRT.speedMul;
      entry.velocity.set(
        (treeRandom() - 0.5) * 0.02 + windRT.dirX * fallPush,
        -0.01 - treeRandom() * 0.02,
        (treeRandom() - 0.5) * 0.02 + windRT.dirZ * fallPush
      );
      entry.rotSpeed.set(
        (treeRandom() - 0.5) * 0.05 * windRT.speedMul,
        (treeRandom() - 0.5) * 0.05 * windRT.speedMul,
        (treeRandom() - 0.5) * 0.03 * windRT.speedMul
      );
      entry.swayOffset = treeRandom() * Math.PI * 2;
      entry.life = 1;
      entry.active = true;
      const colorIdx = Math.floor(treeRandom() * leafColorsArr.length);
      fallingLeafIMTree.setColorAt(entry.idx, new THREE.Color(leafColorsArr[colorIdx]));
    }
    // Visuels des premiers stades : graine → germe → jeune pousse → jeune arbre.
    // Ils évitent l'apparition irréaliste d'un gros tronc dès les premiers clics.
    const saplingPartsTree = [];
    function makeSaplingMaterialTree(color, opacity = 1) {
      return new THREE.MeshStandardMaterial({
        color,
        roughness: 0.74,
        metalness: 0.02,
        transparent: true,
        opacity,
        side: THREE.DoubleSide
      });
    }
    function addSaplingPartTree(mesh, start, peak, end, maxScale = 1) {
      mesh.visible = false;
      mesh.frustumCulled = false;
      const baseScale = mesh.scale.clone();
      saplingGroupTree.add(mesh);
      saplingPartsTree.push({ mesh, start, peak, end, maxScale, baseScale });
      return mesh;
    }
    function buildSaplingStagesTree() {
      saplingPartsTree.length = 0;
      const seedMat = makeSaplingMaterialTree(0x6a4a2f, 0.95);
      const stemMat = makeSaplingMaterialTree(0x4a3828, 0.98);
      const youngStemMat = makeSaplingMaterialTree(0x3f3124, 0.98);
      const leafMat = makeSaplingMaterialTree(0x4f8f2f, 0.96);
      const youngLeafMat = makeSaplingMaterialTree(0x6aa83d, 0.96);

      const d3 = fibonacciGrowthCurveTree(3);
      const d5 = fibonacciGrowthCurveTree(5);
      const d8 = fibonacciGrowthCurveTree(8);
      const d13 = fibonacciGrowthCurveTree(13);
      const d21 = fibonacciGrowthCurveTree(21);
      const d34 = fibonacciGrowthCurveTree(34);
      const d55 = fibonacciGrowthCurveTree(55);
      const d89 = fibonacciGrowthCurveTree(89);

      function makeBaseStemTree(height, radiusTop, radiusBottom, mat, radial = 10) {
        const geo = new THREE.CylinderGeometry(radiusTop, radiusBottom, height, radial);
        // Origine à la base : le scale donne une vraie pousse depuis le sol.
        geo.translate(0, height * 0.5, 0);
        const mesh = new THREE.Mesh(geo, mat.clone ? mat.clone() : mat);
        mesh.position.set(0, 0, 0);
        return mesh;
      }

      function makeTwigTree(baseY, length, angle, elevation, radius, mat) {
        const geo = new THREE.CylinderGeometry(radius * 0.52, radius, length, 8);
        geo.translate(0, length * 0.5, 0);
        const mesh = new THREE.Mesh(geo, mat.clone ? mat.clone() : mat);
        const dir = new THREE.Vector3(
          Math.cos(angle) * Math.cos(elevation),
          Math.sin(elevation),
          Math.sin(angle) * Math.cos(elevation)
        ).normalize();
        mesh.position.set(0, baseY, 0);
        mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir);
        return { mesh, dir, baseY, length, angle };
      }

      function addLeafAtTree(pos, angle, start, peak, end, scale = 1, mat = leafMat) {
        const leaf = new THREE.Mesh(new THREE.SphereGeometry(0.115, 8, 5), mat.clone());
        leaf.scale.set(1.45 * scale, 0.18 * scale, 0.70 * scale);
        leaf.position.copy(pos);
        leaf.rotation.set(0.55, -angle, 0.18);
        addSaplingPartTree(leaf, start, peak, end, 1.0);
        return leaf;
      }

      // Graine : courte, claire, avant l'apparition du brin.
      const seed = new THREE.Mesh(new THREE.SphereGeometry(0.16, 12, 8), seedMat);
      seed.scale.set(1.25, 0.50, 0.95);
      seed.position.set(0, 0.055, 0);
      addSaplingPartTree(seed, 0.0, fibonacciGrowthCurveTree(2), d8, 1.0);

      // Une seule tige initiale, progressive et lisible : pas de bouquet fouillis.
      const stemA = makeBaseStemTree(0.58, 0.020, 0.034, stemMat, 9);
      addSaplingPartTree(stemA, d3, d5, d13, 1.0);

      const stemB = makeBaseStemTree(0.96, 0.024, 0.044, stemMat, 9);
      stemB.rotation.z = 0.015;
      addSaplingPartTree(stemB, d5, d13, d21, 1.0);

      const stemC = makeBaseStemTree(1.42, 0.030, 0.056, youngStemMat, 10);
      stemC.rotation.z = 0.025;
      stemC.rotation.x = -0.012;
      addSaplingPartTree(stemC, d13, d21, d34, 1.0);

      const stemD = makeBaseStemTree(2.08, 0.040, 0.078, youngStemMat, 10);
      stemD.rotation.z = 0.035;
      stemD.rotation.x = 0.018;
      addSaplingPartTree(stemD, d21, d34, d89, 1.0);

      // Feuilles simples dès J5 : petites, attachées, pas en amas.
      addLeafAtTree(new THREE.Vector3(-0.105, 0.48, 0.025), Math.PI * 0.52, d5, d8, d34, 0.78, youngLeafMat);
      addLeafAtTree(new THREE.Vector3(0.115, 0.51, -0.020), -Math.PI * 0.48, d5, d8, d34, 0.78, youngLeafMat);

      // Croissance stable : quelques feuilles montent le long du brin à chaque palier Fibonacci.
      const leafSpecs = [
        { day: 8,  y: 0.78, a: 0.35, s: 0.80 },
        { day: 13, y: 1.02, a: 2.65, s: 0.86 },
        { day: 21, y: 1.28, a: 4.35, s: 0.92 },
        { day: 34, y: 1.62, a: 1.35, s: 0.95 }
      ];
      for (const spec of leafSpecs) {
        const ds = fibonacciGrowthCurveTree(spec.day);
        const dp = fibonacciGrowthCurveTree(spec.day === 8 ? 13 : spec.day === 13 ? 21 : spec.day === 21 ? 34 : 55);
        const pos = new THREE.Vector3(Math.cos(spec.a) * 0.12, spec.y, Math.sin(spec.a) * 0.12);
        addLeafAtTree(pos, spec.a, ds, dp, d89, spec.s, youngLeafMat);
      }

      // Premier rameau : un seul rameau au départ, puis un second plus tard.
      // Le joueur voit une ramification continue, pas une explosion de branches.
      const twigA = makeTwigTree(1.05, 0.62, 0.25, 0.30, 0.017, stemMat);
      addSaplingPartTree(twigA.mesh, d21, d34, d89, 1.0);
      for (let i = 0; i < 4; i++) {
        const f = 0.45 + i * 0.16;
        const side = i % 2 === 0 ? 1 : -1;
        const sideDir = new THREE.Vector3(-twigA.dir.z, 0, twigA.dir.x).normalize().multiplyScalar(0.055 * side);
        const pos = new THREE.Vector3().copy(twigA.dir).multiplyScalar(twigA.length * f).add(sideDir).add(new THREE.Vector3(0, twigA.baseY + i * 0.012, 0));
        addLeafAtTree(pos, twigA.angle + side * 0.45, d21, d34, d89, 0.78, youngLeafMat);
      }

      const twigB = makeTwigTree(1.34, 0.78, 2.72, 0.24, 0.018, stemMat);
      addSaplingPartTree(twigB.mesh, d34, d55, 0.30, 1.0);
      for (let i = 0; i < 5; i++) {
        const f = 0.35 + i * 0.14;
        const side = i % 2 === 0 ? 1 : -1;
        const sideDir = new THREE.Vector3(-twigB.dir.z, 0, twigB.dir.x).normalize().multiplyScalar(0.06 * side);
        const pos = new THREE.Vector3().copy(twigB.dir).multiplyScalar(twigB.length * f).add(sideDir).add(new THREE.Vector3(0, twigB.baseY + i * 0.012, 0));
        addLeafAtTree(pos, twigB.angle + side * 0.38, d34, d55, 0.30, 0.84, youngLeafMat);
      }
    }
    function updateSaplingStagesTree(progress, elapsed) {
      for (const part of saplingPartsTree) {
        const { mesh, start, peak, end, maxScale, baseScale } = part;
        if (progress <= start || progress >= end) {
          mesh.visible = false;
          continue;
        }
        mesh.visible = true;
        const fadeIn = smoothStepTree((progress - start) / Math.max(0.0001, peak - start));
        const fadeOut = 1 - smoothStepTree((progress - peak) / Math.max(0.0001, end - peak));
        const alpha = Math.max(0, Math.min(1, Math.min(fadeIn, fadeOut)));
        const grow = Math.max(0.001, maxScale * fadeIn);
        mesh.scale.copy(baseScale).multiplyScalar(grow);
        mesh.rotation.y += Math.sin(elapsed * 0.8 + start * 20) * 0.0008;
        if (mesh.material) {
          mesh.material.opacity = 0.12 + alpha * 0.88;
          if (mesh.material.color && mesh.geometry?.type === 'SphereGeometry') {
            mesh.material.color.lerp(treeSeasonState.leafHue, 0.08);
          }
        }
      }
    }

    // Build tree function
    function buildTree() {
      resetTreeRandomStreamTree();
      while (treeGroup.children.length > 0) {
        const child = treeGroup.children[0];
        treeGroup.remove(child);
        if (child.geometry && !child.isInstancedMesh) child.geometry.dispose();
        if (child.material && !Array.isArray(child.material) && !child.isInstancedMesh) child.material.dispose();
      }
      while (saplingGroupTree.children.length > 0) {
        const child = saplingGroupTree.children[0];
        saplingGroupTree.remove(child);
        if (child.geometry) child.geometry.dispose();
        if (child.material && !Array.isArray(child.material)) child.material.dispose();
      }
      branches.length = 0;
      leavesArr.length = 0;
      canopyPatchesArr.length = 0;
      flowersArr.length = 0;
      buildSaplingStagesTree();
      for (let i = 0; i < leafInstancedMeshesTree.length; i++) {
        leafInstancedMeshesTree[i].count = 0;
        treeGroup.add(leafInstancedMeshesTree[i]);
      }
      for (let i = 0; i < flowerInstancedMeshesTree.length; i++) {
        flowerInstancedMeshesTree[i].count = 0;
        treeGroup.add(flowerInstancedMeshesTree[i]);
      }
      flowerCenterIMTree.count = 0;
      treeGroup.add(flowerCenterIMTree);
      const trunkDir = new THREE.Vector3(0.012, 1, 0.004).normalize();
      // Chêne centenaire : charpente plus stable, plus fine, et feuillage abondant
      // qui suit les branches dès leur apparition.
      const rootBranchTree = createBranchTree(new THREE.Vector3(0, 0, 0), trunkDir, 20.5, 1.28, 0, 0);

      // Cime terminale : ajoute de vraies branches au sommet du tronc pour éviter
      // l'effet de cylindre coupé visible au centre de l'arbre adulte.
      const apexBaseTree = rootBranchTree.curve.getPointAt(0.965);
      const apexTangentTree = rootBranchTree.curve.getTangentAt(0.965).normalize();
      const apexDirsTree = [
        new THREE.Vector3(0.10, 1.00, 0.02),
        new THREE.Vector3(0.55, 0.72, 0.30),
        new THREE.Vector3(-0.48, 0.76, -0.22),
        new THREE.Vector3(0.20, 0.82, -0.56),
        new THREE.Vector3(-0.18, 0.92, 0.42),
        new THREE.Vector3(0.38, 0.88, -0.18)
      ];
      for (let ai = 0; ai < apexDirsTree.length; ai++) {
        const d = apexDirsTree[ai].addScaledVector(apexTangentTree, 0.45).normalize();
        const len = 6.4 + ai * 0.9 + treeRandom() * 1.4;
        const r = 0.34 + treeRandom() * 0.10;
        const apexBranch = createBranchTree(apexBaseTree.clone(), d, len, r, 1, Math.max(0.18, rootBranchTree.growthEnd * 0.72));
        apexBranch.parentBranch = rootBranchTree;
        apexBranch.parentStartT = 0.965;
        rootBranchTree.children.push(apexBranch);
      }

      function addBranchTipLeafClusterTree(b, branchIndex, extra = 1.0) {
        // Feuilles de pointe : elles suivent l'extrémité réellement visible de la branche
        // pendant la croissance. Ainsi, chaque branche garde un petit bourgeon/feuillage
        // à son extrémité, au lieu d'avoir des bouts nus jusqu'à la fin.
        const golden = Math.PI * (3 - Math.sqrt(5));
        const count = Math.max(8, Math.round((b.depth <= 1 ? 18 : b.depth === 2 ? 15 : b.depth === 3 ? 11 : 8) * extra));
        for (let ti = 0; ti < count; ti++) {
          const tEnd = 0.965 + seeded01Tree(branchIndex + 43, ti + 9) * 0.028;
          const ringAngle = ti * golden + branchIndex * 1.47;
          const pointGrowth = b.growthStart + (b.growthEnd - b.growthStart) * Math.min(0.72, tEnd);
          createLeafTree(b.curve.getPointAt(tEnd), {
            branch: b,
            branchT: tEnd,
            ringAngle,
            jitterScale: b.depth <= 1 ? 0.62 : 0.72,
            targetScale: (b.depth <= 1 ? 0.78 : b.depth <= 3 ? 0.70 : 0.58) + seeded01Tree(ti + 19, branchIndex) * 0.22,
            seasonSeed: Math.min(0.16, seeded01Tree(branchIndex + 173, ti + 37) * 0.25),
            terminalFollower: true,
            tipPriority: 1,
            growthStart: Math.max(0.006, Math.min(pointGrowth - 0.20 + seeded01Tree(ti + 7, branchIndex) * 0.018, fibonacciGrowthCurveTree(5) + b.depth * 0.009))
          });
        }
      }

      branches.forEach((b, branchIndex) => {
        // Distribution optimisée :
        // - pas de meshes supplémentaires par patch foliaire (meilleures perfs GPU)
        // - feuilles instanciées seulement
        // - chaque branche reçoit une présence terminale garantie
        // - densité répartie le long du dernier tiers / des rameaux, sans rubans continus
        const golden = Math.PI * (3 - Math.sqrt(5));
        const isUpperCrownTree = b.endPos.y > 9.5 || (b.depth <= 1 && b.startPos.y > 10.0);
        const isApexTree = b.parentBranch === rootBranchTree || b.startPos.y > 12.0 || b.endPos.y > 13.0;

        const tMin = b.depth === 0 ? 0.68 :
                     b.depth === 1 ? 0.34 :
                     b.depth === 2 ? 0.22 :
                     b.depth === 3 ? 0.16 : 0.10;
        const tMax = 0.985;

        // Quantité modérée : on couvre mieux, mais on ne surcharge plus le GPU.
        const densityByDepth = b.depth === 0 ? 0.55 :
                               b.depth === 1 ? 0.95 :
                               b.depth === 2 ? 1.15 :
                               b.depth === 3 ? 1.05 : 0.78;
        const baseCount = Math.round((b.length * 1.15 + 8) * densityByDepth);
        const crownBonus = isUpperCrownTree ? 5 : 0;
        const numLeaves = Math.min(
          b.depth <= 1 ? 28 : b.depth === 2 ? 30 : 22,
          Math.max(8, baseCount + crownBonus)
        );

        for (let i = 0; i < numLeaves; i++) {
          const u = (i + 0.5) / numLeaves;
          const jitter = (seeded01Tree(branchIndex + 19, i + 5) - 0.5) * 0.035;
          const t = THREE.MathUtils.clamp(tMin + (tMax - tMin) * u + jitter, tMin, tMax);
          const ringAngle = i * golden + branchIndex * 0.77;

          const scaleByDepth = b.depth === 0 ? 0.30 :
                               b.depth === 1 ? 0.46 :
                               b.depth === 2 ? 0.60 :
                               b.depth === 3 ? 0.66 : 0.54;

          const leafScale = (b.depth <= 1 ? 0.54 :
                             b.depth === 2 ? 0.64 :
                             b.depth === 3 ? 0.58 : 0.48)
                            + seeded01Tree(i + 13, branchIndex) * 0.16;

          const pointGrowth = b.growthStart + (b.growthEnd - b.growthStart) * t;

          createLeafTree(b.curve.getPointAt(t), {
            branch: b,
            branchT: t,
            ringAngle,
            jitterScale: scaleByDepth,
            targetScale: leafScale,
            // répartition saisonnière stable mais équilibrée
            seasonSeed: THREE.MathUtils.clamp(0.10 + seeded01Tree(branchIndex + 17, i + 29) * 0.88, 0, 0.98),
            growthStart: Math.max(
              0.006,
              Math.min(
                pointGrowth - 0.12 + seeded01Tree(i, branchIndex) * 0.025,
                fibonacciGrowthCurveTree(5) + b.depth * 0.012
              )
            )
          });
        }

        // Feuillage terminal garanti : quelques bourgeons/feuilles au bout de chaque
        // branche dès qu'elle existe, pour ne plus avoir de branches totalement nues.
        // Ces feuilles sont prioritaires mais petites en hiver.
        const tipCount = isApexTree ? 9 :
                         b.depth <= 1 ? 7 :
                         b.depth === 2 ? 6 :
                         b.depth === 3 ? 5 : 4;
        for (let ti = 0; ti < tipCount; ti++) {
          const uTip = tipCount === 1 ? 0.5 : ti / (tipCount - 1);
          const tEnd = THREE.MathUtils.clamp(0.90 + uTip * 0.09, 0.88, 0.995);
          const ringAngle = ti * golden + branchIndex * 1.41;
          const pointGrowth = b.growthStart + (b.growthEnd - b.growthStart) * tEnd;

          createLeafTree(b.curve.getPointAt(tEnd), {
            branch: b,
            branchT: tEnd,
            ringAngle,
            jitterScale: isApexTree ? 0.50 : 0.46,
            targetScale: (isApexTree ? 0.62 : 0.52) + seeded01Tree(ti + 31, branchIndex) * 0.12,
            seasonSeed: isApexTree ? 0.025 + seeded01Tree(branchIndex + 71, ti + 5) * 0.12
                                    : 0.06 + seeded01Tree(branchIndex + 71, ti + 5) * 0.18,
            terminalFollower: true,
            tipPriority: isApexTree ? 2 : 1,
            growthStart: Math.max(
              0.006,
              Math.min(
                pointGrowth - 0.18 + seeded01Tree(ti + 7, branchIndex) * 0.02,
                fibonacciGrowthCurveTree(5) + b.depth * 0.009
              )
            )
          });
        }

        if (b.depth >= 3 && treeRandom() > 0.965) {
          createFlowerTree(b.endPos);
        }
      });
    }
    buildTree();
    // UI pour l’arbre : croissance Fibonacci en 365 clics exactement.
    const uiContainerTree = document.createElement('div');
    uiContainerTree.className = 'tree-growth-ui';
    (document.querySelector('.microcosm-ritual-dock .ritual-progress-anchor') || document.body).appendChild(uiContainerTree);

    const growthCardTree = document.createElement('div');
    growthCardTree.className = 'tree-growth-card';
    uiContainerTree.appendChild(growthCardTree);

    const growthTitleTree = document.createElement('div');
    growthTitleTree.className = 'tree-growth-title';
    growthTitleTree.textContent = 'Croissance naturelle · Fibonacci';
    growthCardTree.appendChild(growthTitleTree);

    const growthStatusTree = document.createElement('div');
    growthStatusTree.className = 'tree-growth-status';
    growthCardTree.appendChild(growthStatusTree);

    const progressContainerTree = document.createElement('div');
    progressContainerTree.className = 'tree-progress-track';
    growthCardTree.appendChild(progressContainerTree);

    const progressBarTree = document.createElement('div');
    progressBarTree.className = 'tree-progress-fill';
    progressContainerTree.appendChild(progressBarTree);

    const buttonRowTree = document.createElement('div');
    buttonRowTree.className = 'tree-button-row';
    growthCardTree.appendChild(buttonRowTree);

    let growthSliderTree = null;

    const TREE_CYCLE_MODAL_DISMISSED_KEY = 'microcosm-tree-cycle-modal-dismissed-v1';
    let cycleModalShownThisSession = false;

    function shouldShowCycleModalTree() {
      if (growthClicksTree < FIB_GROWTH_TOTAL_CLICKS_TREE) return false;
      try {
        return localStorage.getItem(TREE_CYCLE_MODAL_DISMISSED_KEY) !== treeGameSeedString;
      } catch (_) {
        return !cycleModalShownThisSession;
      }
    }

    function openCycleModalTree() {
      const modal = document.getElementById('cycle-modal');
      if (!modal || !shouldShowCycleModalTree()) return;
      modal.classList.add('open');
      modal.setAttribute('aria-hidden', 'false');
      cycleModalShownThisSession = true;
    }

    function closeCycleModalTree({ remember = true } = {}) {
      const modal = document.getElementById('cycle-modal');
      if (!modal) return;
      modal.classList.remove('open');
      modal.setAttribute('aria-hidden', 'true');
      if (remember) {
        try { localStorage.setItem(TREE_CYCLE_MODAL_DISMISSED_KEY, treeGameSeedString); } catch (_) {}
      }
    }

    function updateGrowthUITree() {
      const percent = Math.round(targetGrowth * 1000) / 10;
      const stageName = getFibonacciStageNameTree(growthClicksTree);
      const seasonName = seasonModeTree ? ` · ${treeSeasonState.name}` : '';
      growthStatusTree.textContent = `${growthClicksTree}/${FIB_GROWTH_TOTAL_CLICKS_TREE} jours · ${stageName}${seasonName} · ${percent}%`;
      const waterBtn = document.getElementById('water-tree-btn');
      const waterHint = document.getElementById('water-tree-btn-hint');
      const ritualLabel = document.getElementById('save-tree-state-label');
      if (waterBtn) {
        const done = growthClicksTree >= FIB_GROWTH_TOTAL_CLICKS_TREE;
        waterBtn.disabled = done;
        waterBtn.setAttribute('aria-disabled', done ? 'true' : 'false');
        waterBtn.classList.toggle('is-disabled', done);
        waterBtn.firstChild.textContent = done ? 'Arbre accompli' : 'Arroser mon arbre';
      }
      if (waterHint) waterHint.textContent = growthClicksTree >= FIB_GROWTH_TOTAL_CLICKS_TREE
        ? 'Cycle annuel complet'
        : `Jour ${String(growthClicksTree).padStart(3, '0')}/365 · action volontaire`;
      if (ritualLabel) ritualLabel.textContent = `Jour ${String(growthClicksTree).padStart(3, '0')}/365 · ${stageName} · ${percent}%`;
      if (pushButtonTree) {
        pushButtonTree.disabled = growthClicksTree >= FIB_GROWTH_TOTAL_CLICKS_TREE;
        pushButtonTree.textContent = growthClicksTree >= FIB_GROWTH_TOTAL_CLICKS_TREE ? 'Chêne accompli' : 'Arroser mon arbre';
      }
      if (growthClicksTree >= FIB_GROWTH_TOTAL_CLICKS_TREE) {
        requestAnimationFrame(openCycleModalTree);
      }
    }

    function advanceFibonacciGrowthTree(options = {}) {
      if (growthClicksTree >= FIB_GROWTH_TOTAL_CLICKS_TREE) return;
      const syncCamera = options.syncCamera !== false;
      manualGrowthModeTree = false;
      growthClicksTree += 1;
      recordMicrocosmTreeClick(new Date());
      targetGrowth = fibonacciGrowthCurveTree(growthClicksTree);
      isGrowing = growthProgress < targetGrowth;
      if (syncCamera && !userCameraLocked) {
        restoreCameraAutoFollow();
        updateTreeCameraFrame(0.016, true);
      }
      updateGrowthUITree();
    }

    function resetFibonacciGrowthTree() {
      newTreeGameSeedTree();
      try { localStorage.removeItem(TREE_CYCLE_MODAL_DISMISSED_KEY); } catch (_) {}
      closeCycleModalTree({ remember: false });
      newGrassGameSeed();
      renderer.computeAsync(computeInitGrass).catch((err) => console.warn('Impossible de recalculer l’herbe :', err));
      growthClicksTree = 0;
      growthProgress = 0;
      targetGrowth = 0;
      isGrowing = false;
      updateGrassIslandScale();
      buildTree();
      islandRoot.position.set(0, 0, 0);
      islandRoot.rotation.set(0, 0, 0);
      restoreCameraAutoFollow({ clearUserLock: true });
      updateTreeCameraFrame(0.016, true);
      updateGrowthUITree();
    }

    function createUIButtonTree(text, onClick, variant = 'primary') {
      const btn = document.createElement('button');
      btn.textContent = text;
      btn.className = variant === 'secondary' ? 'tree-btn secondary' : 'tree-btn';
      btn.addEventListener('click', (event) => {
        event.stopPropagation();
        onClick();
      });
      buttonRowTree.appendChild(btn);
      return btn;
    }

    let pushButtonTree = null;
    pushButtonTree = createUIButtonTree('Pousser +1 jour', advanceFibonacciGrowthTree);
    createUIButtonTree('Nouvelle graine', resetFibonacciGrowthTree, 'secondary');
    updateGrowthUITree();

    renderer.domElement.addEventListener('click', () => {
      // STABLE PROD : la scène ne compte plus comme “clic/jour”.
      // Seuls les CTA explicites déclenchent advanceFibonacciGrowthTree().
      // Un clic sur le canvas peut seulement sortir du splashscreen et verrouiller la caméra.
      if (openingGrassSplashActive) {
        exitOpeningGrassSplash();
        return;
      }
      disableCameraAutoFollow();
    });
    // Pollen/fireflies particles for tree
    const particleCountTree = 90;
    const particleGeoTree = new THREE.BufferGeometry();
    const particlePositionsTree = new Float32Array(particleCountTree * 3);
    const particleDataTree = [];
    for (let i = 0; i < particleCountTree; i++) {
      const angle = treeRandom() * Math.PI * 2;
      const radius = 4 + treeRandom() * 20;
      particlePositionsTree[i * 3] = Math.cos(angle) * radius;
      particlePositionsTree[i * 3 + 1] = 2 + treeRandom() * 34;
      particlePositionsTree[i * 3 + 2] = Math.sin(angle) * radius;
      particleDataTree.push({ speed: 0.2 + treeRandom() * 0.5, offset: treeRandom() * Math.PI * 2, radius: radius, baseY: particlePositionsTree[i * 3 + 1] });
    }
    particleGeoTree.setAttribute('position', new THREE.BufferAttribute(particlePositionsTree, 3));
    const particleMatTree = new THREE.PointsMaterial({ color: 0xffccd5, size: 0.1, transparent: true, opacity: 0.5, blending: THREE.NormalBlending, depthWrite: false });
    const particlesTree = new THREE.Points(particleGeoTree, particleMatTree);
    islandRoot.add(particlesTree);
    // Timer for falling leaves
    let leafTimerTree = 0;
    let frameCountTree = 0;
    // ───── UI Panel (ocean) construction ─────
    const uiPanel = document.getElementById('ui-panel');
    const uiToggle = document.getElementById('ui-toggle');
    let panelOpen = false;
    uiToggle.addEventListener('click', () => {
      panelOpen = !panelOpen;
      uiPanel.classList.toggle('open', panelOpen);
      uiToggle.classList.toggle('active', panelOpen);
      uiToggle.textContent = panelOpen ? '✕' : '☰';
    });
    function fmtVal(v, step) {
      if (step >= 1) return Math.round(v).toString();
      const d = Math.max(0, -Math.floor(Math.log10(step)));
      return v.toFixed(Math.min(d, 3));
    }
    function buildSlider(label, val, min, max, step, onChange) {
      const row = document.createElement('div');
      row.className = 'ui-row';
      const lbl = document.createElement('span');
      lbl.className = 'ui-label';
      lbl.textContent = label;
      const wrap = document.createElement('div');
      wrap.className = 'ui-slider-wrap';
      const inp = document.createElement('input');
      inp.type = 'range';
      inp.className = 'ui-slider';
      inp.min = min; inp.max = max; inp.step = step; inp.value = val;
      const vDisp = document.createElement('span');
      vDisp.className = 'ui-val';
      vDisp.textContent = fmtVal(val, step);
      inp.addEventListener('input', () => {
        const v = parseFloat(inp.value);
        vDisp.textContent = fmtVal(v, step);
        onChange(v);
      });
      wrap.appendChild(inp);
      wrap.appendChild(vDisp);
      row.appendChild(lbl);
      row.appendChild(wrap);
      return row;
    }
    function buildColor(label, val, onChange) {
      const row = document.createElement('div');
      row.className = 'ui-row';
      const lbl = document.createElement('span');
      lbl.className = 'ui-label';
      lbl.textContent = label;
      const wrap = document.createElement('div');
      wrap.className = 'ui-color-wrap';
      const swatch = document.createElement('div');
      swatch.className = 'ui-color-swatch';
      swatch.style.background = val;
      const inp = document.createElement('input');
      inp.type = 'color';
      inp.className = 'ui-color-input';
      inp.value = val;
      inp.addEventListener('input', () => {
        swatch.style.background = inp.value;
        onChange(inp.value);
      });
      wrap.appendChild(swatch);
      wrap.appendChild(inp);
      row.appendChild(lbl);
      row.appendChild(wrap);
      return row;
    }
    function buildSelect(label, options, selected, onChange) {
      const row = document.createElement('div');
      row.className = 'ui-row';
      const lbl = document.createElement('span');
      lbl.className = 'ui-label';
      lbl.textContent = label;
      const sel = document.createElement('select');
      sel.className = 'ui-select';
      for (const opt of options) {
        const o = document.createElement('option');
        o.value = opt; o.textContent = opt;
        if (opt === selected) o.selected = true;
        sel.appendChild(o);
      }
      sel.addEventListener('change', () => onChange(sel.value));
      row.appendChild(lbl);
      row.appendChild(sel);
      return row;
    }
    function buildToggle(label, checked, onChange) {
      const row = document.createElement('div');
      row.className = 'ui-row';
      const lbl = document.createElement('span');
      lbl.className = 'ui-label';
      lbl.textContent = label;
      const inp = document.createElement('input');
      inp.type = 'checkbox';
      inp.checked = checked;
      inp.style.cursor = 'pointer';
      inp.style.width = '18px';
      inp.style.height = '18px';
      inp.addEventListener('change', () => onChange(inp.checked));
      row.appendChild(lbl);
      row.appendChild(inp);
      return row;
    }
    function buildTextInput(label, placeholder, onCommit, initialValue = '') {
      const row = document.createElement('div');
      row.className = 'ui-row';
      const lbl = document.createElement('span');
      lbl.className = 'ui-label';
      lbl.textContent = label;
      const inp = document.createElement('input');
      inp.type = 'text';
      inp.className = 'ui-text-input';
      inp.placeholder = placeholder;
      inp.value = initialValue || '';
      inp.title = initialValue || placeholder || label;
      inp.addEventListener('keydown', (event) => {
        if (event.key === 'Enter') onCommit(inp.value);
      });
      inp.addEventListener('blur', () => {
        if (inp.value.trim()) onCommit(inp.value);
      });
      row.appendChild(lbl);
      row.appendChild(inp);
      return row;
    }
    function buildButton(label, onClick) {
      const row = document.createElement('div');
      row.className = 'ui-row';
      const btn = document.createElement('button');
      btn.className = 'ui-button';
      btn.textContent = label;
      btn.style.width = '100%';
      btn.addEventListener('click', onClick);
      row.appendChild(btn);
      return row;
    }
    function buildSection(title, items, collapsed = true, extraClass = '') {
      const sec = document.createElement('div');
      sec.className = 'ui-section' + (collapsed ? ' collapsed' : '') + (extraClass ? ' ' + extraClass : '');
      sec.dataset.sectionTitle = title;
      const header = document.createElement('div');
      header.className = 'ui-section-header';
      const tEl = document.createElement('span');
      tEl.className = 'ui-section-title';
      tEl.textContent = title;
      const arrow = document.createElement('span');
      arrow.className = 'ui-section-arrow';
      arrow.textContent = '▼';
      header.appendChild(tEl);
      header.appendChild(arrow);
      const body = document.createElement('div');
      body.className = 'ui-section-body';
      for (const item of items) body.appendChild(item);
      header.addEventListener('click', () => sec.classList.toggle('collapsed'));
      sec.appendChild(header);
      sec.appendChild(body);
      return sec;
    }
    function buildInfoCard(html, pills = []) {
      const card = document.createElement('div');
      card.className = 'ui-info-card';
      card.innerHTML = html;
      if (pills.length) {
        const row = document.createElement('div');
        row.className = 'ui-pill-row';
        for (const pillText of pills) {
          const pill = document.createElement('span');
          pill.className = 'ui-pill';
          pill.textContent = pillText;
          row.appendChild(pill);
        }
        card.appendChild(row);
      }
      return card;
    }
    // Brand header
    const brandOcean = document.createElement('div');
    brandOcean.className = 'ui-brand';
    brandOcean.textContent = 'MICROCOSME';
    uiPanel.appendChild(brandOcean);

    uiPanel.appendChild(buildSection('Microclimat', [
      buildInfoCard(
        '<strong>Source unique.</strong> Vent, air, humidité, nuages, pluie, lumière, océan, herbe et rivage sont synchronisés depuis ce bloc.',
        ['gratuit 365j', 'horloge locale', 'météo live']
      ),
      buildToggle('Auto horloge', microClimate.autoBrowserClock, v => {
        microClimate.autoBrowserClock = v;
        if (v) microClimate.liveWeatherEnabled = false;
      }),
      buildToggle('Météo live', microClimate.liveWeatherEnabled, v => {
        microClimate.liveWeatherEnabled = v;
        microClimate.autoBrowserClock = !v;
        if (v) requestLiveMicroWeather();
        updateWeatherHUD(true);
      }),
      buildSlider('Vent', microClimate.windSpeed, 0, 35, 0.5, v => { setManualMicroClimate('windSpeed', v); }),
      buildSlider('Direction', THREE.MathUtils.radToDeg(microClimate.windDirection), 0, 360, 1, v => { setManualMicroClimate('windDirection', THREE.MathUtils.degToRad(v)); }),
      buildSlider('Température', microClimate.airTemperature, -5, 38, 0.5, v => { setManualMicroClimate('airTemperature', v); }),
      buildSlider('Humidité', microClimate.humidity, 0, 100, 1, v => { setManualMicroClimate('humidity', v); }),
      buildSlider('Nuages', microClimate.cloudCover, 0, 1, 0.01, v => { setManualMicroClimate('cloudCover', v); }),
      buildSlider('Pluie', microClimate.rain, 0, 1, 0.01, v => { setManualMicroClimate('rain', v); })
    ], false, 'product-section'));

    uiPanel.appendChild(buildSection('Eau · rivage', [
      buildInfoCard(
        '<strong>Réglages vraiment visibles.</strong> Ils servent à corriger la fusion eau/terre sans casser l’océan.',
        ['niveau', 'marée', 'fondu', 'mousse']
      ),
      buildSlider('Niveau eau', oceanCycleState.baseLevel, -2.0, 0.4, 0.01, v => { oceanManualOverrides.waterLevel = true; oceanCycleState.baseLevel = v; oceanMesh.position.y = v; }),
      buildToggle('Marée', oceanCycleState.tideEnabled, v => { oceanCycleState.tideEnabled = v; }),
      buildSlider('Force marée', oceanCycleState.tideAmplitude, 0.0, 0.22, 0.005, v => { oceanManualOverrides.tideAmplitude = true; oceanCycleState.manualTideControls = true; oceanCycleState.tideAmplitude = v; }),
      buildSlider('Calme rivage', 0.72, 0.0, 1.0, 0.01, v => { oceanManualOverrides.shoreCalmDistance = true; oceanShoreMinimumWave.value = THREE.MathUtils.lerp(0.12, 0.005, v); }),
      buildSlider('Zone rivage', oceanShoreCalmDistance.value, 3.0, 28.0, 0.5, v => { oceanManualOverrides.shoreCalmDistance = true; oceanShoreCalmDistance.value = v; }),
      buildSlider('Mousse rivage', oceanShoreFoamStrength.value, 0.0, 1.4, 0.01, v => { oceanManualOverrides.shoreFoamStrength = true; oceanShoreFoamStrength.value = v; }),
      buildToggle('Mousse organique', shoreFadeState.enabled, v => {
        oceanManualOverrides.shoreFade = true;
        shoreFadeState.enabled = v;
        if (shoreFadePoints) shoreFadePoints.material.opacity = v ? shoreFadeState.opacity : 0;
      }),
      buildSlider('Densité mousse', shoreFadeState.density, 120, 2200, 10, v => {
        oceanManualOverrides.shoreFade = true;
        shoreFadeState.density = v;
        rebuildShoreOrganicFoamParticles(groundRadiusGrass.value);
      }),
      buildSlider('Largeur mousse', shoreFadeState.width, 0.25, 2.4, 0.05, v => {
        oceanManualOverrides.shoreFade = true;
        shoreFadeState.width = v;
        rebuildShoreOrganicFoamParticles(groundRadiusGrass.value);
      })
    ], false, 'product-section'));

    uiPanel.appendChild(buildSection('Terre · herbe', [
      buildInfoCard(
        `<strong>Herbe dense par défaut.</strong> Le multiplicateur libère réellement plus de brins depuis le pool GPU. Desktop : ×${MICROCOSM_RUNTIME_PERF.grassMaxDensityMultiplier}. Mobile : plafond automatique pour préserver la fluidité.`,
        ['manuel', 'densité visible', `pool ${Math.round(MICROCOSM_RUNTIME_PERF.grassBladeInstances / 1000)}k`]
      ),
      buildSlider('Densité générée ×', grassDensityMultiplier.value, 0.25, MICROCOSM_RUNTIME_PERF.grassMaxDensityMultiplier, 0.25, v => {
        grassDensityManualOverride = true;
        grassDensityMultiplier.value = v;
      }),
      buildSlider('Densité microclimat', grassDensity.value, 0.45, 2.0, 0.01, v => {
        grassDensityManualOverride = true;
        grassDensity.value = v;
      }),
      buildButton('Reprendre densité météo', () => { grassDensityManualOverride = false; grassDensityMultiplier.value = 1.0; }),
      buildSlider('Longueur lame', grassBladeLengthUser, 0.4, 2.0, 0.05, v => { grassBladeLengthUser = v; updateGrassIslandScale(); }),
      buildSlider('Largeur lame', grassBladeWidthUser, 0.5, 2.0, 0.05, v => { grassBladeWidthUser = v; updateGrassIslandScale(); })
    ], false));

    uiPanel.appendChild(buildSection('Développeur · gland jouable', [
      buildInfoCard(
        '<strong>Gland jouable par défaut.</strong> Le corps physique reste invisible, mais le modèle GLB du gland est maintenant calé au contact de la terre. Si le fichier distant est bloqué, un gland procédural local prend le relais.',
        ['gland jouable', 'contact terre', 'GLB/GLTF']
      ),
      buildSlider('Vitesse déplacement', interactiveSphereState.speed, 10, 140, 1, v => { interactiveSphereState.speed = v; }),
      buildSlider('Gravité', interactiveSphereState.gravity, 2, 60, 0.5, v => { interactiveSphereState.gravity = v; }),
      buildSlider('Force saut', interactiveSphereState.jumpForce, 1, 22, 0.5, v => { interactiveSphereState.jumpForce = v; }),
      buildSlider('Amortissement', interactiveSphereState.damping, 0.82, 0.99, 0.005, v => { interactiveSphereState.damping = v; }),
      buildTextInput('URL modèle 3D', 'https://.../modele.glb', value => { loadInteractiveAvatarModel(value); }, DEFAULT_INTERACTIVE_AVATAR_MODEL_URL),
      buildButton('Recharger le gland par défaut', resetInteractiveAvatarToDefault),
      buildButton('Afficher proxy physique blanc', () => clearInteractiveAvatarModel({ showPhysicsProxy: true }))
    ], true, 'dev-wide'));


    uiPanel.appendChild(buildSection('VR · espace réel', [
      buildInfoCard(
        '<strong>18 m² non imposés par défaut.</strong> Cette limite s’active seulement pour une vraie zone parcourable VR.',
        ['desktop libre', 'mobile libre', 'VR optionnel']
      ),
      buildToggle('Limite VR 18m²', vrPlayableState.enabled, v => { vrPlayableState.enabled = v; }),
      buildToggle('Voir zone VR', vrPlayableState.visible, v => {
        vrPlayableState.visible = v;
        vrPlayableRing.visible = v;
      }),
      buildToggle('Masquer HUD en VR', vrRuntimeState.hideHudInVR, v => {
        vrRuntimeState.hideHudInVR = v;
        setMicrocosmHudVisibilityForVR(vrRuntimeState.active);
      }),
      buildSelect('Vue joueur XR', ['3e personne', '1re personne'], vrRuntimeState.viewMode, v => {
        vrRuntimeState.viewMode = v;
        restoreCameraAutoFollow({ clearUserLock: true });
        updateTreeCameraFrame(0.016, true);
      }),
      buildToggle('Confort VR', islandMotionState.vrComfort, v => { islandMotionState.vrComfort = v; }),
      buildSlider('Qualité VR', vrRuntimeState.comfortPixelRatio, 0.75, 1.6, 0.05, v => {
        vrRuntimeState.comfortPixelRatio = v;
        if (vrRuntimeState.active) renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, v));
      }),
      buildToggle('Alléger particules', vrRuntimeState.reduceParticles, v => { vrRuntimeState.reduceParticles = v; }),
      buildToggle('Calmer océan VR', vrRuntimeState.reduceOceanWhileVR, v => { vrRuntimeState.reduceOceanWhileVR = v; })
    ], true, 'product-section'));

    uiPanel.appendChild(buildSection('Offre · cycle annuel', [
      buildInfoCard(
        '<strong>Logique produit.</strong> Gratuit pendant la première année : l’arbre grandit jour après jour. À 365 jours, l’utilisateur choisit la suite.',
        ['free 365j', '4,99€ licence', '19,99€ à vie']
      ),
      buildInfoCard(
        '<strong>Gratuit :</strong> carte HTML autonome partageable.<br><strong>Licence 4,99€ :</strong> 10 entretiens/jour + carte mémoire + mode accueil sans HUD.<br><strong>À vie 19,99€ :</strong> sauvegardes illimitées + partage avancé.'
      )
    ], true, 'product-section'));

    const growthControlRowTree = buildSlider('Courbe de croissance', 0, 0, 100, 0.1, v => { setTreeGrowthByPercent(v); });
    growthSliderTree = growthControlRowTree.querySelector('input[type="range"]');
    uiPanel.appendChild(buildSection('Développeur · croissance', [
      buildInfoCard(
        '<strong>Bloc dev.</strong> À conserver pour tester, mais à cacher aux joueurs non premium dans une version stable.',
        ['debug', 'QA', 'base']
      ),
      buildSelect('Formule test', ['Gratuit', 'Add-on vents & marées', 'À vie', 'QA payant temporaire'], 'Gratuit', setMicrocosmProductAccessPreview),
      growthControlRowTree,
      buildToggle('Saisons FR', seasonModeTree, v => { seasonModeTree = v; updateGrowthUITree(); }),
      buildSlider('Vitesse pousse', growthSpeedTree, 0.01, 0.25, 0.005, v => { growthSpeedTree = v; })
    ], true, 'dev-wide'));

    const noiseNames = { 'Domain Warp': 0, 'Ridged': 1, 'Cellular': 2, 'Billow': 3, 'Swiss': 4, 'Turbulent': 5 };
    const bumpNoiseNames = { 'Perlin': 0, 'Ridged': 1, 'Domain Warp': 2, 'Billow': 3, 'Crosshatch': 4, 'FBM': 5 };

    uiPanel.appendChild(buildSection('Développeur · océan shader', [
      buildInfoCard(
        '<strong>Expert.</strong> Ces paramètres sont utiles pour le rendu, mais ne doivent pas être les réglages principaux joueur.',
        ['shader', 'foam', 'fog', 'color']
      ),
      buildSlider('Houle', params.heightScale, 0.05, 1.7, 0.02, v => { heightScale.value = v; params.heightScale = v; renderer.compute(initWaves); }),
      buildSlider('Chop', params.choppiness, 0, 1.8, 0.02, v => { choppiness.value = v; }),
      buildSlider('Courbure océan', oceanCurveAmount.value, 0.0, 1.0, 0.01, v => { oceanCurveAmount.value = v; }),
      buildSlider('Rayon courbure', Math.abs(oceanPlanetCurveLimit.value), 8.0, 80.0, 0.5, v => { oceanPlanetCurveLimit.value = -Math.abs(v); }),
      buildColor('Water', params.waterColor, v => { waterColor.value.set(v); }),
      buildColor('Sky', params.skyColor, v => skyColor.value.set(v)),
      buildSlider('SSS', params.sssIntensity, 0, 3, 0.01, v => { sssIntensity.value = v; }),
      buildSlider('Rough', params.roughness, 0.01, 0.95, 0.01, v => { roughness_u.value = v; }),
      buildSlider('Fresnel', params.fresnelPower, 0.5, 10, 0.1, v => { fresnelPower.value = v; }),
      buildSlider('Reflect', params.reflectionStrength, 0, 1, 0.01, v => { reflectionStrength.value = v; }),
      buildSlider('Bump', params.bumpStrength, 0, 0.8, 0.005, v => { bumpStrengthU.value = v; }),
      buildSelect('Bump Type', Object.keys(bumpNoiseNames), 'FBM', v => { bumpNoiseType.value = bumpNoiseNames[v]; }),
      buildColor('Foam', params.foamColor, v => foamColorU.value.set(v)),
      buildSlider('Foam seuil', params.foamThreshold, 0, 2, 0.05, v => { foamThreshold.value = v; }),
      buildSlider('Foam intensité', params.foamIntensity, 0, 30, 0.05, v => { foamIntensity.value = v; }),
      buildSelect('Foam noise', Object.keys(noiseNames), 'Turbulent', v => { foamNoiseType.value = noiseNames[v]; }),
      buildSlider('Fog densité', params.fogDensity, 0, 1, 0.01, v => { fogDensity.value = v; }),
      buildSlider('Fog near', params.fogNear, 0, 1000, 10, v => { fogNear.value = v; }),
      buildSlider('Fog far', params.fogFar, 200, 5000, 50, v => { fogFar.value = v; })
    ], true, 'dev-wide'));

    uiPanel.appendChild(buildSection('Développeur · scène', [
      buildToggle('PLY skybox externe', plySkyboxWanted, v => {
        plySkyboxWanted = v;
        applyPlySkyboxVisibility();
      }),
      buildToggle('Fallback jour', skyDome.visible, v => {
        if (plySkybox && plySkybox.visible) return;
        skyDome.visible = v;
      }),
      buildSlider('Soleil hauteur', params.sunElevation, -1.5, 33, 0.1, v => { params.sunElevation = v; updateSun(params.sunElevation, params.sunAzimuth); }),
      buildSlider('Soleil azimut', params.sunAzimuth, 0, 360, 1, v => { params.sunAzimuth = v; updateSun(params.sunElevation, params.sunAzimuth); }),
      buildToggle('Flat floor', flatFloor.visible, v => {
        flatFloor.visible = v;
        flatFloorState.enabled = v;
      }),
      buildColor('Couleur floor', flatFloorState.color, v => {
        flatFloorState.color = v;
        flatFloorMat.color.set(v);
      }),
      buildSlider('Hauteur floor', flatFloorState.y, -30.0, -2.0, 0.1, v => {
        flatFloorState.y = v;
        flatFloor.position.y = v;
      }),
      buildToggle('Îlot vivant sync', islandMotionState.enabled, v => { islandMotionState.enabled = v; }),
      buildSlider('Flottaison', islandMotionState.bobAmplitude, 0.0, 0.18, 0.005, v => { islandMotionState.bobAmplitude = v; }),
      buildSlider('Tangage', islandMotionState.tiltAmplitude, 0.0, 0.05, 0.001, v => { islandMotionState.tiltAmplitude = v; })
    ], true, 'dev-wide'));

    // ───── Mode accueil premium : plein écran sans HUD ─────
    function setMicrocosmKioskMode(active) {
      document.body.classList.toggle('microcosm-kiosk-active', !!active);
      if (!active) document.body.classList.remove('microcosm-kiosk-pending');
    }

    async function requestMicrocosmFullscreenWithoutHud() {
      const target = microcosmMount || document.documentElement;
      document.getElementById('premium-panel')?.classList.remove('open');
      setMicrocosmKioskMode(true);
      try {
        if (target.requestFullscreen) {
          await target.requestFullscreen({ navigationUI: 'hide' });
        } else if (target.webkitRequestFullscreen) {
          target.webkitRequestFullscreen();
        }
      } catch (err) {
        console.warn('Fullscreen sans HUD non disponible, HUD masqué uniquement :', err);
      }
    }

    async function exitMicrocosmFullscreenWithoutHud() {
      setMicrocosmKioskMode(false);
      try {
        if (document.fullscreenElement && document.exitFullscreen) await document.exitFullscreen();
        else if (document.webkitFullscreenElement && document.webkitExitFullscreen) document.webkitExitFullscreen();
      } catch (err) {
        console.warn('Sortie fullscreen impossible :', err);
      }
    }

    document.addEventListener('fullscreenchange', () => {
      if (!document.fullscreenElement) setMicrocosmKioskMode(false);
    });
    document.addEventListener('webkitfullscreenchange', () => {
      if (!document.webkitFullscreenElement) setMicrocosmKioskMode(false);
    });
    window.addEventListener('keydown', (event) => {
      if ((event.key || '').toLowerCase() === 'h' && document.body.classList.contains('microcosm-kiosk-active')) {
        exitMicrocosmFullscreenWithoutHud();
      }
    });

    // ───── Export / Import / Card partageable ─────
    // =====================================================
    // MICROCOSM V8.19 CARD VIEWER FLOW CLEANUP
    // =====================================================
    // Objectif : ne pas ajouter de fonctionnalités lourdes, mais rendre le flow
    // carte mémoire plus premium : viewer flottant, clic/tap pour retourner,
    // HUD fantômes, titres adaptatifs et monétisation douce.

    // =====================================================
    // MICROCOSM V8.15 STABLE PROD — notes développeur
    // =====================================================
    // Stable/prod : l'action principale payante est saveTreeMemoryBundle().
    // Elle génère un seul fichier HTML autonome en mémoire puis ouvre une
    // viewer 3D glass natif. Le téléchargement HTML ne part que si l'utilisateur
    // clique ensuite sur le CTA “Enregistrer”.
    //
    // À conserver : collectMicrocosmState(), buildCollectorCardPayload(),
    // buildTreeMemoryBundle(), buildStandaloneCollectorBundleHtml(),
    // buildTransparentCollectorTextureSvg().
    //
    // Fonctions legacy / compatibilité :
    // - buildStandaloneCollectorCardHtml() : ancien template carte dynamique,
    //   conservé uniquement pour éviter les ruptures si un ancien bouton l'appelle.
    // - createShareCardPng() : fallback visuel si la carte HTML ne peut pas être créée.
    // - saveBundleFiles() : fallback téléchargement unique ; ne doit plus servir à
    //   télécharger plusieurs fichiers à la suite.
    //
    // À ne pas toucher sans demande explicite : croissance Fibonacci, seeds,
    // localStorage, growthClicksTree, growthProgress, targetGrowth.
    function collectMicrocosmState() {
      // EXPORT_HTML_FIX_V8_14 : tierInfo doit être recalculé localement.
      // Avant correction, l'export HTML/JSON appelait tierInfo hors scope,
      // ce qui lançait une ReferenceError et bloquait la génération de carte.
      const tierInfo = resolveCardTier();
      return {
        version: 'microcosm-settings-v3-vr-optimized',
        exportedAt: new Date().toISOString(),
        tree: {
          userSeed: treeUserSeedString,
          gameSeed: treeGameSeedString,
          growthClicks: growthClicksTree,
          growthProgress,
          targetGrowth,
          seasonMode: seasonModeTree
        },
        grass: {
          userSeed: grassUserSeedString,
          gameSeed: grassGameSeedString,
          density: grassDensity.value,
          densityMultiplier: grassDensityMultiplier.value,
          bladeLength: grassBladeLengthUser,
          bladeWidth: grassBladeWidthUser
        },
        microClimate: {
          autoBrowserClock: microClimate.autoBrowserClock,
          liveWeatherEnabled: microClimate.liveWeatherEnabled,
          locationLabel: microClimate.locationLabel,
          latitude: microClimate.latitude,
          longitude: microClimate.longitude,
          windSpeed: microClimate.windSpeed,
          windDirection: microClimate.windDirection,
          windForce: microClimate.windForce,
          airTemperature: microClimate.airTemperature,
          humidity: microClimate.humidity,
          pressure: microClimate.pressure,
          cloudCover: microClimate.cloudCover,
          rain: microClimate.rain
        },
        ocean: {
          waterLevel: oceanCycleState.baseLevel,
          tideEnabled: oceanCycleState.tideEnabled,
          tideAmplitude: oceanCycleState.tideAmplitude,
          manualTideControls: oceanCycleState.manualTideControls,
          manualOverrides: { ...oceanManualOverrides },
          curveAmount: oceanCurveAmount.value,
          sphereRadius: oceanSphereRadiusU.value,
          shoreMinimumWave: oceanShoreMinimumWave.value,
          shoreCalmDistance: oceanShoreCalmDistance.value,
          shoreFoamStrength: oceanShoreFoamStrength.value,
          shoreFade: { ...shoreFadeState }
        },
        activity: getMicrocosmActivitySnapshot(tierInfo.tier === 'eternal-glass' ? MICROCOSM_CONFIG.cards.contributionDays * 8 : MICROCOSM_CONFIG.cards.contributionDays),
        vr: {
          playable18m2: vrPlayableState.enabled,
          showPlayableRing: vrPlayableState.visible
        }
      };
    }

    function downloadBlob(filename, text, type = 'application/json') {
      const blob = new Blob([text], { type });
      const a = document.createElement('a');
      a.href = URL.createObjectURL(blob);
      a.download = filename;
      document.body.appendChild(a);
      a.click();
      URL.revokeObjectURL(a.href);
      a.remove();
    }


    /* ─────────────────────────────────────────────────────────────
       EXPORT_STABILITY_V8_13 · capture robuste
       Certains navigateurs/WebGPU refusent renderer.domElement.toDataURL()
       selon le contexte graphique ou les ressources chargées. L'export ne doit
       jamais échouer pour autant : on tente d'abord la vraie capture, puis on
       crée une image de secours propre afin que le HTML/SVG/JSON restent exportables.
    ───────────────────────────────────────────────────────────── */
    function createFallbackMicrocosmSnapshotDataUrl(reason = '') {
      const c = document.createElement('canvas');
      c.width = 1280;
      c.height = 720;
      const ctx = c.getContext('2d');
      const W = c.width;
      const H = c.height;
      const g = ctx.createLinearGradient(0, 0, W, H);
      g.addColorStop(0, '#1f170c');
      g.addColorStop(0.48, '#17220e');
      g.addColorStop(1, '#050704');
      ctx.fillStyle = g;
      ctx.fillRect(0, 0, W, H);

      const sky = ctx.createRadialGradient(W * 0.44, H * 0.20, 20, W * 0.44, H * 0.28, W * 0.55);
      sky.addColorStop(0, 'rgba(244,213,138,0.22)');
      sky.addColorStop(0.45, 'rgba(85,107,45,0.18)');
      sky.addColorStop(1, 'rgba(0,0,0,0)');
      ctx.fillStyle = sky;
      ctx.fillRect(0, 0, W, H);

      ctx.save();
      ctx.translate(W * 0.5, H * 0.56);
      ctx.beginPath();
      ctx.ellipse(0, 0, W * 0.30, H * 0.13, 0, 0, Math.PI * 2);
      ctx.fillStyle = 'rgba(87, 112, 43, 0.82)';
      ctx.fill();
      ctx.strokeStyle = 'rgba(244,213,138,0.20)';
      ctx.lineWidth = 3;
      ctx.stroke();

      ctx.strokeStyle = 'rgba(255,248,225,0.58)';
      ctx.lineWidth = 6;
      ctx.beginPath();
      ctx.moveTo(0, 0);
      ctx.lineTo(0, -H * 0.22 * Math.max(0.08, growthProgress || 0.08));
      ctx.stroke();
      ctx.restore();

      ctx.fillStyle = 'rgba(255,248,225,0.94)';
      ctx.font = '900 44px Inter, system-ui, sans-serif';
      ctx.fillText('MICROCOSM INTERACTIF 9', 64, 88);
      ctx.fillStyle = 'rgba(244,213,138,0.88)';
      ctx.font = '800 26px Inter, system-ui, sans-serif';
      ctx.fillText('Capture de secours exportable', 64, 128);

      ctx.fillStyle = 'rgba(226,218,188,0.78)';
      ctx.font = '700 22px Inter, system-ui, sans-serif';
      ctx.fillText(`${String(growthClicksTree).padStart(3, '0')}/365 · ${getFibonacciStageNameTree(growthClicksTree)}`, 64, H - 86);
      if (reason) {
        ctx.font = '600 16px Inter, system-ui, sans-serif';
        ctx.fillText(String(reason).slice(0, 110), 64, H - 52);
      }
      return c.toDataURL('image/png');
    }

    function safeCaptureMicrocosmSnapshot() {
      try {
        const data = renderer?.domElement?.toDataURL?.('image/png');
        if (typeof data === 'string' && data.startsWith('data:image/png') && data.length > 256) return data;
        return createFallbackMicrocosmSnapshotDataUrl('capture WebGPU vide ou indisponible');
      } catch (err) {
        console.warn('Capture WebGPU remplacée par une capture de secours :', err);
        return createFallbackMicrocosmSnapshotDataUrl(err?.message || 'capture WebGPU indisponible');
      }
    }

    function exportMicrocosmSettings() {
      const state = collectMicrocosmState();
      const day = String(state.tree.growthClicks).padStart(3, '0');
      downloadBlob(`microcosm-arbre-jour-${day}.json`, JSON.stringify(state, null, 2));
    }

    function applyImportedMicrocosmSettings(state) {
      if (!state || typeof state !== 'object') return;
      if (state.tree) {
        if (Number.isFinite(state.tree.growthClicks)) {
          growthClicksTree = THREE.MathUtils.clamp(Math.round(state.tree.growthClicks), 0, FIB_GROWTH_TOTAL_CLICKS_TREE);
          growthProgress = fibonacciGrowthCurveTree(growthClicksTree);
          targetGrowth = growthProgress;
          isGrowing = false;
        }
        if (typeof state.tree.seasonMode === 'boolean') seasonModeTree = state.tree.seasonMode;
      }
      if (state.grass) {
        if (Number.isFinite(state.grass.density)) {
          grassDensity.value = state.grass.density;
          grassDensityManualOverride = true;
        }
        if (Number.isFinite(state.grass.densityMultiplier)) {
          grassDensityMultiplier.value = THREE.MathUtils.clamp(state.grass.densityMultiplier, 0.25, MICROCOSM_RUNTIME_PERF.grassMaxDensityMultiplier);
          grassDensityManualOverride = true;
        }
        if (Number.isFinite(state.grass.bladeLength)) grassBladeLengthUser = state.grass.bladeLength;
        if (Number.isFinite(state.grass.bladeWidth)) grassBladeWidthUser = state.grass.bladeWidth;
      }
      if (state.microClimate) {
        Object.assign(microClimate, state.microClimate);
        microClimate.liveWeatherEnabled = false;
        microClimate.autoBrowserClock = false;
      }
      if (state.ocean) {
        if (Number.isFinite(state.ocean.waterLevel)) {
          oceanCycleState.baseLevel = state.ocean.waterLevel;
          oceanMesh.position.y = state.ocean.waterLevel;
        }
        if (typeof state.ocean.tideEnabled === 'boolean') oceanCycleState.tideEnabled = state.ocean.tideEnabled;
        if (Number.isFinite(state.ocean.tideAmplitude)) oceanCycleState.tideAmplitude = state.ocean.tideAmplitude;
        if (typeof state.ocean.manualTideControls === 'boolean') oceanCycleState.manualTideControls = state.ocean.manualTideControls;
        if (state.ocean.manualOverrides && typeof state.ocean.manualOverrides === 'object') Object.assign(oceanManualOverrides, state.ocean.manualOverrides);
        if (Number.isFinite(state.ocean.curveAmount)) oceanCurveAmount.value = state.ocean.curveAmount;
        if (Number.isFinite(state.ocean.sphereRadius)) oceanSphereRadiusU.value = state.ocean.sphereRadius;
        if (Number.isFinite(state.ocean.shoreMinimumWave)) oceanShoreMinimumWave.value = state.ocean.shoreMinimumWave;
        if (Number.isFinite(state.ocean.shoreCalmDistance)) oceanShoreCalmDistance.value = state.ocean.shoreCalmDistance;
        if (Number.isFinite(state.ocean.shoreFoamStrength)) oceanShoreFoamStrength.value = state.ocean.shoreFoamStrength;
        if (state.ocean.shoreFade) Object.assign(shoreFadeState, state.ocean.shoreFade);
      }
      if (state.activity && Array.isArray(state.activity.days)) {
        state.activity.days.forEach((item) => {
          if (!item?.date) return;
          microActivityState.daily[item.date] = {
            visits: Number(item.visits || 0),
            clicks: Number(item.clicks || 0),
            places: Array.isArray(item.places) ? item.places : []
          };
        });
        if (Array.isArray(state.activity.locations)) microActivityState.locations = state.activity.locations;
        saveMicroActivityState();
      }
      if (state.vr) {
        if (typeof state.vr.playable18m2 === 'boolean') vrPlayableState.enabled = state.vr.playable18m2;
        if (typeof state.vr.showPlayableRing === 'boolean') {
          vrPlayableState.visible = state.vr.showPlayableRing;
          vrPlayableRing.visible = state.vr.showPlayableRing;
        }
      }
      updateGrassIslandScale();
      rebuildShoreOrganicFoamParticles(groundRadiusGrass.value);
      updateGrowthUITree();
      updateWeatherHUD(true);
      renderer.compute(initWaves);
    }

    function importMicrocosmSettingsFile(file) {
      if (!file) return;
      const reader = new FileReader();
      reader.onload = () => {
        try {
          applyImportedMicrocosmSettings(JSON.parse(reader.result));
        } catch (err) {
          console.warn('Import JSON impossible :', err);
          alert('Import impossible : fichier JSON invalide.');
        }
      };
      reader.readAsText(file);
    }

    function captureCurrentViewPng() {
      try {
        const day = String(growthClicksTree).padStart(3, '0');
        const a = document.createElement('a');
        a.href = renderer.domElement.toDataURL('image/png');
        a.download = `microcosm-arbre-jour-${day}.png`;
        a.click();
      } catch (err) {
        console.warn('Capture impossible :', err);
        alert('Capture impossible dans ce navigateur.');
      }
    }


    function escapeMicrocosmCardText(value) {
      return String(value ?? '')
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#039;');
    }

    function safeMicrocosmCardJson(data) {
      return JSON.stringify(data).replace(/</g, '\\u003c');
    }


    function getMicrocosmStorageWarningText() {
      return 'Si tu nettoies ce navigateur ou le stockage local, la mémoire de ton arbre disparaît. Sauvegarde-le pour conserver sa carte, sa texture et son JSON de restauration.';
    }

    function updateStorageWarningText() {
      const node = document.getElementById('storage-warning');
      if (!node) return;
      node.innerHTML = '<strong>Mémoire locale :</strong> ' + escapeMicrocosmCardText(getMicrocosmStorageWarningText()) + ' Utilise <strong>“Sauvegarder mon arbre”</strong>.';
    }

    const MICROCOSM_PRICE_TABLE = {
      EUR: { winds: 4.99, lifetime: 19.99 },
      USD: { winds: 4.99, lifetime: 19.99 },
      GBP: { winds: 4.99, lifetime: 19.99 },
      CHF: { winds: 4.90, lifetime: 19.90 },
      CAD: { winds: 6.99, lifetime: 27.99 },
      AUD: { winds: 7.99, lifetime: 29.99 },
      JPY: { winds: 500, lifetime: 2000 },
      CNY: { winds: 35, lifetime: 149 },
      INR: { winds: 399, lifetime: 1499 },
      BRL: { winds: 24.90, lifetime: 99.90 },
      MAD: { winds: 49, lifetime: 199 }
    };

    function inferCurrencyFromLocale(locale) {
      const lang = String(locale || navigator.language || 'fr-FR').toUpperCase();
      if (lang.includes('-JP')) return 'JPY';
      if (lang.includes('-CN')) return 'CNY';
      if (lang.includes('-IN')) return 'INR';
      if (lang.includes('-BR')) return 'BRL';
      if (lang.includes('-MA')) return 'MAD';
      if (lang.includes('-CA')) return 'CAD';
      if (lang.includes('-AU')) return 'AUD';
      if (lang.includes('-CH')) return 'CHF';
      if (lang.includes('-GB')) return 'GBP';
      if (lang.includes('-US')) return 'USD';
      if (lang.includes('-IE')) return 'EUR';
      if (lang.includes('-BE')) return 'EUR';
      if (lang.includes('-DE')) return 'EUR';
      if (lang.includes('-ES')) return 'EUR';
      if (lang.includes('-IT')) return 'EUR';
      if (lang.includes('-PT')) return 'EUR';
      return 'EUR';
    }

    function formatRoundedPrice(amount, currency, locale = navigator.language || 'fr-FR') {
      return new Intl.NumberFormat(locale, {
        style: 'currency',
        currency,
        maximumFractionDigits: currency === 'JPY' || Number.isInteger(amount) ? 0 : 2,
        minimumFractionDigits: currency === 'JPY' || Number.isInteger(amount) ? 0 : 2
      }).format(amount);
    }

    function resolveRoundedMicrocosmPrices(locale = navigator.language || 'fr-FR') {
      const currency = inferCurrencyFromLocale(locale);
      const base = MICROCOSM_PRICE_TABLE[currency] || MICROCOSM_PRICE_TABLE.EUR;
      return {
        locale,
        currency,
        winds: base.winds,
        lifetime: base.lifetime,
        windsLabel: formatRoundedPrice(base.winds, currency, locale),
        lifetimeLabel: formatRoundedPrice(base.lifetime, currency, locale)
      };
    }

    function syncProductPricingUi() {
      const prices = resolveRoundedMicrocosmPrices();
      const freeLine = document.getElementById('premium-line-free');
      const lifeLine = document.getElementById('premium-line-life');
      const addonLine = document.getElementById('premium-line-addon');
      const addonFeaturesLine = document.getElementById('premium-line-addon-features');
      const cardsLine = document.getElementById('premium-line-cards');
      const cyclePremiumBtn = document.getElementById('cycle-premium-btn');
      if (freeLine) freeLine.textContent = 'Gratuit : 1 clic / jour pendant 365 jours';
      if (lifeLine) lifeLine.textContent = 'À vie : arbre éternel + mémoire illimitée · ' + prices.lifetimeLabel;
      if (addonLine) addonLine.textContent = 'Add-on “vents & marées” : ' + prices.windsLabel + ' sur gratuit ou à vie';
      if (addonFeaturesLine) addonFeaturesLine.textContent = 'Débloque ' + premiumProductState.windsClicksPerDay + ' clics/jour au départ + outils + artefacts + mode accueil sans HUD';
      if (cardsLine) cardsLine.textContent = 'Cartes : 2D originales en gratuit · version glass collector pour les arbres à vie';
      if (cyclePremiumBtn) cyclePremiumBtn.innerHTML = 'Continuer à vie · ' + escapeMicrocosmCardText(prices.lifetimeLabel) + '<br><small>arbre à vie + carte mémoire illimitée</small>';
    }

    function isMicrocosmMemorySaveUnlocked() {
      return !!(premiumProductState.lifetimeUnlocked || premiumProductState.windsUnlocked || premiumProductState.localTestingOverride);
    }

    function syncPaidMemoryUi() {
      const saveBtn = document.getElementById('save-tree-btn');
      const hint = document.getElementById('save-tree-btn-hint');
      const unlocked = isMicrocosmMemorySaveUnlocked();
      // V8.20 : “Voir ma carte” est une action de jeu gratuite.
      // Seul le CTA “Enregistrer” dans le viewer déclenche la sauvegarde payante.
      if (saveBtn) {
        saveBtn.disabled = false;
        saveBtn.setAttribute('aria-disabled', 'false');
        saveBtn.classList.remove('is-disabled');
      }
      if (hint) hint.textContent = unlocked
        ? 'Viewer flottant · Enregistrer disponible'
        : 'Viewer flottant · sauvegarde autonome à déverrouiller';
      syncFloatingMemoryCardActions?.();
    }

    function unlockMicrocosmWindsAddon() {
      premiumProductState.windsUnlocked = true;
      syncPaidMemoryUi();
      syncProductPricingUi();
    }

    function unlockMicrocosmLifetime() {
      premiumProductState.lifetimeUnlocked = true;
      syncPaidMemoryUi();
      syncProductPricingUi();
    }

    function lockMicrocosmToFreeMode() {
      premiumProductState.windsUnlocked = false;
      premiumProductState.lifetimeUnlocked = false;
      premiumProductState.localTestingOverride = false;
      syncPaidMemoryUi();
      syncProductPricingUi();
    }


    function setMicrocosmProductAccessPreview(mode) {
      const normalized = String(mode || 'Gratuit').toLowerCase();
      premiumProductState.windsUnlocked = false;
      premiumProductState.lifetimeUnlocked = false;
      premiumProductState.localTestingOverride = false;
      if (normalized.includes('vents')) {
        premiumProductState.windsUnlocked = true;
      } else if (normalized.includes('vie')) {
        premiumProductState.lifetimeUnlocked = true;
      } else if (normalized.includes('qa')) {
        premiumProductState.localTestingOverride = true;
      }
      syncPaidMemoryUi();
      syncProductPricingUi();
    }

    window.MicrocosmProductAccess = {
      unlockWindsAddon: unlockMicrocosmWindsAddon,
      unlockLifetime: unlockMicrocosmLifetime,
      lockToFree: lockMicrocosmToFreeMode,
      setPreviewMode: setMicrocosmProductAccessPreview,
      sync: syncPaidMemoryUi
    };

    function resolveCardTier() {
      if (premiumProductState.lifetimeUnlocked) {
        return { tier: 'eternal-glass', tierLabel: 'Carte mémoire à vie', badge: 'Lifetime Glass' };
      }
      if (premiumProductState.windsUnlocked) {
        return { tier: 'winds-addon', tierLabel: 'Artefact vents & marées', badge: 'Artifact Edition' };
      }
      return { tier: 'free-2d', tierLabel: 'Carte 2D originale', badge: '2D Originale' };
    }

    function buildCollectorCardPayload() {
      const dayRaw = growthClicksTree;
      const day = String(dayRaw).padStart(3, '0');
      const snapshot = safeCaptureMicrocosmSnapshot();
      const progressPct = Math.round(growthProgress * 100);
      const stage = getFibonacciStageNameTree(growthClicksTree);
      const place = weatherPlaceLabel();
      const windDeg = Math.round((THREE.MathUtils.radToDeg(microClimate.windDirection) + 360) % 360);
      const editionHash = hashGrassSeedString(`${treeUserSeedString}|${treeGameSeedString}|${growthClicksTree}`);
      const edition = (editionHash % 999999).toString().padStart(6, '0');
      const tierInfo = resolveCardTier();
      const prices = resolveRoundedMicrocosmPrices();
      let rarity = progressPct >= 100 ? 'Cycle complet' : progressPct >= 89 ? 'Canopée rare' : progressPct >= 55 ? 'Floraison' : progressPct >= 18 ? 'Jeune pousse' : 'Graine vivante';
      if (tierInfo.tier === 'eternal-glass') rarity = 'Mémoire éternelle';
      if (tierInfo.tier === 'winds-addon') rarity = 'Artefact climatique';
      return {
        schema: 'microcosm-interactif-9.dynamic-card.v2',
        title: 'Microcosm · Carte mémoire',
        edition,
        rarity,
        tier: tierInfo.tier,
        tierLabel: tierInfo.tierLabel,
        tierBadge: tierInfo.badge,
        day: dayRaw,
        dayLabel: `${day}/365`,
        progressPct,
        stage,
        place,
        generatedAt: new Date().toLocaleString('fr-FR', { dateStyle: 'long', timeStyle: 'short' }),
        seedPublic: String(treeUserSeedString || treeGameSeedString || 'microcosm').slice(0, 24),
        temperature: Math.round(microClimate.airTemperature),
        feelsLike: Math.round(microClimate.liveWeatherApparent ?? microClimate.airTemperature),
        humidity: Math.round(microClimate.humidity),
        pressure: Math.round(microClimate.pressure ?? 1013),
        clouds: Math.round((microClimate.cloudCover ?? 0) * 100),
        windSpeed: Math.round(microClimate.windSpeed),
        windDirection: windDeg,
        sync: microClimate.liveWeatherEnabled && microClimate.lastLiveWeatherFetch ? 'LIVE' : 'AUTO',
        cleanupWarning: getMicrocosmStorageWarningText(),
        prices,
        userAlias: 'Gardien local #' + edition,
        authStampUrl: YGGDRASIL_AUTH_STAMP_URL,
        arLaunchUrl: getMicrocosmArLaunchUrl({ edition, seedPublic: String(treeUserSeedString || treeGameSeedString || 'microcosm').slice(0, 24), day: dayRaw }),
        qrCodeUrl: getQrImageUrl(getMicrocosmArLaunchUrl({ edition, seedPublic: String(treeUserSeedString || treeGameSeedString || 'microcosm').slice(0, 24), day: dayRaw })),
        activity: getMicrocosmActivitySnapshot(tierInfo.tier === 'eternal-glass' ? MICROCOSM_CONFIG.cards.contributionDays * 8 : MICROCOSM_CONFIG.cards.contributionDays),
        snapshot,
        note: 'Carte HTML autonome générée depuis Microcosm Interactif 9. Elle embarque une capture, les statistiques de croissance, le microclimat et la mémoire de restauration.'
      };
    }

    function buildMemoryBundleManifest(payload, state) {
      return {
        schema: 'microcosm-interactif-9.tree-memory-bundle.v1',
        exportedAt: new Date().toISOString(),
        edition: payload.edition,
        tier: payload.tier,
        tierLabel: payload.tierLabel,
        day: payload.day,
        place: payload.place,
        files: {
          memoryCardHtml: `${MICROCOSM_CONFIG.cards.memoryCardFilenamePrefix}-${payload.edition}.html`,
          treeStateJson: `microcosm-tree-memory-${payload.edition}.json`,
          texturePng: `microcosm-card-texture-${payload.edition}.png`,
          textureSvg: `microcosm-card-texture-${payload.edition}.svg`,
          manifestJson: `microcosm-tree-bundle-${payload.edition}.json`
        },
        restore: {
          warning: payload.cleanupWarning,
          instructions: [
            'Ouvre à nouveau Microcosm Interactif 9.',
            'Utilise le bouton “Importer JSON”.',
            'Sélectionne le fichier de mémoire exporté pour retrouver l’arbre.',
            'La carte HTML autonome conserve aussi une copie téléchargeable du JSON.'
          ]
        },
        tree: state.tree,
        activity: payload.activity,
        microClimate: state.microClimate
      };
    }

    function loadImageFromDataUrl(dataUrl) {
      return new Promise((resolve, reject) => {
        const img = new Image();
        img.onload = () => resolve(img);
        img.onerror = reject;
        img.src = dataUrl;
      });
    }

    function getPrestigeCardTitle(payload = {}) {
      const p = Number(payload.progressPct || 0);
      if (p >= 100) return 'Chêne centenaire accompli';
      if (p >= 70) return 'Couronne en formation';
      if (p >= 32) return 'Branches en expansion';
      if (p >= 12) return 'Jeune pousse vivante';
      return 'Graine en éveil';
    }

    /* ─────────────────────────────────────────────────────────────
       CARD_RENDERER_V8_11
       Zone configurable : mise en page de la face avant exportée en PNG.
       Objectif : carte sombre, premium, lisible, proche d'un certificat collector.
       - aucune modification de la croissance de l'arbre ;
       - rendu stable en canvas, sans dépendance externe obligatoire ;
       - le QR scannable réel reste disponible en HTML/SVG pour éviter le taint canvas.
    ───────────────────────────────────────────────────────────── */
    async function renderTransparentCollectorTextureCanvas(payload) {
      const img = await loadImageFromDataUrl(payload.snapshot);
      const canvas = document.createElement('canvas');
      canvas.width = 1080;
      canvas.height = 1600;
      const ctx = canvas.getContext('2d');
      const W = canvas.width;
      const H = canvas.height;

      const layout = {
        outerX: 90,
        outerY: 78,
        outerW: 900,
        outerH: 1444,
        innerPad: 18,
        contentX: 170,
        contentW: 740,
        radiusOuter: 62,
        radiusInner: 48
      };

      const isLifetime = payload.tier === 'eternal-glass';
      const isWinds = payload.tier === 'winds-addon';
      const holoOpacity = isLifetime ? 0.18 : isWinds ? 0.13 : 0.08;
      const title = getPrestigeCardTitle(payload);
      const subtitle = payload.rarity || payload.tierLabel || 'Graine vivante';

      function rr(x, y, w, h, r) {
        ctx.beginPath();
        ctx.moveTo(x + r, y);
        ctx.arcTo(x + w, y, x + w, y + h, r);
        ctx.arcTo(x + w, y + h, x, y + h, r);
        ctx.arcTo(x, y + h, x, y, r);
        ctx.arcTo(x, y, x + w, y, r);
        ctx.closePath();
      }

      function objectCoverDraw(image, x, y, w, h) {
        const iw = image.width || 1;
        const ih = image.height || 1;
        const scale = Math.max(w / iw, h / ih);
        const dw = iw * scale;
        const dh = ih * scale;
        const dx = x + (w - dw) * 0.5;
        const dy = y + (h - dh) * 0.5;
        ctx.drawImage(image, dx, dy, dw, dh);
      }

      function fillTextFit(text, x, y, maxWidth, font, minSize = 16) {
        const m = String(font).match(/(\d+)px/);
        const baseSize = m ? parseInt(m[1], 10) : 24;
        let size = baseSize;
        while (size > minSize) {
          ctx.font = font.replace(/\d+px/, size + 'px');
          if (ctx.measureText(String(text)).width <= maxWidth) break;
          size -= 1;
        }
        ctx.fillText(String(text), x, y);
      }

      function drawWrappedTitle(text, x, y, maxWidth, lineHeight, maxLines = 3) {
        const words = String(text).toUpperCase().split(/\s+/).filter(Boolean);
        const lines = [];
        let line = '';
        ctx.font = '950 76px Inter, Arial Black, system-ui, sans-serif';
        for (const word of words) {
          const test = line ? line + ' ' + word : word;
          if (ctx.measureText(test).width > maxWidth && line) {
            lines.push(line);
            line = word;
          } else {
            line = test;
          }
        }
        if (line) lines.push(line);
        const finalLines = lines.slice(0, maxLines);
        if (lines.length > maxLines) finalLines[maxLines - 1] += '…';
        ctx.fillStyle = 'rgba(255,248,225,0.96)';
        ctx.font = '950 76px Inter, Arial Black, system-ui, sans-serif';
        ctx.textBaseline = 'alphabetic';
        finalLines.forEach((l, idx) => ctx.fillText(l, x, y + idx * lineHeight));
        return y + finalLines.length * lineHeight;
      }

      function drawHolo(x, y, w, h, alpha, radius = 44) {
        ctx.save();
        rr(x, y, w, h, radius);
        ctx.clip();
        const grad = ctx.createLinearGradient(x, y, x + w, y + h);
        grad.addColorStop(0.00, `rgba(255,230,150,${alpha})`);
        grad.addColorStop(0.23, `rgba(132,180,80,${alpha * 0.85})`);
        grad.addColorStop(0.48, `rgba(97,205,255,${alpha * 0.72})`);
        grad.addColorStop(0.71, `rgba(255,74,120,${alpha * 0.56})`);
        grad.addColorStop(1.00, `rgba(255,255,255,${alpha * 0.38})`);
        ctx.globalCompositeOperation = 'screen';
        ctx.fillStyle = grad;
        ctx.fillRect(x, y, w, h);
        ctx.globalAlpha = 0.34;
        ctx.strokeStyle = 'rgba(255,255,255,0.14)';
        ctx.lineWidth = 1;
        for (let i = -h; i < w; i += 28) {
          ctx.beginPath();
          ctx.moveTo(x + i, y + h);
          ctx.lineTo(x + i + h, y);
          ctx.stroke();
        }
        ctx.restore();
      }

      function drawPseudoQr(x, y, size) {
        rr(x, y, size, size, 18);
        ctx.fillStyle = 'rgba(255,248,225,0.92)';
        ctx.fill();
        const n = 13;
        const pad = 13;
        const cell = (size - pad * 2) / n;
        function seedBit(i, j) {
          const h = hashGrassSeedString(`${payload.edition}:${i}:${j}`);
          return (h % 100) > 48;
        }
        ctx.fillStyle = '#151207';
        for (let j = 0; j < n; j++) {
          for (let i = 0; i < n; i++) {
            const finder = (i < 4 && j < 4) || (i > n - 5 && j < 4) || (i < 4 && j > n - 5);
            if (finder || seedBit(i, j)) {
              ctx.fillRect(x + pad + i * cell + 1, y + pad + j * cell + 1, Math.max(2, cell - 2), Math.max(2, cell - 2));
            }
          }
        }
      }

      function statBox(label, value, x, y, w, h) {
        rr(x, y, w, h, 22);
        ctx.fillStyle = 'rgba(11,10,5,0.38)';
        ctx.fill();
        ctx.strokeStyle = 'rgba(255,240,184,0.12)';
        ctx.lineWidth = 1.2;
        ctx.stroke();
        ctx.fillStyle = 'rgba(226,218,188,0.58)';
        ctx.font = '800 17px Inter, system-ui, sans-serif';
        ctx.fillText(label.toUpperCase(), x + 20, y + 30);
        ctx.fillStyle = 'rgba(255,248,225,0.96)';
        fillTextFit(value, x + 20, y + 68, w - 40, '950 28px Inter, system-ui, sans-serif', 18);
      }

      ctx.clearRect(0, 0, W, H);

      // Ombre externe et cadre épais type carte prestige.
      ctx.shadowColor = 'rgba(0,0,0,0.62)';
      ctx.shadowBlur = 44;
      ctx.shadowOffsetY = 24;
      rr(layout.outerX, layout.outerY, layout.outerW, layout.outerH, layout.radiusOuter);
      const frame = ctx.createLinearGradient(layout.outerX, layout.outerY, layout.outerX + layout.outerW, layout.outerY + layout.outerH);
      frame.addColorStop(0.00, 'rgba(255,240,184,0.92)');
      frame.addColorStop(0.18, 'rgba(143,102,49,0.88)');
      frame.addColorStop(0.44, 'rgba(42,52,18,0.82)');
      frame.addColorStop(0.70, 'rgba(198,151,80,0.88)');
      frame.addColorStop(1.00, 'rgba(255,248,225,0.90)');
      ctx.fillStyle = frame;
      ctx.fill();
      ctx.shadowBlur = 0;
      ctx.shadowOffsetY = 0;

      const ix = layout.outerX + layout.innerPad;
      const iy = layout.outerY + layout.innerPad;
      const iw = layout.outerW - layout.innerPad * 2;
      const ih = layout.outerH - layout.innerPad * 2;
      rr(ix, iy, iw, ih, layout.radiusInner);
      const inner = ctx.createLinearGradient(ix, iy, ix, iy + ih);
      inner.addColorStop(0.00, 'rgba(66,49,23,0.94)');
      inner.addColorStop(0.28, 'rgba(25,27,11,0.95)');
      inner.addColorStop(0.70, 'rgba(10,16,6,0.96)');
      inner.addColorStop(1.00, 'rgba(7,6,3,0.98)');
      ctx.fillStyle = inner;
      ctx.fill();
      drawHolo(ix, iy, iw, ih, holoOpacity, layout.radiusInner);

      // Double filet intérieur.
      rr(ix + 18, iy + 18, iw - 36, ih - 36, 38);
      ctx.strokeStyle = 'rgba(255,248,225,0.20)';
      ctx.lineWidth = 2;
      ctx.stroke();
      rr(ix + 34, iy + 34, iw - 68, ih - 68, 30);
      ctx.strokeStyle = 'rgba(198,151,80,0.14)';
      ctx.lineWidth = 1;
      ctx.stroke();

      const cx = layout.contentX;
      const cw = layout.contentW;
      ctx.fillStyle = 'rgba(255,248,225,0.70)';
      ctx.font = '900 18px Inter, system-ui, sans-serif';
      ctx.letterSpacing = '3px';
      ctx.fillText('MICROCOSM INTERACTIF 9', cx, 190);
      rr(782, 152, 134, 50, 25);
      ctx.fillStyle = 'rgba(15,10,5,0.44)';
      ctx.fill();
      ctx.strokeStyle = 'rgba(255,240,184,0.18)';
      ctx.stroke();
      ctx.textAlign = 'center';
      ctx.fillStyle = 'rgba(255,248,225,0.90)';
      ctx.font = '900 18px Inter, system-ui, sans-serif';
      ctx.fillText('#' + payload.edition, 849, 184);
      ctx.textAlign = 'left';

      const titleEndY = drawWrappedTitle(title, cx, 285, cw, 78, 3);
      ctx.fillStyle = 'rgba(244,213,138,0.95)';
      ctx.font = '900 21px Inter, system-ui, sans-serif';
      ctx.letterSpacing = '4px';
      ctx.fillText(String(subtitle).toUpperCase(), cx, Math.max(440, titleEndY + 8));

      const imgX = cx;
      const imgY = 488;
      const imgW = cw;
      const imgH = 342;
      rr(imgX, imgY, imgW, imgH, 34);
      ctx.save();
      ctx.clip();
      objectCoverDraw(img, imgX, imgY, imgW, imgH);
      const vignette = ctx.createRadialGradient(imgX + imgW * 0.5, imgY + imgH * 0.38, imgW * 0.15, imgX + imgW * 0.5, imgY + imgH * 0.48, imgW * 0.72);
      vignette.addColorStop(0, 'rgba(255,255,255,0.00)');
      vignette.addColorStop(0.66, 'rgba(0,0,0,0.10)');
      vignette.addColorStop(1, 'rgba(0,0,0,0.58)');
      ctx.fillStyle = vignette;
      ctx.fillRect(imgX, imgY, imgW, imgH);
      ctx.restore();
      drawHolo(imgX, imgY, imgW, imgH, isLifetime ? 0.09 : 0.055, 34);

      // Barre de progression intégrée à la capture.
      const px = imgX + 54;
      const py = imgY + imgH - 88;
      const pw = imgW - 108;
      const ph = 68;
      rr(px, py, pw, ph, 34);
      ctx.fillStyle = 'rgba(10,9,5,0.72)';
      ctx.fill();
      const trackX = px + 34;
      const trackY = py + 27;
      const trackW = pw - 170;
      rr(trackX, trackY, trackW, 16, 99);
      ctx.fillStyle = 'rgba(255,248,225,0.12)';
      ctx.fill();
      rr(trackX, trackY, trackW * THREE.MathUtils.clamp(payload.progressPct / 100, 0, 1), 16, 99);
      const pg = ctx.createLinearGradient(trackX, trackY, trackX + trackW, trackY);
      pg.addColorStop(0, '#7d9638');
      pg.addColorStop(0.55, '#c79d43');
      pg.addColorStop(1, '#fff0a8');
      ctx.fillStyle = pg;
      ctx.fill();
      ctx.fillStyle = '#fff8e1';
      ctx.font = '950 27px Inter, system-ui, sans-serif';
      ctx.textAlign = 'right';
      ctx.fillText(`${payload.progressPct}%`, px + pw - 36, py + 46);
      ctx.textAlign = 'left';

      const sx = cx;
      const sy = 872;
      const sw = 224;
      const sh = 92;
      const gap = 22;
      statBox('Jour', payload.dayLabel || `${String(payload.day).padStart(3, '0')}/365`, sx, sy, sw, sh);
      statBox('Température', `${payload.temperature}°C`, sx + sw + gap, sy, sw, sh);
      statBox('Vent', `${payload.windSpeed} km/h`, sx + (sw + gap) * 2, sy, sw, sh);
      statBox('Humidité', `${payload.humidity}%`, sx, sy + 112, sw, sh);
      statBox('Nuages', `${payload.clouds}%`, sx + sw + gap, sy + 112, sw, sh);
      statBox('Synchro', payload.sync, sx + (sw + gap) * 2, sy + 112, sw, sh);

      // Certificat bas : QR stylisé + identité de graine + lieux.
      const certX = cx;
      const certY = 1246;
      const certW = cw;
      const certH = 176;
      rr(certX, certY, certW, certH, 30);
      ctx.fillStyle = 'rgba(8,7,4,0.58)';
      ctx.fill();
      ctx.strokeStyle = 'rgba(255,240,184,0.16)';
      ctx.lineWidth = 1.3;
      ctx.stroke();
      drawPseudoQr(certX + 28, certY + 28, 104);
      ctx.fillStyle = 'rgba(255,248,225,0.96)';
      ctx.font = '950 25px Inter, system-ui, sans-serif';
      ctx.fillText(`CERTIFICAT DE GRAINE #${payload.edition}`, certX + 154, certY + 55);
      ctx.fillStyle = 'rgba(226,218,188,0.74)';
      ctx.font = '650 18px Inter, system-ui, sans-serif';
      fillTextFit('Croissance Fibonacci · golden ratio · cycle annuel vivant', certX + 154, certY + 90, 548, '650 18px Inter, system-ui, sans-serif', 13);
      const loc = (payload.activity?.locations || []).map(v => v.place).filter(Boolean).slice(0, 3).join(' · ') || payload.place || 'lieu local';
      fillTextFit('Lieux : ' + loc, certX + 154, certY + 122, 548, '650 18px Inter, system-ui, sans-serif', 12);
      ctx.fillStyle = 'rgba(244,213,138,0.86)';
      ctx.font = '800 15px Inter, system-ui, sans-serif';
      ctx.fillText('SCAN AR/XR', certX + 46, certY + 150);

      // Liseré final et shine diagonal.
      ctx.save();
      rr(ix, iy, iw, ih, layout.radiusInner);
      ctx.clip();
      const shine = ctx.createLinearGradient(ix, iy + 220, ix + iw, iy + 940);
      shine.addColorStop(0, 'rgba(255,255,255,0)');
      shine.addColorStop(0.48, 'rgba(255,248,225,0.18)');
      shine.addColorStop(0.54, 'rgba(255,255,255,0)');
      ctx.globalCompositeOperation = 'screen';
      ctx.fillStyle = shine;
      ctx.fillRect(ix, iy, iw, ih);
      ctx.restore();

      return canvas;
    }

    function canvasToBlob(canvas, type = 'image/png') {
      return new Promise((resolve, reject) => {
        canvas.toBlob((blob) => blob ? resolve(blob) : reject(new Error('Blob PNG impossible')), type);
      });
    }

    function splitArUrlForSvg(url) {
      const clean = String(url || '').trim();
      if (!clean) return ['', ''];
      const qIndex = clean.indexOf('?');
      if (qIndex > 0) return [clean.slice(0, qIndex + 1), clean.slice(qIndex + 1)];
      const max = 52;
      if (clean.length <= max) return [clean, ''];
      let cut = clean.lastIndexOf('/', max);
      if (cut < 20) cut = max;
      return [clean.slice(0, cut + 1), clean.slice(cut + 1)];
    }

    function buildTransparentCollectorTextureSvg(payload, texturePngDataUrl = '') {
      const qrUrl = payload.qrCodeUrl || getQrImageUrl(payload.arLaunchUrl || getMicrocosmArLaunchUrl(payload));
      const stampUrl = payload.authStampUrl || YGGDRASIL_AUTH_STAMP_URL;
      const arUrlLines = splitArUrlForSvg(payload.arLaunchUrl || '');
      return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="1600" viewBox="0 0 1080 1600" fill="none">
  <title>Microcosm prestige collector texture #${escapeMicrocosmCardText(payload.edition)}</title>
  <desc>Texture SVG alignée sur la carte PNG prestige. Le QR AR/XR et le logo Yggdrasil sont superposés comme reflet holographique masqué par le SVG vectoriel.</desc>
  <defs>
    <linearGradient id="holoSeal" x1="0" y1="0" x2="1080" y2="1600" gradientUnits="userSpaceOnUse">
      <stop offset="0" stop-color="#fff0b8" stop-opacity="0.12"/>
      <stop offset="0.30" stop-color="#85d861" stop-opacity="0.10"/>
      <stop offset="0.58" stop-color="#61cdff" stop-opacity="0.13"/>
      <stop offset="0.78" stop-color="#ff4a78" stop-opacity="0.10"/>
      <stop offset="1" stop-color="#ffffff" stop-opacity="0.08"/>
    </linearGradient>
    <filter id="softGlow"><feGaussianBlur stdDeviation="8" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
    <mask id="yggdrasilHoloMask" maskUnits="userSpaceOnUse" x="90" y="120" width="900" height="1180">
      <rect x="0" y="0" width="1080" height="1600" fill="black"/>
      <image href="${escapeMicrocosmCardText(stampUrl)}" x="120" y="230" width="840" height="840" preserveAspectRatio="xMidYMid meet" opacity="1"/>
    </mask>
    <linearGradient id="holoSweep" x1="90" y1="78" x2="990" y2="1522" gradientUnits="userSpaceOnUse">
      <stop offset="0" stop-color="#fff0b8" stop-opacity="0"/>
      <stop offset="0.22" stop-color="#fff0b8" stop-opacity="0.38"/>
      <stop offset="0.42" stop-color="#85d861" stop-opacity="0.26"/>
      <stop offset="0.60" stop-color="#61cdff" stop-opacity="0.34"/>
      <stop offset="0.78" stop-color="#ff4a78" stop-opacity="0.24"/>
      <stop offset="1" stop-color="#ffffff" stop-opacity="0"/>
    </linearGradient>
  </defs>
  ${texturePngDataUrl ? `<image href="${texturePngDataUrl}" x="0" y="0" width="1080" height="1600" preserveAspectRatio="none"/>` : ''}
  <g opacity="0.42" style="mix-blend-mode:screen" filter="url(#softGlow)">
    <rect x="90" y="120" width="900" height="1180" rx="62" fill="url(#holoSweep)" mask="url(#yggdrasilHoloMask)"/>
  </g>
  <g opacity="0.12" style="mix-blend-mode:screen">
    <rect x="90" y="78" width="900" height="1444" rx="62" fill="url(#holoSeal)"/>
  </g>
  <g opacity="0.98">
    <rect x="202" y="1274" width="104" height="104" rx="18" fill="rgba(255,248,225,0.92)"/>
    <image href="${escapeMicrocosmCardText(qrUrl)}" x="212" y="1284" width="84" height="84" preserveAspectRatio="xMidYMid meet"/>
    <text x="254" y="1418" text-anchor="middle" fill="#f4d58a" font-family="Inter, Arial, sans-serif" font-size="13" font-weight="900" letter-spacing="1.8">SCAN AR/XR</text>
  </g>
  <text x="324" y="1346" fill="rgba(226,218,188,0.78)" font-family="Inter, Arial, sans-serif" font-size="16" font-weight="650">
    <tspan x="324" dy="0">${escapeMicrocosmCardText(arUrlLines[0])}</tspan>
    <tspan x="324" dy="24">${escapeMicrocosmCardText(arUrlLines[1])}</tspan>
  </text>
</svg>`;
    }

    function buildStandaloneCollectorBundleHtml(bundle) {
      const json = safeMicrocosmCardJson(bundle);
      return `<!DOCTYPE html>
<html lang="fr">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Microcosm · Carte mémoire #${escapeMicrocosmCardText(bundle.cardData.edition)}</title>
  <style>
    :root {
      --cream: #fff8e1;
      --cream-soft: rgba(255,248,225,.78);
      --gold: #d6a85a;
      --dark: #080704;
      --rx: -4deg;
      --ry: 5deg;
      --mx: 56%;
      --my: 34%;
      --logo-x: 50%;
      --logo-y: 50%;
      --holo: .18;
      --holo-tilt: 0deg;
      --face-radius: 34px;
      --face-fill:
        radial-gradient(circle at 20% 0%, rgba(255,240,184,.12), transparent 38%),
        radial-gradient(circle at 72% 30%, rgba(85,107,45,.12), transparent 34%),
        linear-gradient(180deg, rgba(38,29,15,.98) 0%, rgba(14,20,9,.985) 52%, rgba(6,5,3,.99) 100%);
      --yggdrasil-mask: url('${escapeMicrocosmCardText(YGGDRASIL_AUTH_STAMP_URL)}');
    }
    * { box-sizing: border-box; }
    html, body {
      min-height: 100%;
      margin: 0;
      font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
      color: var(--cream);
      background:
        radial-gradient(circle at 22% 0%, rgba(214,168,90,.14), transparent 38%),
        radial-gradient(circle at 76% 22%, rgba(97,205,255,.08), transparent 30%),
        linear-gradient(180deg, #100d07 0%, #050503 100%);
    }
    .page {
      min-height: 100vh;
      display: grid;
      grid-template-columns: minmax(320px, 620px) minmax(290px, 440px);
      gap: 46px;
      align-items: center;
      justify-content: center;
      padding: 34px;
    }
    .card-stage { perspective: 1700px; }
    .micro-card {
      width: min(100%, 560px);
      aspect-ratio: 1080 / 1600;
      position: relative;
      transform-style: preserve-3d;
      transform: rotateX(var(--rx)) rotateY(var(--ry));
      transition: transform .22s ease;
      border-radius: var(--face-radius);
      box-shadow: 0 42px 90px rgba(0,0,0,.48);
      contain: layout paint;
    }
    .micro-card.is-flipped { transform: rotateX(var(--rx)) rotateY(calc(180deg + var(--ry))); }
    .card-face {
      position: absolute;
      inset: 0;
      overflow: hidden;
      border-radius: var(--face-radius);
      backface-visibility: hidden;
      background: var(--face-fill);
      border: 1px solid rgba(255,248,225,.16);
      box-shadow:
        inset 0 0 0 1px rgba(214,168,90,.22),
        inset 0 0 0 10px rgba(255,248,225,.026),
        inset 0 0 42px rgba(0,0,0,.32);
      isolation: isolate;
    }
    .card-back { transform: rotateY(180deg); }
    .card-face::before,
    .card-face::after,
    .ygg-holo-sweep {
      content: '';
      position: absolute;
      inset: 0;
      pointer-events: none;
    }
    @keyframes microHoloDrift {
      0% { background-position: 0% 0%, var(--mx) var(--my); transform: translate3d(-1.2%, -.7%, 0) rotate(calc(var(--holo-tilt) * .28)); }
      50% { background-position: 100% 55%, var(--mx) var(--my); transform: translate3d(1.2%, .7%, 0) rotate(var(--holo-tilt)); }
      100% { background-position: 0% 0%, var(--mx) var(--my); transform: translate3d(-1.2%, -.7%, 0) rotate(calc(var(--holo-tilt) * .28)); }
    }
    .card-face::before {
      inset: -18%;
      z-index: 7;
      background:
        linear-gradient(112deg, transparent 10%, rgba(255,255,255,.045) 32%, rgba(255,248,225,.24) 47%, rgba(97,205,255,.13) 54%, transparent 72%),
        radial-gradient(circle at var(--mx) var(--my), rgba(255,255,255,.18), transparent 34%);
      background-size: 220% 220%, 100% 100%;
      mix-blend-mode: screen;
      opacity: .62;
      animation: microHoloDrift 7.5s ease-in-out infinite;
    }
    .card-face::after,
    .ygg-holo-sweep {
      z-index: 6;
      background:
        repeating-linear-gradient(128deg,
          rgba(255,240,184,.00) 0%,
          rgba(255,240,184,calc(var(--holo) * .88)) 9%,
          rgba(133,216,97,calc(var(--holo) * .68)) 17%,
          rgba(97,205,255,calc(var(--holo) * .78)) 27%,
          rgba(255,74,120,calc(var(--holo) * .64)) 38%,
          rgba(255,240,184,.00) 52%),
        radial-gradient(circle at var(--mx) var(--my), rgba(255,255,255,.42), transparent 42%);
      background-size: 260% 260%, 100% 100%;
      background-position: var(--mx) var(--my), var(--mx) var(--my);
      -webkit-mask-image: var(--yggdrasil-mask);
      mask-image: var(--yggdrasil-mask);
      -webkit-mask-size: 86% auto;
      mask-size: 86% auto;
      -webkit-mask-position: var(--logo-x) var(--logo-y);
      mask-position: var(--logo-x) var(--logo-y);
      -webkit-mask-repeat: no-repeat;
      mask-repeat: no-repeat;
      mix-blend-mode: plus-lighter;
      opacity: .72;
      filter: saturate(2) brightness(1.25) drop-shadow(0 0 28px rgba(255,248,225,.12));
      animation: microHoloDrift 9.5s ease-in-out infinite reverse;
    }
    .ygg-holo-sweep {
      z-index: 8;
      opacity: .40;
      -webkit-mask-size: 108% auto;
      mask-size: 108% auto;
      filter: saturate(2.4) brightness(1.3) drop-shadow(0 0 32px rgba(244,213,138,.12));
    }
    .prestige-inner {
      position: relative;
      z-index: 2;
      height: 100%;
      padding: 38px 36px 34px;
      border-radius: inherit;
      background: transparent;
      min-height: 0;
    }
    .card-front .prestige-inner,
    .card-back .prestige-inner {
      display: flex;
      flex-direction: column;
      overflow: hidden;
    }
    .card-frame-line,
    .card-frame-line::before {
      position: absolute;
      inset: 26px;
      content: '';
      border-radius: 26px;
      border: 1px solid rgba(255,240,184,.22);
      pointer-events: none;
      z-index: 3;
    }
    .card-frame-line::before { inset: 10px; border-color: rgba(85,107,45,.22); }
    .top-row { display: flex; justify-content: space-between; align-items: flex-start; gap: 14px; flex: 0 0 auto; }
    .kicker { color: rgba(255,248,225,.70); font-size: 10px; font-weight: 950; letter-spacing: .22em; text-transform: uppercase; }
    .edition-pill { padding: 7px 12px; border-radius: 999px; background: rgba(5,5,3,.42); border: 1px solid rgba(255,248,225,.12); color: rgba(255,248,225,.92); font-size: 10px; font-weight: 950; letter-spacing: .12em; white-space: nowrap; }
    .front-title, .back-title { color: var(--cream); font-weight: 950; text-transform: uppercase; letter-spacing: .045em; line-height: .92; }
    .front-title { margin: 30px 0 8px; font-size: clamp(38px, 6.4vw, 62px); max-width: 94%; flex: 0 0 auto; }
    .back-title { margin: 18px 0 10px; font-size: clamp(38px, 6vw, 58px); max-width: 100%; }
    .subline { color: #f4d58a; font-size: 11px; font-weight: 950; letter-spacing: .22em; text-transform: uppercase; margin-bottom: 14px; flex: 0 0 auto; }
    .photo-window { position: relative; height: 210px; flex: 0 0 210px; border-radius: 24px; overflow: hidden; border: 1px solid rgba(255,240,184,.16); background: rgba(255,255,255,.04); box-shadow: inset 0 1px 0 rgba(255,255,255,.10); }
    .photo-window img { display: block; width: 100%; height: 100%; object-fit: cover; }
    .photo-window::after { content: ''; position: absolute; inset: 0; background: radial-gradient(circle at center, transparent 0%, rgba(0,0,0,.12) 58%, rgba(0,0,0,.58) 100%); pointer-events: none; }
    .progress-shell { position: absolute; left: 30px; right: 30px; bottom: 20px; z-index: 2; display: grid; grid-template-columns: 1fr auto; gap: 14px; align-items: center; padding: 11px 15px; border-radius: 999px; background: rgba(8,7,4,.72); border: 1px solid rgba(255,248,225,.10); backdrop-filter: blur(10px); }
    .progress-track { height: 11px; border-radius: 999px; background: rgba(255,248,225,.12); overflow: hidden; }
    .progress-fill { height: 100%; width: 0%; border-radius: inherit; background: linear-gradient(90deg, #6e8f2e, #d6a85a, #fff0b8); box-shadow: 0 0 18px rgba(244,213,138,.42); }
    .progress-num { font-weight: 950; font-size: 15px; color: var(--cream); }
    .stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 16px; flex: 0 0 auto; }
    .stat, .id-line, .meta { padding: 10px 12px; border-radius: 16px; background: rgba(7,6,3,.46); border: 1px solid rgba(255,248,225,.10); box-shadow: inset 0 1px 0 rgba(255,255,255,.07); min-width: 0; }
    .stat small, .id-line small, .meta small { display: block; color: rgba(255,248,225,.50); font-size: 8px; letter-spacing: .13em; text-transform: uppercase; font-weight: 950; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    .stat b, .id-line b, .meta b { display: block; margin-top: 5px; color: var(--cream); font-size: 14px; line-height: 1.16; font-weight: 950; overflow-wrap: anywhere; }
    .certificate-band, .auth-band { display: grid; grid-template-columns: 68px 1fr; gap: 12px; align-items: center; margin-top: 16px; padding: 10px; border-radius: 20px; background: rgba(7,6,3,.52); border: 1px solid rgba(255,248,225,.12); flex: 0 0 auto; min-height: 92px; }
    .qr-card-small, .qr-card { background: rgba(255,248,225,.94); border-radius: 16px; padding: 8px; box-shadow: 0 18px 34px rgba(0,0,0,.30); align-self: start; }
    .qr-card-small img, .qr-card img { display: block; width: 100%; aspect-ratio: 1; object-fit: contain; border-radius: 8px; }
    .qr-card-small span, .qr-card span { display: block; margin-top: 6px; text-align: center; color: #151207; font-size: 7px; font-weight: 950; letter-spacing: .12em; text-transform: uppercase; }
    .certificate-band strong, .auth-band strong { display: block; color: var(--cream); font-size: 13px; font-weight: 950; letter-spacing: .10em; text-transform: uppercase; }
    .certificate-band span, .auth-band span, .ar-url { display: block; margin-top: 5px; color: rgba(226,218,188,.70); font-size: 10px; line-height: 1.34; }
    .card-back .prestige-inner { padding-top: 38px; }
    .back-grid { display: grid; grid-template-columns: 1fr 130px; gap: 16px; align-items: start; flex: 0 0 auto; }
    .qr-card { padding: 9px; border-radius: 22px; }
    .qr-card span { font-size: 7px; }
    .back-copy { color: rgba(226,218,188,.78); font-size: 12px; line-height: 1.35; font-weight: 650; max-width: 100%; margin: 0 0 12px; flex: 0 0 auto; }
    .id-lines { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 10px; flex: 0 0 auto; }
    .activity-panel { margin-top: 12px; padding: 11px; border-radius: 20px; background: rgba(7,6,3,.46); border: 1px solid rgba(255,248,225,.12); min-width: 0; flex: 0 0 auto; }
    .activity-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 0 0 9px; }
    .activity-head h2 { margin: 0; font-size: 11px; letter-spacing: .12em; text-transform: uppercase; color: rgba(255,248,225,.88); }
    .activity-year-controls { display: none; align-items: center; gap: 6px; color: rgba(255,248,225,.78); font-weight: 950; font-size: 10px; }
    body[data-tier="eternal-glass"] .activity-year-controls { display: inline-flex; }
    .activity-year-controls button { width: 22px; height: 22px; padding: 0; display: grid; place-items: center; border-radius: 999px; font-size: 12px; line-height: 1; }
    .activity-grid { display: grid; grid-template-columns: repeat(53, 6px); grid-auto-rows: 6px; gap: 2px; overflow: hidden; padding-bottom: 0; max-width: 100%; }
    .activity-cell { width: 6px; height: 6px; border-radius: 2px; background: rgba(255,248,225,.08); }
    .activity-cell.l1 { background: rgba(85,107,45,.48); }
    .activity-cell.l2 { background: rgba(95,145,54,.68); }
    .activity-cell.l3 { background: rgba(128,180,74,.84); }
    .activity-cell.l4 { background: rgba(180,213,112,.96); }
    .auth-band { margin-top: 12px; grid-template-columns: 58px 1fr; min-height: 80px; }
    .auth-stamp { width: 58px; height: 58px; border-radius: 50%; background: linear-gradient(135deg, rgba(255,240,184,.84), rgba(133,216,97,.52), rgba(97,205,255,.66), rgba(255,74,120,.42)); -webkit-mask-image: var(--yggdrasil-mask); mask-image: var(--yggdrasil-mask); -webkit-mask-size: contain; mask-size: contain; -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; -webkit-mask-position: center; mask-position: center; mix-blend-mode: plus-lighter; filter: drop-shadow(0 0 18px rgba(244,213,138,.18)); }
    .ar-url { color: rgba(244,213,138,.88); word-break: break-word; font-size: 10px; }
    .ar-url span { display: block; overflow-wrap: anywhere; }
    .memory-action-panel { display: grid; gap: 12px; margin-top: 18px; }
    .action-group { display: grid; gap: 8px; padding: 10px; border-radius: 20px; background: rgba(7,6,3,.28); border: 1px solid rgba(255,248,225,.09); }
    .action-group-title { color: rgba(226,218,188,.62); font-size: 9px; font-weight: 950; letter-spacing: .16em; text-transform: uppercase; }
    .action-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
    button { appearance: none; min-height: 44px; border: 1px solid rgba(244,213,138,.18); border-radius: 16px; background: linear-gradient(145deg, rgba(79,110,38,.58), rgba(42,26,18,.48)); color: var(--cream); padding: 11px 14px; font-weight: 850; cursor: pointer; text-align: center; line-height: 1.15; }
    button.primary { min-height: 50px; border-color: rgba(244,213,138,.34); background: radial-gradient(circle at 14% 0%, rgba(244,213,138,.20), transparent 48%), linear-gradient(145deg, rgba(79,110,38,.72), rgba(42,26,18,.58)); }
    button.secondary { background: linear-gradient(145deg, rgba(27,20,12,.64), rgba(52,39,21,.48)); }
    .memory-note { margin: 4px 2px 0; color: rgba(226,218,188,.58); font-size: 11px; line-height: 1.4; }
    .side-panel { padding: 22px; border-radius: 28px; background: linear-gradient(145deg, rgba(27,20,12,.74), rgba(52,39,21,.50)); border: 1px solid rgba(244,213,138,.16); box-shadow: 0 26px 52px rgba(0,0,0,.28), inset 0 1px 0 rgba(255,255,255,.08); }
    .side-panel h2 { margin: 0 0 10px; font-size: 28px; line-height: 1.08; }
    .side-panel p, .warning { color: rgba(226,218,188,.76); line-height: 1.55; font-size: 14px; }
    .meta-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-top: 16px; }
    .warning { margin-top: 14px; padding: 14px; border-radius: 18px; background: rgba(7,6,3,.38); border: 1px solid rgba(255,248,225,.10); }
    @media (max-width: 1040px) { .page { grid-template-columns: 1fr; gap: 20px; padding: 16px; } .micro-card { margin: 0 auto; } .side-panel { max-width: 620px; margin: 0 auto; } }
    @media (max-width: 620px) { .page { padding: 10px; } .micro-card { width: min(100%, 430px); } .prestige-inner { padding: 28px 22px 24px; } .front-title, .back-title { font-size: 32px; } .photo-window { height: 168px; flex-basis: 168px; } .back-grid { grid-template-columns: 1fr 104px; gap: 10px; } .qr-card { width: 104px; } .id-lines, .meta-grid, .stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; } .activity-grid { grid-template-columns: repeat(53, 5px); grid-auto-rows: 5px; gap: 1.5px; } .activity-cell { width: 5px; height: 5px; } .action-row { grid-template-columns: 1fr; } button { width: 100%; } .side-panel { padding: 16px; border-radius: 22px; } }
    @media (max-width: 420px) { .prestige-inner { padding: 24px 18px 20px; } .front-title, .back-title { font-size: 28px; } .photo-window { height: 146px; flex-basis: 146px; } .stat, .id-line, .meta { padding: 8px; border-radius: 12px; } .stat b, .id-line b, .meta b { font-size: 12px; } .certificate-band { grid-template-columns: 58px 1fr; min-height: 76px; } .qr-card-small { padding: 6px; border-radius: 12px; } .back-grid { grid-template-columns: 1fr 92px; } .qr-card { width: 92px; padding: 6px; border-radius: 16px; } .activity-grid { grid-template-columns: repeat(53, 4px); grid-auto-rows: 4px; gap: 1px; } .activity-cell { width: 4px; height: 4px; } }
    @media (max-height: 760px) and (min-width: 1041px) { .page { align-items: start; } .micro-card { width: min(48vw, 480px); } .side-panel { align-self: start; max-height: calc(100dvh - 40px); overflow: auto; } }

    /* CARD_LAYOUT_RECOVERY_V8_13 · verrouillage anti-décalage des cartes exportées */
    .card-front .certificate-band {
      margin-top: auto;
      margin-bottom: 0;
    }
    .card-back .auth-band {
      margin-top: auto;
      margin-bottom: 0;
    }
    .card-back .prestige-inner {
      gap: 10px;
    }
    .activity-panel {
      overflow: hidden !important;
    }
    .activity-grid {
      height: 54px !important;
      max-height: 54px !important;
      overflow: hidden !important;
      align-content: start;
    }
    .activity-cell {
      flex: 0 0 auto;
    }
    @media (min-width: 1041px) and (max-height: 900px) {
      .micro-card {
        width: min(100%, 500px);
      }
      .page {
        align-items: center;
      }
    }
  </style>
</head>
<body data-tier="${escapeMicrocosmCardText(bundle.cardData.tier)}">
  <main class="page">
    <section class="card-stage">
      <article class="micro-card" id="micro-card">
        <section class="card-face card-front">
          <div class="card-frame-line" aria-hidden="true"></div>
          <div class="ygg-holo-sweep" aria-hidden="true"></div>
          <div class="prestige-inner">
            <div class="top-row">
              <div class="kicker">Microcosm Interactif 9</div>
              <div class="edition-pill" id="front-edition">#------</div>
            </div>
            <h1 class="front-title" id="front-title">Arbre vivant</h1>
            <div class="subline" id="front-subline">Graine vivante</div>
            <div class="photo-window">
              <img id="world-shot" alt="Capture du microcosme">
              <div class="progress-shell">
                <div class="progress-track"><div class="progress-fill" id="progress-fill"></div></div>
                <div class="progress-num" id="progress-num">0%</div>
              </div>
            </div>
            <div class="stats-grid" id="stats-grid"></div>
            <div class="certificate-band">
              <div class="qr-card-small"><img id="front-qr" alt="QR code AR/XR"><span>Scan AR/XR</span></div>
              <div><strong id="front-certificate">Certificat de graine</strong><span id="front-certificate-copy"></span></div>
            </div>
          </div>
        </section>
        <section class="card-face card-back">
          <div class="card-frame-line" aria-hidden="true"></div>
          <div class="ygg-holo-sweep" aria-hidden="true"></div>
          <div class="prestige-inner">
            <div class="back-grid">
              <div>
                <div class="kicker">Certificat holographique</div>
                <h1 class="back-title" id="back-title">Carte mémoire</h1>
              </div>
              <div class="qr-card"><img id="qr-code" alt="QR code AR / XR"><span>Scan AR / XR</span></div>
            </div>
            <p class="back-copy" id="back-copy"></p>
            <div class="id-lines" id="id-lines"></div>
            <div class="activity-panel">
              <div class="activity-head">
                <h2>Activité annuelle</h2>
                <div class="activity-year-controls" id="activity-year-controls">
                  <button type="button" id="activity-prev-year" aria-label="Année précédente">‹</button>
                  <span id="activity-year-label"></span>
                  <button type="button" id="activity-next-year" aria-label="Année suivante">›</button>
                </div>
              </div>
              <div class="activity-grid" id="activity-grid"></div>
            </div>
            <div class="auth-band"><div class="auth-stamp" aria-hidden="true"></div><div><strong>Authenticité Microcosm · #<span id="edition-inline"></span></strong><span>Le logo Yggdrasil est inclus comme masque vectoriel dans le reflet holographique dynamique, sur le recto et sur le verso.</span><span class="ar-url" id="ar-url"></span></div></div>
          </div>
        </section>
      </article>
    </section>
    <aside class="side-panel">
      <div class="side-kicker">Carte mémoire autonome</div>
      <h2>Actions de la carte</h2>
      <p>Cette page embarque toute la mémoire de l’arbre. Les exports se font ici, un clic à la fois, pour éviter tout blocage du navigateur.</p>
      <div class="memory-action-panel" aria-label="Actions disponibles sur la carte mémoire">
        <div class="action-group">
          <div class="action-group-title">Consulter</div>
          <div class="action-row">
            <button type="button" class="primary" id="flip-card">Recto / verso</button>
            <button type="button" id="open-ar-xr">Ouvrir AR/XR</button>
          </div>
        </div>
        <div class="action-group">
          <div class="action-group-title">Restaurer</div>
          <div class="action-row">
            <button type="button" class="secondary" id="copy-tree-json">Copier JSON</button>
            <button type="button" id="download-tree-json">Télécharger JSON</button>
          </div>
        </div>
        <div class="action-group">
          <div class="action-group-title">Textures & manifeste</div>
          <div class="action-row">
            <button type="button" class="secondary" id="download-texture-png">PNG</button>
            <button type="button" class="secondary" id="download-texture-svg">SVG</button>
          </div>
          <button type="button" class="secondary" id="download-manifest">Télécharger le manifeste</button>
        </div>
        <p class="memory-note">Aucune action secondaire ne se lance automatiquement : chaque export demande un clic utilisateur distinct.</p>
      </div>
      <div class="meta-grid" id="meta-grid"></div>
      <div class="warning" id="cleanup-warning"></div>
    </aside>
  </main>
  <script>
    const BUNDLE = ${json};
    const CARD_DATA = BUNDLE.cardData;
    const TREE_STATE = BUNDLE.treeState;
    const MANIFEST = BUNDLE.manifest;
    const $ = (selector) => document.querySelector(selector);
    const card = $('#micro-card');
    const clamp = (v, min, max) => Math.min(max, Math.max(min, v));

    function getPrestigeCardTitle(data) {
      if (data.progressPct >= 100) return 'Chêne centenaire accompli';
      if (data.progressPct >= 70) return 'Couronne en formation';
      if (data.progressPct >= 32) return 'Branches en expansion';
      if (data.progressPct >= 12) return 'Jeune pousse vivante';
      return 'Graine en éveil';
    }
    function meta(label, value) { const item = document.createElement('div'); item.className = 'meta'; item.innerHTML = '<small></small><b></b>'; item.querySelector('small').textContent = label; item.querySelector('b').textContent = value; return item; }
    function idLine(label, value) { const item = document.createElement('div'); item.className = 'id-line'; item.innerHTML = '<small></small><b></b>'; item.querySelector('small').textContent = label; item.querySelector('b').textContent = value; return item; }
    function stat(label, value) { const item = document.createElement('div'); item.className = 'stat'; item.innerHTML = '<small></small><b></b>'; item.querySelector('small').textContent = label; item.querySelector('b').textContent = value; return item; }
    function downloadBlob(name, blob, type) { const finalBlob = blob instanceof Blob ? blob : new Blob([blob], { type: type || 'application/octet-stream' }); const href = URL.createObjectURL(finalBlob); const a = document.createElement('a'); a.href = href; a.download = name; document.body.appendChild(a); a.click(); setTimeout(() => URL.revokeObjectURL(href), 2000); a.remove(); }
    function downloadDataUrl(name, dataUrl) { const a = document.createElement('a'); a.href = dataUrl; a.download = name; document.body.appendChild(a); a.click(); a.remove(); }
    function fallbackQrSvgDataUrl(label) { const safe = String(label || 'AR/XR').slice(0, 96).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c] || c)); const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 220"><rect width="220" height="220" rx="18" fill="#fff8e1"/><rect x="18" y="18" width="52" height="52" fill="#17110a"/><rect x="150" y="18" width="52" height="52" fill="#17110a"/><rect x="18" y="150" width="52" height="52" fill="#17110a"/><path d="M96 30h18v18H96zm32 0h10v10h-10zm-26 38h16v16h-16zm36 16h48v16h-48zM92 112h22v22H92zm42-2h16v16h-16zm30 18h22v22h-22zM90 158h14v14H90zm28 22h58v14h-58z" fill="#17110a"/><text x="110" y="116" text-anchor="middle" font-family="Inter,Arial,sans-serif" font-size="15" font-weight="900" fill="#17110a">QR AR/XR</text><text x="110" y="136" text-anchor="middle" font-family="Inter,Arial,sans-serif" font-size="8" fill="#17110a">' + safe + '</text></svg>'; return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg); }
    function installQrFallback(img) { if (!img) return; img.onerror = () => { img.onerror = null; img.src = fallbackQrSvgDataUrl(CARD_DATA.arLaunchUrl || CARD_DATA.edition); }; }
    function flipCard() { card.classList.toggle('is-flipped'); }
    function syncPointer(event) {
      const x = event.clientX ?? window.innerWidth * .58;
      const y = event.clientY ?? window.innerHeight * .36;
      const rect = card.getBoundingClientRect();
      const localX = clamp(((x - rect.left) / rect.width) - .5, -.5, .5);
      const localY = clamp(((y - rect.top) / rect.height) - .5, -.5, .5);
      const mx = clamp(((x - rect.left) / rect.width) * 100, 0, 100);
      const my = clamp(((y - rect.top) / rect.height) * 100, 0, 100);
      document.documentElement.style.setProperty('--ry', (localX * 14).toFixed(2) + 'deg');
      document.documentElement.style.setProperty('--rx', (localY * -10).toFixed(2) + 'deg');
      document.documentElement.style.setProperty('--mx', mx.toFixed(2) + '%');
      document.documentElement.style.setProperty('--my', my.toFixed(2) + '%');
      document.documentElement.style.setProperty('--logo-x', (50 + localX * 10).toFixed(2) + '%');
      document.documentElement.style.setProperty('--logo-y', (50 + localY * 8).toFixed(2) + '%');
      document.documentElement.style.setProperty('--holo-tilt', (localX * 7).toFixed(2) + 'deg');
    }
    function activityYearFromDate(value) {
      const date = new Date(value);
      return Number.isFinite(date.getFullYear()) ? date.getFullYear() : new Date().getFullYear();
    }
    const activityYears = Array.from(new Set((CARD_DATA.activity?.days || []).map(item => activityYearFromDate(item.date)))).sort((a, b) => a - b);
    let selectedActivityYear = activityYears.includes(new Date().getFullYear()) ? new Date().getFullYear() : (activityYears[activityYears.length - 1] || new Date().getFullYear());
    function renderActivityGrid() {
      const grid = $('#activity-grid');
      grid.innerHTML = '';
      const days = CARD_DATA.activity?.days || [];
      const filtered = CARD_DATA.tier === 'eternal-glass'
        ? days.filter(item => activityYearFromDate(item.date) === selectedActivityYear)
        : days.slice(Math.max(0, days.length - 371));
      const list = filtered.slice(-371);
      for (let i = 0; i < 371; i++) {
        const item = list[i] || { date: '', visits: 0, clicks: 0, level: 0, places: [] };
        const cell = document.createElement('i');
        const level = Math.max(0, Math.min(4, Number(item.level || 0)));
        const places = Array.isArray(item.places) ? item.places.join(', ') : (item.place || '');
        cell.className = 'activity-cell l' + level;
        cell.title = [item.date, places, 'visites ' + (item.visits || 0), 'clics ' + (item.clicks || 0)].filter(Boolean).join(' · ');
        grid.appendChild(cell);
      }
      const label = $('#activity-year-label');
      if (label) label.textContent = String(selectedActivityYear);
    }
    function changeActivityYear(delta) {
      if (!activityYears.length) return;
      const currentIndex = Math.max(0, activityYears.indexOf(selectedActivityYear));
      const nextIndex = clamp(currentIndex + delta, 0, activityYears.length - 1);
      selectedActivityYear = activityYears[nextIndex];
      renderActivityGrid();
    }
    function hydrateActivity() {
      renderActivityGrid();
    }
    function hydrateCard() {
      document.title = 'Microcosm · Carte prestige #' + CARD_DATA.edition;
      const title = getPrestigeCardTitle(CARD_DATA);
      $('#world-shot').src = CARD_DATA.snapshot;
      installQrFallback($('#front-qr'));
      installQrFallback($('#qr-code'));
      $('#front-qr').src = CARD_DATA.qrCodeUrl || fallbackQrSvgDataUrl(CARD_DATA.arLaunchUrl || CARD_DATA.edition);
      $('#qr-code').src = CARD_DATA.qrCodeUrl || fallbackQrSvgDataUrl(CARD_DATA.arLaunchUrl || CARD_DATA.edition);
      $('#front-title').textContent = title;
      $('#front-subline').textContent = CARD_DATA.rarity;
      $('#front-edition').textContent = '#' + CARD_DATA.edition;
      $('#front-certificate').textContent = 'Certificat de graine #' + CARD_DATA.edition;
      $('#front-certificate-copy').textContent = 'Croissance Fibonacci · golden ratio · cycle annuel vivant · lieu : ' + CARD_DATA.place;
      $('#progress-num').textContent = CARD_DATA.progressPct + '%';
      requestAnimationFrame(() => { $('#progress-fill').style.width = clamp(CARD_DATA.progressPct, 0, 100) + '%'; });
      const stats = $('#stats-grid');
      stats.append(stat('Jour', CARD_DATA.dayLabel), stat('Température', CARD_DATA.temperature + '°C'), stat('Vent', CARD_DATA.windSpeed + ' km/h'), stat('Humidité', CARD_DATA.humidity + '%'), stat('Nuages', CARD_DATA.clouds + '%'), stat('Synchro', CARD_DATA.sync));
      $('#back-title').textContent = title;
      $('#back-copy').textContent = CARD_DATA.tierLabel + ' · ' + CARD_DATA.rarity + ' · jour ' + CARD_DATA.dayLabel + '. Cette face arrière sert de certificat holographique et de point d’entrée AR/XR.';
      $('#edition-inline').textContent = CARD_DATA.edition;
      const arText = CARD_DATA.arLaunchUrl || '';
      const arLines = arText.includes('?') ? [arText.slice(0, arText.indexOf('?') + 1), arText.slice(arText.indexOf('?') + 1)] : [arText, ''];
      $('#ar-url').innerHTML = '<span></span><span></span>';
      $('#ar-url').children[0].textContent = arLines[0];
      $('#ar-url').children[1].textContent = arLines[1];
      $('#cleanup-warning').textContent = CARD_DATA.cleanupWarning + ' Télécharge la mémoire JSON pour restaurer l’arbre après nettoyage du navigateur.';
      const ids = $('#id-lines');
      ids.append(idLine('Édition', '#' + CARD_DATA.edition), idLine('Graine', CARD_DATA.seedPublic), idLine('Lieu récent', CARD_DATA.place), idLine('Microclimat', CARD_DATA.temperature + '°C · vent ' + CARD_DATA.windSpeed + ' km/h · ' + CARD_DATA.humidity + '%'), idLine('Prix à vie', CARD_DATA.prices.lifetimeLabel), idLine('Add-on vents & marées', CARD_DATA.prices.windsLabel));
      const metaGrid = $('#meta-grid');
      metaGrid.append(meta('Rareté', CARD_DATA.rarity), meta('Tier', CARD_DATA.tierLabel), meta('Synchro', CARD_DATA.sync), meta('Générée', CARD_DATA.generatedAt));
      hydrateActivity();
    }
    hydrateCard();
    syncPointer({ clientX: window.innerWidth * .58, clientY: window.innerHeight * .36 });
    document.body.addEventListener('pointermove', syncPointer, { passive: true });
    $('#flip-card').addEventListener('click', flipCard);
    $('#open-ar-xr').addEventListener('click', () => { const url = CARD_DATA.arLaunchUrl || BUNDLE.arLaunchUrl; if (url) window.open(url, '_blank', 'noopener'); });
    $('#copy-tree-json').addEventListener('click', async () => { const json = JSON.stringify(TREE_STATE, null, 2); try { await navigator.clipboard.writeText(json); $('#copy-tree-json').textContent = 'JSON copié'; setTimeout(() => $('#copy-tree-json').textContent = 'Copier JSON', 1300); } catch (_) { downloadBlob('microcosm-tree-memory-' + CARD_DATA.edition + '.json', json, 'application/json'); } });
    $('#download-tree-json').addEventListener('click', () => downloadBlob('microcosm-tree-memory-' + CARD_DATA.edition + '.json', JSON.stringify(TREE_STATE, null, 2), 'application/json'));
    $('#download-texture-png').addEventListener('click', () => downloadDataUrl('microcosm-card-texture-' + CARD_DATA.edition + '.png', BUNDLE.texturePngDataUrl));
    $('#download-texture-svg').addEventListener('click', () => downloadBlob('microcosm-card-texture-' + CARD_DATA.edition + '.svg', BUNDLE.textureSvg, 'image/svg+xml'));
    $('#download-manifest').addEventListener('click', () => downloadBlob('microcosm-tree-bundle-' + CARD_DATA.edition + '.json', JSON.stringify(MANIFEST, null, 2), 'application/json'));
    $('#activity-prev-year')?.addEventListener('click', () => changeActivityYear(-1));
    $('#activity-next-year')?.addEventListener('click', () => changeActivityYear(1));
  <\/script>
</body>
</html>`;
    }

    async function saveBundleFiles(files, useDirectoryPicker = false) {
      // V8.14_EXPORT_SINGLE_MEMORY_CARD : ne jamais lancer plusieurs téléchargements.
      // Le fichier HTML autonome embarque JSON, SVG, PNG dataURL, manifeste, activité, QR et URL AR/XR.
      const file = Array.isArray(files) ? files[0] : files;
      if (!file || !file.blob) throw new Error('Aucun fichier HTML de carte mémoire à télécharger.');
      const href = URL.createObjectURL(file.blob);
      const a = document.createElement('a');
      a.href = href;
      a.download = file.name;
      document.body.appendChild(a);
      a.click();
      setTimeout(() => URL.revokeObjectURL(href), 2400);
      a.remove();
      return 'single-html';
    }

    async function buildTreeMemoryBundle(fullBundle = false) {
      const payload = buildCollectorCardPayload();
      const state = collectMicrocosmState();
      let texturePngDataUrl = payload.snapshot;
      let texturePngBlob = null;
      try {
        const textureCanvas = await renderTransparentCollectorTextureCanvas(payload);
        texturePngDataUrl = textureCanvas.toDataURL('image/png');
        texturePngBlob = await canvasToBlob(textureCanvas, 'image/png');
      } catch (err) {
        console.warn('Texture PNG collector remplacée par la capture de secours :', err);
        texturePngBlob = await fetch(texturePngDataUrl).then(r => r.blob());
      }
      const textureSvg = buildTransparentCollectorTextureSvg(payload, texturePngDataUrl);
      const manifest = buildMemoryBundleManifest(payload, state);
      const bundle = {
        schema: 'microcosm-interactif-9.embedded-memory-card.v2.single-html',
        exportMode: 'single-memory-card-html',
        cardData: payload,
        treeState: state,
        manifest,
        textureSvg,
        texturePngDataUrl,
        snapshot: payload.snapshot,
        activity: payload.activity,
        locations: payload.activity?.locations || [],
        arLaunchUrl: payload.arLaunchUrl,
        qrCodeUrl: payload.qrCodeUrl
      };
      const html = buildStandaloneCollectorBundleHtml(bundle);
      const memoryCardFile = {
        name: `${MICROCOSM_CONFIG.cards.memoryCardFilenamePrefix}-${payload.edition}.html`,
        blob: new Blob([html], { type: 'text/html;charset=utf-8' })
      };
      return { payload, state, bundle, file: memoryCardFile, files: [memoryCardFile] };
    }

    function buildStandaloneCollectorCardHtml(data) {
      const json = safeMicrocosmCardJson(data);
      return `<!DOCTYPE html>
<html lang="fr">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Microcosm · Carte mémoire #${escapeMicrocosmCardText(data.edition)}</title>
  <style>
    :root {
      --soil-0: #080604;
      --soil-1: #17110a;
      --soil-2: #2f2112;
      --soil-3: #6f4c25;
      --olive-1: #293311;
      --olive-2: #556b2d;
      --cream: #fff8e1;
      --cream-soft: rgba(255, 248, 225, 0.74);
      --gold: #f4d58a;
      --gold-soft: rgba(244, 213, 138, 0.34);
      --x: 50vw;
      --y: 50vh;
      --xp: 0.5;
      --yp: 0.5;
      --rx: 0deg;
      --ry: 0deg;
      --shine: 50%;
      color-scheme: dark;
    }
    * { box-sizing: border-box; }
    html, body {
      width: 100%;
      min-height: 100%;
      margin: 0;
      background:
        radial-gradient(circle at calc(var(--xp) * 100%) calc(var(--yp) * 100%), rgba(244, 213, 138, 0.16), transparent 34vw),
        radial-gradient(circle at 82% 12%, rgba(85, 107, 45, 0.22), transparent 34vw),
        linear-gradient(145deg, #080604 0%, #17110a 45%, #060805 100%);
      color: var(--cream);
      font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      overflow-x: hidden;
    }
    body {
      display: grid;
      place-items: center;
      padding: clamp(18px, 4vw, 52px);
    }
    .ambient-grain,
    .ambient-orb {
      position: fixed;
      inset: 0;
      pointer-events: none;
    }
    .ambient-grain {
      opacity: 0.18;
      mix-blend-mode: soft-light;
      background-image:
        radial-gradient(circle at 20% 20%, rgba(255,255,255,0.18) 0 1px, transparent 1.4px),
        radial-gradient(circle at 70% 80%, rgba(244,213,138,0.16) 0 1px, transparent 1.5px);
      background-size: 21px 23px, 31px 29px;
    }
    .ambient-orb {
      background:
        radial-gradient(circle at 18% 10%, rgba(244,213,138,0.18), transparent 24vw),
        radial-gradient(circle at 88% 72%, rgba(85,107,45,0.18), transparent 30vw);
      animation: breathe 9s ease-in-out infinite alternate;
    }
    @keyframes breathe {
      from { opacity: 0.55; transform: scale(1); }
      to { opacity: 0.95; transform: scale(1.04); }
    }
    .page {
      width: min(1180px, 100%);
      display: grid;
      grid-template-columns: minmax(320px, 520px) minmax(280px, 1fr);
      gap: clamp(18px, 4vw, 48px);
      align-items: center;
      position: relative;
      z-index: 1;
    }
    .card-stage {
      perspective: 1400px;
      display: grid;
      place-items: center;
      min-height: min(88vh, 860px);
    }
    .micro-card {
      width: min(500px, 92vw);
      aspect-ratio: 1080 / 1600;
      position: relative;
      transform-style: preserve-3d;
      transform: rotateX(var(--rx)) rotateY(var(--ry));
      transition: transform 0.18s ease;
      border-radius: 36px;
      filter: drop-shadow(0 34px 60px rgba(0,0,0,0.54));
    }
    .micro-card.is-flipped {
      transform: rotateX(var(--rx)) rotateY(calc(180deg + var(--ry)));
    }
    .card-face {
      position: absolute;
      inset: 0;
      overflow: hidden;
      border-radius: inherit;
      backface-visibility: hidden;
      border: 1px solid rgba(255, 248, 225, 0.20);
      background:
        radial-gradient(circle at 22% 0%, rgba(244, 213, 138, 0.22), transparent 28%),
        radial-gradient(circle at 80% 12%, rgba(85, 107, 45, 0.20), transparent 32%),
        linear-gradient(145deg, rgba(111, 76, 37, 0.96), rgba(23, 17, 10, 0.98) 28%, rgba(25, 31, 13, 0.98) 62%, rgba(8, 6, 4, 0.98));
      box-shadow:
        inset 0 1px 0 rgba(255, 248, 225, 0.24),
        inset 0 -1px 0 rgba(0,0,0,0.52),
        0 0 0 10px rgba(244, 213, 138, 0.035);
    }
    .card-face::before {
      content: "";
      position: absolute;
      inset: 10px;
      border-radius: 28px;
      padding: 2px;
      background: linear-gradient(135deg, rgba(255,248,225,0.9), rgba(157,106,50,0.26), rgba(85,107,45,0.36), rgba(244,213,138,0.82));
      -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
      -webkit-mask-composite: xor;
      mask-composite: exclude;
      pointer-events: none;
      opacity: 0.88;
    }
    .card-face::after {
      content: "";
      position: absolute;
      inset: 0;
      background:
        linear-gradient(115deg, transparent 0 37%, rgba(255,255,255,0.24) 47%, transparent 58% 100%),
        repeating-linear-gradient(-38deg, rgba(244,213,138,0.00) 0 16px, rgba(244,213,138,0.08) 17px 18px, rgba(85,107,45,0.06) 19px 26px);
      background-position: calc(var(--shine) * 1) center, center;
      background-size: 210% 210%, 100% 100%;
      opacity: 0.46;
      mix-blend-mode: screen;
      pointer-events: none;
      animation: foil 6s ease-in-out infinite alternate;
    }
    @keyframes foil {
      from { opacity: 0.28; background-position: 0% center, center; }
      to { opacity: 0.62; background-position: 100% center, center; }
    }
    .card-back {
      transform: rotateY(180deg);
      padding: 42px;
      display: grid;
      align-content: center;
      gap: 20px;
    }
    .front-content {
      position: relative;
      z-index: 2;
      height: 100%;
      padding: 42px;
      display: grid;
      grid-template-rows: auto auto 1fr auto auto;
      gap: 18px;
    }
    .card-kicker {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 12px;
      color: rgba(255, 248, 225, 0.76);
      font-size: 12px;
      font-weight: 850;
      letter-spacing: 0.16em;
      text-transform: uppercase;
    }
    .edition-pill {
      border: 1px solid rgba(244,213,138,0.28);
      border-radius: 999px;
      padding: 8px 12px;
      background: rgba(15, 10, 5, 0.34);
      white-space: nowrap;
    }
    h1 {
      margin: 0;
      font-size: clamp(36px, 7vw, 58px);
      line-height: 0.92;
      letter-spacing: -0.06em;
      text-transform: uppercase;
      color: var(--cream);
      text-shadow: 0 1px 0 rgba(255,255,255,0.08), 0 16px 26px rgba(0,0,0,0.28);
    }
    .rarity {
      color: var(--gold);
      font-size: 13px;
      letter-spacing: 0.14em;
      text-transform: uppercase;
      font-weight: 900;
      margin-top: 7px;
    }
    .image-window {
      position: relative;
      min-height: 0;
      border-radius: 28px;
      overflow: hidden;
      border: 1px solid rgba(244,213,138,0.34);
      background: rgba(15,10,5,0.44);
      box-shadow: inset 0 0 0 1px rgba(255,248,225,0.08), 0 18px 40px rgba(0,0,0,0.26);
    }
    .image-window img {
      width: 100%;
      height: 100%;
      object-fit: cover;
      display: block;
      filter: saturate(1.08) contrast(1.04) brightness(0.92);
      transform: scale(1.018);
    }
    .image-window .vignette {
      position: absolute;
      inset: 0;
      background: radial-gradient(circle at center, transparent 45%, rgba(0,0,0,0.55) 100%);
    }
    .progress-shell {
      position: absolute;
      left: 20px;
      right: 20px;
      bottom: 20px;
      padding: 12px 14px;
      border-radius: 999px;
      display: grid;
      grid-template-columns: 1fr auto;
      gap: 14px;
      align-items: center;
      background: rgba(8,6,4,0.66);
      border: 1px solid rgba(244,213,138,0.24);
      backdrop-filter: blur(18px);
    }
    .progress-track {
      height: 10px;
      border-radius: 999px;
      overflow: hidden;
      background: rgba(255,248,225,0.13);
    }
    .progress-fill {
      height: 100%;
      width: 0%;
      border-radius: inherit;
      background: linear-gradient(90deg, #6e8f2e, #c79d43 58%, #fff0a8);
      box-shadow: 0 0 18px rgba(244,213,138,0.52);
      transition: width 1.2s cubic-bezier(.16,1,.3,1);
    }
    .progress-num {
      font-size: 14px;
      font-weight: 950;
      color: var(--cream);
      min-width: 46px;
      text-align: right;
    }
    .stats-grid {
      display: grid;
      grid-template-columns: repeat(3, 1fr);
      gap: 10px;
    }
    .stat {
      border-radius: 18px;
      padding: 12px;
      background: rgba(15, 10, 5, 0.38);
      border: 1px solid rgba(244,213,138,0.16);
      min-width: 0;
    }
    .stat small {
      display: block;
      color: rgba(226,218,188,0.58);
      font-size: 10px;
      font-weight: 800;
      text-transform: uppercase;
      letter-spacing: 0.09em;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }
    .stat b {
      display: block;
      margin-top: 4px;
      color: var(--cream);
      font-size: 18px;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }
    .certificate {
      display: grid;
      grid-template-columns: 76px 1fr;
      gap: 16px;
      align-items: center;
      padding: 14px;
      border-radius: 22px;
      background: rgba(8,6,4,0.44);
      border: 1px solid rgba(244,213,138,0.18);
    }
    .seed-grid {
      display: grid;
      grid-template-columns: repeat(9, 1fr);
      gap: 2px;
      width: 76px;
      height: 76px;
      padding: 8px;
      border-radius: 18px;
      background: rgba(255,248,225,0.06);
      box-shadow: inset 0 0 0 1px rgba(244,213,138,0.16);
    }
    .seed-grid i {
      border-radius: 2px;
      background: rgba(255,248,225,0.14);
    }
    .seed-grid i.on:nth-child(3n) { background: rgba(244,213,138,0.92); }
    .seed-grid i.on:nth-child(3n+1) { background: rgba(146,166,82,0.90); }
    .seed-grid i.on:nth-child(3n+2) { background: rgba(255,248,225,0.82); }
    .certificate strong {
      display: block;
      font-size: 17px;
      color: var(--cream);
      text-transform: uppercase;
    }
    .certificate span {
      display: block;
      margin-top: 5px;
      font-size: 12px;
      color: rgba(226,218,188,0.66);
      line-height: 1.35;
    }
    .back-title {
      font-size: 30px;
      margin: 0;
      line-height: 1.05;
      letter-spacing: -0.04em;
    }
    .back-list {
      display: grid;
      gap: 12px;
      margin: 0;
      padding: 0;
      list-style: none;
    }
    .back-list li {
      padding: 14px 16px;
      border-radius: 18px;
      background: rgba(15,10,5,0.36);
      border: 1px solid rgba(244,213,138,0.14);
      color: rgba(255,248,225,0.82);
      line-height: 1.38;
      font-size: 14px;
    }
    .back-list b {
      color: var(--gold);
      display: block;
      margin-bottom: 4px;
      text-transform: uppercase;
      font-size: 11px;
      letter-spacing: 0.12em;
    }
    .panel {
      position: relative;
      z-index: 2;
      padding: clamp(18px, 3vw, 30px);
      border-radius: 30px;
      background:
        radial-gradient(circle at 18% 0%, rgba(244,213,138,0.16), transparent 34%),
        linear-gradient(145deg, rgba(27,20,12,0.78), rgba(52,39,21,0.48));
      border: 1px solid rgba(216,171,97,0.22);
      box-shadow: 0 26px 58px rgba(7,10,5,0.28), inset 0 1px 0 rgba(255,238,193,0.14);
      backdrop-filter: blur(20px);
    }
    .panel h2 {
      margin: 0 0 10px;
      font-size: clamp(28px, 5vw, 46px);
      line-height: 1;
      letter-spacing: -0.05em;
    }
    .panel p {
      color: rgba(226,218,188,0.76);
      line-height: 1.55;
      margin: 0 0 18px;
    }
    .controls, .card-actions-clean {
      display: grid;
      gap: 10px;
      margin-top: 18px;
    }
    .card-actions-clean {
      padding: 10px;
      border-radius: 20px;
      background: rgba(8,6,4,0.30);
      border: 1px solid rgba(244,213,138,0.12);
    }
    .card-actions-label {
      color: rgba(226,218,188,0.58);
      font-size: 10px;
      letter-spacing: 0.14em;
      text-transform: uppercase;
      font-weight: 900;
    }
    .action-row {
      display: grid;
      grid-template-columns: repeat(2, minmax(0, 1fr));
      gap: 8px;
    }
    button, a.button {
      border: 1px solid rgba(216,171,97,0.22);
      border-radius: 16px;
      min-height: 42px;
      padding: 0 16px;
      color: rgba(255,248,225,0.92);
      background: linear-gradient(145deg, rgba(79,110,38,0.54), rgba(38,69,28,0.36));
      font: 850 13px Inter, system-ui, sans-serif;
      cursor: pointer;
      text-decoration: none;
      text-align: center;
    }
    button.primary, a.button.primary {
      min-height: 50px;
      border-color: rgba(244,213,138,0.34);
      background: radial-gradient(circle at 14% 0%, rgba(244,213,138,0.18), transparent 46%), linear-gradient(145deg, rgba(79,110,38,0.66), rgba(38,69,28,0.42));
    }
    button.secondary, a.button.secondary {
      background: linear-gradient(145deg, rgba(74,53,30,0.54), rgba(22,17,10,0.42));
    }
    .meta-grid {
      display: grid;
      grid-template-columns: repeat(2, minmax(0, 1fr));
      gap: 10px;
      margin-top: 18px;
    }
    .meta {
      border-radius: 18px;
      padding: 13px 14px;
      background: rgba(8,6,4,0.32);
      border: 1px solid rgba(244,213,138,0.12);
    }
    .meta small {
      display: block;
      color: rgba(226,218,188,0.56);
      font-size: 10px;
      letter-spacing: 0.10em;
      text-transform: uppercase;
      font-weight: 850;
    }
    .meta b {
      display: block;
      margin-top: 5px;
      overflow-wrap: anywhere;
    }
    @media (max-width: 920px) {
      body { padding: 18px; }
      .page { grid-template-columns: 1fr; }
      .card-stage { min-height: auto; }
      .panel { order: -1; }
    }
    @media (max-width: 520px) {
      .front-content { padding: 30px; gap: 12px; }
      .stats-grid { grid-template-columns: repeat(2, 1fr); }
      .action-row { grid-template-columns: 1fr; }
      .card-kicker { font-size: 10px; }
      .certificate { grid-template-columns: 62px 1fr; }
      .seed-grid { width: 62px; height: 62px; }
      .card-back { padding: 30px; }
    }
    @media (prefers-reduced-motion: reduce) {
      *, *::before, *::after { animation: none !important; transition: none !important; }
      .micro-card, .micro-card.is-flipped { transform: none !important; }
    }
  </style>
</head>
<body>
  <div class="ambient-orb"></div>
  <div class="ambient-grain"></div>

  <main class="page">
    <section class="card-stage" aria-label="Carte collector dynamique Microcosm">
      <article class="micro-card" id="micro-card">
        <section class="card-face card-front">
          <div class="front-content">
            <div class="card-kicker">
              <span>MICROCOSM INTERACTIF 9</span>
              <span class="edition-pill" data-role="edition">#------</span>
            </div>
            <div>
              <h1 data-role="stage">Arbre vivant</h1>
              <div class="rarity" data-role="rarity">Graine vivante</div>
            </div>
            <div class="image-window">
              <img id="world-shot" alt="Vue du microcosme capturée au moment de la génération">
              <div class="vignette"></div>
              <div class="progress-shell">
                <div class="progress-track"><div class="progress-fill" id="progress-fill"></div></div>
                <div class="progress-num" data-role="progress">0%</div>
              </div>
            </div>
            <div class="stats-grid" id="stats-grid"></div>
            <div class="certificate">
              <div class="seed-grid" id="seed-grid" aria-hidden="true"></div>
              <div>
                <strong data-role="certificate">Certificat de graine</strong>
                <span>Croissance Fibonacci · golden ratio · cycle annuel vivant · carte HTML autonome générée depuis le microcosme local.</span>
              </div>
            </div>
          </div>
        </section>
        <section class="card-face card-back">
          <h2 class="back-title">Mémoire vivante de l’arbre</h2>
          <ul class="back-list" id="back-list"></ul>
          <div class="card-actions-clean">
            <div class="card-actions-label">Actions rapides</div>
            <div class="action-row">
              <button type="button" class="primary" id="flip-front">Recto / verso</button>
              <button type="button" class="secondary" id="copy-json">Copier JSON</button>
            </div>
          </div>
        </section>
      </article>
    </section>

    <aside class="panel">
      <h2>Carte mémoire, pas une image figée.</h2>
      <p>
        Cette page est autonome : elle embarque la capture du microcosme, les données de croissance,
        le microclimat et l’identité de graine. La carte réagit au pointeur, se retourne et reste consultable
        sans le projet original.
      </p>
      <div class="card-actions-clean" aria-label="Actions de la carte dynamique">
        <div class="card-actions-label">Carte dynamique</div>
        <button type="button" class="primary" id="flip-card">Voir recto / verso</button>
        <button type="button" class="secondary" id="download-json">Télécharger le JSON</button>
      </div>
      <div class="meta-grid" id="meta-grid"></div>
    </aside>
  </main>

  <script>
    const CARD_DATA = ${json};

    const $ = (selector) => document.querySelector(selector);
    const card = $('#micro-card');
    const clamp = (v, min, max) => Math.min(max, Math.max(min, v));

    function hashString(value) {
      let hash = 2166136261;
      const str = String(value || 'microcosm');
      for (let i = 0; i < str.length; i++) {
        hash ^= str.charCodeAt(i);
        hash = Math.imul(hash, 16777619);
      }
      return hash >>> 0;
    }

    function setText(role, value) {
      const node = document.querySelector('[data-role="' + role + '"]');
      if (node) node.textContent = value;
    }

    function stat(label, value) {
      const item = document.createElement('div');
      item.className = 'stat';
      item.innerHTML = '<small></small><b></b>';
      item.querySelector('small').textContent = label;
      item.querySelector('b').textContent = value;
      return item;
    }

    function meta(label, value) {
      const item = document.createElement('div');
      item.className = 'meta';
      item.innerHTML = '<small></small><b></b>';
      item.querySelector('small').textContent = label;
      item.querySelector('b').textContent = value;
      return item;
    }

    function hydrateCard(data) {
      document.title = 'Microcosm · Carte mémoire #' + data.edition;
      $('#world-shot').src = data.snapshot;
      setText('edition', '#' + data.edition);
      setText('stage', data.stage);
      setText('rarity', data.rarity);
      setText('progress', data.progressPct + '%');
      setText('certificate', 'Certificat de graine #' + data.edition);
      requestAnimationFrame(() => {
        $('#progress-fill').style.width = clamp(data.progressPct, 0, 100) + '%';
      });

      const stats = $('#stats-grid');
      stats.append(
        stat('Jour', String(data.day).padStart(3, '0') + '/365'),
        stat('Température', data.temperature + '°C'),
        stat('Vent', data.windSpeed + ' km/h'),
        stat('Humidité', data.humidity + '%'),
        stat('Nuages', data.clouds + '%'),
        stat('Synchro', data.sync)
      );

      const back = $('#back-list');
      const rows = [
        ['Microclimat', data.place + ' · ressenti ' + data.feelsLike + '°C · pression ' + data.pressure + ' hPa'],
        ['Graine', data.seedPublic + ' · variante unique localStorage'],
        ['Cycle', data.stage + ' · ' + data.progressPct + '% · ' + data.generatedAt],
        ['Vent global', data.windDirection + '° · un seul vent agit sur océan, herbe, feuilles, rivage et nuages']
      ];
      rows.forEach(([label, value]) => {
        const li = document.createElement('li');
        li.innerHTML = '<b></b><span></span>';
        li.querySelector('b').textContent = label;
        li.querySelector('span').textContent = value;
        back.appendChild(li);
      });

      const metaGrid = $('#meta-grid');
      metaGrid.append(
        meta('Édition', '#' + data.edition),
        meta('Rareté', data.rarity),
        meta('Lieu', data.place),
        meta('Générée', data.generatedAt)
      );

      const seed = $('#seed-grid');
      for (let i = 0; i < 81; i++) {
        const cell = document.createElement('i');
        const n = hashString(data.edition + ':' + i);
        const edge = i < 9 || i >= 72 || i % 9 === 0 || i % 9 === 8;
        if ((n % 100) > (edge ? 35 : 54)) cell.className = 'on';
        seed.appendChild(cell);
      }
    }

    function syncPointer(event) {
      const x = event.clientX ?? window.innerWidth / 2;
      const y = event.clientY ?? window.innerHeight / 2;
      const xp = x / window.innerWidth;
      const yp = y / window.innerHeight;
      document.documentElement.style.setProperty('--x', x + 'px');
      document.documentElement.style.setProperty('--y', y + 'px');
      document.documentElement.style.setProperty('--xp', xp.toFixed(3));
      document.documentElement.style.setProperty('--yp', yp.toFixed(3));
      document.documentElement.style.setProperty('--shine', (xp * 100).toFixed(1) + '%');
      const rect = card.getBoundingClientRect();
      const localX = (x - rect.left) / rect.width - 0.5;
      const localY = (y - rect.top) / rect.height - 0.5;
      document.documentElement.style.setProperty('--ry', clamp(localX * 14, -10, 10).toFixed(2) + 'deg');
      document.documentElement.style.setProperty('--rx', clamp(localY * -12, -8, 8).toFixed(2) + 'deg');
    }

    function flipCard() {
      card.classList.toggle('is-flipped');
    }

    function downloadJson() {
      const blob = new Blob([JSON.stringify(CARD_DATA, null, 2)], { type: 'application/json' });
      const a = document.createElement('a');
      a.href = URL.createObjectURL(blob);
      a.download = 'microcosm-card-' + CARD_DATA.edition + '.json';
      document.body.appendChild(a);
      a.click();
      URL.revokeObjectURL(a.href);
      a.remove();
    }

    async function copyJson() {
      try {
        await navigator.clipboard.writeText(JSON.stringify(CARD_DATA, null, 2));
        $('#copy-json').textContent = 'Données copiées';
        setTimeout(() => $('#copy-json').textContent = 'Copier les données', 1300);
      } catch (err) {
        downloadJson();
      }
    }

    hydrateCard(CARD_DATA);
    document.body.addEventListener('pointermove', syncPointer, { passive: true });
    $('#flip-card').addEventListener('click', flipCard);
    $('#flip-front').addEventListener('click', flipCard);
    $('#download-json').addEventListener('click', downloadJson);
    $('#copy-json').addEventListener('click', copyJson);
  <\/script>
</body>
</html>`;
    }


    async function openCurrentMemoryCardViewer() {
      const button = document.getElementById('save-tree-btn');
      const previousHtml = button ? button.innerHTML : '';
      try {
        if (button) {
          button.disabled = true;
          button.innerHTML = 'Ouverture de la carte…<small>génération du même HTML autonome</small>';
        }
        const { file, payload, bundle } = await buildTreeMemoryBundle(false);
        openFloatingMemoryCard(file, payload, bundle);
      } catch (err) {
        console.warn('Prévisualisation de la carte mémoire HTML impossible :', err);
        createShareCardPng();
      } finally {
        if (button) {
          button.innerHTML = previousHtml;
          syncPaidMemoryUi();
        }
      }
    }

    async function createShareCardHtml() {
      return openCurrentMemoryCardViewer();
    }

    let currentMemoryCardPreview = null;

    function revokeCurrentMemoryCardPreview() {
      currentMemoryCardPreview = null;
    }

    function downloadMemoryCardFile(file) {
      if (!file?.blob) throw new Error('Aucun fichier HTML à télécharger.');
      const href = URL.createObjectURL(file.blob);
      const a = document.createElement('a');
      a.href = href;
      a.download = file.name;
      document.body.appendChild(a);
      a.click();
      setTimeout(() => URL.revokeObjectURL(href), 2400);
      a.remove();
    }

    function setMemoryPopupText(id, value) {
      const node = document.getElementById(id);
      if (node) node.textContent = value == null || value === '' ? '--' : String(value);
    }

    // V8.19 · Les titres de stade peuvent être longs : on adapte la taille
    // au contenu au lieu de couper la carte. À utiliser pour le viewer popup,
    // sans toucher à la croissance Fibonacci validée.
    function fitMemoryCardTitle(id, value, options = {}) {
      const node = document.getElementById(id);
      if (!node) return;
      const text = String(value || 'Graine en éveil').trim();
      node.textContent = text || 'Graine en éveil';
      const len = text.length;
      const max = Number(options.max || 42);
      const min = Number(options.min || 22);
      let size = max;
      if (len > 14) size = Math.min(size, 37);
      if (len > 19) size = Math.min(size, 33);
      if (len > 26) size = Math.min(size, 29);
      if (len > 34) size = Math.min(size, 25);
      size = Math.max(min, size);
      node.style.fontSize = `${size}px`;
      node.style.lineHeight = len > 24 ? '0.94' : '0.90';
      node.style.letterSpacing = len > 24 ? '-0.025em' : '0.015em';
      node.style.maxWidth = '100%';
      node.style.overflowWrap = 'anywhere';
    }

    function toggleMemoryCardFace() {
      const viewer = document.getElementById('memory-card-viewer');
      if (!viewer) return;
      viewer.classList.toggle('is-flipped');
      syncMemoryCardFlipButton();
    }

    function syncFloatingMemoryCardActions() {
      const saveAllowed = isMicrocosmMemorySaveUnlocked();
      const downloadBtn = document.getElementById('memory-card-download-btn');
      const note = document.getElementById('memory-card-more-options-note');
      if (downloadBtn) {
        downloadBtn.disabled = false;
        downloadBtn.setAttribute('aria-disabled', saveAllowed ? 'false' : 'true');
        downloadBtn.classList.toggle('is-soft-locked', !saveAllowed);
        downloadBtn.textContent = saveAllowed ? 'Enregistrer' : 'Déverrouiller la mémoire';
        downloadBtn.title = saveAllowed
          ? 'Télécharger le fichier HTML autonome de la carte mémoire.'
          : 'Ouvrir les options de mémoire autonome.';
      }
      if (note) note.textContent = saveAllowed
        ? 'HTML autonome prêt'
        : 'consultation gratuite · export autonome verrouillé';
    }

    function openFloatingMemoryCard(file, payload = {}, bundle = null) {
      const modal = document.getElementById('memory-card-modal');
      const subtitle = document.getElementById('memory-card-modal-subtitle');
      const viewer = document.getElementById('memory-card-viewer');
      if (!modal || !file?.blob) return;
      revokeCurrentMemoryCardPreview();
      currentMemoryCardPreview = { file, payload, bundle };

      const texture = bundle?.texturePngDataUrl || payload?.snapshot || '';
      const encodedTextureUrl = texture ? `url("${String(texture).replace(/"/g, '%22')}")` : '';
      const frontFace = document.querySelector('.memory-card-face.memory-card-front');
      if (frontFace) {
        if (encodedTextureUrl) {
          frontFace.classList.add('export-card-front');
          frontFace.style.setProperty('--memory-card-export-texture', encodedTextureUrl);
        } else {
          frontFace.classList.remove('export-card-front');
          frontFace.style.removeProperty('--memory-card-export-texture');
        }
      }
      const shot = document.getElementById('memory-card-view-shot');
      if (shot) {
        shot.style.setProperty('--memory-card-shot', texture ? encodedTextureUrl : 'radial-gradient(circle at 50% 55%, rgba(79,110,38,0.55), rgba(8,7,4,0.90))');
      }
      if (viewer) {
        viewer.classList.remove('is-flipped');
        viewer.style.setProperty('--tilt-x', '0deg');
        viewer.style.setProperty('--tilt-y', '0deg');
        viewer.style.setProperty('--shine-x', '45%');
        viewer.style.setProperty('--shine-y', '18%');
      }
      const flipBtn = document.getElementById('memory-card-flip-btn');
      if (flipBtn) flipBtn.textContent = 'Voir verso';

      const progress = Math.max(0, Math.min(100, Number(payload?.progressPct || Math.round(growthProgress * 100) || 0)));
      const qrSrc = payload?.qrCodeUrl || bundle?.qrCodeUrl || '';
      const qrFront = document.getElementById('memory-card-view-qr');
      const qrBack = document.getElementById('memory-card-back-qr');
      for (const qr of [qrFront, qrBack]) {
        if (!qr) continue;
        qr.hidden = !qrSrc;
        if (qrSrc) qr.src = qrSrc;
        qr.onerror = () => { qr.hidden = true; };
      }

      if (subtitle) subtitle.textContent = payload?.edition
        ? 'Édition #' + payload.edition + ' · viewer 3D flottant'
        : 'Carte HTML autonome prête à sauvegarder';
      setMemoryPopupText('memory-card-edition-meta', payload?.edition ? 'Édition #' + payload.edition + ' · fichier HTML autonome' : 'Carte HTML autonome');
      fitMemoryCardTitle('memory-card-title-line', payload?.stageName || payload?.stage || 'Carte mémoire', { max: 40, min: 22 });
      setMemoryPopupText('memory-card-popup-summary', 'Le recto reprend la texture PNG/SVG intégrée au HTML autonome payant. Clique ou touche la carte pour voir le verso certificat.');
      setMemoryPopupText('memory-card-popup-day', `${String(payload?.day ?? growthClicksTree).padStart(3, '0')}/365`);
      setMemoryPopupText('memory-card-popup-tier', payload?.tierLabel || resolveCardTier().tierLabel);
      setMemoryPopupText('memory-card-popup-weather', `${Math.round(microClimate.airTemperature)}°C · vent ${Math.round(microClimate.windSpeed)} km/h · humidité ${Math.round(microClimate.humidity)}%`);
      setMemoryPopupText('memory-card-popup-seed', payload?.seedPublic || String(treeUserSeedString || treeGameSeedString || 'microcosm').slice(0, 24));

      setMemoryPopupText('memory-card-view-edition', payload?.edition ? '#' + payload.edition : '#------');
      fitMemoryCardTitle('memory-card-view-title', getPrestigeCardTitle(payload), { max: 42, min: 22 });
      setMemoryPopupText('memory-card-view-rarity', payload?.rarity || payload?.tierLabel || 'Mémoire vivante');
      const progressBar = document.getElementById('memory-card-view-progress-bar');
      if (progressBar) progressBar.style.width = progress + '%';
      setMemoryPopupText('memory-card-view-progress-label', progress + '%');
      setMemoryPopupText('memory-card-view-day', payload?.dayLabel || `${String(payload?.day ?? growthClicksTree).padStart(3, '0')}/365`);
      setMemoryPopupText('memory-card-view-temp', `${Math.round(payload?.temperature ?? microClimate.airTemperature)}°C`);
      setMemoryPopupText('memory-card-view-wind', `${Math.round(payload?.windSpeed ?? microClimate.windSpeed)} km/h`);
      setMemoryPopupText('memory-card-view-humidity', `${Math.round(payload?.humidity ?? microClimate.humidity)}%`);
      setMemoryPopupText('memory-card-view-clouds', `${Math.round(payload?.clouds ?? ((microClimate.cloudCover || 0) * 100))}%`);
      setMemoryPopupText('memory-card-view-sync', payload?.sync || (microClimate.liveWeatherEnabled ? 'LIVE' : 'AUTO'));
      setMemoryPopupText('memory-card-view-cert', `Certificat de graine #${payload?.edition || '------'}`);

      fitMemoryCardTitle('memory-card-back-title', payload?.tierLabel || 'Identité de l’arbre', { max: 34, min: 20 });
      setMemoryPopupText('memory-card-back-seed', payload?.seedPublic || String(treeUserSeedString || treeGameSeedString || 'microcosm').slice(0, 24));
      setMemoryPopupText('memory-card-back-place', payload?.place || weatherPlaceLabel());
      setMemoryPopupText('memory-card-back-generated', payload?.generatedAt || new Date().toLocaleString('fr-FR', { dateStyle: 'medium', timeStyle: 'short' }));
      const activity = payload?.activity || bundle?.activity || {};
      const visits = Number(activity.totalVisits || 0);
      const clicks = Number(activity.totalClicks || growthClicksTree || 0);
      setMemoryPopupText('memory-card-back-activity', `${visits} visites · ${clicks} clics`);
      setMemoryPopupText('memory-card-back-note', payload?.note || 'Même données, même texture et même certificat que la carte HTML autonome.');
      setMemoryPopupText('memory-card-back-url', String(payload?.arLaunchUrl || bundle?.arLaunchUrl || MICROCOSM_CONFIG.urls.arBaseUrl || '').replace(/^https?:\/\//, '').slice(0, 64));

      syncFloatingMemoryCardActions();
      document.body.classList.add('microcosm-memory-card-open');
      modal.classList.add('open');
      modal.removeAttribute('hidden');
      modal.setAttribute('aria-hidden', 'false');
    }

    function closeFloatingMemoryCard() {
      const modal = document.getElementById('memory-card-modal');
      const shot = document.getElementById('memory-card-view-shot');
      const viewer = document.getElementById('memory-card-viewer');
      for (const qr of [document.getElementById('memory-card-view-qr'), document.getElementById('memory-card-back-qr')]) {
        if (qr) qr.removeAttribute('src');
      }
      const frontFace = document.querySelector('.memory-card-face.memory-card-front');
      if (frontFace) {
        frontFace.classList.remove('export-card-front');
        frontFace.style.removeProperty('--memory-card-export-texture');
      }
      if (shot) shot.style.removeProperty('--memory-card-shot');
      if (viewer) {
        viewer.classList.remove('is-flipped');
        viewer.style.setProperty('--tilt-x', '0deg');
        viewer.style.setProperty('--tilt-y', '0deg');
      }
      if (modal) {
        modal.classList.remove('open');
        modal.setAttribute('hidden', '');
        modal.setAttribute('aria-hidden', 'true');
      }
      document.body.classList.remove('microcosm-memory-card-open');
      revokeCurrentMemoryCardPreview();
    }

    async function saveTreeMemoryBundle() {
      const button = document.getElementById('save-tree-btn');
      const hint = document.getElementById('save-tree-btn-hint');
      if (!isMicrocosmMemorySaveUnlocked()) {
        document.getElementById('premium-panel')?.classList.add('open');
        return;
      }
      const prevHtml = button ? button.innerHTML : '';
      try {
        if (button) {
          button.disabled = true;
          button.innerHTML = 'Préparation de la carte…<small>génération du HTML autonome</small>';
        }
        const { file, payload, bundle } = await buildTreeMemoryBundle(true);
        openFloatingMemoryCard(file, payload, bundle);
        if (button) {
          button.innerHTML = 'Carte prête<small>clique sur Enregistrer dans la popup</small>';
          setTimeout(() => { button.innerHTML = prevHtml; syncPaidMemoryUi(); }, 1800);
        }
        if (hint) hint.textContent = 'Carte ouverte en popup glass ; téléchargement via Enregistrer';
      } catch (err) {
        console.warn('Sauvegarde de la carte mémoire HTML impossible :', err);
        if (button) {
          button.innerHTML = 'Export impossible<small>réessaie après actualisation</small>';
          setTimeout(() => { button.innerHTML = prevHtml; syncPaidMemoryUi(); }, 1800);
        }
      } finally {
        syncPaidMemoryUi();
      }
    }

    async function createShareCardPng() {

      try {
        const day = String(growthClicksTree).padStart(3, '0');
        const shot = safeCaptureMicrocosmSnapshot();
        const img = await new Promise((resolve, reject) => {
          const im = new Image();
          im.onload = () => resolve(im);
          im.onerror = reject;
          im.src = shot;
        });

        const card = document.createElement('canvas');
        card.width = 1080;
        card.height = 1600;
        const ctx = card.getContext('2d');
        const W = card.width;
        const H = card.height;
        const safe = 54;
        const progressPct = Math.round(growthProgress * 100);
        const stage = getFibonacciStageNameTree(growthClicksTree);
        const place = weatherPlaceLabel();
        const windDeg = (THREE.MathUtils.radToDeg(microClimate.windDirection) + 360) % 360;
        const editionHash = hashGrassSeedString(`${treeUserSeedString}|${treeGameSeedString}|${growthClicksTree}`);
        const edition = (editionHash % 999999).toString().padStart(6, '0');
        const rarity = progressPct >= 100 ? 'Cycle complet' : progressPct >= 89 ? 'Canopée rare' : progressPct >= 55 ? 'Floraison' : progressPct >= 18 ? 'Jeune pousse' : 'Graine vivante';

        function rr(x, y, w, h, r) {
          ctx.beginPath();
          ctx.roundRect(x, y, w, h, r);
        }
        function fillTextFit(text, x, y, maxWidth, font, minSize = 20) {
          const parts = font.match(/^(.*?)(\d+)px(.*)$/);
          let size = parts ? parseInt(parts[2], 10) : 32;
          const before = parts ? parts[1] : '';
          const after = parts ? parts[3] : ' system-ui, sans-serif';
          ctx.font = font;
          while (ctx.measureText(text).width > maxWidth && size > minSize) {
            size -= 2;
            ctx.font = `${before}${size}px${after}`;
          }
          ctx.fillText(text, x, y);
        }
        function objectCoverDraw(image, x, y, w, h) {
          const s = Math.max(w / image.width, h / image.height);
          const iw = image.width * s;
          const ih = image.height * s;
          ctx.drawImage(image, x + (w - iw) / 2, y + (h - ih) / 2, iw, ih);
        }
        function drawHolographicLayer(x, y, w, h, alpha = 0.18) {
          ctx.save();
          rr(x, y, w, h, 54);
          ctx.clip();
          const holo = ctx.createLinearGradient(x - w * 0.2, y, x + w * 1.2, y + h);
          holo.addColorStop(0.00, 'rgba(255,244,199,0.00)');
          holo.addColorStop(0.13, `rgba(244,213,138,${alpha * 0.85})`);
          holo.addColorStop(0.27, `rgba(116,145,73,${alpha * 0.72})`);
          holo.addColorStop(0.42, `rgba(255,255,255,${alpha * 0.62})`);
          holo.addColorStop(0.58, `rgba(191,121,69,${alpha * 0.68})`);
          holo.addColorStop(0.74, `rgba(142,169,91,${alpha * 0.74})`);
          holo.addColorStop(1.00, 'rgba(255,244,199,0.00)');
          ctx.globalCompositeOperation = 'screen';
          ctx.fillStyle = holo;
          ctx.fillRect(x, y, w, h);
          ctx.globalAlpha = 0.16;
          ctx.strokeStyle = 'rgba(255,248,225,0.40)';
          ctx.lineWidth = 2;
          for (let i = -h; i < w; i += 34) {
            ctx.beginPath();
            ctx.moveTo(x + i, y + h);
            ctx.lineTo(x + i + h * 0.72, y);
            ctx.stroke();
          }
          ctx.restore();
        }
        function drawFoilDots(x, y, w, h, count = 360) {
          ctx.save();
          rr(x, y, w, h, 54);
          ctx.clip();
          for (let i = 0; i < count; i++) {
            const n = hashGrassSeedString(`${edition}-${i}`);
            const px = x + ((n & 0xffff) / 0xffff) * w;
            const py = y + (((n >>> 16) & 0xffff) / 0xffff) * h;
            const r = 0.7 + ((n >>> 9) & 7) * 0.18;
            ctx.globalAlpha = 0.10 + ((n >>> 5) & 7) * 0.012;
            ctx.fillStyle = i % 3 === 0 ? '#f4d58a' : (i % 3 === 1 ? '#fff8e1' : '#9db66a');
            ctx.beginPath();
            ctx.arc(px, py, r, 0, Math.PI * 2);
            ctx.fill();
          }
          ctx.restore();
          ctx.globalAlpha = 1;
        }
        function drawSeedGlyph(x, y, size) {
          ctx.save();
          ctx.translate(x, y);
          const cells = 9;
          const gap = size * 0.018;
          const cell = (size - gap * (cells - 1)) / cells;
          rr(-10, -10, size + 20, size + 20, 24);
          ctx.fillStyle = 'rgba(15,10,5,0.40)';
          ctx.fill();
          for (let gy = 0; gy < cells; gy++) {
            for (let gx = 0; gx < cells; gx++) {
              const edge = gx === 0 || gy === 0 || gx === cells - 1 || gy === cells - 1;
              const bit = (hashGrassSeedString(`${edition}:${gx}:${gy}`) % 100) > (edge ? 35 : 54);
              if (bit) {
                const warm = hashGrassSeedString(`${edition}:warm:${gx}:${gy}`) % 3;
                ctx.fillStyle = warm === 0 ? 'rgba(244,213,138,0.94)' : warm === 1 ? 'rgba(146,166,82,0.92)' : 'rgba(255,248,225,0.88)';
                ctx.fillRect(gx * (cell + gap), gy * (cell + gap), cell, cell);
              }
            }
          }
          ctx.restore();
        }
        function statBox(label, value, x, y, w, h) {
          rr(x, y, w, h, 22);
          ctx.fillStyle = 'rgba(23,18,11,0.44)';
          ctx.fill();
          ctx.strokeStyle = 'rgba(216,171,97,0.20)';
          ctx.lineWidth = 1.4;
          ctx.stroke();
          ctx.fillStyle = 'rgba(221,210,174,0.68)';
          ctx.font = '700 20px Inter, system-ui, sans-serif';
          ctx.fillText(label.toUpperCase(), x + 22, y + 31);
          ctx.fillStyle = 'rgba(255,248,225,0.96)';
          fillTextFit(value, x + 22, y + 67, w - 44, '850 30px Inter, system-ui, sans-serif', 18);
        }

        ctx.clearRect(0, 0, W, H);
        const bg = ctx.createRadialGradient(W * 0.2, H * 0.05, 80, W * 0.52, H * 0.48, H * 0.92);
        bg.addColorStop(0, '#6f4c25');
        bg.addColorStop(0.25, '#2f2414');
        bg.addColorStop(0.62, '#1a1f10');
        bg.addColorStop(1, '#090704');
        ctx.fillStyle = bg;
        ctx.fillRect(0, 0, W, H);

        ctx.save();
        ctx.globalAlpha = 0.42;
        const aura = ctx.createRadialGradient(W * 0.52, H * 0.42, 40, W * 0.52, H * 0.42, W * 0.72);
        aura.addColorStop(0, '#f4d58a');
        aura.addColorStop(0.25, 'rgba(125,145,68,0.80)');
        aura.addColorStop(1, 'rgba(0,0,0,0)');
        ctx.fillStyle = aura;
        ctx.fillRect(0, 0, W, H);
        ctx.restore();

        const outerX = safe;
        const outerY = 48;
        const outerW = W - safe * 2;
        const outerH = H - 88;
        ctx.shadowColor = 'rgba(0,0,0,0.62)';
        ctx.shadowBlur = 38;
        ctx.shadowOffsetY = 24;
        rr(outerX, outerY, outerW, outerH, 58);
        const frameGrad = ctx.createLinearGradient(outerX, outerY, outerX + outerW, outerY + outerH);
        frameGrad.addColorStop(0, '#fff0b8');
        frameGrad.addColorStop(0.18, '#9d6a32');
        frameGrad.addColorStop(0.42, '#293311');
        frameGrad.addColorStop(0.70, '#d6a85a');
        frameGrad.addColorStop(1, '#fff4cf');
        ctx.fillStyle = frameGrad;
        ctx.fill();
        ctx.shadowBlur = 0;
        ctx.shadowOffsetY = 0;

        rr(outerX + 10, outerY + 10, outerW - 20, outerH - 20, 50);
        const innerGrad = ctx.createLinearGradient(0, outerY, 0, outerY + outerH);
        innerGrad.addColorStop(0, 'rgba(45,33,17,0.97)');
        innerGrad.addColorStop(0.52, 'rgba(18,25,11,0.96)');
        innerGrad.addColorStop(1, 'rgba(42,26,14,0.98)');
        ctx.fillStyle = innerGrad;
        ctx.fill();
        drawHolographicLayer(outerX + 10, outerY + 10, outerW - 20, outerH - 20, 0.20);
        drawFoilDots(outerX + 10, outerY + 10, outerW - 20, outerH - 20, 460);

        // Top collector header
        ctx.fillStyle = 'rgba(255,248,225,0.78)';
        ctx.font = '800 24px Inter, system-ui, sans-serif';
        ctx.letterSpacing = '0px';
        ctx.fillText('MICROCOSM INTERACTIF 9', 106, 132);
        ctx.fillStyle = 'rgba(244,213,138,0.96)';
        ctx.font = '950 62px Inter, system-ui, sans-serif';
        ctx.fillText('ARBRE VIVANT', 104, 196);

        rr(786, 96, 176, 104, 30);
        ctx.fillStyle = 'rgba(15,10,5,0.42)';
        ctx.fill();
        ctx.strokeStyle = 'rgba(244,213,138,0.42)';
        ctx.stroke();
        ctx.textAlign = 'center';
        ctx.fillStyle = '#fff4cf';
        ctx.font = '950 42px Inter, system-ui, sans-serif';
        ctx.fillText(day, 874, 143);
        ctx.font = '700 18px Inter, system-ui, sans-serif';
        ctx.fillStyle = 'rgba(226,218,188,0.72)';
        ctx.fillText('/365 JOURS', 874, 171);
        ctx.textAlign = 'left';

        // Main image window
        const imgX = 104;
        const imgY = 238;
        const imgW = 872;
        const imgH = 735;
        rr(imgX - 12, imgY - 12, imgW + 24, imgH + 24, 44);
        ctx.fillStyle = 'rgba(255,248,225,0.08)';
        ctx.fill();
        ctx.strokeStyle = 'rgba(244,213,138,0.36)';
        ctx.lineWidth = 2;
        ctx.stroke();
        ctx.save();
        rr(imgX, imgY, imgW, imgH, 34);
        ctx.clip();
        objectCoverDraw(img, imgX, imgY, imgW, imgH);
        const vignette = ctx.createRadialGradient(imgX + imgW * 0.5, imgY + imgH * 0.46, imgW * 0.15, imgX + imgW * 0.5, imgY + imgH * 0.48, imgW * 0.78);
        vignette.addColorStop(0, 'rgba(255,255,255,0.00)');
        vignette.addColorStop(0.70, 'rgba(0,0,0,0.10)');
        vignette.addColorStop(1, 'rgba(0,0,0,0.58)');
        ctx.fillStyle = vignette;
        ctx.fillRect(imgX, imgY, imgW, imgH);
        ctx.restore();
        drawHolographicLayer(imgX, imgY, imgW, imgH, 0.10);

        // Progress capsule over image
        rr(132, 902, 816, 48, 24);
        ctx.fillStyle = 'rgba(15,10,5,0.58)';
        ctx.fill();
        ctx.strokeStyle = 'rgba(244,213,138,0.24)';
        ctx.stroke();
        rr(150, 920, 620, 13, 99);
        ctx.fillStyle = 'rgba(255,248,225,0.12)';
        ctx.fill();
        rr(150, 920, 620 * THREE.MathUtils.clamp(growthProgress, 0, 1), 13, 99);
        const pg = ctx.createLinearGradient(150, 920, 770, 920);
        pg.addColorStop(0, '#6e8f2e');
        pg.addColorStop(0.58, '#c79d43');
        pg.addColorStop(1, '#fff0a8');
        ctx.fillStyle = pg;
        ctx.fill();
        ctx.fillStyle = '#fff8e1';
        ctx.font = '850 23px Inter, system-ui, sans-serif';
        ctx.textAlign = 'right';
        ctx.fillText(`${progressPct}%`, 922, 934);
        ctx.textAlign = 'left';

        // Identity section
        ctx.fillStyle = '#fff8e1';
        ctx.font = '950 48px Inter, system-ui, sans-serif';
        fillTextFit(stage, 104, 1058, 660, '950 48px Inter, system-ui, sans-serif', 28);
        rr(780, 1002, 196, 82, 25);
        ctx.fillStyle = 'rgba(79,110,38,0.28)';
        ctx.fill();
        ctx.strokeStyle = 'rgba(216,171,97,0.20)';
        ctx.stroke();
        ctx.fillStyle = 'rgba(244,213,138,0.96)';
        ctx.font = '850 24px Inter, system-ui, sans-serif';
        ctx.textAlign = 'center';
        ctx.fillText(rarity, 878, 1052);
        ctx.textAlign = 'left';

        ctx.fillStyle = 'rgba(226,218,188,0.72)';
        ctx.font = '650 24px Inter, system-ui, sans-serif';
        fillTextFit(`Microclimat · ${place}`, 104, 1100, 830, '650 24px Inter, system-ui, sans-serif', 16);

        const sx = 104;
        const sy = 1142;
        const sw = 268;
        const sh = 92;
        statBox('Température', `${Math.round(microClimate.airTemperature)}°C`, sx, sy, sw, sh);
        statBox('Vent', `${Math.round(microClimate.windSpeed)} km/h`, sx + 294, sy, sw, sh);
        statBox('Humidité', `${Math.round(microClimate.humidity)}%`, sx + 588, sy, sw, sh);
        statBox('Nuages', `${Math.round(microClimate.cloudCover * 100)}%`, sx, sy + 112, sw, sh);
        statBox('Direction', `${Math.round(windDeg)}°`, sx + 294, sy + 112, sw, sh);
        statBox('Synchro', microClimate.liveWeatherEnabled && microClimate.lastLiveWeatherFetch ? 'LIVE' : 'AUTO', sx + 588, sy + 112, sw, sh);

        // Bottom certificate band
        rr(104, 1382, 872, 108, 30);
        ctx.fillStyle = 'rgba(15,10,5,0.46)';
        ctx.fill();
        ctx.strokeStyle = 'rgba(216,171,97,0.18)';
        ctx.stroke();
        drawSeedGlyph(126, 1400, 70);
        ctx.fillStyle = 'rgba(255,248,225,0.94)';
        ctx.font = '900 24px Inter, system-ui, sans-serif';
        ctx.fillText(`CERTIFICAT DE GRAINE #${edition}`, 222, 1425);
        ctx.fillStyle = 'rgba(226,218,188,0.72)';
        ctx.font = '650 19px Inter, system-ui, sans-serif';
        ctx.fillText('Croissance Fibonacci · golden ratio · cycle annuel vivant', 222, 1458);
        ctx.fillText('Carte générée depuis ton microcosme local', 222, 1484);

        // Final foil shine and border
        ctx.save();
        rr(outerX + 10, outerY + 10, outerW - 20, outerH - 20, 50);
        ctx.clip();
        const shine = ctx.createLinearGradient(0, 220, W, 1020);
        shine.addColorStop(0.00, 'rgba(255,255,255,0.00)');
        shine.addColorStop(0.42, 'rgba(255,255,255,0.00)');
        shine.addColorStop(0.50, 'rgba(255,248,225,0.22)');
        shine.addColorStop(0.58, 'rgba(255,255,255,0.00)');
        shine.addColorStop(1.00, 'rgba(255,255,255,0.00)');
        ctx.globalCompositeOperation = 'screen';
        ctx.fillStyle = shine;
        ctx.fillRect(0, 0, W, H);
        ctx.restore();
        rr(outerX + 22, outerY + 22, outerW - 44, outerH - 44, 44);
        ctx.strokeStyle = 'rgba(255,248,225,0.18)';
        ctx.lineWidth = 2;
        ctx.stroke();

        const a = document.createElement('a');
        a.href = card.toDataURL('image/png');
        a.download = `microcosm-collector-card-jour-${day}.png`;
        a.click();
      } catch (err) {
        console.warn('Création de card collector impossible :', err);
        captureCurrentViewPng();
      }
    }

    document.getElementById('water-tree-btn')?.addEventListener('click', (event) => {
      event.stopPropagation();
      advanceFibonacciGrowthTree();
    });
    document.getElementById('save-tree-btn')?.addEventListener('click', (event) => {
      event.stopPropagation();
      openCurrentMemoryCardViewer();
    });
    document.getElementById('export-settings-btn')?.addEventListener('click', exportMicrocosmSettings);
    document.getElementById('import-settings-btn')?.addEventListener('click', () => document.getElementById('import-settings-file')?.click());
    document.getElementById('import-settings-file')?.addEventListener('change', (event) => importMicrocosmSettingsFile(event.target.files?.[0]));
    document.getElementById('capture-card-btn')?.addEventListener('click', createShareCardHtml);
    document.getElementById('open-premium-options-btn')?.addEventListener('click', () => {
      const panel = document.getElementById('premium-panel');
      if (!panel) return;
      panel.classList.add('open');
      panel.setAttribute('aria-hidden', 'false');
    });
    function resetMemoryCardViewerTilt() {
      const viewer = document.getElementById('memory-card-viewer');
      if (!viewer) return;
      viewer.style.setProperty('--tilt-x', '0deg');
      viewer.style.setProperty('--tilt-y', '0deg');
      viewer.style.setProperty('--shine-x', '45%');
      viewer.style.setProperty('--shine-y', '18%');
    }

    function updateMemoryCardViewerTilt(event) {
      const viewer = document.getElementById('memory-card-viewer');
      if (!viewer || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
      const rect = viewer.getBoundingClientRect();
      if (!rect.width || !rect.height) return;
      const x = (event.clientX - rect.left) / rect.width;
      const y = (event.clientY - rect.top) / rect.height;
      viewer.style.setProperty('--tilt-y', `${(x - 0.5) * 16}deg`);
      viewer.style.setProperty('--tilt-x', `${(0.5 - y) * 12}deg`);
      viewer.style.setProperty('--shine-x', `${Math.round(x * 100)}%`);
      viewer.style.setProperty('--shine-y', `${Math.round(y * 100)}%`);
    }

    function syncMemoryCardFlipButton() {
      const viewer = document.getElementById('memory-card-viewer');
      const btn = document.getElementById('memory-card-flip-btn');
      if (viewer && btn) btn.textContent = viewer.classList.contains('is-flipped') ? 'Voir recto' : 'Voir verso';
    }

    document.getElementById('memory-card-viewer')?.addEventListener('pointermove', updateMemoryCardViewerTilt);
    document.getElementById('memory-card-viewer')?.addEventListener('pointerleave', resetMemoryCardViewerTilt);
    document.getElementById('memory-card-preview-panel')?.addEventListener('click', (event) => {
      if (event.target.closest?.('button, a, input, textarea, select, summary')) return;
      toggleMemoryCardFace();
    });
    document.getElementById('memory-card-viewer')?.addEventListener('click', (event) => {
      if (event.target.closest?.('button, a, input, textarea, select, summary')) return;
      event.stopPropagation();
      toggleMemoryCardFace();
    });
    document.getElementById('memory-card-flip-btn')?.addEventListener('click', (event) => {
      event.stopPropagation();
      toggleMemoryCardFace();
    });
    document.getElementById('memory-card-close-btn')?.addEventListener('click', closeFloatingMemoryCard);
    document.getElementById('memory-card-close-x')?.addEventListener('click', closeFloatingMemoryCard);
    document.getElementById('memory-card-download-btn')?.addEventListener('click', () => {
      if (!isMicrocosmMemorySaveUnlocked()) {
        document.getElementById('premium-panel')?.classList.add('open');
        syncFloatingMemoryCardActions();
        return;
      }
      if (currentMemoryCardPreview?.file) {
        downloadMemoryCardFile(currentMemoryCardPreview.file);
        const btn = document.getElementById('memory-card-download-btn');
        if (btn) {
          const prev = btn.textContent;
          btn.textContent = 'Mémoire enregistrée';
          setTimeout(() => { btn.textContent = prev || 'Enregistrer'; syncFloatingMemoryCardActions(); }, 1500);
        }
      }
    });
    document.getElementById('memory-card-open-ar-btn')?.addEventListener('click', () => {
      const url = currentMemoryCardPreview?.payload?.arLaunchUrl || currentMemoryCardPreview?.bundle?.arLaunchUrl;
      if (url) window.open(url, '_blank', 'noopener');
    });
    document.getElementById('memory-card-modal')?.addEventListener('click', (event) => {
      if (event.target?.id === 'memory-card-modal') closeFloatingMemoryCard();
    });
    window.addEventListener('keydown', (event) => {
      if (event.key === 'Escape' && document.getElementById('memory-card-modal')?.classList.contains('open')) closeFloatingMemoryCard();
    });
    syncProductPricingUi();
    syncPaidMemoryUi();
    updateStorageWarningText();
    document.getElementById('cycle-keep-btn')?.addEventListener('click', () => {
      closeCycleModalTree({ remember: true });
    });
    document.getElementById('cycle-card-btn')?.addEventListener('click', () => {
      closeCycleModalTree({ remember: true });
      document.getElementById('capture-card-btn')?.click();
    });
    document.getElementById('cycle-cut-btn')?.addEventListener('click', () => {
      resetFibonacciGrowthTree();
    });
    document.getElementById('cycle-premium-btn')?.addEventListener('click', () => {
      closeCycleModalTree({ remember: true });
      document.getElementById('premium-panel')?.classList.add('open');
    });
    document.getElementById('cycle-modal')?.addEventListener('click', (event) => {
      if (event.target?.id === 'cycle-modal') closeCycleModalTree({ remember: true });
    });

    document.getElementById('premium-cta')?.addEventListener('click', () => {
      document.getElementById('premium-panel')?.classList.toggle('open');
    });
    document.getElementById('presentation-fullscreen-btn')?.addEventListener('click', requestMicrocosmFullscreenWithoutHud);


    // Cadrage initial : splashscreen dans l'herbe. Après le premier clic,
    // la caméra reprend le cadrage de croissance normal.
    if (openingGrassSplashActive) setOpeningGrassSplashCamera();
    else updateTreeCameraFrame(0.016, true);

    // Mouse interaction for grass
    const raycaster = new THREE.Raycaster();
    const mouseNDC = new THREE.Vector2();
    const grassPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
    const hitPoint = new THREE.Vector3();
    window.addEventListener('mousemove', (e) => {
      mouseNDC.set((e.clientX / window.innerWidth) * 2 - 1, -(e.clientY / window.innerHeight) * 2 + 1);
      raycaster.setFromCamera(mouseNDC, camera);
      // Interaction herbe en coordonnées locales de l'îlot : même si le micro-monde flotte,
      // le vent/push restent centrés au bon endroit sur la surface.
      grassPlane.constant = -islandRoot.position.y;
      if (raycaster.ray.intersectPlane(grassPlane, hitPoint)) {
        islandRoot.worldToLocal(hitPoint);
        mouseWorld.value.copy(hitPoint);
      }
    });
    window.addEventListener('mouseleave', () => {
      mouseWorld.value.set(99999, 0, 99999);
    });
    // ───── Mise à jour du soleil (océan et ciel) ─────
    function updateSun(elevation, azimuth) {
      const phi = THREE.MathUtils.degToRad(90 - elevation);
      const theta = THREE.MathUtils.degToRad(azimuth);
      const pos = new THREE.Vector3().setFromSphericalCoords(1, phi, theta);
      sunLight.position.copy(pos).multiplyScalar(100);
      sunDir.value.copy(pos).normalize();
      if (sky?.sunPosition?.value) sky.sunPosition.value.copy(pos);
    }
    updateSun(params.sunElevation, params.sunAzimuth);
    await renderer.computeAsync(initWaves);
    await renderer.computeAsync(computeInitGrass);
    updateMicroClimateFromBrowserClock(new Date());
    applyMicroClimateToWorld(0);
    updateWeatherHUD(true);
    recordMicrocosmVisit(new Date());
    function updateGrassIslandScale() {
      const p = THREE.MathUtils.clamp(growthProgress, 0, 1);
      const eased = p * p * (3 - 2 * p);
      // La terre doit rester légèrement plus ample que l'herbe.
      // On sépare donc le rayon du sol (terre) de celui de la canopée d'herbe.
      const earthRadius = THREE.MathUtils.lerp(14.5, 26.0, eased) * grassSeedConfig.size;
      // L'herbe va presque jusqu'au bord de la terre : il ne reste qu'une fine lèvre organique.
      const grassRadius = earthRadius * MICROCOSM_CONFIG.island.grassToEarthRatio;
      grassVisibleRadius.value = grassRadius;
      const prevRadius = groundRadiusGrass.value;
      groundRadiusGrass.value = earthRadius;
      // La longueur et la largeur de l'herbe grandissent avec l'arbre,
      // tout en gardant les réglages utilisateur comme multiplicateurs.
      bladeHeight.value = grassBladeLengthUser * THREE.MathUtils.lerp(0.38, 1.12, eased);
      bladeWidth.value = grassBladeWidthUser * THREE.MathUtils.lerp(0.58, 1.08, eased);
      // Courbure de l'îlot réduite : le kokedama reste organique, sans faire plonger
      // la terre sous l'océan à l'âge adulte.
      islandCurveStrength.value = THREE.MathUtils.lerp(0.0035, 0.0018, eased);
      oceanMatchedCurveStrength.value = islandCurveStrength.value;
      // On découpe un peu au-delà de la terre : l'eau vient mourir au bord au lieu de passer dessous/dedans.
      // Collision océan/terre : rayon moyen aligné sur l'îlot organique + un offset de rivage.
      // Le liseré visible est non circulaire via les particules rebuildShoreOrganicFoamParticles().
      oceanIslandCutRadius.value = earthRadius + MICROCOSM_CONFIG.island.earthEdgeMargin + MICROCOSM_CONFIG.island.shoreMargin * 0.72;
      oceanIslandCutFeather.value = THREE.MathUtils.lerp(1.35, 2.25, eased);
      oceanShoreCalmDistance.value = THREE.MathUtils.lerp(3.8, 6.4, eased);
      if (!islandEarthMesh || Math.abs(prevRadius - earthRadius) > 0.85) {
        rebuildIslandEarth(earthRadius);
        if (grassGround?.geometry) grassGround.geometry.dispose();
        grassGround.geometry = buildCurvedGrassGroundGeometry();
      }
    }
    updateGrassIslandScale();
    // ───── Animation loop unifiée ─────
    const clock = new THREE.Clock();
    async function animate() {
      const dt = Math.min(clock.getDelta(), 0.05);
      if (document.hidden && !renderer.xr.isPresenting) return;
      updateMicroClimateFromBrowserClock(new Date());
      if (microClimate.liveWeatherEnabled && Date.now() - microClimate.lastLiveWeatherFetch > 10 * 60 * 1000) {
        requestLiveMicroWeather();
      }
      applyMicroClimateToWorld(clock.elapsedTime);
      updateWeatherHUD();
      const windRT = getWindRuntime();
      const season = getSeasonFromDayTree(Math.max(1, growthClicksTree));
      if (seasonModeTree) Object.assign(treeSeasonState, season);
      else Object.assign(treeSeasonState, { name: 'neutre', leafVisibility: 1, leafScale: 1, leafHue: new THREE.Color(0x2f7d32), fallingLeaves: 0.03 });
      if (oceanCycleState.tideEnabled) {
        const dayPhase = (growthClicksTree / FIB_GROWTH_TOTAL_CLICKS_TREE) * Math.PI * 2 * PHI;
        const wind01 = THREE.MathUtils.clamp(globalWind.speed / 50, 0, 1);
        const tide = Math.sin(clock.elapsedTime * oceanCycleState.tideSpeed * PHI + dayPhase)
          * oceanCycleState.tideAmplitude * (0.65 + wind01 * 0.45);
        oceanCycleState.lastTide = tide;
        islandMotionState.lastTide = tide;
        oceanMesh.position.y = oceanCycleState.baseLevel + tide;
      } else {
        oceanCycleState.lastTide = 0;
        islandMotionState.lastTide = 0;
        oceanMesh.position.y = oceanCycleState.baseLevel;
      }
      updateFloatingIslandMotion(clock.elapsedTime, windRT);
      if (shoreFadePoints) {
        shoreFadePoints.material.opacity = shoreFadeState.enabled
          ? shoreFadeState.opacity * (0.76 + 0.24 * Math.sin(clock.elapsedTime * (0.8 + globalWind.speed * 0.025) + microClimate.tide01))
          : 0;
        // Pas de rotation indépendante : le rivage reste attaché au tracé de l’îlot.
        shoreFadePoints.rotation.set(0, 0, 0);
      }
      vrPlayableRing.visible = vrPlayableState.visible;
      updateIcosahedron(dt);
      // Compute updates for grass and ocean. V8.21: on espace les passes les plus coûteuses.
      if ((frameCountTree % MICROCOSM_RUNTIME_PERF.grassUpdateEvery) === 0) renderer.compute(computeUpdateGrass);
      renderer.compute(computeOceanCoarse);
      if ((frameCountTree % MICROCOSM_RUNTIME_PERF.oceanFineUpdateEvery) === 0) renderer.compute(computeOceanFine);
      // Update tree growth
      if (isGrowing && growthProgress < targetGrowth) {
        growthProgress += dt * growthSpeedTree;
        if (growthProgress >= targetGrowth) {
          growthProgress = targetGrowth;
          if (growthProgress >= 1) isGrowing = false;
        }
      }
      updateGrassIslandScale();
      progressBarTree.style.width = `${growthProgress * 100}%`;
      if (growthSliderTree && !manualGrowthModeTree) growthSliderTree.value = Math.round(growthProgress * 1000) / 10;
      updateTreeCameraFrame(dt);
      controls.update();
      updateSaplingStagesTree(growthProgress, clock.elapsedTime);
      const oakScaleTree = matureOakScaleTree(growthProgress);
      treeGroup.scale.setScalar(oakScaleTree);
      // Update tree branches
      for (let bi = 0; bi < branches.length; bi++) {
        const b = branches[bi];
        const branchT = (growthProgress - b.growthStart) / (b.growthEnd - b.growthStart);
        if (branchT <= 0) {
          if (b.mesh.visible) b.mesh.visible = false;
          continue;
        }
        if (!b.mesh.visible) b.mesh.visible = true;
        const clamped = branchT > 1 ? 1 : branchT;
        const eased = 1 - Math.pow(1 - clamped, 2.5);
        const visibleSegments = Math.max(0, Math.floor(eased * b.segments));
        const indexCount = visibleSegments * b.radialSegments * 6;
        if (indexCount <= 0) {
          b.mesh.visible = false;
          continue;
        }
        b.mesh.geometry.setDrawRange(0, indexCount);
        if (eased > 0.5 && b.depth >= 2) {
          const timeT = clock.elapsedTime;
          const phaseZ = timeT * (0.38 + windRT.speedMul * 0.42) + b.depth * 1.2 + b.growthStart * 10;
          const ownSwayZ = Math.sin(phaseZ) * 0.004 * b.depth * windRT.branchAmp * (0.45 + Math.abs(windRT.dirX));
          const ownSwayX = Math.sin(timeT * (0.32 + windRT.speedMul * 0.36) + b.growthStart * 5) * 0.002 * b.depth * windRT.branchAmp * (0.45 + Math.abs(windRT.dirZ));
          let totalSwayZ = ownSwayZ;
          let totalSwayX = ownSwayX;
          let parent = b.parentBranch;
          while (parent && parent.depth >= 2) {
            const pPhaseZ = timeT * (0.38 + windRT.speedMul * 0.42) + parent.depth * 1.2 + parent.growthStart * 10;
            totalSwayZ += Math.sin(pPhaseZ) * 0.004 * parent.depth * windRT.branchAmp * (0.45 + Math.abs(windRT.dirX));
            totalSwayX += Math.sin(timeT * (0.32 + windRT.speedMul * 0.36) + parent.growthStart * 5) * 0.002 * parent.depth * windRT.branchAmp * (0.45 + Math.abs(windRT.dirZ));
            parent = parent.parentBranch;
          }
          const sp = b.startPos;
          b.mesh.position.set(0, 0, 0);
          b.mesh.rotation.set(0, 0, 0);
          b.mesh.position.sub(sp);
          b.mesh.rotation.z = totalSwayZ;
          b.mesh.rotation.x = totalSwayX;
          b.mesh.position.applyEuler(b.mesh.rotation);
          b.mesh.position.add(sp);
        } else if (b.depth >= 2) {
          b.mesh.position.set(0, 0, 0);
          b.mesh.rotation.set(0, 0, 0);
        }
      }
      // Update guaranteed canopy patches (attached to branches, season-aware).
      for (let ci = 0; ci < canopyPatchesArr.length; ci++) {
        const cp = canopyPatchesArr[ci];
        const b = cp.branch;
        const branchLocalRaw = (growthProgress - b.growthStart) / Math.max(0.0001, b.growthEnd - b.growthStart);
        const branchLocal = THREE.MathUtils.clamp(branchLocalRaw, 0, 1);
        const easedBranchLocal = 1 - Math.pow(1 - branchLocal, 2.5);
        if (branchLocal <= 0.025 || easedBranchLocal < cp.branchT - 0.075) {
          cp.mesh.visible = false;
          continue;
        }
        const visibleT = Math.min(cp.branchT, Math.max(0.08, easedBranchLocal - 0.012));
        cp.mesh.visible = true;
        const pos = b.curve.getPointAt(visibleT).add(cp.localOffset);
        if (b.depth >= 2) {
          pos.sub(b.startPos).applyEuler(b.mesh.rotation).add(b.startPos);
        }
        const timeT = clock.elapsedTime;
        pos.x += Math.sin(timeT * 0.7 * windRT.speedMul + cp.swayOffset) * 0.018 * windRT.leafAmp * windRT.dirX;
        pos.z += Math.cos(timeT * 0.6 * windRT.speedMul + cp.swayOffset) * 0.016 * windRT.leafAmp * windRT.dirZ;
        cp.mesh.position.copy(pos);
        cp.mesh.rotation.set(
          Math.sin(timeT * 0.35 + cp.swayOffset) * 0.08 * windRT.leafAmp,
          cp.ringAngle + timeT * 0.05 * windRT.speedMul,
          Math.cos(timeT * 0.31 + cp.swayOffset) * 0.06 * windRT.leafAmp
        );
        let seasonGate = 1;
        if (seasonModeTree) {
          seasonGate = cp.seasonSeed <= treeSeasonState.leafVisibility ? 1 : 0;
          if (cp.priority >= 2) seasonGate = Math.max(seasonGate, treeSeasonState.name === 'hiver' ? 0.82 : 1.0);
          else seasonGate = Math.max(seasonGate, treeSeasonState.name === 'hiver' ? 0.64 : 0.92);
        }
        const growScale = THREE.MathUtils.smoothstep(branchLocal, Math.max(0, cp.branchT - 0.10), cp.branchT + 0.03);
        const s = cp.baseScale * treeSeasonState.leafScale * seasonGate * Math.max(0.18, growScale);
        cp.mesh.scale.setScalar(s);
        if (cp.mesh.material?.color) cp.mesh.material.color.lerp(treeSeasonState.leafHue, 0.16);
      }

      // Update leaves
      const leafDirtyFlagsTree = new Uint8Array(leafInstancedMeshesTree.length);
      if ((frameCountTree & 15) === 0) {
        for (let mi = 0; mi < leafInstanceMatsTree.length; mi++) {
          leafInstanceMatsTree[mi].color.lerp(treeSeasonState.leafHue, 0.18);
        }
      }
      for (let li = 0; li < leavesArr.length; li++) {
        const l = leavesArr[li];
        let visibleBranchTForLeafTree = l.branchT;
        if (l.branch) {
          const branchLocalRaw = (growthProgress - l.branch.growthStart) / Math.max(0.0001, l.branch.growthEnd - l.branch.growthStart);
          const branchLocal = THREE.MathUtils.clamp(branchLocalRaw, 0, 1);
          const easedBranchLocal = 1 - Math.pow(1 - branchLocal, 2.5);
          // Feuilles terminales : elles restent collées au bout actuellement visible.
          // Les autres feuilles attendent encore que leur segment existe, pour éviter le flottement.
          if (l.terminalFollower) {
            if (branchLocal <= 0.035) {
              if (l.currentScale > 0) {
                l.currentScale = 0;
                _leafMatrixTree.makeScale(0.001, 0.001, 0.001);
                _leafMatrixTree.setPosition(l.originalPos.x, l.originalPos.y, l.originalPos.z);
                l.instancedMesh.setMatrixAt(l.instanceIdx, _leafMatrixTree);
                leafDirtyFlagsTree[l.colorIdx] = 1;
              }
              continue;
            }
            visibleBranchTForLeafTree = Math.min(l.branchT, Math.max(0.055, easedBranchLocal - 0.018));
          } else if (branchLocal < l.branchT - 0.055) {
            if (l.currentScale > 0) {
              l.currentScale = 0;
              _leafMatrixTree.makeScale(0.001, 0.001, 0.001);
              _leafMatrixTree.setPosition(l.originalPos.x, l.originalPos.y, l.originalPos.z);
              l.instancedMesh.setMatrixAt(l.instanceIdx, _leafMatrixTree);
              leafDirtyFlagsTree[l.colorIdx] = 1;
            }
            continue;
          }
        }
        const leafT = (growthProgress - l.growthStart) * 10.0;
        if (leafT <= 0) {
          if (l.currentScale > 0) {
            l.currentScale = 0;
            _leafMatrixTree.makeScale(0.001, 0.001, 0.001);
            _leafMatrixTree.setPosition(l.originalPos.x, l.originalPos.y, l.originalPos.z);
            l.instancedMesh.setMatrixAt(l.instanceIdx, _leafMatrixTree);
            leafDirtyFlagsTree[l.colorIdx] = 1;
          }
          continue;
        }
        const t = leafT > 1 ? 1 : leafT;
        const omt = 1 - t;
        const elastic = t < 1 ? 1 - omt * omt * omt * Math.cos(t * 2.5133) : 1;
        let seasonalGate = 1;
        let prioritySeasonScale = 1;
        if (seasonModeTree) {
          seasonalGate = l.seasonSeed <= treeSeasonState.leafVisibility ? 1 : 0;
          if (l.tipPriority) {
            // Garantie légère : une branche garde des bourgeons/quelques feuilles
            // terminales, mais l'automne/hiver restent réellement moins feuillus.
            let minTipGate = 0;
            if (treeSeasonState.name === 'printemps') minTipGate = l.tipPriority >= 2 ? 0.55 : 0.42;
            else if (treeSeasonState.name === 'été') minTipGate = l.tipPriority >= 2 ? 0.70 : 0.55;
            else if (treeSeasonState.name === 'automne') minTipGate = l.tipPriority >= 2 ? 0.34 : 0.22;
            else minTipGate = l.tipPriority >= 2 ? 0.18 : 0.10;
            seasonalGate = Math.max(seasonalGate, minTipGate);

            if (treeSeasonState.name === 'hiver') prioritySeasonScale = 0.58;
            else if (treeSeasonState.name === 'automne') prioritySeasonScale = 0.76;
          }
        }
        const scale = elastic * l.targetScale * treeSeasonState.leafScale * prioritySeasonScale * seasonalGate;
        if (scale < 0.001) {
          if (l.currentScale > 0) {
            l.currentScale = 0;
            _leafMatrixTree.makeScale(0.001, 0.001, 0.001);
            _leafMatrixTree.setPosition(l.originalPos.x, l.originalPos.y, l.originalPos.z);
            l.instancedMesh.setMatrixAt(l.instanceIdx, _leafMatrixTree);
            leafDirtyFlagsTree[l.colorIdx] = 1;
          }
          continue;
        }
        if (t >= 1 && l.currentScale === scale && (li & 3) !== (frameCountTree & 3)) continue;
        const timeT = clock.elapsedTime;
        const phase = timeT * l.swaySpeed * windRT.speedMul + l.swayOffset;
        _tempEulerTree.set(
          l.baseRotX + Math.sin(phase * 0.5) * 0.1 * windRT.leafAmp,
          l.baseRotY + timeT * 0.15 * windRT.speedMul,
          l.baseRotZ + Math.cos(phase * 0.4) * 0.06 * windRT.leafAmp
        );
        _tempQuatTree.setFromEuler(_tempEulerTree);
        _tempScaleTree.set(scale, scale, scale);
        if (l.branch) {
          const branchPoint = l.branch.curve.getPointAt(visibleBranchTForLeafTree);
          l.originalPos.copy(branchPoint).add(l.localOffset);
          if (l.branch.depth >= 2) {
            l.originalPos.sub(l.branch.startPos).applyEuler(l.branch.mesh.rotation).add(l.branch.startPos);
          }
        }
        _tempVecTree.set(
          l.originalPos.x + Math.sin(phase) * 0.032 * windRT.leafAmp * windRT.dirX,
          l.originalPos.y + Math.sin(phase * 0.7) * 0.016 * windRT.leafAmp,
          l.originalPos.z + Math.cos(phase * 0.85) * 0.026 * windRT.leafAmp * windRT.dirZ
        );
        _leafMatrixTree.compose(_tempVecTree, _tempQuatTree, _tempScaleTree);
        l.instancedMesh.setMatrixAt(l.instanceIdx, _leafMatrixTree);
        l.currentScale = scale;
        leafDirtyFlagsTree[l.colorIdx] = 1;
      }
      for (let i = 0; i < leafInstancedMeshesTree.length; i++) {
        if (leafDirtyFlagsTree[i]) leafInstancedMeshesTree[i].instanceMatrix.needsUpdate = true;
      }
      // Update flowers
      const flowerDirtyFlagsTree = new Uint8Array(flowerInstancedMeshesTree.length);
      let centerDirtyTree = false;
      const petalAngleStep = Math.PI * 2 / 5;
      const petalCos = [1, 0.309, -0.809, -0.809, 0.309];
      const petalSin = [0, 0.951, 0.588, -0.588, -0.951];
      for (let fi = 0; fi < flowersArr.length; fi++) {
        const f = flowersArr[fi];
        const flowerT = (growthProgress - f.growthStart) * 8.3333;
        const t = flowerT <= 0 ? 0 : (flowerT > 1 ? 1 : flowerT);
        if (t >= 1 && f.currentScale === f.targetScale && (fi & 3) !== (frameCountTree & 3)) continue;
        const omt = 1 - t;
        const elastic = t > 0 && t < 1 ? 1 - omt * omt * omt * Math.cos(t * 1.885) : t;
        const scale = elastic * f.targetScale;
        const s = scale < 0.001 ? 0.001 : scale;
        const rotY = f.baseRotY + clock.elapsedTime * 0.15 * windRT.speedMul;
        for (let pi = 0; pi < f.petalIndices.length; pi++) {
          _tempVecTree.set(f.position.x + petalCos[pi] * 0.1 * s, f.position.y, f.position.z + petalSin[pi] * 0.1 * s);
          _tempEulerTree.set(f.baseRotX, rotY - pi * petalAngleStep, f.baseRotZ + 0.5);
          _tempQuatTree.setFromEuler(_tempEulerTree);
          _tempScaleTree.set(s, s, s);
          _leafMatrixTree.compose(_tempVecTree, _tempQuatTree, _tempScaleTree);
          f.instancedMesh.setMatrixAt(f.petalIndices[pi], _leafMatrixTree);
        }
        flowerDirtyFlagsTree[f.colorIdx] = 1;
        _tempVecTree.copy(f.position);
        _tempEulerTree.set(f.baseRotX, rotY, f.baseRotZ);
        _tempQuatTree.setFromEuler(_tempEulerTree);
        _tempScaleTree.set(s, s, s);
        _leafMatrixTree.compose(_tempVecTree, _tempQuatTree, _tempScaleTree);
        flowerCenterIMTree.setMatrixAt(f.centerIdx, _leafMatrixTree);
        centerDirtyTree = true;
        f.currentScale = scale;
      }
      for (let i = 0; i < flowerInstancedMeshesTree.length; i++) {
        if (flowerDirtyFlagsTree[i]) flowerInstancedMeshesTree[i].instanceMatrix.needsUpdate = true;
      }
      if (centerDirtyTree) flowerCenterIMTree.instanceMatrix.needsUpdate = true;
      // Falling leaves for tree
      if (growthProgress > 0.18 && treeSeasonState.fallingLeaves > 0.02) {
        leafTimerTree += dt * (0.4 + treeSeasonState.fallingLeaves * 3.0);
        if (leafTimerTree > 1.2) {
          leafTimerTree = 0;
          spawnFallingLeafTree();
        }
      }
      let fallingDirtyTree = false;
      let fallingColorDirtyTree = false;
      for (let i = 0; i < FALLING_LEAF_POOL_SIZE_TREE; i++) {
        const fl = fallingLeafPoolTree[i];
        if (!fl.active) continue;
        const pos = fl.position;
        const vel = fl.velocity;
        pos.x += vel.x + Math.sin(clock.elapsedTime * 2 * windRT.speedMul + fl.swayOffset) * 0.01 * windRT.fallAmp * windRT.dirX;
        pos.y += vel.y;
        pos.z += vel.z + Math.cos(clock.elapsedTime * 1.7 * windRT.speedMul + fl.swayOffset) * 0.01 * windRT.fallAmp * windRT.dirZ;
        fl.rotation.x += fl.rotSpeed.x;
        fl.rotation.y += fl.rotSpeed.y;
        fl.rotation.z += fl.rotSpeed.z;
        vel.y -= 0.0001;
        if (pos.y < 0.1) {
          fl.life -= dt * 0.5;
          if (fl.life <= 0) {
            fl.active = false;
            _leafMatrixTree.makeScale(0.001, 0.001, 0.001);
            _leafMatrixTree.setPosition(0, -100, 0);
            fallingLeafIMTree.setMatrixAt(fl.idx, _leafMatrixTree);
            fallingDirtyTree = true;
            continue;
          } else {
            fl.opacity = fl.life;
            const colorIdx = Math.floor(treeRandom() * leafColorsArr.length);
            const c = new THREE.Color(leafColorsArr[colorIdx]).multiplyScalar(fl.life);
            fallingLeafIMTree.setColorAt(fl.idx, c);
            fallingColorDirtyTree = true;
          }
        }
        _tempEulerTree.set(fl.rotation.x, fl.rotation.y, fl.rotation.z);
        _tempQuatTree.setFromEuler(_tempEulerTree);
        _tempScaleTree.set(fl.scale, fl.scale, fl.scale);
        _tempVecTree.copy(pos);
        _leafMatrixTree.compose(_tempVecTree, _tempQuatTree, _tempScaleTree);
        fallingLeafIMTree.setMatrixAt(fl.idx, _leafMatrixTree);
        fallingDirtyTree = true;
      }
      if (fallingDirtyTree) fallingLeafIMTree.instanceMatrix.needsUpdate = true;
      if (fallingColorDirtyTree) fallingLeafIMTree.instanceColor.needsUpdate = true;
      // Update pollen particles for tree
      if ((frameCountTree & 3) === 0) {
        const positions = particlesTree.geometry.attributes.position.array;
        for (let i = 0; i < particleCountTree; i++) {
          const pd = particleDataTree[i];
          const ang = clock.elapsedTime * pd.speed * 0.1 * windRT.speedMul + pd.offset;
          positions[i * 3] = Math.cos(ang) * pd.radius + windRT.dirX * Math.sin(clock.elapsedTime * 0.6 * windRT.speedMul + pd.offset) * windRT.leafAmp * 0.18;
          positions[i * 3 + 1] = pd.baseY + Math.sin(clock.elapsedTime * pd.speed * 0.5 * windRT.speedMul + pd.offset) * 1.5;
          positions[i * 3 + 2] = Math.sin(ang) * pd.radius + windRT.dirZ * Math.cos(clock.elapsedTime * 0.6 * windRT.speedMul + pd.offset) * windRT.leafAmp * 0.18;
        }
        particlesTree.geometry.attributes.position.needsUpdate = true;
      }
      particleMatTree.opacity = 0.15 + Math.sin(clock.elapsedTime * 0.5) * 0.1 + growthProgress * 0.25;
      pointLight.intensity = 0.2 + growthProgress * 0.3 + Math.sin(clock.elapsedTime * 1.5) * 0.08;
      pointLight.position.y = 7 + growthProgress * 22;
      frameCountTree++;
      renderer.render(scene, camera);
      if ((frameCountTree % MICROCOSM_RUNTIME_PERF.statsUpdateEvery) === 0) stats.update();
    }
    renderer.setAnimationLoop(animate);
    window.addEventListener('resize', () => {
      camera.aspect = window.innerWidth / window.innerHeight;
      camera.updateProjectionMatrix();
      renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, renderer.xr.isPresenting ? vrRuntimeState.comfortPixelRatio : MICROCOSM_RUNTIME_PERF.maxPixelRatio));
      renderer.setSize(window.innerWidth, window.innerHeight);
      requestAnimationFrame(updateHudStackLayout);
    });
    requestAnimationFrame(updateHudStackLayout);
  </script>
				</div>
				</div>
				</div>
				</div>
		<p>Cet article <a href="https://presentcomposedesign.fr/36446-2/">//*</a> est apparu en premier sur <a href="https://presentcomposedesign.fr">Présent Composé design</a>.</p>
]]></content:encoded>
					
		
		
			</item>
	</channel>
</rss>
