<?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, 27 May 2026 07:45:01 +0000</lastBuildDate>
	<language>fr-FR</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.0</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>Les MoliAires</title>
		<link>https://presentcomposedesign.fr/les-moliaires/</link>
		
		<dc:creator><![CDATA[admin]]></dc:creator>
		<pubDate>Sat, 16 May 2026 08:50:59 +0000</pubDate>
				<category><![CDATA[Design produit]]></category>
		<guid isPermaLink="false">https://presentcomposedesign.fr/?p=36620</guid>

					<description><![CDATA[<p>Cet article <a href="https://presentcomposedesign.fr/les-moliaires/">Les MoliAires</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/les-moliaires/">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>3D Puzzle Viewer 🧩 Puzzle 3D Importer une image Mélanger le puzzle Sauvegarder la progression Générer un lien de partage Pièces correctement placées : 0 Glissez les pièces avec la souris. Molette : zoom Clic droit : rotation caméra 🧩 Puzzle 3D Importer une image Mélanger le puzzle Générer un lien de partage Pièces correctement placées : 0 Glissez les pièces avec la souris. Molette : zoom Clic droit : rotation caméra Puzzle 3D 🧩 Puzzle 3D Importer une image Mélanger le puzzle Pièces correctement placées : 0 🧩 Puzzle 3D Importer une image Mélanger le puzzle Générer un lien de partage Pièces correctement placées : 0 Glissez les pièces avec la souris. Molette : zoom Clic droit : rotation caméra let scene, camera, renderer, controls; let raycaster = new THREE.Raycaster(); let mouse = new THREE.Vector2(); let puzzlePieces = []; let selectedPiece = null; let offset = new THREE.Vector3(); let plane = new THREE.Plane(); let intersection = new THREE.Vector3(); const puzzleSize = 4; const pieceSize = 1; let originalPositions = []; init(); animate(); restorePuzzle(); function init() { scene = new THREE.Scene(); scene.background = new THREE.Color(0x111111); camera = new THREE.PerspectiveCamera( 60, window.innerWidth / window.innerHeight, 0.1, 1000 ); camera.position.set(0, 0, 8); renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); document.body.appendChild(renderer.domElement); controls = new THREE.OrbitControls(camera, renderer.domElement); controls.enableDamping = true; const ambient = new THREE.AmbientLight(0xffffff, 1.2); scene.add(ambient); const directional = new THREE.DirectionalLight(0xffffff, 1.5); directional.position.set(5, 10, 5); scene.add(directional); window.addEventListener('resize', onResize); renderer.domElement.addEventListener('pointerdown', onPointerDown); renderer.domElement.addEventListener('pointermove', onPointerMove); renderer.domElement.addEventListener('pointerup', onPointerUp); document .getElementById('imageUpload') .addEventListener('change', handleImageUpload); document .getElementById('shuffleBtn') .addEventListener('click', explodePuzzle); document .getElementById('shareBtn') .addEventListener('click', generateShareLink); } function onResize() { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); } function clearPuzzle() { puzzlePieces.forEach(piece => { scene.remove(piece); }); puzzlePieces = []; originalPositions = []; } function handleImageUpload(e) { console.log('Import image déclenché'); const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = function(event) { createPuzzle(event.target.result); }; reader.readAsDataURL(file); // Sauvegarde locale de l'image source localStorage.setItem('puzzle-image', URL.createObjectURL(file)); } function createPuzzle(imageSrc) { clearPuzzle(); const loader = new THREE.TextureLoader(); loader.load(imageSrc, texture => { texture.minFilter = THREE.LinearFilter; for (let y = 0; y < puzzleSize; y++) { for (let x = 0; x < puzzleSize; x++) { const geometry = new THREE.BoxGeometry( pieceSize, pieceSize, 0.2 ); const materials = []; for (let i = 0; i < 6; i++) { materials.push( new THREE.MeshStandardMaterial({ color: i === 4 ? 0xffffff : 0x222222, map: i === 4 ? texture.clone() : null }) ); } const piece = new THREE.Mesh(geometry, materials); const tx = x / puzzleSize; const ty = y / puzzleSize; piece.material[4].map.repeat.set( 1 / puzzleSize, 1 / puzzleSize ); piece.material[4].map.offset.set( tx, 1 - (1 / puzzleSize) - ty ); piece.material[4].map.needsUpdate = true; const posX = (x - puzzleSize / 2) * pieceSize + pieceSize / 2; const posY = -(y - puzzleSize / 2) * pieceSize - pieceSize / 2; piece.position.set(posX, posY, 0); piece.userData.correctPosition = { x: posX, y: posY, z: 0 }; originalPositions.push({ x: posX, y: posY, z: 0 }); scene.add(piece); puzzlePieces.push(piece); } } explodePuzzle(); savePuzzle(); }); } function explodePuzzle() { puzzlePieces.forEach(piece => { piece.position.x += (Math.random() - 0.5) * 10; piece.position.y += (Math.random() - 0.5) * 10; piece.position.z += (Math.random() - 0.5) * 4; piece.rotation.x = Math.random() * Math.PI; piece.rotation.y = Math.random() * Math.PI; }); updateProgress(); } function onPointerDown(event) { mouse.x = (event.clientX / window.innerWidth) * 2 - 1; mouse.y = -(event.clientY / window.innerHeight) * 2 + 1; raycaster.setFromCamera(mouse, camera); const intersects = raycaster.intersectObjects(puzzlePieces); if (intersects.length > 0) { selectedPiece = intersects[0].object; plane.setFromNormalAndCoplanarPoint( camera.getWorldDirection(plane.normal), selectedPiece.position ); if (raycaster.ray.intersectPlane(plane, intersection)) { offset.copy(intersection).sub(selectedPiece.position); } controls.enabled = false; } } function onPointerMove(event) { mouse.x = (event.clientX / window.innerWidth) * 2 - 1; mouse.y = -(event.clientY / window.innerHeight) * 2 + 1; raycaster.setFromCamera(mouse, camera); if (selectedPiece) { if (raycaster.ray.intersectPlane(plane, intersection)) { selectedPiece.position.copy(intersection.sub(offset)); } } } function onPointerUp() { if (selectedPiece) { const correct = selectedPiece.userData.correctPosition; const dist = selectedPiece.position.distanceTo( new THREE.Vector3(correct.x, correct.y, correct.z) ); if (dist < 0.5) { selectedPiece.position.set(correct.x, correct.y, correct.z); selectedPiece.rotation.set(0, 0, 0); } updateProgress(); savePuzzle(); } selectedPiece = null; controls.enabled = true; } function updateProgress() { let good = 0; puzzlePieces.forEach(piece => { const c = piece.userData.correctPosition; const dist = piece.position.distanceTo( new THREE.Vector3(c.x, c.y, c.z) ); if (dist < 0.01) { good++; } }); document.getElementById('progress').innerHTML = `Pièces correctement placées : ${good} / ${puzzlePieces.length}`; } function savePuzzle() { if (!puzzlePieces.length) return; const data = puzzlePieces.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 }, correctPosition: piece.userData.correctPosition })); localStorage.setItem('puzzle-progress', JSON.stringify(data)); } function restorePuzzle() { const saved = localStorage.getItem('puzzle-progress'); if (!saved) return; console.log('Progression restaurée automatiquement'); } function generateShareLink() { if (!puzzlePieces.length) return; const shareData = puzzlePieces.map(piece => ({ p: piece.position, r: piece.rotation, c: piece.userData.correctPosition })); const encoded = btoa(JSON.stringify(shareData)); const url = `${window.location.origin}${window.location.pathname}?puzzle=${encoded}`; const input = document.getElementById('shareLink'); input.value = url; input.select(); navigator.clipboard.writeText(url);</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-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="3bc7f57" data-element_type="container" data-e-type="container">
					<div class="e-con-inner">
				<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">
					<!DOCTYPE html>
<html lang="fr">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>3D Puzzle Viewer</title>

  <script src="https://cdn.jsdelivr.net/npm/three@0.161.0/build/three.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/three@0.161.0/examples/js/controls/OrbitControls.js"></script>

  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
      font-family: Arial, sans-serif;
    }

    body {
      overflow: hidden;
      background: #0f0f0f;
      color: white;
    }

    canvas {
      display: block;
    }

    #hud {
      position: absolute;
      top: 20px;
      left: 20px;
      z-index: 10;
      background: rgba(0,0,0,0.7);
      padding: 16px;
      border-radius: 14px;
      width: 300px;
      backdrop-filter: blur(10px);
      border: 1px solid rgba(255,255,255,0.1);
    }

    #hud h1 {
      font-size: 18px;
      margin-bottom: 14px;
    }

    .btn {
      width: 100%;
      margin-top: 10px;
      padding: 12px;
      border: none;
      border-radius: 10px;
      cursor: pointer;
      font-size: 14px;
      font-weight: bold;
      transition: 0.2s ease;
    }

    .btn:hover {
      transform: scale(1.02);
    }

    .primary {
      background: #ffffff;
      color: black;
    }

    .secondary {
      background: #1e1e1e;
      color: white;
      border: 1px solid rgba(255,255,255,0.1);
    }

    input[type="file"] {
      display: none;
    }

    .file-label {
      display: block;
      text-align: center;
      padding: 12px;
      background: #1b1b1b;
      border-radius: 10px;
      cursor: pointer;
      border: 1px dashed rgba(255,255,255,0.2);
    }

    #shareLink {
      margin-top: 12px;
      width: 100%;
      background: #121212;
      border: 1px solid rgba(255,255,255,0.1);
      padding: 10px;
      border-radius: 10px;
      color: white;
    }

    #status {
      margin-top: 12px;
      font-size: 12px;
      opacity: 0.8;
      line-height: 1.4;
    }

    #progress {
      margin-top: 10px;
      font-size: 13px;
    }

    @media (max-width: 768px) {
      #hud {
        width: calc(100% - 40px);
      }
    }
  </style>
</head>
<body>

<div id="hud">
  <h1>🧩 Puzzle 3D</h1>

  <label class="file-label" for="imageUpload">
    Importer une image
  </label>

  <input type="file" id="imageUpload" accept="image/*" />

  <button class="btn primary" id="shuffleBtn">
    Mélanger le puzzle
  </button>

  <button class="btn secondary" id="saveBtn">
    Sauvegarder la progression
  </button>

  <button class="btn secondary" id="shareBtn">
    Générer un lien de partage
  </button>

  <input type="text" id="shareLink" readonly placeholder="Lien de partage..." />

  <div id="progress">
    Pièces correctement placées : 0
  </div>

  <div id="status">
    Glissez les pièces avec la souris.<br>
    Molette : zoom<br>
    Clic droit : rotation caméra
  </div>
</div>

<script>
  let scene, camera, renderer, controls;
  let raycaster = new THREE.Raycaster();
  let mouse = new THREE.Vector2();

  let puzzlePieces = [];
  let selectedPiece = null;
  let offset = new THREE.Vector3();
  let plane = new THREE.Plane();
  let intersection = new THREE.Vector3();

  const puzzleSize = 4;
  const pieceSize = 1;

  let originalPositions = [];

  init();
  animate();
  restorePuzzle();

  function init() {
    scene = new THREE.Scene();
    scene.background = new THREE.Color(0x111111);

    camera = new THREE.PerspectiveCamera(
      60,
      window.innerWidth / window.innerHeight,
      0.1,
      1000
    );

    camera.position.set(0, 0, 8);

    renderer = new THREE.WebGLRenderer({ antialias: true });
    renderer.setSize(window.innerWidth, window.innerHeight);
    document.body.appendChild(renderer.domElement);

    controls = new THREE.OrbitControls(camera, renderer.domElement);
    controls.enableDamping = true;

    const ambient = new THREE.AmbientLight(0xffffff, 1.2);
    scene.add(ambient);

    const directional = new THREE.DirectionalLight(0xffffff, 1.5);
    directional.position.set(5, 10, 5);
    scene.add(directional);

    window.addEventListener('resize', onResize);

    renderer.domElement.addEventListener('pointerdown', onPointerDown);
    renderer.domElement.addEventListener('pointermove', onPointerMove);
    renderer.domElement.addEventListener('pointerup', onPointerUp);

    document
      .getElementById('imageUpload')
      .addEventListener('change', handleImageUpload);

    document
      .getElementById('shuffleBtn')
      .addEventListener('click', explodePuzzle);

    document
      .getElementById('saveBtn')
      .addEventListener('click', savePuzzle);

    document
      .getElementById('shareBtn')
      .addEventListener('click', generateShareLink);
  }

  function onResize() {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
  }

  function clearPuzzle() {
    puzzlePieces.forEach(piece => {
      scene.remove(piece);
    });

    puzzlePieces = [];
    originalPositions = [];
  }

  function handleImageUpload(e) {
    const file = e.target.files[0];

    if (!file) return;

    const reader = new FileReader();

    reader.onload = function(event) {
      createPuzzle(event.target.result);
    };

    reader.readAsDataURL(file);
  }

  function createPuzzle(imageSrc) {
    clearPuzzle();

    const loader = new THREE.TextureLoader();

    loader.load(imageSrc, texture => {
      texture.minFilter = THREE.LinearFilter;

      for (let y = 0; y < puzzleSize; y++) {
        for (let x = 0; x < puzzleSize; x++) {

          const geometry = new THREE.BoxGeometry(
            pieceSize,
            pieceSize,
            0.2
          );

          const materials = [];

          for (let i = 0; i < 6; i++) {
            materials.push(
              new THREE.MeshStandardMaterial({
                color: i === 4 ? 0xffffff : 0x222222,
                map: i === 4 ? texture.clone() : null
              })
            );
          }

          const piece = new THREE.Mesh(geometry, materials);

          const tx = x / puzzleSize;
          const ty = y / puzzleSize;

          piece.material[4].map.repeat.set(
            1 / puzzleSize,
            1 / puzzleSize
          );

          piece.material[4].map.offset.set(
            tx,
            1 - (1 / puzzleSize) - ty
          );

          piece.material[4].map.needsUpdate = true;

          const posX = (x - puzzleSize / 2) * pieceSize + pieceSize / 2;
          const posY = -(y - puzzleSize / 2) * pieceSize - pieceSize / 2;

          piece.position.set(posX, posY, 0);

          piece.userData.correctPosition = {
            x: posX,
            y: posY,
            z: 0
          };

          originalPositions.push({
            x: posX,
            y: posY,
            z: 0
          });

          scene.add(piece);
          puzzlePieces.push(piece);
        }
      }

      explodePuzzle();
      savePuzzle();
    });
  }

  function explodePuzzle() {
    puzzlePieces.forEach(piece => {
      piece.position.x += (Math.random() - 0.5) * 10;
      piece.position.y += (Math.random() - 0.5) * 10;
      piece.position.z += (Math.random() - 0.5) * 4;

      piece.rotation.x = Math.random() * Math.PI;
      piece.rotation.y = Math.random() * Math.PI;
    });

    updateProgress();
  }

  function onPointerDown(event) {
    mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
    mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;

    raycaster.setFromCamera(mouse, camera);

    const intersects = raycaster.intersectObjects(puzzlePieces);

    if (intersects.length > 0) {
      selectedPiece = intersects[0].object;

      plane.setFromNormalAndCoplanarPoint(
        camera.getWorldDirection(plane.normal),
        selectedPiece.position
      );

      if (raycaster.ray.intersectPlane(plane, intersection)) {
        offset.copy(intersection).sub(selectedPiece.position);
      }

      controls.enabled = false;
    }
  }

  function onPointerMove(event) {
    mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
    mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;

    raycaster.setFromCamera(mouse, camera);

    if (selectedPiece) {
      if (raycaster.ray.intersectPlane(plane, intersection)) {
        selectedPiece.position.copy(intersection.sub(offset));
      }
    }
  }

  function onPointerUp() {
    if (selectedPiece) {
      const correct = selectedPiece.userData.correctPosition;

      const dist = selectedPiece.position.distanceTo(
        new THREE.Vector3(correct.x, correct.y, correct.z)
      );

      if (dist < 0.5) {
        selectedPiece.position.set(correct.x, correct.y, correct.z);
        selectedPiece.rotation.set(0, 0, 0);
      }

      updateProgress();
      savePuzzle();
    }

    selectedPiece = null;
    controls.enabled = true;
  }

  function updateProgress() {
    let good = 0;

    puzzlePieces.forEach(piece => {
      const c = piece.userData.correctPosition;

      const dist = piece.position.distanceTo(
        new THREE.Vector3(c.x, c.y, c.z)
      );

      if (dist < 0.01) {
        good++;
      }
    });

    document.getElementById('progress').innerHTML =
      `Pièces correctement placées : ${good} / ${puzzlePieces.length}`;
  }

  function savePuzzle() {
    if (!puzzlePieces.length) return;

    const data = puzzlePieces.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
      },
      correctPosition: piece.userData.correctPosition
    }));

    localStorage.setItem('puzzle-progress', JSON.stringify(data));
  }

  function restorePuzzle() {
    const saved = localStorage.getItem('puzzle-progress');

    if (!saved) return;

    console.log('Progression trouvée.');
  }

  function generateShareLink() {
    if (!puzzlePieces.length) return;

    const shareData = puzzlePieces.map(piece => ({
      p: piece.position,
      r: piece.rotation,
      c: piece.userData.correctPosition
<!DOCTYPE html>
<html lang="fr">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>3D Puzzle Viewer</title>

  <script src="https://cdn.jsdelivr.net/npm/three@0.161.0/build/three.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/three@0.161.0/examples/js/controls/OrbitControls.js"></script>

  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
      font-family: Arial, sans-serif;
    }

    body {
      overflow: hidden;
      background: #0f0f0f;
      color: white;
    }

    canvas {
      display: block;
    }

    #hud {
      position: absolute;
      top: 20px;
      left: 20px;
      z-index: 10;
      background: rgba(0,0,0,0.7);
      padding: 16px;
      border-radius: 14px;
      width: 300px;
      backdrop-filter: blur(10px);
      border: 1px solid rgba(255,255,255,0.1);
    }

    #hud h1 {
      font-size: 18px;
      margin-bottom: 14px;
    }

    .btn {
      width: 100%;
      margin-top: 10px;
      padding: 12px;
      border: none;
      border-radius: 10px;
      cursor: pointer;
      font-size: 14px;
      font-weight: bold;
      transition: 0.2s ease;
    }

    .btn:hover {
      transform: scale(1.02);
    }

    .primary {
      background: #ffffff;
      color: black;
    }

    .secondary {
      background: #1e1e1e;
      color: white;
      border: 1px solid rgba(255,255,255,0.1);
    }

    input[type="file"] {
      display: none;
    }

    .file-label {
      display: block;
      text-align: center;
      padding: 12px;
      background: #1b1b1b;
      border-radius: 10px;
      cursor: pointer;
      border: 1px dashed rgba(255,255,255,0.2);
    }

    #shareLink {
      margin-top: 12px;
      width: 100%;
      background: #121212;
      border: 1px solid rgba(255,255,255,0.1);
      padding: 10px;
      border-radius: 10px;
      color: white;
    }

    #status {
      margin-top: 12px;
      font-size: 12px;
      opacity: 0.8;
      line-height: 1.4;
    }

    #progress {
      margin-top: 10px;
      font-size: 13px;
    }

    @media (max-width: 768px) {
      #hud {
        width: calc(100% - 40px);
      }
    }
  </style>
</head>
<body>

<div id="hud">
  <h1>🧩 Puzzle 3D</h1>

  <label class="file-label" for="imageUpload">
    Importer une image
  </label>

  <input type="file" id="imageUpload" accept="image/*" capture="environment" />

  <button class="btn primary" id="shuffleBtn">
    Mélanger le puzzle
  </button>

  

  <button class="btn secondary" id="shareBtn">
    Générer un lien de partage
  </button>

  <input type="text" id="shareLink" readonly placeholder="Lien de partage..." />

  <div id="progress">
    Pièces correctement placées : 0
  </div>

  <div id="status">
    Glissez les pièces avec la souris.<br>
    Molette : zoom<br>
    Clic droit : rotation caméra
  </div>
</div>

<script>
  let scene, camera, renderer, controls;
  let raycaster = new THREE.Raycaster();
  let mouse = new THREE.Vector2();

  let puzzlePieces = [];
  let selectedPiece = null;
  let offset = new THREE.Vector3();
  let plane = new THREE.Plane();
  let intersection = new THREE.Vector3();

  const puzzleSize = 4;
  const pieceSize = 1;

  let originalPositions = [];

  init();
  animate();
  restorePuzzle();

  function init() {
    scene = new THREE.Scene();
    scene.background = new THREE.Color(0x111111);

    camera = new THREE.PerspectiveCamera(
      60,
      window.innerWidth / window.innerHeight,
      0.1,
      1000
    );

    camera.position.set(0, 0, 8);

    renderer = new THREE.WebGLRenderer({ antialias: true });
    renderer.setSize(window.innerWidth, window.innerHeight);
    document.body.appendChild(renderer.domElement);

    controls = new THREE.OrbitControls(camera, renderer.domElement);
    controls.enableDamping = true;

    const ambient = new THREE.AmbientLight(0xffffff, 1.2);
    scene.add(ambient);

    const directional = new THREE.DirectionalLight(0xffffff, 1.5);
    directional.position.set(5, 10, 5);
    scene.add(directional);

    window.addEventListener('resize', onResize);

    renderer.domElement.addEventListener('pointerdown', onPointerDown);
    renderer.domElement.addEventListener('pointermove', onPointerMove);
    renderer.domElement.addEventListener('pointerup', onPointerUp);

    document
      .getElementById('imageUpload')
      .addEventListener('change', handleImageUpload);

    document
      .getElementById('shuffleBtn')
      .addEventListener('click', explodePuzzle);

    

    document
      .getElementById('shareBtn')
      .addEventListener('click', generateShareLink);
  }

  function onResize() {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
  }

  function clearPuzzle() {
    puzzlePieces.forEach(piece => {
      scene.remove(piece);
    });

    puzzlePieces = [];
    originalPositions = [];
  }

  function handleImageUpload(e) {
    console.log('Import image déclenché');
    const file = e.target.files[0];

    if (!file) return;

    const reader = new FileReader();

    reader.onload = function(event) {
      createPuzzle(event.target.result);
    };

    reader.readAsDataURL(file);

    // Sauvegarde locale de l'image source
    localStorage.setItem('puzzle-image', URL.createObjectURL(file));
  }

  function createPuzzle(imageSrc) {
    clearPuzzle();

    const loader = new THREE.TextureLoader();

    loader.load(imageSrc, texture => {
      texture.minFilter = THREE.LinearFilter;

      for (let y = 0; y < puzzleSize; y++) {
        for (let x = 0; x < puzzleSize; x++) {

          const geometry = new THREE.BoxGeometry(
            pieceSize,
            pieceSize,
            0.2
          );

          const materials = [];

          for (let i = 0; i < 6; i++) {
            materials.push(
              new THREE.MeshStandardMaterial({
                color: i === 4 ? 0xffffff : 0x222222,
                map: i === 4 ? texture.clone() : null
              })
            );
          }

          const piece = new THREE.Mesh(geometry, materials);

          const tx = x / puzzleSize;
          const ty = y / puzzleSize;

          piece.material[4].map.repeat.set(
            1 / puzzleSize,
            1 / puzzleSize
          );

          piece.material[4].map.offset.set(
            tx,
            1 - (1 / puzzleSize) - ty
          );

          piece.material[4].map.needsUpdate = true;

          const posX = (x - puzzleSize / 2) * pieceSize + pieceSize / 2;
          const posY = -(y - puzzleSize / 2) * pieceSize - pieceSize / 2;

          piece.position.set(posX, posY, 0);

          piece.userData.correctPosition = {
            x: posX,
            y: posY,
            z: 0
          };

          originalPositions.push({
            x: posX,
            y: posY,
            z: 0
          });

          scene.add(piece);
          puzzlePieces.push(piece);
        }
      }

      explodePuzzle();
      savePuzzle();
    });
  }

  function explodePuzzle() {
    puzzlePieces.forEach(piece => {
      piece.position.x += (Math.random() - 0.5) * 10;
      piece.position.y += (Math.random() - 0.5) * 10;
      piece.position.z += (Math.random() - 0.5) * 4;

      piece.rotation.x = Math.random() * Math.PI;
      piece.rotation.y = Math.random() * Math.PI;
    });

    updateProgress();
  }

  function onPointerDown(event) {
    mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
    mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;

    raycaster.setFromCamera(mouse, camera);

    const intersects = raycaster.intersectObjects(puzzlePieces);

    if (intersects.length > 0) {
      selectedPiece = intersects[0].object;

      plane.setFromNormalAndCoplanarPoint(
        camera.getWorldDirection(plane.normal),
        selectedPiece.position
      );

      if (raycaster.ray.intersectPlane(plane, intersection)) {
        offset.copy(intersection).sub(selectedPiece.position);
      }

      controls.enabled = false;
    }
  }

  function onPointerMove(event) {
    mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
    mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;

    raycaster.setFromCamera(mouse, camera);

    if (selectedPiece) {
      if (raycaster.ray.intersectPlane(plane, intersection)) {
        selectedPiece.position.copy(intersection.sub(offset));
      }
    }
  }

  function onPointerUp() {
    if (selectedPiece) {
      const correct = selectedPiece.userData.correctPosition;

      const dist = selectedPiece.position.distanceTo(
        new THREE.Vector3(correct.x, correct.y, correct.z)
      );

      if (dist < 0.5) {
        selectedPiece.position.set(correct.x, correct.y, correct.z);
        selectedPiece.rotation.set(0, 0, 0);
      }

      updateProgress();
      savePuzzle();
    }

    selectedPiece = null;
    controls.enabled = true;
  }

  function updateProgress() {
    let good = 0;

    puzzlePieces.forEach(piece => {
      const c = piece.userData.correctPosition;

      const dist = piece.position.distanceTo(
        new THREE.Vector3(c.x, c.y, c.z)
      );

      if (dist < 0.01) {
        good++;
      }
    });

    document.getElementById('progress').innerHTML =
      `Pièces correctement placées : ${good} / ${puzzlePieces.length}`;
  }

  function savePuzzle() {
    if (!puzzlePieces.length) return;

    const data = puzzlePieces.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
      },
      correctPosition: piece.userData.correctPosition
    }));

    localStorage.setItem('puzzle-progress', JSON.stringify(data));
  }

  function restorePuzzle() {
    const saved = localStorage.getItem('puzzle-progress');

    if (!saved) return;

    console.log('Progression restaurée automatiquement');
}

  function generateShareLink() {
    if (!puzzlePieces.length) return;

    const shareData = puzzlePieces.map(piece => ({
      p: piece.position,
      r: piece.rotation,
      c: piece.userData.correctPosition
    }));

    const encoded = btoa(JSON.stringify(shareData));

    const url = `${window.location.origin}${window.location.pathname}?puzzle=${encoded}`;

    const input = document.getElementById('shareLink');

    input.value = url;
    input.select();

    navigator.clipboard.writeText(url);
  }

  function loadSharedPuzzle() {
    const params = new URLSearchParams(window.location.search);
    const puzzle = params.get('puzzle');

    if (!puzzle) return;

    try {
      const data = JSON.parse(atob(puzzle));
      console.log(data);
    } catch(err) {
      console.error(err);
    }
  }

  loadSharedPuzzle();

  function animate() {
    requestAnimationFrame(animate);

    controls.update();
    renderer.render(scene, camera);
  }
</script>

</body>
</html><!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Puzzle 3D</title>

<style>
*{
  margin:0;
  padding:0;
  box-sizing:border-box;
  font-family:Arial,sans-serif;
}

body{
  overflow:hidden;
  background:#050505;
  color:white;
}

canvas{
  display:block;
}

#hud{
  position:absolute;
  top:20px;
  left:20px;
  z-index:10;
  width:320px;
  padding:20px;
  border-radius:24px;
  background:rgba(0,0,0,.78);
  backdrop-filter:blur(12px);
  border:1px solid rgba(255,255,255,.08);
}

#hud h1{
  font-size:20px;
  margin-bottom:18px;
}

.file-label,
.btn{
  width:100%;
  display:flex;
  align-items:center;
  justify-content:center;
  height:64px;
  border-radius:18px;
  cursor:pointer;
  margin-bottom:14px;
  font-size:16px;
  font-weight:700;
  transition:.2s;
  user-select:none;
}

.file-label{
  background:#171717;
  border:1px dashed rgba(255,255,255,.12);
}

.file-label:hover{
  transform:scale(1.02);
}

.btn{
  background:white;
  color:black;
  border:none;
}

.btn:hover{
  transform:scale(1.02);
}

input[type=file]{
  display:none;
}

#progress{
  margin-top:10px;
  font-size:14px;
  opacity:.8;
  line-height:1.5;
}

@media(max-width:768px){

  #hud{
    width:calc(100% - 40px);
  }

}
</style>
</head>

<body>

<div id="hud">

  <h1>🧩 Puzzle 3D</h1>

  <label class="file-label" for="upload">
    Importer une image
  </label>

  <input
    type="file"
    id="upload"
    accept="image/png,image/jpeg,image/jpg,image/webp"
    capture="environment"
  />

  <button class="btn" id="shuffle">
    Mélanger le puzzle
  </button>

  <div id="progress">
    Pièces correctement placées : 0
  </div>

</div>

<script type="module">

import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.161.0/build/three.module.js';

import { OrbitControls } from 'https://cdn.jsdelivr.net/npm/three@0.161.0/examples/jsm/controls/OrbitControls.js';

let scene;
let camera;
let renderer;
let controls;

let raycaster = new THREE.Raycaster();
let mouse = new THREE.Vector2();

let pieces = [];

let selected = null;

let plane = new THREE.Plane();
let offset = new THREE.Vector3();
let intersection = new THREE.Vector3();

const SIZE = 4;
const PIECE = 1;

init();
animate();
restore();

function init(){

  scene = new THREE.Scene();
  scene.background = new THREE.Color(0x050505);

  camera = new THREE.PerspectiveCamera(
    60,
    window.innerWidth/window.innerHeight,
    0.1,
    1000
  );

  camera.position.set(0,0,8);

  renderer = new THREE.WebGLRenderer({
    antialias:true
  });

  renderer.setSize(window.innerWidth,window.innerHeight);

  document.body.appendChild(renderer.domElement);

  controls = new OrbitControls(camera,renderer.domElement);

  controls.enableDamping = true;

  const ambient = new THREE.AmbientLight(0xffffff,1.5);
  scene.add(ambient);

  const dir = new THREE.DirectionalLight(0xffffff,2);
  dir.position.set(5,10,5);

  scene.add(dir);

  window.addEventListener('resize',resize);

  renderer.domElement.addEventListener('pointerdown',down);
  renderer.domElement.addEventListener('pointermove',move);
  renderer.domElement.addEventListener('pointerup',up);

  document
    .getElementById('upload')
    .addEventListener('change',upload);

  document
    .getElementById('shuffle')
    .addEventListener('click',explode);

}

function resize(){

  camera.aspect = window.innerWidth/window.innerHeight;
  camera.updateProjectionMatrix();

  renderer.setSize(window.innerWidth,window.innerHeight);

}

async function upload(e){

  const file = e.target.files[0];

  if(!file) return;

  if(!file.type.startsWith('image/')){
    alert('Image invalide');
    return;
  }

  const reader = new FileReader();

  reader.onload = function(event){

    const base64 = event.target.result;

    localStorage.setItem(
      'puzzle-image',
      base64
    );

    createPuzzle(base64);

  };

  reader.onerror = function(){

    alert('Erreur import image');

  };

  reader.readAsDataURL(file);

}

function clearPuzzle(){

  pieces.forEach(p=>scene.remove(p));

  pieces = [];

}

function createPuzzle(src){

  clearPuzzle();

  const loader = new THREE.TextureLoader();

  loader.load(src,function(texture){

    texture.minFilter = THREE.LinearFilter;

    for(let y=0;y<SIZE;y++){

      for(let x=0;x<SIZE;x++){

        const geo = new THREE.BoxGeometry(
          PIECE,
          PIECE,
          .2
        );

        const mats = [];

        for(let i=0;i<6;i++){

          mats.push(
            new THREE.MeshStandardMaterial({
              color:i===4 ? 0xffffff : 0x222222,
              map:i===4 ? texture.clone() : null
            })
          );

        }

        const mesh = new THREE.Mesh(geo,mats);

        mesh.material[4].map.repeat.set(
          1/SIZE,
          1/SIZE
        );

        mesh.material[4].map.offset.set(
          x/SIZE,
          1-(1/SIZE)-y/SIZE
        );

        mesh.material[4].map.needsUpdate = true;

        const px =
          (x-SIZE/2)*PIECE + PIECE/2;

        const py =
          -(y-SIZE/2)*PIECE - PIECE/2;

        mesh.position.set(px,py,0);

        mesh.userData.correct = {
          x:px,
          y:py,
          z:0
        };

        scene.add(mesh);

        pieces.push(mesh);

      }

    }

    explode();

  });

}

function explode(){

  pieces.forEach(piece=>{

    piece.position.x +=
      (Math.random()-.5)*10;

    piece.position.y +=
      (Math.random()-.5)*10;

    piece.position.z +=
      (Math.random()-.5)*4;

    piece.rotation.x =
      Math.random()*Math.PI;

    piece.rotation.y =
      Math.random()*Math.PI;

  });

  save();
  progress();

}

function down(event){

  mouse.x =
    (event.clientX/window.innerWidth)*2-1;

  mouse.y =
    -(event.clientY/window.innerHeight)*2+1;

  raycaster.setFromCamera(mouse,camera);

  const hit =
    raycaster.intersectObjects(pieces);

  if(hit.length){

    selected = hit[0].object;

    plane.setFromNormalAndCoplanarPoint(
      camera.getWorldDirection(plane.normal),
      selected.position
    );

    if(
      raycaster.ray.intersectPlane(
        plane,
        intersection
      )
    ){

      offset.copy(intersection)
      .sub(selected.position);

    }

    controls.enabled = false;

  }

}

function move(event){

  mouse.x =
    (event.clientX/window.innerWidth)*2-1;

  mouse.y =
    -(event.clientY/window.innerHeight)*2+1;

  raycaster.setFromCamera(mouse,camera);

  if(selected){

    if(
      raycaster.ray.intersectPlane(
        plane,
        intersection
      )
    ){

      selected.position.copy(
        intersection.sub(offset)
      );

    }

  }

}

function up(){

  if(selected){

    const c = selected.userData.correct;

    const dist =
      selected.position.distanceTo(
        new THREE.Vector3(c.x,c.y,c.z)
      );

    if(dist < .5){

      selected.position.set(
        c.x,
        c.y,
        c.z
      );

      selected.rotation.set(0,0,0);

    }

    progress();
    save();

  }

  selected = null;

  controls.enabled = true;

}

function progress(){

  let ok = 0;

  pieces.forEach(piece=>{

    const c = piece.userData.correct;

    const dist =
      piece.position.distanceTo(
        new THREE.Vector3(c.x,c.y,c.z)
      );

    if(dist < .01){
      ok++;
    }

  });

  document.getElementById(
    'progress'
  ).innerHTML =
    `Pièces correctement placées : ${ok}/${pieces.length}`;

}

function save(){

  if(!pieces.length) return;

  const data = 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
    },

    correct:piece.userData.correct

  }));

  localStorage.setItem(
    'puzzle-save',
    JSON.stringify(data)
  );

}

function restore(){

  const image =
    localStorage.getItem('puzzle-image');

  if(image){

    createPuzzle(image);

    setTimeout(()=>{

      const save =
        localStorage.getItem('puzzle-save');

      if(!save) return;

      const data = JSON.parse(save);

      data.forEach((d,i)=>{

        if(!pieces[i]) return;

        pieces[i].position.set(
          d.position.x,
          d.position.y,
          d.position.z
        );

        pieces[i].rotation.set(
          d.rotation.x,
          d.rotation.y,
          d.rotation.z
        );

<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Puzzle 3D</title>

<style>
*{
  margin:0;
  padding:0;
  box-sizing:border-box;
  font-family:Arial,sans-serif;
}

body{
  overflow:hidden;
  background:#050505;
  color:white;
}

canvas{
  display:block;
}

#hud{
  position:absolute;
  top:20px;
  left:20px;
  z-index:10;
  width:320px;
  padding:20px;
  border-radius:24px;
  background:rgba(0,0,0,.78);
  backdrop-filter:blur(12px);
  border:1px solid rgba(255,255,255,.08);
}

#hud h1{
  font-size:20px;
  margin-bottom:18px;
}

.file-label,
.btn{
  width:100%;
  display:flex;
  align-items:center;
  justify-content:center;
  height:64px;
  border-radius:18px;
  cursor:pointer;
  margin-bottom:14px;
  font-size:16px;
  font-weight:700;
  transition:.2s;
  user-select:none;
}

.file-label{
  background:#171717;
  border:1px dashed rgba(255,255,255,.12);
}

.file-label:hover{
  transform:scale(1.02);
}

.btn{
  background:white;
  color:black;
  border:none;
}

.btn:hover{
  transform:scale(1.02);
}

input[type=file]{
  display:none;
}

#progress{
  margin-top:10px;
  font-size:14px;
  opacity:.8;
  line-height:1.5;
}

@media(max-width:768px){

  #hud{
    width:calc(100% - 40px);
  }

}
</style>
</head>

<body>

<div id="hud">

  <h1>🧩 Puzzle 3D</h1>

  <label class="file-label" for="upload">
    Importer une image
  </label>

  <input
    type="file"
    id="upload"
    accept="image/png,image/jpeg,image/jpg,image/webp"
    capture="environment"
  />

  <button class="btn" id="shuffle">
    Mélanger le puzzle
  </button>

  <div id="progress">
    Pièces correctement placées : 0
  </div>

</div>

<script type="module">

import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.161.0/build/three.module.js';

import { OrbitControls } from 'https://cdn.jsdelivr.net/npm/three@0.161.0/examples/jsm/controls/OrbitControls.js';

let scene;
let camera;
let renderer;
let controls;

let raycaster = new THREE.Raycaster();
let mouse = new THREE.Vector2();

let pieces = [];

let selected = null;

let plane = new THREE.Plane();
let offset = new THREE.Vector3();
let intersection = new THREE.Vector3();

const SIZE = 4;
const PIECE = 1;

init();
animate();
restore();

function init(){

  scene = new THREE.Scene();
  scene.background = new THREE.Color(0x050505);

  camera = new THREE.PerspectiveCamera(
    60,
    window.innerWidth/window.innerHeight,
    0.1,
    1000
  );

  camera.position.set(0,0,8);

  renderer = new THREE.WebGLRenderer({
    antialias:true
  });

  renderer.setSize(window.innerWidth,window.innerHeight);

  document.body.appendChild(renderer.domElement);

  controls = new OrbitControls(camera,renderer.domElement);

  controls.enableDamping = true;

  const ambient = new THREE.AmbientLight(0xffffff,1.5);
  scene.add(ambient);

  const dir = new THREE.DirectionalLight(0xffffff,2);
  dir.position.set(5,10,5);

  scene.add(dir);

  window.addEventListener('resize',resize);

  renderer.domElement.addEventListener('pointerdown',down);
  renderer.domElement.addEventListener('pointermove',move);
  renderer.domElement.addEventListener('pointerup',up);

  document
    .getElementById('upload')
    .addEventListener('change',upload);

  document
    .getElementById('shuffle')
    .addEventListener('click',explode);

}

function resize(){

  camera.aspect = window.innerWidth/window.innerHeight;
  camera.updateProjectionMatrix();

  renderer.setSize(window.innerWidth,window.innerHeight);

}

async function upload(e){

  const file = e.target.files[0];

  if(!file) return;

  if(!file.type.startsWith('image/')){
    alert('Image invalide');
    return;
  }

  const reader = new FileReader();

  reader.onload = function(event){

    const base64 = event.target.result;

    localStorage.setItem(
      'puzzle-image',
      base64
    );

    createPuzzle(base64);

  };

  reader.onerror = function(){

    alert('Erreur import image');

  };

  reader.readAsDataURL(file);

}

function clearPuzzle(){

  pieces.forEach(p=>scene.remove(p));

  pieces = [];

}

function createPuzzle(src){

  clearPuzzle();

  const loader = new THREE.TextureLoader();

  loader.load(src,function(texture){

    texture.minFilter = THREE.LinearFilter;

    for(let y=0;y<SIZE;y++){

      for(let x=0;x<SIZE;x++){

        const geo = new THREE.BoxGeometry(
          PIECE,
          PIECE,
          .2
        );

        const mats = [];

        for(let i=0;i<6;i++){

          mats.push(
            new THREE.MeshStandardMaterial({
              color:i===4 ? 0xffffff : 0x222222,
              map:i===4 ? texture.clone() : null
            })
          );

        }

        const mesh = new THREE.Mesh(geo,mats);

        mesh.material[4].map.repeat.set(
          1/SIZE,
          1/SIZE
        );

        mesh.material[4].map.offset.set(
          x/SIZE,
          1-(1/SIZE)-y/SIZE
        );

        mesh.material[4].map.needsUpdate = true;

        const px =
          (x-SIZE/2)*PIECE + PIECE/2;

        const py =
          -(y-SIZE/2)*PIECE - PIECE/2;

        mesh.position.set(px,py,0);

        mesh.userData.correct = {
          x:px,
          y:py,
          z:0
        };

        scene.add(mesh);

        pieces.push(mesh);

      }

    }

    explode();

  });

}

function explode(){

  pieces.forEach(piece=>{

    piece.position.x +=
      (Math.random()-.5)*10;

    piece.position.y +=
      (Math.random()-.5)*10;

    piece.position.z +=
      (Math.random()-.5)*4;

    piece.rotation.x =
      Math.random()*Math.PI;

    piece.rotation.y =
      Math.random()*Math.PI;

  });

  save();
  progress();

}

function down(event){

  mouse.x =
    (event.clientX/window.innerWidth)*2-1;

  mouse.y =
    -(event.clientY/window.innerHeight)*2+1;

  raycaster.setFromCamera(mouse,camera);

  const hit =
    raycaster.intersectObjects(pieces);

  if(hit.length){

    selected = hit[0].object;

    plane.setFromNormalAndCoplanarPoint(
      camera.getWorldDirection(plane.normal),
      selected.position
    );

    if(
      raycaster.ray.intersectPlane(
        plane,
        intersection
      )
    ){

      offset.copy(intersection)
      .sub(selected.position);

    }

    controls.enabled = false;

  }

}

function move(event){

  mouse.x =
    (event.clientX/window.innerWidth)*2-1;

  mouse.y =
    -(event.clientY/window.innerHeight)*2+1;

  raycaster.setFromCamera(mouse,camera);

  if(selected){

    if(
      raycaster.ray.intersectPlane(
        plane,
        intersection
      )
    ){

      selected.position.copy(
        intersection.sub(offset)
      );

    }

  }

}

function up(){

  if(selected){

    const c = selected.userData.correct;

    const dist =
      selected.position.distanceTo(
        new THREE.Vector3(c.x,c.y,c.z)
      );

    if(dist < .5){

      selected.position.set(
        c.x,
        c.y,
        c.z
      );

      selected.rotation.set(0,0,0);

    }

    progress();
    save();

  }

  selected = null;

  controls.enabled = true;

}

function progress(){

  let ok = 0;

  pieces.forEach(piece=>{

    const c = piece.userData.correct;

    const dist =
      piece.position.distanceTo(
        new THREE.Vector3(c.x,c.y,c.z)
      );

    if(dist < .01){
      ok++;
    }

  });

  document.getElementById(
    'progress'
  ).innerHTML =
    `Pièces correctement placées : ${ok}/${pieces.length}`;

}

function save(){

  if(!pieces.length) return;

  const data = 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
    },

    correct:piece.userData.correct

  }));

  localStorage.setItem(
    'puzzle-save',
    JSON.stringify(data)
  );

}

function restore(){

  const image =
    localStorage.getItem('puzzle-image');

  if(image){

    createPuzzle(image);

    setTimeout(()=>{

      const save =
        localStorage.getItem('puzzle-save');

      if(!save) return;

      const data = JSON.parse(save);

      data.forEach((d,i)=>{

        if(!pieces[i]) return;

        pieces[i].position.set(
          d.position.x,
          d.position.y,
          d.position.z
        );

        pieces[i].rotation.set(
          d.rotation.x,
          d.rotation.y,
          d.rotation.z
<!DOCTYPE html>
<html lang="fr">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>3D Puzzle Viewer</title>

  <script src="https://cdn.jsdelivr.net/npm/three@0.161.0/build/three.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/three@0.161.0/examples/js/controls/OrbitControls.js"></script>

<!-- IMPORTANT CODEPEN -->
<!-- Dans CodePen, activez :
1. Settings > JS > Babel
2. Settings > JS > Add External Scripts :
   https://cdn.jsdelivr.net/npm/three@0.161.0/build/three.min.js
   https://cdn.jsdelivr.net/npm/three@0.161.0/examples/js/controls/OrbitControls.js
-->

  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
      font-family: Arial, sans-serif;
    }

    body {
      overflow: hidden;
      background: #0f0f0f;
      color: white;
    }

    canvas {
      display: block;
    }

    #hud {
      position: absolute;
      top: 20px;
      left: 20px;
      z-index: 10;
      background: rgba(0,0,0,0.7);
      padding: 16px;
      border-radius: 14px;
      width: 300px;
      backdrop-filter: blur(10px);
      border: 1px solid rgba(255,255,255,0.1);
    }

    #hud h1 {
      font-size: 18px;
      margin-bottom: 14px;
    }

    .btn {
      width: 100%;
      margin-top: 10px;
      padding: 12px;
      border: none;
      border-radius: 10px;
      cursor: pointer;
      font-size: 14px;
      font-weight: bold;
      transition: 0.2s ease;
    }

    .btn:hover {
      transform: scale(1.02);
    }

    .primary {
      background: #ffffff;
      color: black;
    }

    .secondary {
      background: #1e1e1e;
      color: white;
      border: 1px solid rgba(255,255,255,0.1);
    }

    input[type="file"] {
      display: none;
    }

    .file-label {
      display: block;
      text-align: center;
      padding: 12px;
      background: #1b1b1b;
      border-radius: 10px;
      cursor: pointer;
      border: 1px dashed rgba(255,255,255,0.2);
    }

    #shareLink {
      margin-top: 12px;
      width: 100%;
      background: #121212;
      border: 1px solid rgba(255,255,255,0.1);
      padding: 10px;
      border-radius: 10px;
      color: white;
    }

    #status {
      margin-top: 12px;
      font-size: 12px;
      opacity: 0.8;
      line-height: 1.4;
    }

    #progress {
      margin-top: 10px;
      font-size: 13px;
    }

    @media (max-width: 768px) {
      #hud {
        width: calc(100% - 40px);
      }
    }
  </style>
</head>
<body>

<div id="hud">
  <h1>🧩 Puzzle 3D</h1>

  <label class="file-label" for="imageUpload">
    Importer une image
  </label>

  <input type="file" id="imageUpload" accept="image/*" capture="environment" />

  <button class="btn primary" id="shuffleBtn">
    Mélanger le puzzle
  </button>

  

  <button class="btn secondary" id="shareBtn">
    Générer un lien de partage
  </button>

  <input type="text" id="shareLink" readonly placeholder="Lien de partage..." />

  <div id="progress">
    Pièces correctement placées : 0
  </div>

  <div id="status">
    Glissez les pièces avec la souris.<br>
    Molette : zoom<br>
    Clic droit : rotation caméra
  </div>
</div>

<script>
  let scene, camera, renderer, controls;
  let raycaster = new THREE.Raycaster();
  let mouse = new THREE.Vector2();

  let puzzlePieces = [];
  let selectedPiece = null;
  let offset = new THREE.Vector3();
  let plane = new THREE.Plane();
  let intersection = new THREE.Vector3();

  const puzzleSize = 4;
  const pieceSize = 1;

  let originalPositions = [];

  init();
  animate();
  restorePuzzle();

  function init() {
    scene = new THREE.Scene();
    scene.background = new THREE.Color(0x111111);

    camera = new THREE.PerspectiveCamera(
      60,
      window.innerWidth / window.innerHeight,
      0.1,
      1000
    );

    camera.position.set(0, 0, 8);

    renderer = new THREE.WebGLRenderer({ antialias: true });
    renderer.setSize(window.innerWidth, window.innerHeight);
    document.body.appendChild(renderer.domElement);

    controls = new THREE.OrbitControls(camera, renderer.domElement);
    controls.enableDamping = true;

    const ambient = new THREE.AmbientLight(0xffffff, 1.2);
    scene.add(ambient);

    const directional = new THREE.DirectionalLight(0xffffff, 1.5);
    directional.position.set(5, 10, 5);
    scene.add(directional);

    window.addEventListener('resize', onResize);

    renderer.domElement.addEventListener('pointerdown', onPointerDown);
    renderer.domElement.addEventListener('pointermove', onPointerMove);
    renderer.domElement.addEventListener('pointerup', onPointerUp);

    document
      .getElementById('imageUpload')
      .addEventListener('change', handleImageUpload);

    document
      .getElementById('shuffleBtn')
      .addEventListener('click', explodePuzzle);

    

    document
      .getElementById('shareBtn')
      .addEventListener('click', generateShareLink);
  }

  function onResize() {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
  }

  function clearPuzzle() {
    puzzlePieces.forEach(piece => {
      scene.remove(piece);
    });

    puzzlePieces = [];
    originalPositions = [];
  }

  function handleImageUpload(e) {
    console.log('Import image déclenché');
    const file = e.target.files[0];

    if (!file) return;

    const reader = new FileReader();

    reader.onload = function(event) {
      createPuzzle(event.target.result);
    };

    reader.readAsDataURL(file);

    // Sauvegarde locale de l'image source
    localStorage.setItem('puzzle-image', URL.createObjectURL(file));
  }

  function createPuzzle(imageSrc) {
    clearPuzzle();

    const loader = new THREE.TextureLoader();

    loader.load(imageSrc, texture => {
      texture.minFilter = THREE.LinearFilter;

      for (let y = 0; y < puzzleSize; y++) {
        for (let x = 0; x < puzzleSize; x++) {

          const geometry = new THREE.BoxGeometry(
            pieceSize,
            pieceSize,
            0.2
          );

          const materials = [];

          for (let i = 0; i < 6; i++) {
            materials.push(
              new THREE.MeshStandardMaterial({
                color: i === 4 ? 0xffffff : 0x222222,
                map: i === 4 ? texture.clone() : null
              })
            );
          }

          const piece = new THREE.Mesh(geometry, materials);

          const tx = x / puzzleSize;
          const ty = y / puzzleSize;

          piece.material[4].map.repeat.set(
            1 / puzzleSize,
            1 / puzzleSize
          );

          piece.material[4].map.offset.set(
            tx,
            1 - (1 / puzzleSize) - ty
          );

          piece.material[4].map.needsUpdate = true;

          const posX = (x - puzzleSize / 2) * pieceSize + pieceSize / 2;
          const posY = -(y - puzzleSize / 2) * pieceSize - pieceSize / 2;

          piece.position.set(posX, posY, 0);

          piece.userData.correctPosition = {
            x: posX,
            y: posY,
            z: 0
          };

          originalPositions.push({
            x: posX,
            y: posY,
            z: 0
          });

          scene.add(piece);
          puzzlePieces.push(piece);
        }
      }

      explodePuzzle();
      savePuzzle();
    });
  }

  function explodePuzzle() {
    puzzlePieces.forEach(piece => {
      piece.position.x += (Math.random() - 0.5) * 10;
      piece.position.y += (Math.random() - 0.5) * 10;
      piece.position.z += (Math.random() - 0.5) * 4;

      piece.rotation.x = Math.random() * Math.PI;
      piece.rotation.y = Math.random() * Math.PI;
    });

    updateProgress();
  }

  function onPointerDown(event) {
    mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
    mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;

    raycaster.setFromCamera(mouse, camera);

    const intersects = raycaster.intersectObjects(puzzlePieces);

    if (intersects.length > 0) {
      selectedPiece = intersects[0].object;

      plane.setFromNormalAndCoplanarPoint(
        camera.getWorldDirection(plane.normal),
        selectedPiece.position
      );

      if (raycaster.ray.intersectPlane(plane, intersection)) {
        offset.copy(intersection).sub(selectedPiece.position);
      }

      controls.enabled = false;
    }
  }

  function onPointerMove(event) {
    mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
    mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;

    raycaster.setFromCamera(mouse, camera);

    if (selectedPiece) {
      if (raycaster.ray.intersectPlane(plane, intersection)) {
        selectedPiece.position.copy(intersection.sub(offset));
      }
    }
  }

  function onPointerUp() {
    if (selectedPiece) {
      const correct = selectedPiece.userData.correctPosition;

      const dist = selectedPiece.position.distanceTo(
        new THREE.Vector3(correct.x, correct.y, correct.z)
      );

      if (dist < 0.5) {
        selectedPiece.position.set(correct.x, correct.y, correct.z);
        selectedPiece.rotation.set(0, 0, 0);
      }

      updateProgress();
      savePuzzle();
    }

    selectedPiece = null;
    controls.enabled = true;
  }

  function updateProgress() {
    let good = 0;

    puzzlePieces.forEach(piece => {
      const c = piece.userData.correctPosition;

      const dist = piece.position.distanceTo(
        new THREE.Vector3(c.x, c.y, c.z)
      );

      if (dist < 0.01) {
        good++;
      }
    });

    document.getElementById('progress').innerHTML =
      `Pièces correctement placées : ${good} / ${puzzlePieces.length}`;
  }

  function savePuzzle() {
    if (!puzzlePieces.length) return;

    const data = puzzlePieces.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
      },
      correctPosition: piece.userData.correctPosition
    }));

    localStorage.setItem('puzzle-progress', JSON.stringify(data));
  }

  function restorePuzzle() {
    const saved = localStorage.getItem('puzzle-progress');

    if (!saved) return;

    console.log('Progression restaurée automatiquement');
}

  function generateShareLink() {
    if (!puzzlePieces.length) return;

    const shareData = puzzlePieces.map(piece => ({
      p: piece.position,
      r: piece.rotation,
      c: piece.userData.correctPosition
    }));

    const encoded = btoa(JSON.stringify(shareData));

    const url = `${window.location.origin}${window.location.pathname}?puzzle=${encoded}`;

    const input = document.getElementById('shareLink');

    input.value = url;
    input.select();

    navigator.clipboard.writeText(url);
				</div>
				</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>
		<item>
		<title>spz</title>
		<link>https://presentcomposedesign.fr/spz/</link>
		
		<dc:creator><![CDATA[admin]]></dc:creator>
		<pubDate>Fri, 10 Apr 2026 08:18:05 +0000</pubDate>
				<category><![CDATA[Design produit]]></category>
		<guid isPermaLink="false">https://presentcomposedesign.fr/?p=36271</guid>

					<description><![CDATA[<p>Initialisation Ouvrir en AR / VR Position &#38; Echelle X0.00 Y0.00 Z0.00 Echelle Reglages Ambiant0.70 Lumiere0.90 Opacite1.00 Brillance0.40 Reflexion0.20 Medias Video Plat 360 Sphere Cylindrique Cubique Taille3.0 Charger video Retirer video Image Plat 360 Sphere Cylindrique Cubique Taille3.0 Charger image Retirer image Audio Volume0.80 Charger audio Triggers XR GPS &#8212;, &#8212; Rayon m50 Activer GPS + Zone ici Image Tracker Largeur m0.20 Ajouter tracker &#160;Anim Aucune Tourniquet Flottement Respiration Expansion Suivre souris Orbite douce Scroll Zoom pulse Apparition GlisserRotation ScrollZoom Shift+dragPan Dbl-clicReset</p>
<p>Cet article <a href="https://presentcomposedesign.fr/spz/">spz</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="36271" class="elementor elementor-36271">
				<div class="elementor-element elementor-element-a43c5a5 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="a43c5a5" data-element_type="container" data-e-type="container" data-settings="{&quot;background_background&quot;:&quot;classic&quot;}">
				<div class="elementor-element elementor-element-888a871 elementor-widget__width-inherit elementor-widget elementor-widget-html" data-id="888a871" data-element_type="widget" data-e-type="widget" data-widget_type="html.default">
				<div class="elementor-widget-container">
					<!--
╔═══════════════════════════════════════════════════════════════╗
║ XR Viewer Ultimate V2, © All rights reserved.                 ║
║---------------------------------------------------------------║
║ 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/                         ║
╚═══════════════════════════════════════════════════════════════╝

@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%#*++==------===**%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#++-::::::::::::::::::::::-=*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#+-::::::::::::::::::::::::::::::::=*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@#+::::::::::::::::::::::::::::::::::::::::=%@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@%+:::::::::::::::::=++***+=-::::::::::::::::::::=%@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@#=::::::::::::::::+%@@@@@@@@@@%+-:::::::::::::::::::=#@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@#-::::::::::::::::#@@@@@@@@@@@@@@@@%-:::::::::::::::::::=#@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@%=::::::::::::::::*@@@@@@**==-=+##@@@@@*-:::::::::::::::::::=%@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@=:::::::::::::::::%@@@@@+::::::::::-*@@@@#-::::::::::::::::::::=@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@#::::::::::::::::::%@@@@=::::::::::::::-%@@@#-:::::::::::::::::::::#@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@=::::::::::::::::::*@@@@=::::::::::::::::-%@@@*::::::::::::::::::::::=@@@@@@@@@@@@@@@
@@@@@@@@@@@@@%-::::::::::::::::::-@@@@=::::::::::::::::::-%@@@-::::::::::::::::::::::-%@@@@@@@@@@@@@
@@@@@@@@@@@@#::::::::::::::::::::*@@@#::::::::::::::::::::=@@@*::::::::::::::::::::::::*@@@@@@@@@@@@
@@@@@@@@@@@#:::::::::::::::::::::%@@@+::::::::::::::::::::-@@@%:::::::::::::::::::::::::*@@@@@@@@@@@
@@@@@@@@@@+::::::::::::::::::::::@@@@-:::::::::::::::::::::%@@@::::::::::::::::::::::::::*@@@@@@@@@@
@@@@@@@@@*:::::::::::::::::::::::@@@@=:::::::::::::::::::::@@@@:::::::::::::::::::::::::::*@@@@@@@@@
@@@@@@@@%::::::::::::::::::::::::@@@@*:::::::::::::::::::::@@@%::::::::::::::::::::::::::::*@@@@@@@@
@@@@@@@%:::::::::::::::::::::::::@@@@%::::::::::::::::::::+@@@*:::::::::::::::::::::::::::::%@@@@@@@
@@@@@@@::::::::::::::::::::::::::@@@@@*::::::::::::::::::-@@@@::::::::::::::::::::::::::::::-@@@@@@@
@@@@@@=::::::::::::::::::::::::::@@@@@@*::::::::::::::::-@@@@+:::::::::::::::::::::::::::::::*@@@@@@
@@@@@%:::::::::::::::::::::::::::@@@@@@@*::::::::::::::-%@@@#:::::::::::::::::::::::::::::::::*@@@@@
@@@@@::::::::::::::::::::::::::::@@@@@@@@@*::::::::::+@@@@@*::::::::::::::::::::::::::::::::::-@@@@@
@@@@*::::::::::::::::::::::::::::@@@#=@@@@@@%#*+=**%@@@@@@=::::::::::::::::::::::::::::::::::::*@@@@
@@@@:::::::::::::::::::::::::::::@@@#::*@@@@@@@@@@@@@@@@*:::::::::::::::::::::::::::::::::::::::@@@@
@@@*:::::::::::::::::::::::::::::@@@#::::=*@@@@@@@@@%#=:::::::::::::::::::::::::::::::::::::::::#@@@
@@@=:::::::::::::::::::::::::::::@@@#::::::::-==+=-:::::::::::::::::::::::::::::::::::::::::::::=@@@
@@@::::::::::::::::::::::::::::::@@@#:::::::::::::::::::::::::::::::::::::::::::::::::--:::::::::@@@
@@*::::::::::::::::::::::::::::::@@@#:::::::::+######*:::::::::::::::::::::::::::::::+@=:::::::::#@@
@@*::::::::::::::::::::::::::::::@@@#:::--::::===+*+==:::::=-:::::::=-::::::-::::::::+@=:::::::::+@@
@@:::::::::::::::::::::::::::::::@@@#::+@#@@::-#@@%@%=::=@@@@+:::+@@@@@+-::@@@@%*-::#@@%#:::::::::@@
@@:::::::::::::::::+*##%%#*=-::::@@@#::+@@=::-%@:::-*@=:%@-:=:::*@*:::-@*::@@-:=@%::=*@*=:::::::::@@
@@::::::::::::::+%@@@@@@@@@@@%*-:@@@#::+@*:::*@@@@@@@@#:+@%*-:::@@@@@@@@@-:@%:::#@:::+@=::::::::::@@
@%::::::::::::*@@@@@@@@@@@@@@@@@#+@@#::+@+:::+@=-------:::=%@*::@%-------::@%:::#@:::+@=::::::::::%@
@%::::::::::-@@@@@@++-::::-=#@@@@@+*#::+@+:::-@%=-:+%%-:-=::#@::*@*-::+%=::@%:::#@:::+@=::::::::::#@
@%:::::::::=@@@@%+::::::::::::#@@@@**::+@+::::-#@@@@%=::#@%%@+:::+@@@@@*:::@%:::#@:::+@=::::::::::%@
@%::::::::-@@@@+:::::::::::::::-@@@@+:::-::::::::--:::::::=-:::::::-=-:::::--:::-=----=-::::::::::%@
@@:::::::-@@@@*:::::::::::::::::-%%%*:::::::::::::::::::::::::::::::::::::::::::+%%%%%%#::::::::::@@
@@:::::::*@@@#:::::::+#@%*-::::::=+*#*=+#*=-:::::+#%%#-::::::=#%%#=::::-#@@#-::::=##%#+-::::::::::@@
@@-::::::@@@@-:::::-%@#+*%@*:::::%@#+#@@+*@*::::%@+==#@*::::%@#+=%@*-::%@-=*=:::*@*+=+@%-::::::::-@@
@@+:::::-@@@@::::::#@=::::#@=::::%@::=@*::#@-::*@=::::-@+::*@*::::*@*::*@*-::::-@@####%@#::::::::*@@
@@#:::::-@@@*::::::@@:::::=@*::::%@::=@*::#@-::%@::::::@*::%@-::::-@%:::=%@%-::*@#******+::::::::*@@
@@@-::::-@@@%::::::#@+::::%@-::::%@::=@*::#@-::@@-::::=@-::*@*::::*@*:::-::@%::=@*::::-=-::::::::@@@
@@@+:::::@@@@-::::::%@%*#@@+:::::%@::=@*::#@-::@@@#=+#@+::::*@%*+@@*:::#@*+@*:::*@%*+%@%::::::::-@@@
@@@#:::::#@@@+:::::::=*##+-::::::+*::-*=::+*:::@%=*%#*-::::::=*%#*-:::::=*#+:::::-*##*=--=::::::*@@@
@@@@-::::=@@@@-::::::::::::::::::=**+::::::::::@%:::::::::::::::::::::::-==++**##%%@@@@@@@-::::-@@@@
@@@@*:::::*@@@@-::::::::::::::::+@@@#::::::::::::::::::::::::-==++**##=-#@@@@@@@@@@@@@@@@@=::::*@@@@
@@@@@-:::::#@@@@+-:::::::::::::*@@@@::::::-==++--##%@@@@@@@@@#*#@@%%@@@@@@@@@@@@@@@@@@@#@@@+:::-@@@@
@@@@@%::::::*@@@@@+-:::::::::+@@@@%:=@@@@@@@@@@==@@@**%@@@@*-#@*-@@*@@@*-+**@@@:%@@==@++@@*:::%@@@@@
@@@@@@*::::::=@@@@@@@%%**##@@@@@@=::=@@@@#-:--=-=@@=+@+=@@@:%@@@=+@-*@*-@@@+:@@=#@:*@@%-@@#::+@@@@@@
@@@@@@@-:::::::+%@@@@@@@@@@@@@@#-::::@@@+-%@@@*--@@:@@=*@@@%=*%@@@@*=@-*@@@*:+@=+=#@@@%:@@%:-@@@@@@@
@@@@@@@@-::::::::=*%@@@@@@@##-:::::::@@@:#@@@@@*:@@:%==@@@@@@%+-%@@#-@*+@@@:+:@*:*@@@@@:%@@-@@@@@@@@
@@@@@@@@#-:::::::::::::-:::::::::::::@@%:%@@@@@*-@@-#@@@#-@*%@@@-#@@:@@#=+:%@-#@:@@@@@@*=@@%@@@@@@@@
@@@@@@@@@*:::::::::::::::::::::::::::#@@--@@@@%:*@@%*+*=*@@=+@@@=+@@-#@@@@@@@=#@#@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@*-:::::::::::::::::::::::::#@@@+-==-:#@@@@@@@@@@@@#++*#@@@@@@@#@@@%:@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@*-::::::::::::::::::::::::+@@@@@%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@-=-:+**++==--::%@@@@@@@@@
@@@@@@@@@@@@#-:::::::::::::::::::::::=@@@@@@@@@@@@@@@@@@@@@%%##**++=---::::::::::::::::::::%@@@@@@@@
@@@@@@@@@@@@@@-::::::::::::::::::::::-@@@@@@%###**++=--::::::::::::::::::::::::::::::::::-%@@@@@@@@@
@@@@@@@@@@@@@@@+::::::::::::::::::::::--::::::::::::::::::::::::::::::::::::::::::::::::+@@@@@@@@@@@
@@@@@@@@@@@@@@@@%-::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::-%@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@+-:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::+@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@%=::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::+%@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@%=::::::::::::::::::::::::::::::::::::::::::::::::::::::::::=%@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@%=-:::::::::::::::::::::::::::::::::::::::::::::::::::::+%@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@*-:::::::::::::::::::::::::::::::::::::::::::::::::#@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@%+-:::::::::::::::::::::::::::::::::::::::::::+%@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%*=-:::::::::::::::::::::::::::::::::::+#@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%**=-:::::::::::::::::::::::::=+#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%**++=====+++*##@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@

@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#****+++++****##%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@**+-::::::::::::::::::::==*#%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*+-::::::::::::::::::::::::::::::==#%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@#+-::::::::::::::::::::::::::::::::::::::=*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@*-::::::::::::::::::::::::::::::::::::::::::::=*%@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@#+:::::::::::::::++****%%@@@@@%%##**=--:::::::::::::=#@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@*=:::::::::::::+*%@@@@@@@@@@@@@@@@@@@@@@@#*=-:::::::::::=#@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@#=:::::::::::+*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#=-::::::::::=#@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@%=::::::::::+%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*=::::::::::=%@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@*::::::::::*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#+-:::::::::*%@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@%=::::::::=*@@@@@@@@@@@@@@%#*********#%%@@@@@@@@@@@@@@@@@@@%+-::::::::=%@@@@@@@@@@@@@@
@@@@@@@@@@@@@#:::::::::#@@@@@@@@@@@**+-::::::::::::::::-+#%@%@@@@@@@@@@@@@@%+-::::::::*@@@@@@@@@@@@@
@@@@@@@@@@@@+::::::::+@@@@@@@@@@*-::::::::::::::::::::::#@@@##%%@@@@@@@@@@@@@@+::::::::*%@@@@@@@@@@@
@@@@@@@@@@@=:::::::=#@@@@@@@@@+::::::::::::::::::::::::-*@@@@@@#%%@@@@@@@@@@@@@*-:::::::=%@@@@@@@@@@
@@@@@@@@@@=:::::::*@@@@@@@@@%=::::::::::::::::::::::::::*@@@@@@@@##%@@@@@@@@@@@@%+:::::::=%@@@@@@@@@
@@@@@@@@%=:::::::#@@@@@@@@@%:::::::::::::::::::::::::::+@@@@@@@@@@@%%@@@@@@@@@@@@@*-::::::-%@@@@@@@@
@@@@@@@@=::::::-#@@@@@@@@@%-:::::::::::::::::::::::::==#@@@@@@@@@@@@%#@@@@@@@@@@@@@#-::::::=%@@@@@@@
@@@@@@@=::::::-%@@@@@@@@@@=:::::::::::::::::::::::::*=#%@@@@@@@@@@@@@%#@@@@@@@@@@@@@#-::::::*@@@@@@@
@@@@@@*::::::-#@@@@@@@@@@*::::::::::::::::::::::::*=#+%@@@%@@@@@@@@@@@%%@@@@@@@@@@@@@%-::::::*@@@@@@
@@@@@%:::::::#@@@@@@@@@@%:::::::::::::::::::---:::-:-+*@%%@@@@@@@@@@@@@*%@@@@@@@@@@@@@%-::::::%@@@@@
@@@@@-::::::#@@@@@@@@@@@-::::::::::::::::::::+=#-=#*#=+%%@@@@@@@@@@@@@@@%@@@@@@@@@@@@@@#-:::::-%@@@@
@@@@*::::::*@@@@@@@@@@@#:::::::::::::::::::::-++=#==#+-%%%@@@@@@@@@@@@@@%@@@@@@@@@@@@@@@*::::::*@@@@
@@@%::::::-@@@@@@@@@@@@=::::::::::::::::::::::::+=++#%=%*@@@@@@@@@@@@@@@@*@@@@@@@@@@@@@@@-::::::%@@@
@@@*::::::#@@@@@@@@@@@%:::::::::::::::::::::::::+#:+@%+%@@@@@@@@@@@@@@@@@@#@@@@@@@@@@@@@@%-:::::*@@@
@@%::::::=@@@@@@@@@@@@*::::::::::::::::::::::::-#*-#+=@@%@%@@@@@@@@@@@@@@@#@@@@@@@@@@@@@@@*::::::%@@
@@*::::::%@@@@@@@@@@@@=:::::::::::::::::::::::+=#@++=+%@@@@@@@@@@@@@@#@@@+-%@@@@@@@@@@@@@@@-:::::*@@
@@::::::*@@@@@@@@@@@@@-:::::::::::::::::::::::-**+@+*=%@%@@@@@@@@@@@#@@%=::*@@@@@@@@@@@@@@@*::::::%@
@%::::::#@@@@@@@@@@@@@::::::::::::::::::::::::::==#@%+@%+*%%@@@@@@@#==+::=-#@@@@@@@@@@@@@@@#::::::*@
@*:::::-@@@@@@@@@@@@@@::::::::::::::::::::::::::::***%-@*@%%@@@@@@@%%=+*%%%@@@@@@@@@@@@@@@@@-:::::+@
@=:::::*@@@@@@@@@@@@@@-::::::::::::::::::::::::::-+@+=+*%*%@@@@@@@%@@@%*=:*@@@@@@@@@@@@@@@@@+:::::=@
%::::::*@@@@@@@@@@@@@@*::::::::::::::::::::::-=-=+#**#=%+@@*@@@@@@@@@@@+=**%@@@@@@@@@@@@@@@@#::::::%
%::::::%@@@@@@@@@@@@@@#:::::::::::::::::::+#@++@%@@#*+*+#=@@@@@@@@@@@@@@*%@#%@@@@@@@@@@@@@@@#::::::#
*::::::@@@@@@@@@@@@@@@@-:::::::::::::::::*@@@:::%@@@@*---**@@@@@@@@@@@@@@@@@@%@@@@@@@@@@@@@@%::::::*
*::::::@@@@@@@@@@@@@@@@+:::::::::::::::::@@@@%::*@@@@@+-:=%+@@@@@@@@@@@@@@@@@@%@@@@@@@@@@@@@@::::::*
*:::::-@@@@@@@@@@@@@@@@#:::::::::::::::::%=%@@-:+@@@@@*::%*@@@@@@@@@@@@@@@@@@@@%@@@@@@@@@@@@@-:::::*
*:::::=@@@@@@@@@@@@@@@@@+::::::::::::::::*:-@*::*-*@@@@-:-=%@%@@@@@@@@@@@@@@@@@#@@@@@@@@@@@@@::::::*
*:::::-@@@@@@@@@@@@@@@@@%-:::::::::::::::*+:%@@:::-@@@@-:=+%%@%@@@@@@@@@@@*@@@@@@@@@@@@@@@@@@::::::*
*::::::%@@@@@@@@@@@@@@@@@*::::::::::::::::*=-@*::::%@@@+:+*@@@%@@@@@@@@@@@%@@%#@@@@@@@@@@@@@%::::::*
#::::::%@@@@@@@@@@@@@@@@@%-::::::::::::::::#+**:::::%@@*:-=%@*@@@@@@@@@@@#**%@@@@@@@@@@@@@@@%::::::#
@::::::*@@@@@@@@@@@@@@@@@@*::::::::::::::::-%@@@@@**@@%=::=+@@@@@%@@@@@@@@@%@@@@@@@@@@@@@@@@*::::::@
@-:::::*@@@@@@@@@@@@@@@@@@@=:::::::::::::::::#@@@@@@@@%:::+=%@@@@@@@@@@@@@@@%@@@@@@@@@@@@@@@*:::::-@
@+:::::=@@@@@@@@@@@@@@@@@@@%-:::::::::::::::::*%@@@*%@-:::-*=*%@@@@@@@@@@@@@%@@@@@@@@@@@@@@@::::::*@
@*::::::%@@@@@@@@@@@@@@@@@@@*:::::::::::::::::::=%%+#@=:::=*++@@@@@@@@@@@@@#@@@@@@@@@@@@@@@%::::::*@
@@-:::::*@@@@@@@@@@@@@@@@@@@#::::::::::::::::----:=###::::=**%%@@@@@@@@@@@+%@@@@@@@@@@@@@@@+:::::-@@
@@+:::::-%@@@@@@@@@@@@@@@@@@@-:::::::::::::=@@@@@+::::::::-*#%%%@@@@@@@@@%*%@@@@@@@@@@@@@@%::::::*@@
@@#::::::*@@@@@@@@@@@@@@@@@@@+::::::::::::::%@@@@@-:::::::+%@%%%@@@@@@@@@%@@@@@@@@@@@@@@@@+::::::%@@
@@@+::::::%@@@@@@@@@@@@@@@@@@*::::::::::::::*@@@@@-:::::::-+#@%%@%@@@@@@@%@@@@@@@@@@@@@@@%::::::=@@@
@@@%-:::::*@@@@@@@@@@@@@@@@@@*::::::::::::::#@@@@@::::::::::*@%**%@@@@@@@#@@@@@@@@@@@@@@@=::::::%@@@
@@@@*::::::%@@@@@@@@@@@@@@@@@*::::::::::::::*@@@@@#-::::::::===+-%%@@@@@@%%@@@@@@@@@@@@@*::::::*@@@@
@@@@@-:::::-%@@@@@@@@@@@@@@@@+::::::::::::::=#%@@@@*--#=::::::--:=@@%%%@@%@@@@@@@@@@@@@#::::::-@@@@@
@@@@@#-:::::-%@@@@@@@@@@@@@@@=::::::::::::::==@%@%%%#+%%*:::::::-=#%%*%@#*@@@@@@@@@@@@%:::::::#@@@@@
@@@@@@*::::::*%@@@@@@@@@@**+-:::::::::::::::@#@@%@@@%#%@*::::-::::==::%%*@@@@@@@@@@@@%=::::::*@@@@@@
@@@@@@@+::::::*@@@@@@@@*::::::::::::::::::-##%%@#@@@@%%%*-==*%:-::==+**@@@@@@@@@@@@@%=::::::=@@@@@@@
@@@@@@@@-::::::*%@@@@*::::::::::::::::::::::%%%%%@@@@@@@#%@@%%@@@@@@@@@@@@@@@@@@@@@%=::::::=@@@@@@@@
@@@@@@@@%-::::::=%@@%:::::::::::::::::::::#@%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#:::::::-@@@@@@@@@
@@@@@@@@@@-::::::-%@#::::::::::::::::::::+@@@@@%@@@@@@@@@@@%%@@@@@@@@@@@@@@@@@@@@*:::::::=@@@@@@@@@@
@@@@@@@@@@@-:::::::*@-:::::::::::::::::::-%@@@@@@@@@@@@@@@@*@@@@@@@@@@@@@@@@@@@@=:::::::=@@@@@@@@@@@
@@@@@@@@@@@@+:::::::==:::::::::::::::::::-%@@@@@@@@@@@@@@@%@@@@@@@@@@@@@@@@@@@#-:::::::+@@@@@@@@@@@@
@@@@@@@@@@@@@*-:::::::::::::::::::::::::::=@@@@@@@@@@@@@@%#@@@@@@@@@@@@@@@@@%=::::::::#@@@@@@@@@@@@@
@@@@@@@@@@@@@@#-:::::::::::::::::::::::::::*@@@@@@@@@@@@#@@@@@@@@@@@@@@@@@@+::::::::=#@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@*-::::::::::::::::::::::::::*%@@@@@@@@@@*@@@@@@@@@@@@@@@@+:::::::::*@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@%=:::::::::::::::::::::::::::*%@@@@@@@@#@@@@@@@@@@@@@*=:::::::::=%@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@#-:::::::::::::::::::::::::::*%@@@@@@@%#@@@@@@@@*+-:::::::::=#@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@#-:::::::::::::::::::::::::::*%@@@@@@@%@@@@*+:::::::::::=#@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@#=-::::::::::::::::::::::::::+%@@@@%**--::::::::::::+#@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@%*-:::::::::::::::::::::::::::::::::::::::::::::*%@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@#*-:::::::::::::::::::::::::::::::::::::::+%@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@##=-:::::::::::::::::::::::::::::::+*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@##*=-::::::::::::::::::::-+**%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%##****+++++****%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
-->

<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>

<style>
#xrvu-root {
  all: unset !important; display: block !important; position: relative !important;
  width: 100% !important; height: 840px !important; background: #08080e !important;
  border-radius: 14px !important; overflow: hidden !important;
  font-family: 'Inter Tight', system-ui, -apple-system, sans-serif !important;
  font-size: 13px !important; -webkit-font-smoothing: antialiased;
  color: rgba(255,255,255,.9) !important; line-height: 1.4 !important; box-sizing: border-box !important;
}
#xrvu-root *, #xrvu-root *::before, #xrvu-root *::after { box-sizing: border-box !important; }
#xrvu-root *:not(svg):not(svg *) { font-family: 'Inter Tight', system-ui, -apple-system, sans-serif !important; }
#xrvu-root {
  --xrvu-primary: #0010ef; --xrvu-accent: #1df2d6; --xrvu-text: rgba(255,255,255,.9);
  --xrvu-muted: rgba(255,255,255,.45); --xrvu-dim: rgba(255,255,255,.2);
  --xrvu-glass-bg: rgba(4,6,16,.82); --xrvu-glass-bd: rgba(255,255,255,.12); --xrvu-sep: rgba(255,255,255,.07);
}
#xrvu-gs-stage, #xrvu-three-stage { position: absolute; inset: 0; z-index: 1; }
#xrvu-gs-stage canvas, #xrvu-three-stage canvas { display: block; width: 100% !important; height: 100% !important; touch-action: none; }
#xrvu-bg { position: absolute; inset: 0; z-index: 0; background-size: cover; background-position: center; }
#xrvu-bg-video { position: absolute; inset: 0; z-index: 0; width: 100%; height: 100%; object-fit: cover; }
#xrvu-overlay { position: absolute; inset: 0; z-index: 30; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; transition: opacity .7s ease; pointer-events: none; }
#xrvu-overlay.fade { opacity: 0; }
#xrvu-overlay.hidden { display: none; }
#xrvu-spinner { width: 38px; height: 38px; border: 2px solid rgba(255,255,255,.05); border-top-color: var(--xrvu-primary); border-right-color: rgba(0,16,239,.2); border-radius: 50%; animation: xrvu-spin .9s linear infinite; }
@keyframes xrvu-spin { to { transform: rotate(360deg); } }
#xrvu-status { font-size: 10px; letter-spacing: .14em; text-transform: uppercase; color: var(--xrvu-dim); min-height: 14px; }
#xrvu-status.error { color: #e05555; text-transform: none; letter-spacing: 0; font-size: 11px; max-width: 280px; text-align: center; line-height: 1.5; }
#xrvu-barwrap { width: 160px; height: 1px; background: rgba(255,255,255,.07); border-radius: 1px; }
#xrvu-progressbar { height: 100%; width: 0%; background: var(--xrvu-primary); border-radius: 1px; transition: width .15s linear; }
#xrvu-brand { position: absolute; top: 14px; left: 14px; z-index: 20; display: flex; align-items: center; gap: 9px; pointer-events: none; }
#xrvu-brand-logo { max-height: 32px; display: block; }
#xrvu-brand-title { font-size: 14px; font-weight: 700; color: var(--xrvu-text); letter-spacing: .02em; }
#xrvu-bar {
  position: absolute !important; bottom: 10px !important; left: 50% !important; transform: translateX(-50%) !important;
  z-index: 25 !important; display: flex !important; gap: 4px !important; align-items: center !important;
  background: var(--xrvu-glass-bg) !important; backdrop-filter: blur(14px) saturate(1.6) !important;
  -webkit-backdrop-filter: blur(14px) saturate(1.6) !important; border: 1px solid var(--xrvu-glass-bd) !important;
  border-radius: 999px !important; padding: 5px 6px !important; box-shadow: 0 12px 32px rgba(0,0,0,.6) !important;
  margin: 0 !important; list-style: none !important; width: auto !important; max-width: none !important;
  white-space: nowrap !important; transition: opacity .35s ease !important;
}
#xrvu-bar.hud-hidden { opacity: 0 !important; pointer-events: none !important; }
#xrvu-instructions {
  position: absolute !important; bottom: 56px !important; left: 50% !important; transform: translateX(-50%) !important;
  z-index: 24 !important; display: flex !important; gap: 18px !important; pointer-events: none !important;
  transition: opacity .4s ease !important; white-space: nowrap !important;
}
#xrvu-instructions.inst-hidden { opacity: 0 !important; }
#xrvu-root button.xrvu-btn {
  all: unset !important; box-sizing: border-box !important; width: 34px !important; height: 34px !important;
  border-radius: 999px !important; display: flex !important; align-items: center !important; justify-content: center !important;
  cursor: pointer !important; color: var(--xrvu-muted) !important; background: transparent !important;
  transition: background .14s, color .14s, transform .14s !important; padding: 0 !important; margin: 0 !important;
  line-height: 1 !important; outline: none !important; -webkit-appearance: none !important; appearance: none !important; position: relative !important;
}
#xrvu-root button.xrvu-btn:hover { background: rgba(255,255,255,.08) !important; transform: translateY(-1px) !important; color: var(--xrvu-text) !important; }
#xrvu-root button.xrvu-btn.active { color: var(--xrvu-accent) !important; background: rgba(29,242,214,.08) !important; }
#xrvu-root button.xrvu-btn svg { width: 15px; height: 15px; pointer-events: none; flex-shrink: 0; }
#xrvu-btn-anim { width: auto !important; padding: 0 11px !important; gap: 7px !important; font-size: 10px !important; letter-spacing: .07em !important; }
#xrvu-btn-anim svg { width: 13px !important; height: 13px !important; }
.xrvu-sep { width: 1px; background: var(--xrvu-sep); margin: 6px 1px; align-self: stretch; }
#xrvu-anim-menu {
  position: absolute !important; bottom: calc(100% + 10px) !important; left: 50% !important; transform: translateX(-50%) !important;
  z-index: 40 !important; background: var(--xrvu-glass-bg) !important; backdrop-filter: blur(18px) saturate(1.8) !important;
  -webkit-backdrop-filter: blur(18px) saturate(1.8) !important; border: 1px solid var(--xrvu-glass-bd) !important;
  border-radius: 12px !important; padding: 6px !important; min-width: 176px !important;
  display: none !important; flex-direction: column !important; gap: 2px !important;
  box-shadow: 0 16px 40px rgba(0,0,0,.7) !important;
}
#xrvu-anim-menu.open { display: flex !important; }
.xrvu-anim-item { all: unset !important; box-sizing: border-box !important; display: flex !important; align-items: center !important; gap: 9px !important; padding: 7px 10px !important; border-radius: 8px !important; font-size: 11px !important; color: var(--xrvu-muted) !important; cursor: pointer !important; white-space: nowrap !important; transition: background .12s, color .12s !important; }
.xrvu-anim-item:hover { background: rgba(255,255,255,.07) !important; color: var(--xrvu-text) !important; }
.xrvu-anim-item.active { color: var(--xrvu-accent) !important; background: rgba(29,242,214,.07) !important; }
.xrvu-anim-item svg { width: 13px; height: 13px; flex-shrink: 0; pointer-events: none; }
#xrvu-panel-transform, #xrvu-panel-settings, #xrvu-panel-media, #xrvu-panel-triggers {
  position: absolute !important; bottom: 58px !important; left: 50% !important; transform: translateX(-50%) !important;
  z-index: 35 !important; background: var(--xrvu-glass-bg) !important; backdrop-filter: blur(18px) saturate(1.8) !important;
  -webkit-backdrop-filter: blur(18px) saturate(1.8) !important; border: 1px solid var(--xrvu-glass-bd) !important;
  border-radius: 14px !important; padding: 14px 16px !important; min-width: 260px !important;
  display: none !important; flex-direction: column !important; gap: 10px !important; box-shadow: 0 16px 40px rgba(0,0,0,.7) !important;
}
#xrvu-panel-media { min-width: 290px !important; max-height: 72vh !important; overflow-y: auto !important; }
#xrvu-panel-triggers { left: auto !important; right: 12px !important; transform: none !important; min-width: 230px !important; }
#xrvu-panel-transform.open, #xrvu-panel-settings.open, #xrvu-panel-media.open, #xrvu-panel-triggers.open { display: flex !important; }
.xrvu-panel-title { font-size: 9px !important; letter-spacing: .12em !important; text-transform: uppercase !important; color: var(--xrvu-dim) !important; margin-bottom: 2px !important; }
.xrvu-panel-subtitle { font-size: 9px !important; letter-spacing: .09em !important; text-transform: uppercase !important; color: var(--xrvu-accent) !important; }
.xrvu-row { display: flex !important; align-items: center !important; gap: 8px !important; }
.xrvu-row label { font-size: 10px !important; color: var(--xrvu-dim) !important; width: 46px !important; flex-shrink: 0 !important; text-align: right !important; }
.xrvu-row input[type=range] { all: unset !important; flex: 1 !important; height: 2px !important; background: rgba(255,255,255,.14) !important; border-radius: 2px !important; cursor: pointer !important; -webkit-appearance: none !important; appearance: none !important; accent-color: var(--xrvu-accent) !important; }
.xrvu-row input[type=range]::-webkit-slider-thumb { -webkit-appearance: none !important; width: 12px !important; height: 12px !important; border-radius: 50% !important; background: var(--xrvu-accent) !important; cursor: pointer !important; }
.xrvu-row input[type=range]::-moz-range-thumb { width: 12px !important; height: 12px !important; border-radius: 50% !important; background: var(--xrvu-accent) !important; border: none !important; cursor: pointer !important; }
.xrvu-val { font-size: 9px !important; color: var(--xrvu-muted) !important; width: 32px !important; text-align: right !important; flex-shrink: 0 !important; font-variant-numeric: tabular-nums !important; }
#xrvu-lock-btn { all: unset !important; box-sizing: border-box !important; width: 22px !important; height: 22px !important; border-radius: 5px !important; border: 1px solid var(--xrvu-glass-bd) !important; display: flex !important; align-items: center !important; justify-content: center !important; cursor: pointer !important; color: var(--xrvu-dim) !important; flex-shrink: 0 !important; transition: color .14s, border-color .14s !important; }
#xrvu-lock-btn svg { width: 11px; height: 11px; }
#xrvu-lock-btn.locked { color: var(--xrvu-accent) !important; border-color: var(--xrvu-accent) !important; }
.xrvu-panel-sep { height: 1px; background: var(--xrvu-sep); margin: 2px 0; }
.xrvu-num-input { all: unset !important; box-sizing: border-box !important; width: 58px !important; padding: 2px 5px !important; background: rgba(255,255,255,.06) !important; border: 1px solid rgba(255,255,255,.1) !important; border-radius: 5px !important; font-size: 9px !important; color: var(--xrvu-text) !important; text-align: right !important; cursor: text !important; font-variant-numeric: tabular-nums !important; -moz-appearance: textfield !important; }
.xrvu-num-input::-webkit-inner-spin-button, .xrvu-num-input::-webkit-outer-spin-button { -webkit-appearance: none !important; }
.xrvu-num-input:focus { border-color: var(--xrvu-accent) !important; outline: none !important; }
.xrvu-url-input { all: unset !important; box-sizing: border-box !important; width: 100% !important; padding: 6px 10px !important; background: rgba(255,255,255,.06) !important; border: 1px solid rgba(255,255,255,.1) !important; border-radius: 8px !important; font-size: 10px !important; color: var(--xrvu-text) !important; cursor: text !important; }
.xrvu-url-input::placeholder { color: var(--xrvu-dim) !important; }
.xrvu-url-input:focus { border-color: var(--xrvu-accent) !important; outline: none !important; }
.xrvu-proj-row { display: flex !important; gap: 4px !important; flex-wrap: wrap !important; }
.xrvu-proj-btn { all: unset !important; box-sizing: border-box !important; padding: 3px 9px !important; border-radius: 6px !important; border: 1px solid var(--xrvu-glass-bd) !important; font-size: 9px !important; color: var(--xrvu-muted) !important; cursor: pointer !important; transition: all .12s !important; }
.xrvu-proj-btn:hover { background: rgba(255,255,255,.07) !important; color: var(--xrvu-text) !important; }
.xrvu-proj-btn.active { background: rgba(29,242,214,.1) !important; color: var(--xrvu-accent) !important; border-color: var(--xrvu-accent) !important; }
.xrvu-apply-btn { all: unset !important; box-sizing: border-box !important; width: 100% !important; padding: 6px 0 !important; border-radius: 8px !important; background: rgba(0,16,239,.25) !important; border: 1px solid rgba(0,16,239,.5) !important; font-size: 10px !important; color: var(--xrvu-text) !important; cursor: pointer !important; text-align: center !important; transition: background .12s !important; }
.xrvu-apply-btn:hover { background: rgba(0,16,239,.42) !important; }
.xrvu-apply-btn.danger { background: rgba(180,20,20,.25) !important; border-color: rgba(180,20,20,.5) !important; }
.xrvu-apply-btn.danger:hover { background: rgba(180,20,20,.45) !important; }
#xrvu-btn-sound { position: absolute !important; bottom: 14px !important; left: 14px !important; z-index: 25 !important; all: unset !important; box-sizing: border-box !important; width: 34px !important; height: 34px !important; border-radius: 999px !important; display: flex !important; align-items: center !important; justify-content: center !important; cursor: pointer !important; color: var(--xrvu-muted) !important; background: var(--xrvu-glass-bg) !important; backdrop-filter: blur(14px) !important; -webkit-backdrop-filter: blur(14px) !important; border: 1px solid var(--xrvu-glass-bd) !important; box-shadow: 0 8px 24px rgba(0,0,0,.5) !important; transition: color .14s, transform .14s !important; }
#xrvu-btn-sound svg { width: 15px; height: 15px; pointer-events: none; }
#xrvu-btn-sound:hover { transform: translateY(-1px) !important; color: var(--xrvu-text) !important; }
#xrvu-btn-sound.active { color: var(--xrvu-accent) !important; }
#xrvu-qr-panel { position: absolute; bottom: 58px; right: 12px; z-index: 26; background: var(--xrvu-glass-bg); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); border: 1px solid var(--xrvu-glass-bd); border-radius: 14px; padding: 10px; text-align: center; display: none; }
#xrvu-qr-panel p { font-size: 9px; color: var(--xrvu-muted); letter-spacing: .08em; text-transform: uppercase; margin-top: 6px; }
#xrvu-reticule { position: absolute; inset: 0; z-index: 15; display: none; align-items: center; justify-content: center; pointer-events: none; }
#xrvu-reticule svg { width: 80px; height: 80px; opacity: .7; }
.xrvu-toast { position: absolute; bottom: 70px; left: 50%; transform: translateX(-50%); z-index: 40; background: var(--xrvu-glass-bg); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border: 1px solid var(--xrvu-glass-bd); border-radius: 12px; padding: 9px 18px; font-size: 11px; color: var(--xrvu-muted); pointer-events: none; white-space: nowrap; animation: xrvu-fadeup .25s ease; }
@keyframes xrvu-fadeup { from { opacity:0; transform: translateX(-50%) translateY(8px); } }
.xrvu-hint { font-size: 9px; color: rgba(255,255,255,.25); letter-spacing: .06em; text-transform: uppercase; }
.xrvu-hint b { font-weight: 500; color: rgba(255,255,255,.4); background: rgba(255,255,255,.07); border: 1px solid rgba(255,255,255,.1); border-radius: 3px; padding: 1px 5px; margin-right: 3px; }
#xrvu-gps-coords { font-size: 9px !important; color: var(--xrvu-dim) !important; font-variant-numeric: tabular-nums !important; }
@media (max-width: 480px) {
  #xrvu-root { height: 380px !important; }
  #xrvu-bar { padding: 4px !important; gap: 2px !important; }
  #xrvu-root button.xrvu-btn { width: 30px !important; height: 30px !important; }
  #xrvu-btn-anim { padding: 0 8px !important; font-size: 9px !important; }
  #xrvu-panel-transform, #xrvu-panel-settings, #xrvu-panel-media { min-width: 200px !important; padding: 10px 12px !important; }
}
</style>

<div id="xrvu-root">
  <video id="xrvu-bg-video" autoplay muted loop playsinline style="display:none;"></video>
  <div id="xrvu-bg"></div>
  <div id="xrvu-gs-stage"    style="display:none;"></div>
  <div id="xrvu-three-stage" style="display:none;"></div>

  <div id="xrvu-overlay">
    <div id="xrvu-spinner"></div>
    <div id="xrvu-status">Initialisation</div>
    <div id="xrvu-barwrap"><div id="xrvu-progressbar"></div></div>
  </div>

  <div id="xrvu-brand" style="display:none;">
    <img decoding="async" id="xrvu-brand-logo" src="" alt="logo"/>
    <span id="xrvu-brand-title"></span>
  </div>

  <div id="xrvu-reticule">
    <svg viewBox="0 0 80 80" fill="none">
      <circle cx="40" cy="40" r="30" stroke="#1df2d6" stroke-width="1.5" stroke-dasharray="4 4"/>
      <line x1="40" y1="10" x2="40" y2="20" stroke="#1df2d6" stroke-width="1.5"/>
      <line x1="40" y1="60" x2="40" y2="70" stroke="#1df2d6" stroke-width="1.5"/>
      <line x1="10" y1="40" x2="20" y2="40" stroke="#1df2d6" stroke-width="1.5"/>
      <line x1="60" y1="40" x2="70" y2="40" stroke="#1df2d6" stroke-width="1.5"/>
    </svg>
  </div>

  <div id="xrvu-qr-panel"><div id="xrvu-qr-code"></div><p>Ouvrir en AR / VR</p></div>

  <button id="xrvu-btn-sound" style="display:none;" title="Son">
    <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><polygon points="7,3 3.5,6 2,6 2,10 3.5,10 7,13" fill="currentColor" stroke="currentColor" stroke-width="1"/><path d="M10 5.5a3.5 3.5 0 0 1 0 5M12 3a6.5 6.5 0 0 1 0 10"/></svg>
  </button>

  <div id="xrvu-panel-transform">
    <div class="xrvu-panel-title">Position &amp; Echelle</div>
    <div class="xrvu-row"><label>X</label><input type="range" id="xrvu-tx" min="-5" max="5" step="0.01" value="0"><span class="xrvu-val" id="xrvu-tx-val">0.00</span></div>
    <div class="xrvu-row"><label>Y</label><input type="range" id="xrvu-ty" min="-5" max="5" step="0.01" value="0"><span class="xrvu-val" id="xrvu-ty-val">0.00</span></div>
    <div class="xrvu-row"><label>Z</label><input type="range" id="xrvu-tz" min="-5" max="5" step="0.01" value="0"><span class="xrvu-val" id="xrvu-tz-val">0.00</span></div>
    <div class="xrvu-panel-sep"></div>
    <div class="xrvu-row">
      <label>Echelle</label>
      <input type="range" id="xrvu-tscale" min="-2" max="3" step="0.001" value="0">
      <input type="number" id="xrvu-tscale-num" class="xrvu-num-input" value="1.000" step="0.001">
      <button id="xrvu-lock-btn" class="locked" title="Proportions">
        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="7" width="10" height="8" rx="1.5"/><path d="M5 7V5.5a3 3 0 0 1 6 0V7"/></svg>
      </button>
    </div>
  </div>

  <div id="xrvu-panel-settings">
    <div class="xrvu-panel-title">Reglages</div>
    <div class="xrvu-row"><label>Ambiant</label><input type="range" id="xrvu-s-ambient" min="0" max="2" step="0.01" value="0.7"><span class="xrvu-val" id="xrvu-s-ambient-val">0.70</span></div>
    <div class="xrvu-row"><label>Lumiere</label><input type="range" id="xrvu-s-light" min="0" max="3" step="0.01" value="0.9"><span class="xrvu-val" id="xrvu-s-light-val">0.90</span></div>
    <div class="xrvu-panel-sep"></div>
    <div class="xrvu-row"><label>Opacite</label><input type="range" id="xrvu-s-opacity" min="0" max="1" step="0.01" value="1"><span class="xrvu-val" id="xrvu-s-opacity-val">1.00</span></div>
    <div class="xrvu-row"><label>Brillance</label><input type="range" id="xrvu-s-roughness" min="0" max="1" step="0.01" value="0.4"><span class="xrvu-val" id="xrvu-s-roughness-val">0.40</span></div>
    <div class="xrvu-row"><label>Reflexion</label><input type="range" id="xrvu-s-metalness" min="0" max="1" step="0.01" value="0.2"><span class="xrvu-val" id="xrvu-s-metalness-val">0.20</span></div>
  </div>

  <div id="xrvu-panel-media">
    <div class="xrvu-panel-title">Medias</div>
    <div class="xrvu-panel-subtitle">Video</div>
    <input type="text" id="xrvu-mv-url" class="xrvu-url-input" placeholder="URL video (mp4, webm...)">
    <div class="xrvu-proj-row" id="xrvu-mv-proj">
      <button class="xrvu-proj-btn active" data-proj="flat">Plat</button>
      <button class="xrvu-proj-btn" data-proj="sphere">360 Sphere</button>
      <button class="xrvu-proj-btn" data-proj="cylinder">Cylindrique</button>
      <button class="xrvu-proj-btn" data-proj="cube">Cubique</button>
    </div>
    <div class="xrvu-row"><label>Taille</label><input type="range" id="xrvu-mv-size" min="0.5" max="20" step="0.1" value="3"><span class="xrvu-val" id="xrvu-mv-size-val">3.0</span></div>
    <button class="xrvu-apply-btn" id="xrvu-mv-apply">Charger video</button>
    <button class="xrvu-apply-btn danger" id="xrvu-mv-remove" style="display:none;">Retirer video</button>
    <div class="xrvu-panel-sep"></div>
    <div class="xrvu-panel-subtitle">Image</div>
    <input type="text" id="xrvu-mi-url" class="xrvu-url-input" placeholder="URL image (jpg, png, webp...)">
    <div class="xrvu-proj-row" id="xrvu-mi-proj">
      <button class="xrvu-proj-btn active" data-proj="flat">Plat</button>
      <button class="xrvu-proj-btn" data-proj="sphere">360 Sphere</button>
      <button class="xrvu-proj-btn" data-proj="cylinder">Cylindrique</button>
      <button class="xrvu-proj-btn" data-proj="cube">Cubique</button>
    </div>
    <div class="xrvu-row"><label>Taille</label><input type="range" id="xrvu-mi-size" min="0.5" max="20" step="0.1" value="3"><span class="xrvu-val" id="xrvu-mi-size-val">3.0</span></div>
    <button class="xrvu-apply-btn" id="xrvu-mi-apply">Charger image</button>
    <button class="xrvu-apply-btn danger" id="xrvu-mi-remove" style="display:none;">Retirer image</button>
    <div class="xrvu-panel-sep"></div>
    <div class="xrvu-panel-subtitle">Audio</div>
    <input type="text" id="xrvu-ma-url" class="xrvu-url-input" placeholder="URL audio (mp3, ogg...)">
    <div class="xrvu-row"><label>Volume</label><input type="range" id="xrvu-ma-vol" min="0" max="1" step="0.01" value="0.8"><span class="xrvu-val" id="xrvu-ma-vol-val">0.80</span></div>
    <button class="xrvu-apply-btn" id="xrvu-ma-apply">Charger audio</button>
  </div>

  <div id="xrvu-panel-triggers">
    <div class="xrvu-panel-title">Triggers XR</div>
    <div class="xrvu-panel-subtitle">GPS</div>
    <div id="xrvu-gps-coords">---, ---</div>
    <div class="xrvu-row"><label style="width:auto;">Rayon m</label><input type="range" id="xrvu-gps-radius" min="5" max="500" step="5" value="50"><span class="xrvu-val" id="xrvu-gps-radius-val">50</span></div>
    <input type="text" id="xrvu-gps-zone-url" class="xrvu-url-input" placeholder="URL contenu a declencher">
    <input type="text" id="xrvu-gps-zone-name" class="xrvu-url-input" style="margin-top:4px;" placeholder="Nom de la zone GPS">
    <div style="display:flex;gap:4px;">
      <button class="xrvu-apply-btn" id="xrvu-gps-watch" style="font-size:9px;">Activer GPS</button>
      <button class="xrvu-apply-btn" id="xrvu-gps-add" style="font-size:9px;">+ Zone ici</button>
    </div>
    <div id="xrvu-gps-zones-list" style="font-size:9px;color:var(--xrvu-dim);max-height:80px;overflow-y:auto;"></div>
    <div class="xrvu-panel-sep"></div>
    <div class="xrvu-panel-subtitle">Image Tracker</div>
    <input type="text" id="xrvu-it-url" class="xrvu-url-input" placeholder="URL image cible (jpg/png)">
    <div class="xrvu-row"><label style="width:auto;">Largeur m</label><input type="range" id="xrvu-it-width" min="0.05" max="1" step="0.01" value="0.2"><span class="xrvu-val" id="xrvu-it-width-val">0.20</span></div>
    <input type="text" id="xrvu-it-content" class="xrvu-url-input" style="margin-top:4px;" placeholder="URL contenu (modele, video...)">
    <button class="xrvu-apply-btn" id="xrvu-it-add">Ajouter tracker</button>
    <div id="xrvu-it-list" style="font-size:9px;color:var(--xrvu-dim);"></div>
  </div>

  <div id="xrvu-bar">
    <div style="position:relative;">
      <button class="xrvu-btn" id="xrvu-btn-anim" title="Animations">
        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><rect x="1.5" y="3.5" width="13" height="9" rx="1.5"/><path d="M5 3.5v9M11 3.5v9M1.5 6.5h3M11.5 6.5h3M1.5 9.5h3M11.5 9.5h3"/></svg>
        &nbsp;Anim
      </button>
      <div id="xrvu-anim-menu">
        <button class="xrvu-anim-item" data-mode="none"><svg viewBox="0 0 16 16" fill="currentColor"><rect x="4" y="4" width="8" height="8" rx="1.5"/></svg>Aucune</button>
        <button class="xrvu-anim-item" data-mode="turntable"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2.5a5.5 5.5 0 1 0 5.5 5.5"/><path d="M11 1l3 1.5-1.5 3"/></svg>Tourniquet</button>
        <button class="xrvu-anim-item" data-mode="floating"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><path d="M.5 8c1.5-3.5 3-3.5 4.5 0s3 3.5 4.5 0 3-3.5 4.5 0"/></svg>Flottement</button>
        <button class="xrvu-anim-item" data-mode="breathing"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2.5v5M5 7.5C3.5 7.5 2 8.5 2 10.5s1 3 2.5 3S6.5 12.5 6.5 10.5V7.5M11 7.5c1.5 0 3 1 3 3s-1 3-2.5 3S9.5 12.5 9.5 10.5V7.5"/></svg>Respiration</button>
        <button class="xrvu-anim-item" data-mode="expandcontract"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M1 6V1h5M15 6V1h-5M1 10v5h5M15 10v5h-5"/></svg>Expansion</button>
        <button class="xrvu-anim-item" data-mode="followmouse"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="1.5" width="6" height="10" rx="3"/><line x1="8" y1="1.5" x2="8" y2="5.5"/></svg>Suivre souris</button>
        <button class="xrvu-anim-item" data-mode="smoothorbit"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><circle cx="8" cy="8" r="6.5"/><circle cx="8" cy="8" r="2" fill="currentColor" stroke="none"/></svg>Orbite douce</button>
        <button class="xrvu-anim-item" data-mode="rotateonscroll"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M14 8A6 6 0 1 1 11 3"/><path d="M14 3v5h-5"/></svg>Scroll</button>
        <button class="xrvu-anim-item" data-mode="pulsezoom"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><circle cx="6.5" cy="6.5" r="5"/><path d="M6.5 4.5v4M4.5 6.5h4M13 13l-3.5-3.5"/></svg>Zoom pulse</button>
        <button class="xrvu-anim-item" data-mode="clickfade"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><path d="M2 2l12 12M6.5 6.7A3 3 0 0 0 9.3 9.5"/><path d="M1 8s2.5-4.5 7-4.5c.9 0 1.7.1 2.5.4"/><path d="M13.5 9.8C12.1 11.5 10.1 12.5 8 12.5c-4.5 0-7-4.5-7-4.5"/></svg>Apparition</button>
      </div>
    </div>
    <div class="xrvu-sep"></div>
    <button class="xrvu-btn" id="xrvu-btn-transform" title="Position / Echelle">
      <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><line x1="1" y1="4" x2="15" y2="4"/><line x1="1" y1="8" x2="15" y2="8"/><line x1="1" y1="12" x2="15" y2="12"/><circle cx="5" cy="4" r="1.8" fill="currentColor" stroke="none"/><circle cx="10" cy="8" r="1.8" fill="currentColor" stroke="none"/><circle cx="6" cy="12" r="1.8" fill="currentColor" stroke="none"/></svg>
    </button>
    <button class="xrvu-btn" id="xrvu-btn-settings" title="Reglages">
      <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><circle cx="8" cy="8" r="2.5"/><path d="M8 1.5v2M8 12.5v2M1.5 8h2M12.5 8h2M3.6 3.6l1.4 1.4M11 11l1.4 1.4M3.6 12.4l1.4-1.4M11 5l1.4-1.4"/></svg>
    </button>
    <button class="xrvu-btn" id="xrvu-btn-media" title="Medias — video, image, audio">
      <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><rect x="1" y="3" width="14" height="10" rx="1.5"/><path d="M6.5 6l4 2.5-4 2.5V6z" fill="currentColor" stroke="none"/></svg>
    </button>
    <div class="xrvu-sep"></div>
    <button class="xrvu-btn" id="xrvu-btn-ar" title="Realite Augmentee" style="display:none;">
      <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="1" width="8" height="14" rx="2"/><line x1="6" y1="3.5" x2="10" y2="3.5"/><circle cx="8" cy="12.5" r=".8" fill="currentColor" stroke="none"/></svg>
    </button>
    <button class="xrvu-btn" id="xrvu-btn-vr" title="Mode VR" style="display:none;">
      <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><rect x="1" y="5" width="14" height="7" rx="2"/><circle cx="5.5" cy="8.5" r="1.8"/><circle cx="10.5" cy="8.5" r="1.8"/><line x1="7.3" y1="8.5" x2="8.7" y2="8.5"/></svg>
    </button>
    <button class="xrvu-btn" id="xrvu-btn-triggers" title="Triggers GPS / Image tracker">
      <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="7" r="3"/><path d="M8 1v2M8 12v3M1 7h2M12 7h3M8 10v2"/></svg>
    </button>
    <button class="xrvu-btn" id="xrvu-btn-qr" title="QR Code">
      <svg viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" d="M1 1h6v6H1V1zm1.5 1.5v3h3v-3h-3z"/><path fill-rule="evenodd" d="M9 1h6v6H9V1zm1.5 1.5v3h3v-3h-3z"/><path fill-rule="evenodd" d="M1 9h6v6H1V9zm1.5 1.5v3h3v-3h-3z"/><rect x="9" y="9" width="2" height="2"/><rect x="13" y="9" width="2" height="2"/><rect x="9" y="13" width="6" height="2"/><rect x="11" y="11" width="2" height="2"/></svg>
    </button>
    <button class="xrvu-btn" id="xrvu-btn-fs" title="Plein ecran">
      <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 6V2h4M15 6V2h-4M1 10v4h4M15 10v4h-4"/></svg>
    </button>
  </div>

  <div id="xrvu-instructions">
    <span class="xrvu-hint"><b>Glisser</b>Rotation</span>
    <span class="xrvu-hint"><b>Scroll</b>Zoom</span>
    <span class="xrvu-hint"><b>Shift+drag</b>Pan</span>
    <span class="xrvu-hint"><b>Dbl-clic</b>Reset</span>
  </div>
</div>

<script type="importmap">
{
  "imports": {
    "three":         "https://cdn.jsdelivr.net/npm/three@0.167.0/build/three.module.js",
    "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.167.0/examples/jsm/",
    "gsplat":        "https://cdn.jsdelivr.net/npm/gsplat@1.2.9/dist/index.es.js"
  }
}
</script>

<script type="module">
import * as THREE                   from "three";
import { OrbitControls }            from "three/addons/controls/OrbitControls.js";
import { GLTFLoader }               from "three/addons/loaders/GLTFLoader.js";
import { OBJLoader }                from "three/addons/loaders/OBJLoader.js";
import { FBXLoader }                from "three/addons/loaders/FBXLoader.js";
import { STLLoader }                from "three/addons/loaders/STLLoader.js";
import { XRButton }                 from "three/addons/webxr/XRButton.js";
import { XRControllerModelFactory } from "three/addons/webxr/XRControllerModelFactory.js";
import { OculusHandModel }          from "three/addons/webxr/OculusHandModel.js";

const ICONS = {
  expand:    `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 6V2h4M15 6V2h-4M1 10v4h4M15 10v4h-4"/></svg>`,
  compress:  `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 1v4H1M11 1v4h4M5 15v-4H1M11 15v-4h4"/></svg>`,
  volumeOn:  `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><polygon points="7,3 3.5,6 2,6 2,10 3.5,10 7,13" fill="currentColor" stroke="currentColor" stroke-width="1"/><path d="M10 5.5a3.5 3.5 0 0 1 0 5M12 3a6.5 6.5 0 0 1 0 10"/></svg>`,
  volumeOff: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><polygon points="7,3 3.5,6 2,6 2,10 3.5,10 7,13" fill="currentColor" stroke="currentColor" stroke-width="1"/><path d="M11 7l3 3M14 7l-3 3"/></svg>`,
  lock:      `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="7" width="10" height="8" rx="1.5"/><path d="M5 7V5.5a3 3 0 0 1 6 0V7"/></svg>`,
  lockOpen:  `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="7" width="10" height="8" rx="1.5"/><path d="M5 7V5.5a3 3 0 0 1 6 0"/></svg>`,
};

/* ═══════════════════════════════════════════════
   CONFIG — modifier uniquement cette section
   ═══════════════════════════════════════════════ */
const CONFIG = {
  model:      { url: 'https://presentcomposedesign.fr/wp-content/uploads/2026/04/LABYX1PresentComposedesign.glb', format: 'auto' },
  background: { color: '#08080e', opacity: 1.0, image: null, video: null },
  overlay:    { logo: null, title: null, titleColor: '#ffffff' },
  commands:   { display: true },
  animation:  { mode: 'followmouse', speed: 0.4 },
  transform:  { x: 0, y: 0, z: 0, scale: 1.0 },
  lights:     { ambient: 0.7, directional: 0.9 },
  material:   { opacity: 1.0, roughness: 0.4, metalness: 0.2 },
  media:      { sound: null, soundAutoplay: false, soundLoop: true, image: null },
  xr:         { ar: true, vr: true, gyroscope: true, hitTest: true },
  editorMode: true,
  hud:        { autoHide: true, autoHideDelay: 1500 },
  vr:         { handInteractions: true, teleport: true },
  triggers:   { gps: { enabled: false, radius: 50, zones: [] }, imageTracker: { enabled: false, targets: [] } },
};

/* ─── Helpers ─── */
const $ = id => document.getElementById(id);
const lbl = t   => { $('xrvu-status').className=''; $('xrvu-status').textContent=t; };
const prg = p   => { $('xrvu-progressbar').style.width=Math.min(100,Math.round(p*100))+'%'; };
const err = msg => { $('xrvu-spinner').style.cssText='animation:none;border-top-color:rgba(224,85,85,.4)'; $('xrvu-status').className='error'; $('xrvu-status').textContent=msg; console.error('[XRVU]',msg); };
function toast(msg,ms=2400){ const t=document.createElement('div'); t.className='xrvu-toast'; t.textContent=msg; $('xrvu-root').appendChild(t); setTimeout(()=>{ t.style.opacity='0'; t.style.transition='opacity .35s'; setTimeout(()=>t.remove(),380); },ms); }
function overlayFade(){ const o=$('xrvu-overlay'); o.classList.add('fade'); setTimeout(()=>o.classList.add('hidden'),800); }

/* ─── HUD auto-hide ─── */
let _firstInteraction=false, _hudTimer=null;
function onAnyInteraction(){
  if(!_firstInteraction){
    _firstInteraction=true;
    const inst=$('xrvu-instructions');
    if(inst){ inst.classList.add('inst-hidden'); setTimeout(()=>inst.style.display='none',450); }
  }
  if(!CONFIG.hud.autoHide) return;
  const bar=$('xrvu-bar');
  bar.classList.remove('hud-hidden');
  clearTimeout(_hudTimer);
  _hudTimer=setTimeout(()=>bar.classList.add('hud-hidden'), CONFIG.hud.autoHideDelay);
}
['mousemove','touchstart','click','keydown'].forEach(ev=>
  $('xrvu-root').addEventListener(ev, onAnyInteraction, {passive:true})
);

/* ═══════════════════════════════════════════════
   ENGINE Three.js
   ═══════════════════════════════════════════════ */
let threeRenderer, threeScene, threeCamera, threeControls, threeModelGroup=null;
let ambientLight, keyLight, fillLight;
let xrHitTestSource=null, xrRefSpace=null, xrSession=null, hand1, hand2;

function initThree(){
  const container=$('xrvu-three-stage');
  const wasHidden=container.style.display==='none';
  if(wasHidden) container.style.visibility='hidden';
  container.style.display='block';
  const W=container.clientWidth||$('xrvu-root').clientWidth||800;
  const H=container.clientHeight||$('xrvu-root').clientHeight||520;
  if(wasHidden){ container.style.display='none'; container.style.visibility=''; }
  threeScene=new THREE.Scene(); applyBackground();
  threeCamera=new THREE.PerspectiveCamera(55,W/H,0.01,2000); threeCamera.position.set(0,0.5,3);
  threeRenderer=new THREE.WebGLRenderer({antialias:true,alpha:true,premultipliedAlpha:false});
  threeRenderer.setSize(W,H); threeRenderer.setPixelRatio(Math.min(2,devicePixelRatio));
  threeRenderer.xr.enabled=true; threeRenderer.shadowMap.enabled=true; threeRenderer.shadowMap.type=THREE.PCFSoftShadowMap;
  container.appendChild(threeRenderer.domElement);
  ambientLight=new THREE.AmbientLight(0xffffff,CONFIG.lights.ambient); threeScene.add(ambientLight);
  keyLight=new THREE.DirectionalLight(0xffffff,CONFIG.lights.directional); keyLight.position.set(5,10,7); keyLight.castShadow=true; threeScene.add(keyLight);
  fillLight=new THREE.DirectionalLight(0x8899ff,0.3); fillLight.position.set(-5,5,-5); threeScene.add(fillLight);
  threeControls=new OrbitControls(threeCamera,threeRenderer.domElement);
  threeControls.enableDamping=true; threeControls.dampingFactor=0.06;
  threeControls.addEventListener('start', onAnyInteraction);
  threeControls.addEventListener('start', onUserInteract);
  setupXRButtons();
  const cmf=new XRControllerModelFactory();
  [0,1].forEach(i=>{ const g=threeRenderer.xr.getControllerGrip(i); g.add(cmf.createControllerModel(g)); threeScene.add(g); });
  hand1=threeRenderer.xr.getHand(0); hand1.add(new OculusHandModel(hand1)); threeScene.add(hand1);
  hand2=threeRenderer.xr.getHand(1); hand2.add(new OculusHandModel(hand2)); threeScene.add(hand2);
  setupVRInteractions();
  new ResizeObserver(()=>{ const w=container.clientWidth,h=container.clientHeight; threeCamera.aspect=w/h; threeCamera.updateProjectionMatrix(); threeRenderer.setSize(w,h); }).observe(container);
  threeRenderer.setAnimationLoop(threeLoop);
}

function applyBackground(){
  if(!threeScene) return;
  const c=CONFIG.background;
  if(c.color&&c.opacity>0){ const col=new THREE.Color(c.color); threeRenderer?.setClearColor(col,c.opacity); threeScene.background=c.opacity===1?col:null; }
  else { threeRenderer?.setClearColor(0x000000,0); threeScene.background=null; }
  if(c.image){ $('xrvu-bg').style.backgroundImage=`url(${c.image})`; $('xrvu-bg').style.opacity=String(1-(c.opacity||0)); }
  if(c.video){ const v=$('xrvu-bg-video'); v.src=c.video; v.style.display='block'; v.style.opacity=String(1-(c.opacity||0)); }
}

/* ═══════════════════════════════════════════════
   ENGINE Gaussian Splats
   ═══════════════════════════════════════════════ */
let GSRenderer=null,GSScene=null,GSCamera=null,GSOrbitControls=null,GSLoader=null,_gsplat_ready=false;
async function loadGsplatLib(){
  if(_gsplat_ready) return true;
  try {
    const gs=await import("gsplat");
    GSRenderer=gs.WebGLRenderer||gs.Renderer||gs.default?.WebGLRenderer||gs.default?.Renderer;
    GSScene=gs.Scene||gs.default?.Scene; GSCamera=gs.Camera||gs.default?.Camera;
    GSOrbitControls=gs.OrbitControls||gs.Controls||gs.default?.OrbitControls; GSLoader=gs.Loader||gs.default?.Loader;
    if(!GSRenderer||!GSLoader) throw new Error('exports manquants');
    _gsplat_ready=true; return true;
  } catch(e){ console.warn('[XRVU] gsplat:',e.message); return false; }
}

let gsRenderer,gsScene,gsCamera,gsControls;
let gsSceneRadius=1.0,gsCamInitialized=false,gsAnim_angle=0;
const GS_MS=1000/30; let gsLastFrameTime=0;
const GS_CLK={prev:0};

async function initGS(){
  const ok=await loadGsplatLib(); if(!ok) return;
  const container=$('xrvu-gs-stage');
  try {
    const gsCanvas=document.createElement('canvas');
    gsCanvas.style.cssText='width:100%;height:100%;display:block;touch-action:none;';
    container.appendChild(gsCanvas);
    gsRenderer=new GSRenderer(gsCanvas); gsScene=new GSScene();
    gsCamera=GSCamera?new GSCamera():null;
    gsControls=(GSOrbitControls&&gsCamera)?new GSOrbitControls(gsCamera,gsCanvas):null;
    if(gsRenderer.setPixelRatio) gsRenderer.setPixelRatio(Math.min(1.0,devicePixelRatio));
    else if(gsRenderer.renderer?.setPixelRatio) gsRenderer.renderer.setPixelRatio(Math.min(1.0,devicePixelRatio));
  } catch(e){ console.warn('[XRVU] gsplat init:',e.message); gsScene=null; return; }
  new ResizeObserver(()=>{ if(gsRenderer?.setSize){ const w=container.clientWidth||800,h=container.clientHeight||520; gsRenderer.setSize(w,h); } }).observe(container);
  gsLoop();
}

function gsLoop(ts=0){
  requestAnimationFrame(gsLoop);
  if(!gsRenderer||!gsScene) return;
  if(ts-gsLastFrameTime<GS_MS) return;
  gsLastFrameTime=ts;
  const dt=Math.min((ts-GS_CLK.prev)/1000,0.1); GS_CLK.prev=ts;
  const wasAnimated=applyGSAnimation(dt,ts/1000);
  if(!wasAnimated) gsControls?.update();
  gsRenderer.render(gsScene,gsCamera);
}

function initGSCamera(){
  if(!gsCamera||gsCamInitialized) return;
  gsCamera.position.set(0,gsSceneRadius*0.3,gsSceneRadius*3.0);
  if(gsCamera.lookAt) gsCamera.lookAt(0,0,0);
  if(gsControls?.target?.set) gsControls.target.set(0,0,0);
  if(gsControls?.update) gsControls.update();
  gsCamInitialized=true;
}

/* ═══════════════════════════════════════════════
   LOADERS
   ═══════════════════════════════════════════════ */
let activeEngine=null;
function detectFormat(url,hint){
  if(hint&&hint!=='auto') return hint;
  const low=(url||'').toLowerCase().split('?')[0];
  if(low.endsWith('.spz'))                        return 'spz';
  if(low.endsWith('.ply'))                        return 'ply';
  if(low.endsWith('.splat'))                      return 'splat';
  if(low.endsWith('.glb')||low.endsWith('.gltf')) return 'gltf';
  if(low.endsWith('.obj'))                        return 'obj';
  if(low.endsWith('.fbx'))                        return 'fbx';
  if(low.endsWith('.stl'))                        return 'stl';
  return 'iframe';
}
async function loadModel(url,hint){
  if(!url){ const p=new URLSearchParams(location.search); if(p.has('model')) url=p.get('model'); }
  const fmt=detectFormat(url,hint);
  console.log('[XRVU] Format:',fmt,url); lbl('Chargement...'); prg(0.02);
  try {
    if(fmt==='spz')                     await loadGS_SPZ(url);
    else if(fmt==='ply'||fmt==='splat') await loadGS_Direct(url,fmt);
    else if(fmt==='gltf')               await loadThreeMesh_GLTF(url);
    else if(fmt==='obj')                await loadThreeMesh_OBJ(url);
    else if(fmt==='fbx')                await loadThreeMesh_FBX(url);
    else if(fmt==='stl')                await loadThreeMesh_STL(url);
    else                                loadIframe(url);
  } catch(e){ err(e.message||String(e)); }
}
function showEngine(type){ $('xrvu-gs-stage').style.display=type==='gs'?'block':'none'; $('xrvu-three-stage').style.display=type==='three'?'block':'none'; activeEngine=type; }

/* ─── SPZ decoder ─── */
async function loadGS_SPZ(url){
  if(!gsScene){ await initGS(); } if(!gsScene) throw new Error('gsplat indisponible');
  lbl('Telechargement SPZ...'); prg(0.05);
  const resp=await fetch(url,{mode:'cors'}); if(!resp.ok) throw new Error(`HTTP ${resp.status}`);
  const compressed=await resp.arrayBuffer(); prg(0.15);
  lbl('Decompression...'); prg(0.2);
  if(!window.DecompressionStream) throw new Error('DecompressionStream absent');
  const ds=new DecompressionStream('gzip'); const chunks=[];
  const readTask=(async()=>{ const rd=ds.readable.getReader(); for(;;){ const{done,value}=await rd.read(); if(done) break; chunks.push(value); } })();
  const wr=ds.writable.getWriter(); await wr.write(new Uint8Array(compressed)); await wr.close(); await readTask;
  const rawLen=chunks.reduce((s,c)=>s+c.length,0); const raw=new Uint8Array(rawLen);
  let off=0; for(const c of chunks){ raw.set(c,off); off+=c.length; }
  prg(0.35);
  const dv=new DataView(raw.buffer);
  const magic=dv.getUint32(0,true); if(magic!==0x5053474e) throw new Error('Magic SPZ invalide');
  const version=dv.getUint32(4,true),N=dv.getUint32(8,true);
  const shDeg=raw[12],fracBits=raw[13];
  const shDims=[0,3,8,15,24][Math.min(shDeg,4)];
  const posScale=1.0/(1<<fracBits);
  console.log('[XRVU] SPZ v'+version+' — '+N+' pts'); let p=16;
  lbl('Positions...'); prg(0.4);
  const pos=new Float32Array(N*3);
  if(version===1){ p+=N*6; }
  else { for(let i=0;i<N;i++){ for(let j=0;j<3;j++){ let v=raw[p]|(raw[p+1]<<8)|(raw[p+2]<<16); p+=3; if(v&0x800000) v|=0xFF000000; pos[i*3+j]=(v|0)*posScale; } pos[i*3+1]*=-1; pos[i*3+2]*=-1; } }
  let _normMx=1.0;
  { let sx=0,sy=0,sz=0; for(let i=0;i<N;i++){ sx+=pos[i*3]; sy+=pos[i*3+1]; sz+=pos[i*3+2]; }
    const cx=sx/N,cy=sy/N,cz=sz/N; let mx=0;
    for(let i=0;i<N;i++){ const dx=pos[i*3]-cx,dy=pos[i*3+1]-cy,dz=pos[i*3+2]-cz; mx=Math.max(mx,dx*dx+dy*dy+dz*dz); }
    mx=Math.sqrt(mx);
    for(let i=0;i<N;i++){ pos[i*3]-=cx; pos[i*3+1]-=cy; pos[i*3+2]-=cz; }
    if(mx>0.0001){ const inv=1/mx; for(let i=0;i<N;i++){ pos[i*3]*=inv; pos[i*3+1]*=inv; pos[i*3+2]*=inv; } _normMx=mx; }
    gsSceneRadius=1.0; }
  const opac=new Float32Array(N);
  for(let i=0;i<N;i++){ const a=Math.max(1e-7,Math.min(1-1e-7,raw[p++]/255)); opac[i]=Math.log(a/(1-a)); }
  const col=new Float32Array(N*3);
  for(let i=0;i<N*3;i++) col[i]=(raw[p++]/255-0.5)/0.15;
  const scl=new Float32Array(N*3);
  for(let i=0;i<N*3;i++) scl[i]=raw[p++]/16-10;
  if(_normMx>0.0001){ const logInv=-Math.log(_normMx); for(let i=0;i<N*3;i++) scl[i]+=logInv; }
  lbl('Rotations...'); prg(0.55);
  const rot=new Float32Array(N*4);
  if(version<=2){ for(let i=0;i<N;i++){ const rx=raw[p++]/127.5-1,ry=raw[p++]/127.5-1,rz=raw[p++]/127.5-1; rot[i*4]=Math.sqrt(Math.max(0,1-rx*rx-ry*ry-rz*rz)); rot[i*4+1]=rx; rot[i*4+2]=-ry; rot[i*4+3]=-rz; } }
  else { const S=Math.SQRT1_2/511; for(let i=0;i<N;i++){ const pk=dv.getUint32(p,true); p+=4; const li=pk&3; const dc=b=>((b>>9)&1?-1:1)*(b&511)*S; const a_=dc((pk>>2)&1023),b_=dc((pk>>12)&1023),c_=dc((pk>>22)&1023); const w_=Math.sqrt(Math.max(0,1-a_*a_-b_*b_-c_*c_)); const q=[0,0,0,0]; let si=0; for(let j=0;j<4;j++) q[j]=j===li?w_:[a_,b_,c_][si++]; rot[i*4]=q[0]; rot[i*4+1]=q[1]; rot[i*4+2]=-q[2]; rot[i*4+3]=-q[3]; } }
  prg(0.65);
  const sh=new Float32Array(N*shDims*3);
  for(let i=0;i<N*shDims*3;i++) sh[i]=(raw[p++]-128)/128;
  lbl('Construction PLY...'); prg(0.7);
  let hdr='ply\nformat binary_little_endian 1.0\nelement vertex '+N+'\n';
  hdr+='property float x\nproperty float y\nproperty float z\nproperty float nx\nproperty float ny\nproperty float nz\n';
  hdr+='property float f_dc_0\nproperty float f_dc_1\nproperty float f_dc_2\n';
  for(let j=0;j<shDims*3;j++) hdr+='property float f_rest_'+j+'\n';
  hdr+='property float opacity\nproperty float scale_0\nproperty float scale_1\nproperty float scale_2\n';
  hdr+='property float rot_0\nproperty float rot_1\nproperty float rot_2\nproperty float rot_3\nend_header\n';
  const nP=3+3+3+shDims*3+1+3+4;
  const hB=new TextEncoder().encode(hdr);
  const plyBuf=new ArrayBuffer(hB.length+N*nP*4); new Uint8Array(plyBuf).set(hB);
  const out=new DataView(plyBuf,hB.length); let op=0;
  for(let i=0;i<N;i++){
    out.setFloat32(op,pos[i*3],true); op+=4; out.setFloat32(op,pos[i*3+1],true); op+=4; out.setFloat32(op,pos[i*3+2],true); op+=4;
    out.setFloat32(op,0,true); op+=4; out.setFloat32(op,0,true); op+=4; out.setFloat32(op,0,true); op+=4;
    out.setFloat32(op,col[i*3],true); op+=4; out.setFloat32(op,col[i*3+1],true); op+=4; out.setFloat32(op,col[i*3+2],true); op+=4;
    for(let j=0;j<shDims*3;j++){ out.setFloat32(op,sh[i*shDims*3+j],true); op+=4; }
    out.setFloat32(op,opac[i],true); op+=4;
    out.setFloat32(op,scl[i*3],true); op+=4; out.setFloat32(op,scl[i*3+1],true); op+=4; out.setFloat32(op,scl[i*3+2],true); op+=4;
    out.setFloat32(op,rot[i*4],true); op+=4; out.setFloat32(op,rot[i*4+1],true); op+=4; out.setFloat32(op,rot[i*4+2],true); op+=4; out.setFloat32(op,rot[i*4+3],true); op+=4;
  }
  prg(0.8); lbl('Rendu 3D...');
  const plyFile=new File([plyBuf],'scene.ply',{type:'application/octet-stream'});
  showEngine('gs');
  await GSLoader.LoadFromFileAsync(plyFile,gsScene,pp=>prg(0.8+pp*0.19));
  prg(1); gsCamInitialized=false; initGSCamera();
  overlayFade(); console.log('[XRVU] GS —',N,'gaussians SPZ v'+version);
  setAnimation(CONFIG.animation.mode);
}

async function loadGS_Direct(url,fmt){
  if(!gsScene){ await initGS(); } if(!gsScene) throw new Error('gsplat indisponible');
  lbl('Telechargement '+fmt.toUpperCase()+'...'); prg(0.1);
  const resp=await fetch(url,{mode:'cors'}); if(!resp.ok) throw new Error('HTTP '+resp.status);
  const buf=await resp.arrayBuffer(); prg(0.5); lbl('Rendu 3D...');
  const file=new File([buf],'scene.'+fmt,{type:'application/octet-stream'});
  showEngine('gs');
  await GSLoader.LoadFromFileAsync(file,gsScene,pp=>prg(0.5+pp*0.49));
  prg(1); gsSceneRadius=2.0; gsCamInitialized=false; initGSCamera();
  overlayFade(); setAnimation(CONFIG.animation.mode);
}

let _baseScale=1,_baseY=0;
function showThreeMesh(mesh){
  const box=new THREE.Box3().setFromObject(mesh);
  const center=box.getCenter(new THREE.Vector3());
  const size=box.getSize(new THREE.Vector3()).length();
  mesh.position.sub(center); const nscale=2.0/(size||1); mesh.scale.setScalar(nscale);
  threeModelGroup=new THREE.Group(); threeModelGroup.add(mesh);
  threeModelGroup.position.set(CONFIG.transform.x,CONFIG.transform.y,CONFIG.transform.z);
  _baseScale=nscale*CONFIG.transform.scale; _baseY=CONFIG.transform.y;
  threeModelGroup.scale.setScalar(_baseScale); threeScene.add(threeModelGroup);
  threeCamera.position.set(0,0.5,2.5); threeControls.target.set(0,0,0); threeControls.update();
  applyMaterialConfig(mesh); showEngine('three'); prg(1); overlayFade(); setAnimation(CONFIG.animation.mode);
}
function applyMaterialConfig(obj){
  if(!obj) return;
  obj.traverse(child=>{ if(child.isMesh&&child.material){ const mats=Array.isArray(child.material)?child.material:[child.material]; mats.forEach(m=>{ if(m.roughness!==undefined) m.roughness=CONFIG.material.roughness; if(m.metalness!==undefined) m.metalness=CONFIG.material.metalness; if(m.opacity!==undefined){ m.opacity=CONFIG.material.opacity; m.transparent=CONFIG.material.opacity<1; } m.needsUpdate=true; }); } });
}
function clearThreeMesh(){ if(threeModelGroup){ threeScene.remove(threeModelGroup); threeModelGroup=null; } }
async function loadThreeMesh_GLTF(url){ lbl('Chargement GLB/GLTF...'); clearThreeMesh(); return new Promise((res,rej)=>{ new GLTFLoader().load(url,g=>{ showThreeMesh(g.scene); res(); },e=>prg((e.loaded/e.total)||0),e=>rej(e)); }); }
async function loadThreeMesh_OBJ(url){  lbl('Chargement OBJ...');      clearThreeMesh(); return new Promise((res,rej)=>{ new OBJLoader().load(url,m=>{ showThreeMesh(m); res(); },e=>prg((e.loaded/e.total)||0),e=>rej(e)); }); }
async function loadThreeMesh_FBX(url){  lbl('Chargement FBX...');      clearThreeMesh(); return new Promise((res,rej)=>{ new FBXLoader().load(url,m=>{ showThreeMesh(m); res(); },e=>prg((e.loaded/e.total)||0),e=>rej(e)); }); }
async function loadThreeMesh_STL(url){  lbl('Chargement STL...');      clearThreeMesh(); return new Promise((res,rej)=>{ new STLLoader().load(url,geo=>{ const m=new THREE.Mesh(geo,new THREE.MeshStandardMaterial({color:0xdddddd,roughness:CONFIG.material.roughness,metalness:CONFIG.material.metalness})); showThreeMesh(m); res(); },e=>prg((e.loaded/e.total)||0),e=>rej(e)); }); }
function loadIframe(url){ let src=url; if(url.includes('sketchfab.com/models')){ const id=url.split('/models/')[1]?.split('/')[0]; if(id) src='https://sketchfab.com/models/'+id+'/embed?autostart=1&ui_infos=0'; } const iframe=document.createElement('iframe'); iframe.src=src; iframe.allow='accelerometer;magnetometer;gyroscope;vr;xr-spatial-tracking;fullscreen;autoplay'; iframe.style.cssText='position:absolute;inset:0;width:100%;height:100%;border:0;z-index:10;'; $('xrvu-root').appendChild(iframe); activeEngine='iframe'; overlayFade(); }

/* ═══════════════════════════════════════════════
   MEDIA PROJECTIONS
   ═══════════════════════════════════════════════ */
let _mediaMeshes=[], _mediaVideos=[];

function buildProjectionGeo(projType,size){
  switch(projType){
    case 'sphere':   return new THREE.SphereGeometry(size,64,32);
    case 'cylinder': return new THREE.CylinderGeometry(size,size,size*0.75,64,1,true);
    case 'cube':     return new THREE.BoxGeometry(size,size,size);
    default:         return new THREE.PlaneGeometry(size,size*0.5625);
  }
}

function addVideoProjection(url,projType,size){
  if(!threeScene) return;
  const video=document.createElement('video');
  video.src=url; video.crossOrigin='anonymous'; video.loop=true; video.playsInline=true;
  const tex=new THREE.VideoTexture(video);
  const side=projType==='flat'?THREE.DoubleSide:THREE.BackSide;
  const mesh=new THREE.Mesh(buildProjectionGeo(projType,size),new THREE.MeshBasicMaterial({map:tex,side,toneMapped:false}));
  mesh.position.set(0,projType==='flat'?1.2:0,0);
  threeScene.add(mesh); _mediaMeshes.push(mesh); _mediaVideos.push(video);
  video.play().catch(()=>{ toast('Cliquez pour activer la video'); const once=()=>{ video.play().catch(()=>{}); $('xrvu-root').removeEventListener('click',once); }; $('xrvu-root').addEventListener('click',once); });
  if(activeEngine!=='three') showEngine('three');
}

function addImageProjection(url,projType,size){
  if(!threeScene) return;
  new THREE.TextureLoader().load(url,tex=>{
    tex.colorSpace=THREE.SRGBColorSpace;
    const side=projType==='flat'?THREE.DoubleSide:THREE.BackSide;
    const mesh=new THREE.Mesh(buildProjectionGeo(projType,size),new THREE.MeshBasicMaterial({map:tex,side,toneMapped:false}));
    mesh.position.set(0,projType==='flat'?1.2:0,0);
    threeScene.add(mesh); _mediaMeshes.push(mesh);
    if(activeEngine!=='three') showEngine('three');
  },undefined,()=>toast('Erreur chargement image'));
}

function clearMediaMeshes(){
  _mediaMeshes.forEach(m=>{ m.geometry.dispose(); if(m.material.map) m.material.map.dispose(); m.material.dispose(); threeScene?.remove(m); });
  _mediaMeshes=[];
  _mediaVideos.forEach(v=>{ v.pause(); v.src=''; });
  _mediaVideos=[];
}

/* ═══════════════════════════════════════════════
   VR INTERACTIONS
   ═══════════════════════════════════════════════ */
let vrControllers=[], vrTeleportRing=null, vrTeleportActive=false;
const vrTeleportPoint=new THREE.Vector3();
let vrXRRefSpace=null, vrGrabController=null, vrGrabOffset=new THREE.Matrix4();
let vrBothSqueeze=[false,false], vrInitialSqueezeDist=0, vrInitialSqueezeScale=1;
const _vrRay=new THREE.Raycaster(), _vrMat=new THREE.Matrix4();

function setupVRInteractions(){
  for(let i=0;i<2;i++){
    const ctrl=threeRenderer.xr.getController(i);
    ctrl.userData.index=i;
    ctrl.addEventListener('selectstart',()=>onVRSelectStart(i));
    ctrl.addEventListener('selectend',  ()=>onVRSelectEnd(i));
    ctrl.addEventListener('squeezestart',()=>onVRSqueezeStart(i));
    ctrl.addEventListener('squeezeend',  ()=>onVRSqueezeEnd(i));
    const ray=new THREE.Line(
      new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(0,0,0),new THREE.Vector3(0,0,-1)]),
      new THREE.LineBasicMaterial({color:0x1df2d6,transparent:true,opacity:0.5})
    );
    ray.scale.z=5; ctrl.add(ray);
    threeScene.add(ctrl); vrControllers.push(ctrl);
  }
  const ringGeo=new THREE.RingGeometry(0.28,0.34,32);
  vrTeleportRing=new THREE.Mesh(ringGeo,new THREE.MeshBasicMaterial({color:0x1df2d6,side:THREE.DoubleSide,transparent:true,opacity:0.75}));
  vrTeleportRing.rotation.x=-Math.PI/2; vrTeleportRing.visible=false;
  threeScene.add(vrTeleportRing);
  threeRenderer.xr.addEventListener('sessionstart',()=>{ vrXRRefSpace=threeRenderer.xr.getReferenceSpace(); });
}

function onVRSelectStart(idx){
  const ctrl=vrControllers[idx];
  if(!ctrl) return;
  if(CONFIG.vr.handInteractions&&threeModelGroup){
    _vrMat.identity().extractRotation(ctrl.matrixWorld);
    _vrRay.ray.origin.setFromMatrixPosition(ctrl.matrixWorld);
    _vrRay.ray.direction.set(0,0,-1).applyMatrix4(_vrMat);
    if(_vrRay.intersectObject(threeModelGroup,true).length>0){
      vrGrabController=ctrl;
      vrGrabOffset.copy(ctrl.matrixWorld).invert().multiply(threeModelGroup.matrixWorld);
      return;
    }
  }
  if(CONFIG.vr.teleport&&idx===1) vrTeleportActive=true;
}

function onVRSelectEnd(idx){
  if(vrGrabController===vrControllers[idx]) vrGrabController=null;
  if(CONFIG.vr.teleport&&idx===1&&vrTeleportActive){
    vrTeleportActive=false;
    if(vrTeleportRing.visible&&vrXRRefSpace&&typeof XRRigidTransform!=='undefined'){
      const cam=threeRenderer.xr.getCamera();
      const tf=new XRRigidTransform({x:-(vrTeleportPoint.x-cam.position.x),y:0,z:-(vrTeleportPoint.z-cam.position.z),w:1});
      const newSpace=vrXRRefSpace.getOffsetReferenceSpace(tf);
      vrXRRefSpace=newSpace; threeRenderer.xr.setReferenceSpace(newSpace);
    }
    vrTeleportRing.visible=false;
  }
}

function onVRSqueezeStart(idx){
  vrBothSqueeze[idx]=true;
  if(vrBothSqueeze[0]&&vrBothSqueeze[1]&&threeModelGroup&&vrControllers.length===2){
    const p0=new THREE.Vector3().setFromMatrixPosition(vrControllers[0].matrixWorld);
    const p1=new THREE.Vector3().setFromMatrixPosition(vrControllers[1].matrixWorld);
    vrInitialSqueezeDist=p0.distanceTo(p1);
    vrInitialSqueezeScale=threeModelGroup.scale.x;
  }
}
function onVRSqueezeEnd(idx){ vrBothSqueeze[idx]=false; }

function updateVRInteractions(){
  if(vrGrabController&&threeModelGroup){
    const m=new THREE.Matrix4().copy(vrGrabController.matrixWorld).multiply(vrGrabOffset);
    m.decompose(threeModelGroup.position,threeModelGroup.quaternion,threeModelGroup.scale);
  }
  if(vrBothSqueeze[0]&&vrBothSqueeze[1]&&threeModelGroup&&!vrGrabController&&vrControllers.length===2){
    const p0=new THREE.Vector3().setFromMatrixPosition(vrControllers[0].matrixWorld);
    const p1=new THREE.Vector3().setFromMatrixPosition(vrControllers[1].matrixWorld);
    const dist=p0.distanceTo(p1);
    if(vrInitialSqueezeDist>0.001){
      const ns=Math.max(0.001,vrInitialSqueezeScale*(dist/vrInitialSqueezeDist));
      threeModelGroup.scale.setScalar(ns); syncScaleUI(ns);
    }
  }
  if(vrTeleportActive&&vrControllers[1]){
    const ctrl=vrControllers[1];
    _vrMat.identity().extractRotation(ctrl.matrixWorld);
    _vrRay.ray.origin.setFromMatrixPosition(ctrl.matrixWorld);
    _vrRay.ray.direction.set(0,0,-1).applyMatrix4(_vrMat);
    const doty=_vrRay.ray.direction.y;
    if(doty<-0.1){
      const t=-_vrRay.ray.origin.y/doty;
      if(t>0&&t<20){ vrTeleportPoint.copy(_vrRay.ray.origin).addScaledVector(_vrRay.ray.direction,t); vrTeleportRing.position.set(vrTeleportPoint.x,0.01,vrTeleportPoint.z); vrTeleportRing.visible=true; return; }
    }
    vrTeleportRing.visible=false;
  }
}

/* ═══════════════════════════════════════════════
   AR + VR BUTTONS
   ═══════════════════════════════════════════════ */
function setupXRButtons(){
  if(!threeRenderer) return;
  if(CONFIG.xr.ar&&navigator.xr) navigator.xr.isSessionSupported('immersive-ar').then(ok=>{ if(ok||/android|iphone|ipad/i.test(navigator.userAgent)) $('xrvu-btn-ar').style.display='flex'; }).catch(()=>{});
  if(CONFIG.xr.vr&&navigator.xr) navigator.xr.isSessionSupported('immersive-vr').then(ok=>{ if(ok) $('xrvu-btn-vr').style.display='flex'; }).catch(()=>{});
  $('xrvu-btn-ar').addEventListener('click',async()=>{
    if(!navigator.xr){ toast('WebXR non disponible'); return; }
    try {
      const ok=await navigator.xr.isSessionSupported('immersive-ar');
      if(!ok){ toast('AR non supporte — ouvrez depuis un telephone'); return; }
      const opts={requiredFeatures:['local-floor'],optionalFeatures:['hit-test','dom-overlay','image-tracking'],domOverlay:{root:$('xrvu-root')}};
      const itTargets=CONFIG.triggers.imageTracker.targets;
      if(itTargets.length>0){
        try {
          const bitmaps=await Promise.all(itTargets.map(t=>fetch(t.image).then(r=>r.blob()).then(b=>createImageBitmap(b))));
          opts.trackedImages=bitmaps.map((bmp,i)=>({image:bmp,widthInMeters:itTargets[i].widthInMeters||0.2}));
        } catch(e){ console.warn('[XRVU] ImageTracker:',e.message); }
      }
      xrSession=await navigator.xr.requestSession('immersive-ar',opts);
      await threeRenderer.xr.setSession(xrSession);
      const bgSave=threeScene.background; threeScene.background=null; threeRenderer.setClearColor(0x000000,0);
      $('xrvu-btn-ar').classList.add('active');
      if(CONFIG.xr.hitTest){ xrRefSpace=await xrSession.requestReferenceSpace('local-floor'); const vs=await xrSession.requestReferenceSpace('viewer'); xrHitTestSource=await xrSession.requestHitTestSource({space:vs}); $('xrvu-reticule').style.display='flex'; }
      xrSession.addEventListener('end',()=>{ threeScene.background=bgSave; applyBackground(); xrHitTestSource=null; xrRefSpace=null; $('xrvu-btn-ar').classList.remove('active'); $('xrvu-reticule').style.display='none'; });
    } catch(e){ toast('AR: '+e.message); }
  });
  const vrBtn=XRButton.createButton(threeRenderer,{mode:'immersive-vr',requiredFeatures:['local-floor'],optionalFeatures:['hand-tracking']});
  vrBtn.style.display='none'; document.body.appendChild(vrBtn);
  $('xrvu-btn-vr').addEventListener('click',async()=>{ if(!navigator.xr){ toast('WebXR non disponible'); return; } const ok=await navigator.xr.isSessionSupported('immersive-vr'); if(ok) vrBtn.click(); else toast('VR non supporte'); });
}
function updateHitTest(frame){ if(!xrHitTestSource||!xrRefSpace||!frame) return; const r=frame.getHitTestResults(xrHitTestSource); if(r.length>0&&r[0].getPose(xrRefSpace)) $('xrvu-reticule').style.display='flex'; }

/* ═══════════════════════════════════════════════
   GPS TRIGGER
   ═══════════════════════════════════════════════ */
let _gpsWatchId=null, _lastGPSTrigger=null;
function haversineDistance(lat1,lon1,lat2,lon2){ const R=6371000,dLat=(lat2-lat1)*Math.PI/180,dLon=(lon2-lon1)*Math.PI/180,a=Math.sin(dLat/2)**2+Math.cos(lat1*Math.PI/180)*Math.cos(lat2*Math.PI/180)*Math.sin(dLon/2)**2; return R*2*Math.atan2(Math.sqrt(a),Math.sqrt(1-a)); }

function startGPSWatch(){
  if(!navigator.geolocation){ toast('GPS non disponible'); return; }
  if(_gpsWatchId!==null) return;
  _gpsWatchId=navigator.geolocation.watchPosition(pos=>{
    const{latitude:lat,longitude:lon}=pos.coords;
    $('xrvu-gps-coords').textContent=lat.toFixed(6)+', '+lon.toFixed(6);
    for(const zone of CONFIG.triggers.gps.zones){
      const dist=haversineDistance(lat,lon,zone.lat,zone.lon);
      if(dist<=(zone.radius||CONFIG.triggers.gps.radius)){
        if(_lastGPSTrigger!==zone.name){ _lastGPSTrigger=zone.name; toast('Zone GPS : '+(zone.name||'Zone')); if(zone.url) loadModel(zone.url); }
        return;
      }
    }
    _lastGPSTrigger=null;
  },e=>{ toast('GPS erreur: '+e.message); _gpsWatchId=null; },{enableHighAccuracy:true,maximumAge:3000});
  toast('GPS actif'); $('xrvu-btn-triggers').classList.add('active');
}

function addGPSZone(lat,lon,name,url,radius){
  CONFIG.triggers.gps.zones.push({lat,lon,name:name||'Zone '+(CONFIG.triggers.gps.zones.length+1),url:url||null,radius:radius||CONFIG.triggers.gps.radius});
  renderGPSZonesList();
}

function renderGPSZonesList(){
  const list=$('xrvu-gps-zones-list'); if(!list) return;
  list.innerHTML=CONFIG.triggers.gps.zones.map((z,i)=>`<div style="padding:2px 0;border-bottom:1px solid rgba(255,255,255,.05);">[${i+1}] ${z.name} — ${z.lat.toFixed(4)},${z.lon.toFixed(4)} (${z.radius||50}m) <span style="cursor:pointer;color:#e05555;" data-del="${i}">x</span></div>`).join('');
  list.querySelectorAll('[data-del]').forEach(el=>el.addEventListener('click',()=>{ CONFIG.triggers.gps.zones.splice(parseInt(el.dataset.del),1); renderGPSZonesList(); }));
}

/* ═══════════════════════════════════════════════
   GYROSCOPE + INTERACTIONS
   ═══════════════════════════════════════════════ */
let _gyroAlpha0=null,_userInteracting=false,_idleTimer=null;
function onUserInteract(){ _userInteracting=true; clearTimeout(_idleTimer); _idleTimer=setTimeout(()=>{ _userInteracting=false; },3000); }
function setupGyroscope(){
  if(!CONFIG.xr.gyroscope||!threeCamera) return;
  window.addEventListener('deviceorientation',e=>{ if(!e.gamma) return; if(_gyroAlpha0===null) _gyroAlpha0=e.alpha; if(_userInteracting) return; const alpha=THREE.MathUtils.degToRad((e.alpha||0)-_gyroAlpha0); const beta=THREE.MathUtils.degToRad(e.beta||0); const gamma=THREE.MathUtils.degToRad(e.gamma||0); threeCamera.quaternion.setFromEuler(new THREE.Euler(beta,alpha,-gamma,'YXZ')); });
}
let _pinch0=null;
$('xrvu-root').addEventListener('touchstart',e=>{ if(e.touches.length===2) _pinch0=Math.hypot(e.touches[0].clientX-e.touches[1].clientX,e.touches[0].clientY-e.touches[1].clientY); },{passive:true});
$('xrvu-root').addEventListener('touchmove',e=>{ if(e.touches.length===2&&_pinch0&&threeModelGroup){ const d=Math.hypot(e.touches[0].clientX-e.touches[1].clientX,e.touches[0].clientY-e.touches[1].clientY); const ns=Math.max(0.001,threeModelGroup.scale.x*(d/_pinch0)); threeModelGroup.scale.setScalar(ns); syncScaleUI(ns); _pinch0=d; } },{passive:true});
window.addEventListener('wheel',e=>{ if(animMode==='rotateonscroll'){ if(threeModelGroup) threeModelGroup.rotation.y+=e.deltaY*0.003; else if(gsCamera) gsAnim_angle+=e.deltaY*0.003; onUserInteract(); } },{passive:true});
$('xrvu-root').addEventListener('click',()=>{ if(animMode==='clickfade') _clickFadeDir*=-1; });

/* ═══════════════════════════════════════════════
   ANIMATIONS
   ═══════════════════════════════════════════════ */
let animMode='none',animClock=new THREE.Clock(),_clickFadeDir=1;
const _mouse=new THREE.Vector2();
window.addEventListener('mousemove',e=>{ const r=$('xrvu-root').getBoundingClientRect(); _mouse.x=((e.clientX-r.left)/r.width-0.5)*2; _mouse.y=((e.clientY-r.top)/r.height-0.5)*2; });

function setAnimation(mode){
  animMode=mode;
  document.querySelectorAll('.xrvu-anim-item').forEach(el=>el.classList.toggle('active',el.dataset.mode===mode));
  if(threeControls){ threeControls.autoRotate=(mode==='smoothorbit'); threeControls.autoRotateSpeed=CONFIG.animation.speed*2; }
  if(threeModelGroup){ _baseY=threeModelGroup.position.y; _baseScale=threeModelGroup.scale.x; }
}
function tickAnimations(dt){
  if(_userInteracting||!threeModelGroup) return;
  const s=CONFIG.animation.speed,t=animClock.getElapsedTime();
  switch(animMode){
    case 'turntable':      threeModelGroup.rotation.y+=dt*s*0.5; break;
    case 'floating':       threeModelGroup.position.y=_baseY+Math.sin(t*s*1.2)*0.08; threeModelGroup.rotation.y+=dt*s*0.12; break;
    case 'breathing':      { const p=1+Math.sin(t*s*1.5)*0.04; threeModelGroup.scale.setScalar(_baseScale*p); break; }
    case 'expandcontract': { const p=1+Math.sin(t*s)*0.18; threeModelGroup.scale.setScalar(_baseScale*p); break; }
    case 'followmouse':    threeModelGroup.rotation.y+=(_mouse.x*0.6-threeModelGroup.rotation.y)*dt*2; threeModelGroup.rotation.x+=(-_mouse.y*0.3-threeModelGroup.rotation.x)*dt*2; break;
    case 'pulsezoom':      { const d=2.5+Math.sin(t*s*0.8)*0.9; threeCamera.position.normalize().multiplyScalar(Math.max(0.5,d)); break; }
    case 'clickfade':      threeModelGroup.traverse(c=>{ if(c.isMesh&&c.material){ const ms=Array.isArray(c.material)?c.material:[c.material]; ms.forEach(m=>{ m.transparent=true; m.opacity=THREE.MathUtils.lerp(m.opacity,_clickFadeDir===1?0:1,dt*1.5); }); } }); break;
  }
}
function applyGSAnimation(dt,t){
  if(!gsCamera) return false;
  const s=CONFIG.animation.speed,r=gsSceneRadius*2.5;
  switch(animMode){
    case 'turntable':      if(_userInteracting) return false; gsAnim_angle+=dt*s*0.5; gsCamera.position.x=Math.sin(gsAnim_angle)*r; gsCamera.position.z=Math.cos(gsAnim_angle)*r; gsCamera.position.y=gsSceneRadius*0.5; if(gsCamera.lookAt) gsCamera.lookAt(0,0,0); return true;
    case 'smoothorbit':    if(_userInteracting) return false; gsAnim_angle+=dt*s*0.3; gsCamera.position.x=Math.sin(gsAnim_angle)*r; gsCamera.position.z=Math.cos(gsAnim_angle)*r; gsCamera.position.y=gsSceneRadius*0.4; if(gsCamera.lookAt) gsCamera.lookAt(0,0,0); return true;
    case 'followmouse':    if(_userInteracting){ gsControls?.update(); return false; } { const lf=Math.min(1,dt*1.8); gsCamera.position.x+=(_mouse.x*gsSceneRadius*1.5-gsCamera.position.x)*lf; gsCamera.position.y+=(gsSceneRadius*0.3-_mouse.y*gsSceneRadius*0.6-gsCamera.position.y)*lf; gsCamera.position.z=gsSceneRadius*3.0; if(gsCamera.lookAt) gsCamera.lookAt(0,0,0); } return true;
    case 'floating':       if(_userInteracting) return false; gsCamera.position.y=gsSceneRadius*0.5+Math.sin(t*s)*gsSceneRadius*0.15; return true;
    case 'pulsezoom':      if(_userInteracting) return false; gsCamera.position.z=r+Math.sin(t*s*0.8)*gsSceneRadius*0.5; return true;
    case 'rotateonscroll': gsCamera.position.x=Math.sin(gsAnim_angle)*r; gsCamera.position.z=Math.cos(gsAnim_angle)*r; gsCamera.position.y=gsSceneRadius*0.5; if(gsCamera.lookAt) gsCamera.lookAt(0,0,0); return true;
    default: return false;
  }
}

/* ═══════════════════════════════════════════════
   AUDIO
   ═══════════════════════════════════════════════ */
let audioEl=null,_soundMuted=false;
function setupMedia(){
  if(CONFIG.media.image){ const img=document.createElement('img'); img.src=CONFIG.media.image; img.style.cssText='position:absolute;inset:0;width:100%;height:100%;object-fit:cover;z-index:2;pointer-events:none;'; $('xrvu-root').insertBefore(img,$('xrvu-gs-stage')); }
  if(CONFIG.media.sound) initAudio(CONFIG.media.sound);
}
function initAudio(src){
  if(audioEl){ audioEl.pause(); audioEl.src=''; }
  audioEl=document.createElement('audio'); audioEl.src=src; audioEl.loop=CONFIG.media.soundLoop; audioEl.volume=0.8;
  if(CONFIG.media.soundAutoplay){ audioEl.play().catch(()=>{ toast('Cliquez pour activer le son'); const once=()=>{ audioEl?.play(); $('xrvu-root').removeEventListener('click',once); }; $('xrvu-root').addEventListener('click',once); }); }
  const btn=$('xrvu-btn-sound'); btn.style.display='flex';
  if(CONFIG.media.soundAutoplay) btn.classList.add('active');
  btn.onclick=e=>{ e.stopPropagation(); _soundMuted=!_soundMuted; audioEl.muted=_soundMuted; if(_soundMuted){ audioEl.pause(); btn.classList.remove('active'); btn.innerHTML=ICONS.volumeOff; } else { audioEl.play().catch(()=>{}); btn.classList.add('active'); btn.innerHTML=ICONS.volumeOn; } };
}

/* ═══════════════════════════════════════════════
   PANNEAUX UI
   ═══════════════════════════════════════════════ */
let _proportionalLock=true;

function syncScaleUI(actualScale){
  const sl=$('xrvu-tscale'),nm=$('xrvu-tscale-num');
  if(sl) sl.value=Math.max(-2,Math.min(3,Math.log10(Math.max(0.0001,actualScale))));
  if(nm) nm.value=actualScale.toFixed(4);
}
function applyScale(actualScale){
  actualScale=Math.max(0.0001,actualScale);
  if(threeModelGroup) threeModelGroup.scale.setScalar(actualScale);
  if(activeEngine==='gs') gsSceneRadius=actualScale;
  syncScaleUI(actualScale);
}

function setupTransformPanel(){
  const mkSl=(id,vid,cb)=>{ const s=$(id),v=$(vid); if(!s) return; s.addEventListener('input',()=>{ v.textContent=parseFloat(s.value).toFixed(2); cb(parseFloat(s.value)); }); };
  mkSl('xrvu-tx','xrvu-tx-val',val=>{ if(threeModelGroup) threeModelGroup.position.x=val; });
  mkSl('xrvu-ty','xrvu-ty-val',val=>{ if(threeModelGroup) threeModelGroup.position.y=_baseY+val; });
  mkSl('xrvu-tz','xrvu-tz-val',val=>{ if(threeModelGroup) threeModelGroup.position.z=val; });
  const scaleSl=$('xrvu-tscale'),scaleNum=$('xrvu-tscale-num');
  if(scaleSl) scaleSl.addEventListener('input',()=>{ const actual=Math.pow(10,parseFloat(scaleSl.value)); scaleNum.value=actual.toFixed(4); applyScale(actual); });
  if(scaleNum) scaleNum.addEventListener('change',()=>{ const v=parseFloat(scaleNum.value); if(!isNaN(v)&&v>0) applyScale(v); });
  const lb=$('xrvu-lock-btn');
  lb.addEventListener('click',e=>{ e.stopPropagation(); _proportionalLock=!_proportionalLock; lb.classList.toggle('locked',_proportionalLock); lb.innerHTML=_proportionalLock?ICONS.lock:ICONS.lockOpen; });
}

function setupSettingsPanel(){
  const mkSl=(id,vid,cb)=>{ const s=$(id),v=$(vid); if(!s) return; s.addEventListener('input',()=>{ v.textContent=parseFloat(s.value).toFixed(2); cb(parseFloat(s.value)); }); };
  mkSl('xrvu-s-ambient','xrvu-s-ambient-val',v=>{ if(ambientLight) ambientLight.intensity=v; });
  mkSl('xrvu-s-light','xrvu-s-light-val',v=>{ if(keyLight) keyLight.intensity=v; });
  mkSl('xrvu-s-opacity','xrvu-s-opacity-val',v=>{ CONFIG.material.opacity=v; if(threeModelGroup) applyMaterialConfig(threeModelGroup); });
  mkSl('xrvu-s-roughness','xrvu-s-roughness-val',v=>{ CONFIG.material.roughness=v; if(threeModelGroup) applyMaterialConfig(threeModelGroup); });
  mkSl('xrvu-s-metalness','xrvu-s-metalness-val',v=>{ CONFIG.material.metalness=v; if(threeModelGroup) applyMaterialConfig(threeModelGroup); });
}

function setupMediaPanel(){
  let videoProj='flat',imageProj='flat';
  function initProjBtns(groupId,onSelect){
    document.querySelectorAll('#'+groupId+' .xrvu-proj-btn').forEach(btn=>{
      btn.addEventListener('click',()=>{ document.querySelectorAll('#'+groupId+' .xrvu-proj-btn').forEach(b=>b.classList.remove('active')); btn.classList.add('active'); onSelect(btn.dataset.proj); });
    });
  }
  initProjBtns('xrvu-mv-proj',p=>videoProj=p);
  initProjBtns('xrvu-mi-proj',p=>imageProj=p);
  const mvSz=$('xrvu-mv-size'),mvSzV=$('xrvu-mv-size-val'); if(mvSz) mvSz.addEventListener('input',()=>mvSzV.textContent=parseFloat(mvSz.value).toFixed(1));
  const miSz=$('xrvu-mi-size'),miSzV=$('xrvu-mi-size-val'); if(miSz) miSz.addEventListener('input',()=>miSzV.textContent=parseFloat(miSz.value).toFixed(1));
  const maVol=$('xrvu-ma-vol'),maVolV=$('xrvu-ma-vol-val'); if(maVol) maVol.addEventListener('input',()=>{ maVolV.textContent=parseFloat(maVol.value).toFixed(2); if(audioEl) audioEl.volume=parseFloat(maVol.value); });
  $('xrvu-mv-apply').addEventListener('click',()=>{ const url=$('xrvu-mv-url').value.trim(); if(!url){ toast('Entrez une URL video'); return; } if(!threeScene) initThree(); addVideoProjection(url,videoProj,parseFloat($('xrvu-mv-size').value)); $('xrvu-mv-remove').style.display='block'; toast('Video chargee'); });
  $('xrvu-mv-remove').addEventListener('click',()=>{ clearMediaMeshes(); $('xrvu-mv-remove').style.display='none'; toast('Video retiree'); });
  $('xrvu-mi-apply').addEventListener('click',()=>{ const url=$('xrvu-mi-url').value.trim(); if(!url){ toast('Entrez une URL image'); return; } if(!threeScene) initThree(); addImageProjection(url,imageProj,parseFloat($('xrvu-mi-size').value)); $('xrvu-mi-remove').style.display='block'; toast('Image chargee'); });
  $('xrvu-mi-remove').addEventListener('click',()=>{ clearMediaMeshes(); $('xrvu-mi-remove').style.display='none'; toast('Image retiree'); });
  $('xrvu-ma-apply').addEventListener('click',()=>{ const url=$('xrvu-ma-url').value.trim(); if(!url){ toast('Entrez une URL audio'); return; } CONFIG.media.sound=url; CONFIG.media.soundAutoplay=true; initAudio(url); toast('Audio charge'); });
}

function setupTriggersPanel(){
  const gpsR=$('xrvu-gps-radius'),gpsRV=$('xrvu-gps-radius-val'); if(gpsR) gpsR.addEventListener('input',()=>{ gpsRV.textContent=gpsR.value; CONFIG.triggers.gps.radius=parseInt(gpsR.value); });
  $('xrvu-gps-watch').addEventListener('click',startGPSWatch);
  $('xrvu-gps-add').addEventListener('click',()=>{
    if(!navigator.geolocation){ toast('GPS indisponible'); return; }
    navigator.geolocation.getCurrentPosition(pos=>{ const name=$('xrvu-gps-zone-name').value.trim(),url=$('xrvu-gps-zone-url').value.trim(); addGPSZone(pos.coords.latitude,pos.coords.longitude,name,url,CONFIG.triggers.gps.radius); toast('Zone GPS ajoutee : '+(name||'Zone')); });
  });
  const itW=$('xrvu-it-width'),itWV=$('xrvu-it-width-val'); if(itW) itW.addEventListener('input',()=>itWV.textContent=parseFloat(itW.value).toFixed(2));
  $('xrvu-it-add').addEventListener('click',()=>{
    const imgUrl=$('xrvu-it-url').value.trim(),contentUrl=$('xrvu-it-content').value.trim();
    if(!imgUrl){ toast('Entrez une URL image cible'); return; }
    const w=parseFloat($('xrvu-it-width').value);
    CONFIG.triggers.imageTracker.targets.push({image:imgUrl,widthInMeters:w,contentUrl:contentUrl||null});
    toast('Tracker image ajoute');
    const list=$('xrvu-it-list'); if(list) list.innerHTML+=`<div style="padding:2px 0;">${imgUrl.split('/').pop()} (${w}m)</div>`;
  });
}

/* ═══════════════════════════════════════════════
   INIT UI
   ═══════════════════════════════════════════════ */
function initUI(){
  $('xrvu-bar').style.display=CONFIG.commands.display?'flex':'none';
  applyBackground();
  if(CONFIG.overlay.logo||CONFIG.overlay.title){ const br=$('xrvu-brand'); br.style.display='flex'; if(CONFIG.overlay.logo) $('xrvu-brand-logo').src=CONFIG.overlay.logo; if(CONFIG.overlay.title){ $('xrvu-brand-title').textContent=CONFIG.overlay.title; $('xrvu-brand-title').style.color=CONFIG.overlay.titleColor; } }
  const sync=(id,vid,v)=>{ const el=$(id); if(!el) return; el.value=v; const vel=$(vid); if(vel) vel.textContent=parseFloat(v).toFixed(2); };
  sync('xrvu-tx','xrvu-tx-val',CONFIG.transform.x); sync('xrvu-ty','xrvu-ty-val',CONFIG.transform.y); sync('xrvu-tz','xrvu-tz-val',CONFIG.transform.z);
  $('xrvu-tscale').value=Math.log10(Math.max(0.0001,CONFIG.transform.scale));
  $('xrvu-tscale-num').value=(CONFIG.transform.scale||1).toFixed(4);
  sync('xrvu-s-ambient','xrvu-s-ambient-val',CONFIG.lights.ambient); sync('xrvu-s-light','xrvu-s-light-val',CONFIG.lights.directional);
  sync('xrvu-s-opacity','xrvu-s-opacity-val',CONFIG.material.opacity); sync('xrvu-s-roughness','xrvu-s-roughness-val',CONFIG.material.roughness); sync('xrvu-s-metalness','xrvu-s-metalness-val',CONFIG.material.metalness);
  const animBtn=$('xrvu-btn-anim'),animMenu=$('xrvu-anim-menu'); let _menuOpen=false;
  const openM=()=>{ animMenu.classList.add('open'); _menuOpen=true; animBtn.classList.add('active'); };
  const closeM=()=>{ animMenu.classList.remove('open'); _menuOpen=false; animBtn.classList.remove('active'); };
  animBtn.addEventListener('click',e=>{ e.stopPropagation(); _menuOpen?closeM():openM(); });
  animMenu.addEventListener('click',e=>{ const item=e.target.closest('.xrvu-anim-item'); if(!item) return; setAnimation(item.dataset.mode); closeM(); });
  document.addEventListener('click',e=>{ if(!$('xrvu-root').contains(e.target)||(!animBtn.contains(e.target)&&!animMenu.contains(e.target))) closeM(); });
  const allPanelIds=['xrvu-panel-transform','xrvu-panel-settings','xrvu-panel-media','xrvu-panel-triggers'];
  const allBtnIds=['xrvu-btn-transform','xrvu-btn-settings','xrvu-btn-media','xrvu-btn-triggers'];
  function closeAllPanels(){ allPanelIds.forEach(id=>$(id)?.classList.remove('open')); allBtnIds.forEach(id=>$(id)?.classList.remove('active')); }
  function togglePanel(pId,bId){ const open=!$(pId).classList.contains('open'); closeAllPanels(); $(pId).classList.toggle('open',open); $(bId).classList.toggle('active',open); }
  $('xrvu-btn-transform').addEventListener('click',e=>{ e.stopPropagation(); togglePanel('xrvu-panel-transform','xrvu-btn-transform'); });
  $('xrvu-btn-settings').addEventListener('click',e=>{ e.stopPropagation(); togglePanel('xrvu-panel-settings','xrvu-btn-settings'); });
  $('xrvu-btn-media').addEventListener('click',e=>{ e.stopPropagation(); togglePanel('xrvu-panel-media','xrvu-btn-media'); });
  $('xrvu-btn-triggers').addEventListener('click',e=>{ e.stopPropagation(); togglePanel('xrvu-panel-triggers','xrvu-btn-triggers'); });
  $('xrvu-root').addEventListener('click',closeAllPanels);
  allPanelIds.forEach(id=>$(id)?.addEventListener('click',e=>e.stopPropagation()));
  $('xrvu-btn-qr').addEventListener('click',()=>{ const panel=$('xrvu-qr-panel'); const visible=panel.style.display==='block'; if(visible){ panel.style.display='none'; $('xrvu-btn-qr').classList.remove('active'); return; } panel.style.display='block'; $('xrvu-btn-qr').classList.add('active'); $('xrvu-qr-code').innerHTML=''; const url=CONFIG.model.url?location.href.split('?')[0]+'?model='+encodeURIComponent(CONFIG.model.url):location.href; if(window.QRCode) new window.QRCode($('xrvu-qr-code'),{text:url,width:120,height:120,colorDark:'#0010ef',colorLight:'#ffffff',correctLevel:window.QRCode.CorrectLevel.H}); });
  $('xrvu-btn-fs').addEventListener('click',()=>{ const el=$('xrvu-root'); if(!document.fullscreenElement) el.requestFullscreen?.(); else document.exitFullscreen?.(); });
  document.addEventListener('fullscreenchange',()=>{ $('xrvu-btn-fs').innerHTML=document.fullscreenElement?ICONS.compress:ICONS.expand; });
  $('xrvu-root').addEventListener('dblclick',()=>{ threeControls?.reset(); if(threeCamera) threeCamera.position.set(0,0.5,2.5); if(gsCamera){ gsCamera.position.set(0,gsSceneRadius*0.3,gsSceneRadius*3.0); if(gsCamera.lookAt) gsCamera.lookAt(0,0,0); gsCamInitialized=true; } });
  const params=new URLSearchParams(location.search); if(params.has('model')) CONFIG.model.url=params.get('model');
  setupTransformPanel(); setupSettingsPanel(); setupMediaPanel(); setupTriggersPanel();
  if(CONFIG.hud.autoHide) _hudTimer=setTimeout(()=>$('xrvu-bar').classList.add('hud-hidden'),CONFIG.hud.autoHideDelay*2);
}

/* ═══════════════════════════════════════════════
   BOUCLE + INIT
   ═══════════════════════════════════════════════ */
function threeLoop(t,frame){
  if(frame&&CONFIG.xr.hitTest) updateHitTest(frame);
  if(threeRenderer.xr.isPresenting) updateVRInteractions();
  tickAnimations(animClock.getDelta());
  threeControls?.update();
  threeRenderer.render(threeScene,threeCamera);
}

async function init(){
  try {
    initThree(); initUI(); setupGyroscope(); setupMedia();
    if(CONFIG.triggers.gps.enabled&&CONFIG.triggers.gps.zones.length) startGPSWatch();
    if(CONFIG.model.url){
      const fmt=detectFormat(CONFIG.model.url,CONFIG.model.format);
      if(fmt==='spz'||fmt==='ply'||fmt==='splat') initGS().then(()=>loadModel(CONFIG.model.url,CONFIG.model.format)).catch(e=>err('GS: '+(e.message||e)));
      else { loadModel(CONFIG.model.url,CONFIG.model.format); initGS(); }
    } else { lbl('Aucun modele configure'); initGS(); }
  } catch(e){ err('Init: '+(e.message||String(e))); }
}
init();
</script>
				</div>
				</div>
				</div>
				</div>
		<p>Cet article <a href="https://presentcomposedesign.fr/spz/">spz</a> est apparu en premier sur <a href="https://presentcomposedesign.fr">Présent Composé design</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Paper plane</title>
		<link>https://presentcomposedesign.fr/paper-plane-mode_for_arrival-space/</link>
		
		<dc:creator><![CDATA[admin]]></dc:creator>
		<pubDate>Fri, 27 Mar 2026 01:19:08 +0000</pubDate>
				<category><![CDATA[[ia]]]></category>
		<category><![CDATA[[VR]]]></category>
		<category><![CDATA[Design d'espace]]></category>
		<category><![CDATA[Design produit]]></category>
		<guid isPermaLink="false">https://presentcomposedesign.fr/?p=36135</guid>

					<description><![CDATA[<p>Click here &#x1F861; from your VR headset Paper Plane in VR ᯅ Plein écran &#x1F5D6; Paper Plane Mon tout premier plugin Arrival.Space. Un avatar universel pour le web immersif. Le concept Paper Plane est parti d&#8217;une question simple : et si le meilleur avatar était aussi le plus universel ? Pas de personnage genré, pas de corps humanoïde. Juste un avion en papier, instinctif et léger, qui traverse librement chaque monde d&#8217;Arrival.Space. Pourquoi un avion en papier ? Tout le monde en a plié un. Tout le monde en a lancé un. C&#8217;est peut-être l&#8217;objet le plus démocratique qui existe. En tant qu&#8217;avatar, il efface toute friction d&#8217;identification : pas d&#8217;âge, pas de genre, pas de skin à choisir. Du mouvement pur, et une liberté totale d&#8217;exploration. De SplatGate à Paper Plane Ce projet s&#8217;appuie sur toute l&#8217;expertise en Gaussian Splatting que j&#8217;ai construite avec SplatGate. Paper Plane, c&#8217;est mon premier pas dans l&#8217;écosystème Arrival.Space, un nouveau terrain pour pousser les limites du web immersif, du spatial computing et du vibe coding. 🧊 Gaussian Splatting 🌐 Web Immersif Full VR available Experience -ᯅ- ✔️ Explorer les univers gagnants Essayer Paper Plane Soutenir / Remercier Organisé par Arrival.Space &#038; CHOICE DAO</p>
<p>Cet article <a href="https://presentcomposedesign.fr/paper-plane-mode_for_arrival-space/">Paper plane</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="36135" class="elementor elementor-36135">
						<header class="elementor-section elementor-top-section elementor-element elementor-element-f1e5d23 elementor-section-full_width elementor-section-height-default elementor-section-height-default wpr-particle-no wpr-jarallax-no wpr-parallax-no wpr-sticky-section-no wpr-column-slider-no wpr-equal-height-no" data-id="f1e5d23" data-element_type="section" data-e-type="section">
						<div class="elementor-container elementor-column-gap-no">
					<header class="elementor-column elementor-col-100 elementor-top-column elementor-element elementor-element-8a6a45b" data-id="8a6a45b" data-element_type="column" data-e-type="column">
			<div class="elementor-widget-wrap elementor-element-populated">
						<div class="elementor-element elementor-element-035d3b0 elementor-widget__width-inherit elementor-widget elementor-widget-html" data-id="035d3b0" data-element_type="widget" data-e-type="widget" data-widget_type="html.default">
				<div class="elementor-widget-container">
					<!--
// PAPER PLANE MODE for Arrival space, © All rights reserved.
//
//                                                                              ++==--=+++-=
//                                                                     +++++-:::::-=++=:. .=
//                                                        +++++=++============++*+=:....  .=
//                                              =+++++===================+**#*=-:::.... .:-=
//                                 =+++++++++===========++++**********###*==-::::::..:-====
//                          %%%##********************************##%%%#**+==-::::-=========
//                     **************************************#%%%%%*+**++++==--=+++++++++=-
//               **************************************###%%%%##*************#*++++++++++=
//            ****************########################%%%%%**************######*+++++++++=
//                 ####***#######################%%%%%##*************############*+++++++
//                       ####################%%%%%##**************#################*++++=
//                              @#######%%%%%%%***************######################***+-
//                                   @%%%%##**************############################*+
//                                    %%%###########**#################################*
//                                    %%%#########*####################################
//                                    @%%%####**-     @%#%###########################
//                                    @%%%#+                 %%##################%
//                                                                %%%%%%%%%%%%#%
//                                                                    @%%%%%%@
//
// 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/
//
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%#*++==------===**%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#++-::::::::::::::::::::::-=*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#+-::::::::::::::::::::::::::::::::=*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@#+::::::::::::::::::::::::::::::::::::::::=%@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@%+:::::::::::::::::=++***+=-::::::::::::::::::::=%@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@#=::::::::::::::::+%@@@@@@@@@@%+-:::::::::::::::::::=#@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@#-::::::::::::::::#@@@@@@@@@@@@@@@@%-:::::::::::::::::::=#@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@%=::::::::::::::::*@@@@@@**==-=+##@@@@@*-:::::::::::::::::::=%@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@=:::::::::::::::::%@@@@@+::::::::::-*@@@@#-::::::::::::::::::::=@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@#::::::::::::::::::%@@@@=::::::::::::::-%@@@#-:::::::::::::::::::::#@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@=::::::::::::::::::*@@@@=::::::::::::::::-%@@@*::::::::::::::::::::::=@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@%-::::::::::::::::::-@@@@=::::::::::::::::::-%@@@-::::::::::::::::::::::-%@@@@@@@@@@@@@
// @@@@@@@@@@@@#::::::::::::::::::::*@@@#::::::::::::::::::::=@@@*::::::::::::::::::::::::*@@@@@@@@@@@@
// @@@@@@@@@@@#:::::::::::::::::::::%@@@+::::::::::::::::::::-@@@%:::::::::::::::::::::::::*@@@@@@@@@@@
// @@@@@@@@@@+::::::::::::::::::::::@@@@-:::::::::::::::::::::%@@@::::::::::::::::::::::::::*@@@@@@@@@@
// @@@@@@@@@*:::::::::::::::::::::::@@@@=:::::::::::::::::::::@@@@:::::::::::::::::::::::::::*@@@@@@@@@
// @@@@@@@@%::::::::::::::::::::::::@@@@*:::::::::::::::::::::@@@%::::::::::::::::::::::::::::*@@@@@@@@
// @@@@@@@%:::::::::::::::::::::::::@@@@%::::::::::::::::::::+@@@*:::::::::::::::::::::::::::::%@@@@@@@
// @@@@@@@::::::::::::::::::::::::::@@@@@*::::::::::::::::::-@@@@::::::::::::::::::::::::::::::-@@@@@@@
// @@@@@@=::::::::::::::::::::::::::@@@@@@*::::::::::::::::-@@@@+:::::::::::::::::::::::::::::::*@@@@@@
// @@@@@%:::::::::::::::::::::::::::@@@@@@@*::::::::::::::-%@@@#:::::::::::::::::::::::::::::::::*@@@@@
// @@@@@::::::::::::::::::::::::::::@@@@@@@@@*::::::::::+@@@@@*::::::::::::::::::::::::::::::::::-@@@@@
// @@@@*::::::::::::::::::::::::::::@@@#=@@@@@@%#*+=**%@@@@@@=::::::::::::::::::::::::::::::::::::*@@@@
// @@@@:::::::::::::::::::::::::::::@@@#::*@@@@@@@@@@@@@@@@*:::::::::::::::::::::::::::::::::::::::@@@@
// @@@*:::::::::::::::::::::::::::::@@@#::::=*@@@@@@@@@%#=:::::::::::::::::::::::::::::::::::::::::#@@@
// @@@=:::::::::::::::::::::::::::::@@@#::::::::-==+=-:::::::::::::::::::::::::::::::::::::::::::::=@@@
// @@@::::::::::::::::::::::::::::::@@@#:::::::::::::::::::::::::::::::::::::::::::::::::--:::::::::@@@
// @@*::::::::::::::::::::::::::::::@@@#:::::::::+######*:::::::::::::::::::::::::::::::+@=:::::::::#@@
// @@*::::::::::::::::::::::::::::::@@@#:::--::::===+*+==:::::=-:::::::=-::::::-::::::::+@=:::::::::+@@
// @@:::::::::::::::::::::::::::::::@@@#::+@#@@::-#@@%@%=::=@@@@+:::+@@@@@+-::@@@@%*-::#@@%#:::::::::@@
// @@:::::::::::::::::+*##%%#*=-::::@@@#::+@@=::-%@:::-*@=:%@-:=:::*@*:::-@*::@@-:=@%::=*@*=:::::::::@@
// @@::::::::::::::+%@@@@@@@@@@@%*-:@@@#::+@*:::*@@@@@@@@#:+@%*-:::@@@@@@@@@-:@%:::#@:::+@=::::::::::@@
// @%::::::::::::*@@@@@@@@@@@@@@@@@#+@@#::+@+:::+@=-------:::=%@*::@%-------::@%:::#@:::+@=::::::::::%@
// @%::::::::::-@@@@@@++-::::-=#@@@@@+*#::+@+:::-@%=-:+%%-:-=::#@::*@*-::+%=::@%:::#@:::+@=::::::::::#@
// @%:::::::::=@@@@%+::::::::::::#@@@@**::+@+::::-#@@@@%=::#@%%@+:::+@@@@@*:::@%:::#@:::+@=::::::::::%@
// @%::::::::-@@@@+:::::::::::::::-@@@@+:::-::::::::--:::::::=-:::::::-=-:::::--:::-=----=-::::::::::%@
// @@:::::::-@@@@*:::::::::::::::::-%%%*:::::::::::::::::::::::::::::::::::::::::::+%%%%%%#::::::::::@@
// @@:::::::*@@@#:::::::+#@%*-::::::=+*#*=+#*=-:::::+#%%#-::::::=#%%#=::::-#@@#-::::=##%#+-::::::::::@@
// @@-::::::@@@@-:::::-%@#+*%@*:::::%@#+#@@+*@*::::%@+==#@*::::%@#+=%@*-::%@-=*=:::*@*+=+@%-::::::::-@@
// @@+:::::-@@@@::::::#@=::::#@=::::%@::=@*::#@-::*@=::::-@+::*@*::::*@*::*@*-::::-@@####%@#::::::::*@@
// @@#:::::-@@@*::::::@@:::::=@*::::%@::=@*::#@-::%@::::::@*::%@-::::-@%:::=%@%-::*@#******+::::::::*@@
// @@@-::::-@@@%::::::#@+::::%@-::::%@::=@*::#@-::@@-::::=@-::*@*::::*@*:::-::@%::=@*::::-=-::::::::@@@
// @@@+:::::@@@@-::::::%@%*#@@+:::::%@::=@*::#@-::@@@#=+#@+::::*@%*+@@*:::#@*+@*:::*@%*+%@%::::::::-@@@
// @@@#:::::#@@@+:::::::=*##+-::::::+*::-*=::+*:::@%=*%#*-::::::=*%#*-:::::=*#+:::::-*##*=--=::::::*@@@
// @@@@-::::=@@@@-::::::::::::::::::=**+::::::::::@%:::::::::::::::::::::::-==++**##%%@@@@@@@-::::-@@@@
// @@@@*:::::*@@@@-::::::::::::::::+@@@#::::::::::::::::::::-==++**##=-#@@@@@@@@@@@@@@@@@@@@@=::::*@@@@
// @@@@@-:::::#@@@@+-:::::::::::::*@@@@::::::-==++--##%@@@@@@@@@#*#@@%%@@@@@@@@@@@#@@@@@*#@@@+:::-@@@@@
// @@@@@%::::::*@@@@@+-:::::::::+@@@@%:=@@@@@@@@@@==@@@**%@@@@*-#@*-@@*@@@*-+**@@@:%@@==@++@@*:::%@@@@@
// @@@@@@*::::::=@@@@@@@%%**##@@@@@@=::=@@@@#-:--=-=@@=+@+=@@@:%@@@=+@-*@*-@@@+:@@=#@:*@@%-@@#::+@@@@@@
// @@@@@@@-:::::::+%@@@@@@@@@@@@@@#-::::@@@+-%@@@*--@@:@@=*@@@%=*%@@@@*=@-*@@@*:+@=+=#@@@%:@@%:-@@@@@@@
// @@@@@@@@-::::::::=*%@@@@@@@##-:::::::@@@:#@@@@@*:@@:%==@@@@@@%+-%@@#-@*+@@@:+:@*:*@@@@@:%@@-@@@@@@@@
// @@@@@@@@#-:::::::::::::-:::::::::::::@@%:%@@@@@*-@@-#@@@#-@*%@@@-#@@:@@#=+:%@-#@:@@@@@@*=@@%@@@@@@@@
// @@@@@@@@@*:::::::::::::::::::::::::::#@@--@@@@%:*@@%*+*=*@@=+@@@=+@@-#@@@@@@@=#@#@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@*-:::::::::::::::::::::::::#@@@+-==-:#@@@@@@@@@@@@#++*#@@@@@@@#@@@%:@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@*-::::::::::::::::::::::::+@@@@@%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@-=-:+**++==--::%@@@@@@@@@@@
// @@@@@@@@@@@@#-:::::::::::::::::::::::=@@@@@@@@@@@@@@@@@@@@@%%##**++=---::::::::::::::::%@@@@@@@@@@@@
// @@@@@@@@@@@@@@-::::::::::::::::::::::-@@@@@@%###**++=--::::::::::::::::::::::::::::::-%@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@+::::::::::::::::::::::--::::::::::::::::::::::::::::::::::::::::::::+@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@%-::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::-%@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@+-:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::+@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@%=::::::::::::::::::::::::::::::::::::::::::::::::::::::::::+%@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@%=::::::::::::::::::::::::::::::::::::::::::::::::::::::=%@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@%=-:::::::::::::::::::::::::::::::::::::::::::::::::+%@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@*-:::::::::::::::::::::::::::::::::::::::::::::#@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@%+-:::::::::::::::::::::::::::::::::::::::+%@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%*=-:::::::::::::::::::::::::::::::+#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%**=-:::::::::::::::::::::=+#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%**++=====+++*##@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ 
-->
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<div class="paperplane-container">
    <iframe
        id="arrival-space-iframe-pp"
        src="https://arrival.space/82998375_8446?utm=embed_82998375"
        width="100%"
        height="860"
        allow="camera; microphone; vr; xr; xr-spatial-tracking; fullscreen"
        allowfullscreen
        loading="lazy">
    </iframe>
    <div class="pp-buttons-container">
        <div class="pp-vr-instruction-text">
            Click here &#x1F861; from your VR headset
        </div>
        <a
            href="https://arrival.space/82998375_8446"
            target="_blank"
            class="pp-direct-link pp-vr-button"
        >
            <i class="fa-regular fa-paper-plane"></i> Paper Plane in VR ᯅ
        </a>
        <a
            id="pp-fullscreen-link"
            class="pp-direct-link pp-fullscreen-button"
        >
            Plein écran &#x1F5D6;
        </a>
    </div>
</div>
<style>
    .paperplane-container {
        position: relative;
        width: 100vw;
        max-width: 100%;
        margin: 0;
        padding: 0;
        height: 860px;
        overflow: hidden;
    }
    .pp-buttons-container {
        position: absolute;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        pointer-events: none;
    }
    .pp-vr-instruction-text {
        position: absolute;
        top: 64px;
        left: 50%;
        transform: translateX(-50%);
        color: white;
        font-size: 14px;
        text-align: center;
        z-index: 1001;
        text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8);
        pointer-events: auto;
    }
    .pp-direct-link {
        position: absolute;
        padding: 8px 15px;
        background: #00BFFF;
        color: white;
        text-decoration: none;
        font-weight: bold;
        font-size: 14px;
        border-radius: 8px;
        border: 2px solid white;
        box-shadow: 0 0 8px rgba(0, 191, 255, 0.7);
        z-index: 1000;
        pointer-events: auto;
        cursor: pointer;
        transition: all 0.3s;
        font-family: sans-serif;
    }
    .pp-vr-button {
        top: 16px;
        left: 50%;
        transform: translateX(-50%);
    }
    .pp-fullscreen-button {
        bottom: 20px;
        right: 20px;
    }
    .pp-direct-link:hover {
        background: #1E90FF;
        box-shadow: 0 0 12px rgba(0, 191, 255, 0.9);
        z-index: 1001 !important;
    }
    #arrival-space-iframe-pp {
        border: none;
        display: block;
        width: 100vw;
        margin: 0;
    }
</style>
<script>
    document.getElementById('pp-fullscreen-link').addEventListener('click', function(e) {
        e.preventDefault();
        var iframe = document.getElementById('arrival-space-iframe-pp');
        if (iframe.requestFullscreen) iframe.requestFullscreen();
        else if (iframe.webkitRequestFullscreen) iframe.webkitRequestFullscreen();
        else if (iframe.msRequestFullscreen) iframe.msRequestFullscreen();
    });
</script>
				</div>
				</div>
					</div>
		</header>
					</div>
		</header>
				<section class="elementor-section elementor-top-section elementor-element elementor-element-a5e22ff elementor-section-full_width elementor-section-stretched elementor-section-height-default elementor-section-height-default wpr-particle-no wpr-jarallax-no wpr-parallax-no wpr-sticky-section-no wpr-column-slider-no wpr-equal-height-no" data-id="a5e22ff" data-element_type="section" data-e-type="section" data-settings="{&quot;stretch_section&quot;:&quot;section-stretched&quot;,&quot;background_background&quot;:&quot;classic&quot;}">
						<div class="elementor-container elementor-column-gap-default">
					<header class="elementor-column elementor-col-100 elementor-top-column elementor-element elementor-element-8d89f52" data-id="8d89f52" data-element_type="column" data-e-type="column">
			<div class="elementor-widget-wrap elementor-element-populated">
						<div class="elementor-element elementor-element-2954048 elementor-widget__width-inherit elementor-widget elementor-widget-html" data-id="2954048" data-element_type="widget" data-e-type="widget" data-widget_type="html.default">
				<div class="elementor-widget-container">
					<!--
// PAPER PLANE MODE for Arrival space, © All rights reserved.
//
//                                                                              ++==--=+++-=
//                                                                     +++++-:::::-=++=:. .=
//                                                        +++++=++============++*+=:....  .=
//                                              =+++++===================+**#*=-:::.... .:-=
//                                 =+++++++++===========++++**********###*==-::::::..:-====
//                          %%%##********************************##%%%#**+==-::::-=========
//                     **************************************#%%%%%*+**++++==--=+++++++++=-
//               **************************************###%%%%##*************#*++++++++++=
//            ****************########################%%%%%**************######*+++++++++=
//                 ####***#######################%%%%%##*************############*+++++++
//                       ####################%%%%%##**************#################*++++=
//                              @#######%%%%%%%***************######################***+-
//                                   @%%%%##**************############################*+
//                                    %%%###########**#################################*
//                                    %%%#########*####################################
//                                    @%%%####**-     @%#%###########################
//                                    @%%%#+                 %%##################%
//                                                                %%%%%%%%%%%%#%
//                                                                    @%%%%%%@
//
// 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/
//
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#****+++++****##%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@**+-::::::::::::::::::::==*#%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*+-::::::::::::::::::::::::::::::==#%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@#+-::::::::::::::::::::::::::::::::::::::=*%@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@*-::::::::::::::::::::::::::::::::::::::::::::=*%@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@#+:::::::::::::::++****%%@@@@@%%##**=--:::::::::::::=#@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@*=:::::::::::::+*%@@@@@@@@@@@@@@@@@@@@@@@#*=-:::::::::::=#@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@#=:::::::::::+*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#=-::::::::::=#@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@%=::::::::::+%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*=::::::::::=%@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@*::::::::::*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#+-:::::::::*%@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@%=::::::::=*@@@@@@@@@@@@@@%#*********#%%@@@@@@@@@@@@@@@@@@@%+-::::::::=%@@@@@@@@@@@@@@
// @@@@@@@@@@@@@#:::::::::#@@@@@@@@@@@**+-::::::::::::::::-+#%@%@@@@@@@@@@@@@@%+-::::::::*@@@@@@@@@@@@@
// @@@@@@@@@@@@+::::::::+@@@@@@@@@@*-::::::::::::::::::::::#@@@##%%@@@@@@@@@@@@@@+::::::::*%@@@@@@@@@@@
// @@@@@@@@@@@=:::::::=#@@@@@@@@@+::::::::::::::::::::::::-*@@@@@@#%%@@@@@@@@@@@@@*-:::::::=%@@@@@@@@@@
// @@@@@@@@@@=:::::::*@@@@@@@@@%=::::::::::::::::::::::::::*@@@@@@@@##%@@@@@@@@@@@@%+:::::::=%@@@@@@@@@
// @@@@@@@@@%=:::::::#@@@@@@@@@%:::::::::::::::::::::::::::+@@@@@@@@@@@%%@@@@@@@@@@@@@*-::::::-%@@@@@@@
// @@@@@@@@@=::::::-#@@@@@@@@@%-:::::::::::::::::::::::::==#@@@@@@@@@@@@%#@@@@@@@@@@@@@#-::::::=%@@@@@@
// @@@@@@@@=::::::-%@@@@@@@@@@=:::::::::::::::::::::::::*=#%@@@@@@@@@@@@@%#@@@@@@@@@@@@@#-::::::*@@@@@@
// @@@@@@@*::::::-#@@@@@@@@@@*::::::::::::::::::::::::*=#+%@@@%@@@@@@@@@@@%%@@@@@@@@@@@@@%-::::::*@@@@@
// @@@@@@%:::::::#@@@@@@@@@@%:::::::::::::::::::---:::-:-+*@%%@@@@@@@@@@@@@*%@@@@@@@@@@@@@%-::::::%@@@@
// @@@@@@-::::::#@@@@@@@@@@@-::::::::::::::::::::+=#-=#*#=+%%@@@@@@@@@@@@@@@%@@@@@@@@@@@@@@#-:::::-%@@@
// @@@@@*::::::*@@@@@@@@@@@#:::::::::::::::::::::-++=#==#+-%%%@@@@@@@@@@@@@@%@@@@@@@@@@@@@@@*::::::*@@@
// @@@@%::::::-@@@@@@@@@@@@=::::::::::::::::::::::::+=++#%=%*@@@@@@@@@@@@@@@@*@@@@@@@@@@@@@@@-::::::%@@
// @@@@*::::::#@@@@@@@@@@@%:::::::::::::::::::::::::+#:+@%+%@@@@@@@@@@@@@@@@@@#@@@@@@@@@@@@@@%-:::::*@@
// @@@%::::::=@@@@@@@@@@@@*::::::::::::::::::::::::-#*-#+=@@%@%@@@@@@@@@@@@@@@#@@@@@@@@@@@@@@@*::::::%@
// @@@*::::::%@@@@@@@@@@@@=:::::::::::::::::::::::+=#@++=+%@@@@@@@@@@@@@@#@@@+-%@@@@@@@@@@@@@@@-:::::*@
// @@::::::*@@@@@@@@@@@@@-:::::::::::::::::::::::-**+@+*=%@%@@@@@@@@@@@#@@%=::*@@@@@@@@@@@@@@@*::::::%@
// @%::::::#@@@@@@@@@@@@@::::::::::::::::::::::::::==#@%+@%+*%%@@@@@@@#==+::=-#@@@@@@@@@@@@@@@#::::::*@
// @*:::::-@@@@@@@@@@@@@@::::::::::::::::::::::::::::***%-@*@%%@@@@@@@%%=+*%%%@@@@@@@@@@@@@@@@@-:::::+@
// @=:::::*@@@@@@@@@@@@@@-::::::::::::::::::::::::::-+@+=+*%*%@@@@@@@%@@@%*=:*@@@@@@@@@@@@@@@@@+:::::=@
// %::::::*@@@@@@@@@@@@@@*::::::::::::::::::::::-=-=+#**#=%+@@*@@@@@@@@@@@+=**%@@@@@@@@@@@@@@@@#::::::%
// %::::::%@@@@@@@@@@@@@@#:::::::::::::::::::+#@++@%@@#*+*+#=@@@@@@@@@@@@@@*%@#%@@@@@@@@@@@@@@@#::::::#
// *::::::@@@@@@@@@@@@@@@@-:::::::::::::::::*@@@:::%@@@@*---**@@@@@@@@@@@@@@@@@@%@@@@@@@@@@@@@@%::::::*
// *::::::@@@@@@@@@@@@@@@@+:::::::::::::::::@@@@%::*@@@@@+-:=%+@@@@@@@@@@@@@@@@@@%@@@@@@@@@@@@@@::::::*
// *:::::-@@@@@@@@@@@@@@@@#:::::::::::::::::%=%@@-:+@@@@@*::%*@@@@@@@@@@@@@@@@@@@@%@@@@@@@@@@@@@-:::::*  
// *:::::=@@@@@@@@@@@@@@@@@+::::::::::::::::*:-@*::*-*@@@@-:-=%@%@@@@@@@@@@@@@@@@@#@@@@@@@@@@@@@::::::*
// *:::::-@@@@@@@@@@@@@@@@@%-:::::::::::::::*+:%@@:::-@@@@-:=+%%@%@@@@@@@@@@@*@@@@@@@@@@@@@@@@@@::::::*
// *::::::%@@@@@@@@@@@@@@@@@*::::::::::::::::*=-@*::::%@@@+:+*@@@%@@@@@@@@@@@%@@%#@@@@@@@@@@@@@%::::::*
// #::::::%@@@@@@@@@@@@@@@@@%-::::::::::::::::#+**:::::%@@*:-=%@*@@@@@@@@@@@#**%@@@@@@@@@@@@@@@%::::::#
// @::::::*@@@@@@@@@@@@@@@@@@*::::::::::::::::-%@@@@@**@@%=::=+@@@@@%@@@@@@@@@%@@@@@@@@@@@@@@@@*::::::@
// @-:::::*@@@@@@@@@@@@@@@@@@@=:::::::::::::::::#@@@@@@@@%:::+=%@@@@@@@@@@@@@@@%@@@@@@@@@@@@@@@*:::::-@
// @+:::::=@@@@@@@@@@@@@@@@@@@%-:::::::::::::::::*%@@@*%@-:::-*=*%@@@@@@@@@@@@@%@@@@@@@@@@@@@@@::::::*@
// @*::::::%@@@@@@@@@@@@@@@@@@@*:::::::::::::::::::=%%+#@=:::=*++@@@@@@@@@@@@@#@@@@@@@@@@@@@@@%::::::*@
// @@-:::::*@@@@@@@@@@@@@@@@@@@#::::::::::::::::----:=###::::=**%%@@@@@@@@@@@+%@@@@@@@@@@@@@@@@+:::::-@
// @@+:::::-%@@@@@@@@@@@@@@@@@@@-:::::::::::::=@@@@@+::::::::-*#%%%@@@@@@@@@%*%@@@@@@@@@@@@@@%::::::*@@
// @@#::::::*@@@@@@@@@@@@@@@@@@@+::::::::::::::%@@@@@-:::::::+%@%%%@@@@@@@@@%@@@@@@@@@@@@@@@@+::::::%@@
// @@@+::::::%@@@@@@@@@@@@@@@@@@*::::::::::::::*@@@@@-:::::::-+#@%%@%@@@@@@@%@@@@@@@@@@@@@@@%::::::=@@@
// @@@%-:::::*@@@@@@@@@@@@@@@@@@*::::::::::::::#@@@@@::::::::::*@%**%@@@@@@@#@@@@@@@@@@@@@@@=::::::%@@@
// @@@@*::::::%@@@@@@@@@@@@@@@@@*::::::::::::::*@@@@@#-::::::::===+-%%@@@@@@%%@@@@@@@@@@@@@*::::::*@@@@
// @@@@@-:::::-%@@@@@@@@@@@@@@@@+::::::::::::::=#%@@@@*--#=::::::--:=@@%%%@@%@@@@@@@@@@@@@#::::::-@@@@@
// @@@@@#-:::::-%@@@@@@@@@@@@@@@=::::::::::::::==@%@%%%#+%%*:::::::-=#%%*%@#*@@@@@@@@@@@@%:::::::#@@@@@
// @@@@@@*::::::*%@@@@@@@@@@**+-:::::::::::::::@#@@%@@@%#%@*::::-::::==::%%*@@@@@@@@@@@@%=::::::*@@@@@@
// @@@@@@@+::::::*@@@@@@@@*::::::::::::::::::-##%%@#@@@@%%%*-==*%:-::==+**@@@@@@@@@@@@@%=::::::=@@@@@@@
// @@@@@@@@-::::::*%@@@@*::::::::::::::::::::::%%%%%@@@@@@@#%@@%%@@@@@@@@@@@@@@@@@@@@@%=::::::=@@@@@@@@
// @@@@@@@@%-::::::=%@@%:::::::::::::::::::::#@%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#:::::::-@@@@@@@@@
// @@@@@@@@@@-::::::-%@#::::::::::::::::::::+@@@@@%@@@@@@@@@@@%%@@@@@@@@@@@@@@@@@@@@*:::::::=@@@@@@@@@@
// @@@@@@@@@@@-:::::::*@-:::::::::::::::::::-%@@@@@@@@@@@@@@@@*@@@@@@@@@@@@@@@@@@@@=:::::::=@@@@@@@@@@@
// @@@@@@@@@@@@+:::::::==:::::::::::::::::::-%@@@@@@@@@@@@@@@%@@@@@@@@@@@@@@@@@@@#-:::::::+@@@@@@@@@@@@
// @@@@@@@@@@@@@*-:::::::::::::::::::::::::::=@@@@@@@@@@@@@@%#@@@@@@@@@@@@@@@@@%=::::::::#@@@@@@@@@@@@@
// @@@@@@@@@@@@@@#-:::::::::::::::::::::::::::*@@@@@@@@@@@@#@@@@@@@@@@@@@@@@@@+::::::::=#@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@*-::::::::::::::::::::::::::*%@@@@@@@@@@*@@@@@@@@@@@@@@@@+:::::::::*@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@%=:::::::::::::::::::::::::::*%@@@@@@@@#@@@@@@@@@@@@@*=:::::::::=%@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@#-:::::::::::::::::::::::::::*%@@@@@@@%#@@@@@@@@*+-:::::::::=#@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@#-:::::::::::::::::::::::::::*%@@@@@@@%@@@@*+:::::::::::=#@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@#=-::::::::::::::::::::::::::+%@@@@%**--::::::::::::+#@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@%*-:::::::::::::::::::::::::::::::::::::::::::::*%@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@#*-:::::::::::::::::::::::::::::::::::::::+%@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@##=-:::::::::::::::::::::::::::::::+*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@##*=-::::::::::::::::::::-+**%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%##****+++++****%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
//
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%#*++==------===**@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#++-::::::::::::::::::::::-=*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#+-::::::::::::::::::::::::::::::::=*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@#+::::::::::::::::::::::::::::::::::::::::=%@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@%+:::::::::::::::::=++***+=-::::::::::::::::::::=%@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@#=::::::::::::::::+%@@@@@@@@@@%+-:::::::::::::::::::=#@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@#-::::::::::::::::#@@@@@@@@@@@@@@@@%-:::::::::::::::::::=#@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@%=::::::::::::::::*@@@@@@**==-=+##@@@@@*-:::::::::::::::::::=%@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@=:::::::::::::::::%@@@@@+::::::::::-*@@@@#-::::::::::::::::::::=@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@#::::::::::::::::::%@@@@=::::::::::::::-%@@@#-:::::::::::::::::::::#@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@=::::::::::::::::::*@@@@=::::::::::::::::-%@@@*::::::::::::::::::::::=@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@%-::::::::::::::::::-@@@@=::::::::::::::::::-%@@@-::::::::::::::::::::::-%@@@@@@@@@@@@@
// @@@@@@@@@@@@#::::::::::::::::::::*@@@#::::::::::::::::::::=@@@*::::::::::::::::::::::::*@@@@@@@@@@@@
// @@@@@@@@@@@#:::::::::::::::::::::%@@@+::::::::::::::::::::-@@@%:::::::::::::::::::::::::*@@@@@@@@@@@
// @@@@@@@@@@+::::::::::::::::::::::@@@@-:::::::::::::::::::::%@@@::::::::::::::::::::::::::*@@@@@@@@@@
// @@@@@@@@@*:::::::::::::::::::::::@@@@=:::::::::::::::::::::@@@@:::::::::::::::::::::::::::*@@@@@@@@@
// @@@@@@@@%::::::::::::::::::::::::@@@@*:::::::::::::::::::::@@@%::::::::::::::::::::::::::::*@@@@@@@@
// @@@@@@@%:::::::::::::::::::::::::@@@@%::::::::::::::::::::+@@@*:::::::::::::::::::::::::::::%@@@@@@@
// @@@@@@@::::::::::::::::::::::::::@@@@@*::::::::::::::::::-@@@@::::::::::::::::::::::::::::::-@@@@@@@
// @@@@@@=::::::::::::::::::::::::::@@@@@@*::::::::::::::::-@@@@+:::::::::::::::::::::::::::::::*@@@@@@
// @@@@@%:::::::::::::::::::::::::::@@@@@@@*::::::::::::::-%@@@#:::::::::::::::::::::::::::::::::*@@@@@
// @@@@@::::::::::::::::::::::::::::@@@@@@@@@*::::::::::+@@@@@*::::::::::::::::::::::::::::::::::-@@@@@
// @@@@*::::::::::::::::::::::::::::@@@#=@@@@@@%#*+=**%@@@@@@=::::::::::::::::::::::::::::::::::::*@@@@
// @@@@:::::::::::::::::::::::::::::@@@#::*@@@@@@@@@@@@@@@@*:::::::::::::::::::::::::::::::::::::::@@@@
// @@@*:::::::::::::::::::::::::::::@@@#::::=*@@@@@@@@@%#=:::::::::::::::::::::::::::::::::::::::::#@@@
// @@@=:::::::::::::::::::::::::::::@@@#::::::::-==+=-:::::::::::::::::::::::::::::::::::::::::::::=@@@
// @@@::::::::::::::::::::::::::::::@@@#:::::::::::::::::::::::::::::::::::::::::::::::::--:::::::::@@@
// @@*::::::::::::::::::::::::::::::@@@#:::::::::+######*:::::::::::::::::::::::::::::::+@=:::::::::#@@
// @@*::::::::::::::::::::::::::::::@@@#:::--::::===+*+==:::::=-:::::::=-::::::-::::::::+@=:::::::::+@@
// @@:::::::::::::::::::::::::::::::@@@#::+@#@@::-#@@%@%=::=@@@@+:::+@@@@@+-::@@@@%*-::#@@%#:::::::::@@
// @@:::::::::::::::::+*##%%#*=-::::@@@#::+@@=::-%@:::-*@=:%@-:=:::*@*:::-@*::@@-:=@%::=*@*=:::::::::@@
// @@::::::::::::::+%@@@@@@@@@@@%*-:@@@#::+@*:::*@@@@@@@@#:+@%*-:::@@@@@@@@@-:@%:::#@:::+@=::::::::::@@
// @%::::::::::::*@@@@@@@@@@@@@@@@@#+@@#::+@+:::+@=-------:::=%@*::@%-------::@%:::#@:::+@=::::::::::%@
// @%::::::::::-@@@@@@++-::::-=#@@@@@+*#::+@+:::-@%=-:+%%-:-=::#@::*@*-::+%=::@%:::#@:::+@=::::::::::#@
// @%:::::::::=@@@@%+::::::::::::#@@@@**::+@+::::-#@@@@%=::#@%%@+:::+@@@@@*:::@%:::#@:::+@=::::::::::%@
// @%::::::::-@@@@+:::::::::::::::-@@@@+:::-::::::::--:::::::=-:::::::-=-:::::--:::-=----=-::::::::::%@
// @@:::::::-@@@@*:::::::::::::::::-%%%*:::::::::::::::::::::::::::::::::::::::::::+%%%%%%#::::::::::@@
// @@:::::::*@@@#:::::::+#@%*-::::::=+*#*=+#*=-:::::+#%%#-::::::=#%%#=::::-#@@#-::::=##%#+-::::::::::@@
// @@-::::::@@@@-:::::-%@#+*%@*:::::%@#+#@@+*@*::::%@+==#@*::::%@#+=%@*-::%@-=*=:::*@*+=+@%-::::::::-@@
// @@+:::::-@@@@::::::#@=::::#@=::::%@::=@*::#@-::*@=::::-@+::*@*::::*@*::*@*-::::-@@####%@#::::::::*@@
// @@#:::::-@@@*::::::@@:::::=@*::::%@::=@*::#@-::%@::::::@*::%@-::::-@%:::=%@%-::*@#******+::::::::*@@
// @@@-::::-@@@%::::::#@+::::%@-::::%@::=@*::#@-::@@-::::=@-::*@*::::*@*:::-::@%::=@*::::-=-::::::::@@@
// @@@+:::::@@@@-::::::%@%*#@@+:::::%@::=@*::#@-::@@@#=+#@+::::*@%*+@@*:::#@*+@*:::*@%*+%@%::::::::-@@@
// @@@#:::::#@@@+:::::::=*##+-::::::+*::-*=::+*:::@%=*%#*-::::::=*%#*-:::::=*#+:::::-*##*=--=::::::*@@@
// @@@@-::::=@@@@-::::::::::::::::::=**+::::::::::@%:::::::::::::::::::::::-==++**##%%@@@@@@@-::::-@@@@
// @@@@*:::::*@@@@-::::::::::::::::+@@@#::::::::::::::::::::-==++**##=-#@@@@@@@@@@@@@@@@@@@@@=::::*@@@@
// @@@@@-:::::#@@@@+-:::::::::::::*@@@@::::::-==++--##%@@@@@@@@@#*#@@%%@@@@@@@@@@@#@@@@@*#@@@+:::-@@@@@
// @@@@@%::::::*@@@@@+-:::::::::+@@@@%:=@@@@@@@@@@==@@@**%@@@@*-#@*-@@*@@@*-+**@@@:%@@==@++@@*:::%@@@@@
// @@@@@@*::::::=@@@@@@@%%**##@@@@@@=::=@@@@#-:--=-=@@=+@+=@@@:%@@@=+@-*@*-@@@+:@@=#@:*@@%-@@#::+@@@@@@
// @@@@@@@-:::::::+%@@@@@@@@@@@@@@#-::::@@@+-%@@@*--@@:@@=*@@@%=*%@@@@*=@-*@@@*:+@=+=#@@@%:@@%:-@@@@@@@
// @@@@@@@@-::::::::=*%@@@@@@@##-:::::::@@@:#@@@@@*:@@:%==@@@@@@%+-%@@#-@*+@@@:+:@*:*@@@@@:%@@-@@@@@@@@
// @@@@@@@@#-:::::::::::::-:::::::::::::@@%:%@@@@@*-@@-#@@@#-@*%@@@-#@@:@@#=+:%@-#@:@@@@@@*=@@%@@@@@@@@
// @@@@@@@@@*:::::::::::::::::::::::::::#@@--@@@@%:*@@%*+*=*@@=+@@@=+@@-#@@@@@@@=#@#@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@*-:::::::::::::::::::::::::#@@@+-==-:#@@@@@@@@@@@@#++*#@@@@@@@#@@@%:@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@*-::::::::::::::::::::::::+@@@@@%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@-=-:+**++==--::%@@@@@@@@@@@
// @@@@@@@@@@@@#-:::::::::::::::::::::::=@@@@@@@@@@@@@@@@@@@@@%%##**++=---::::::::::::::::%@@@@@@@@@@@@
// @@@@@@@@@@@@@@-::::::::::::::::::::::-@@@@@@%###**++=--::::::::::::::::::::::::::::::-%@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@+::::::::::::::::::::::--::::::::::::::::::::::::::::::::::::::::::::+@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@%-::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::-%@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@+-:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::+@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@%=::::::::::::::::::::::::::::::::::::::::::::::::::::::::::+%@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@%=::::::::::::::::::::::::::::::::::::::::::::::::::::::=%@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@%=-:::::::::::::::::::::::::::::::::::::::::::::::::+%@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@*-:::::::::::::::::::::::::::::::::::::::::::::#@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@%+-:::::::::::::::::::::::::::::::::::::::+%@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%*=-:::::::::::::::::::::::::::::::+#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%**=-:::::::::::::::::::::=+#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%**++=====+++*##@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ 
-->
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<div class="pp-desc-container">
    <div class="pp-desc-wrap">

        <!-- Badge lauréat -->
        <div class="pp-award-row">
            <div class="pp-laurel-badge">
                <img decoding="async"
                    src="https://presentcomposedesign.fr/wp-content/uploads/2026/03/3rd_place_svg_PresentComposedesign_NWSOPC.svg"
                    alt="3rd Place - New Worlds and Splats Open Call"
                    class="pp-laurel-img"
                />
            </div>
        </div>

        <h2 class="pp-title">Paper Plane <i class="fa-regular fa-paper-plane pp-title-icon"></i></h2>
        <p class="pp-subtitle">Mon tout premier plugin Arrival.Space. Un avatar universel pour le web immersif.</p>

        <div class="pp-divider"></div>

        <div class="pp-content">
            <div class="pp-block">
                <h3 class="pp-heading">Le concept</h3>
                <p>Paper Plane est parti d'une question simple : et si le meilleur avatar était aussi le plus universel ? Pas de personnage genré, pas de corps humanoïde. Juste un avion en papier, instinctif et léger, qui traverse librement chaque monde d'Arrival.Space.</p>
            </div>

            <div class="pp-block">
                <h3 class="pp-heading">Pourquoi un avion en papier ?</h3>
                <p>Tout le monde en a plié un. Tout le monde en a lancé un. C'est peut-être l'objet le plus démocratique qui existe. En tant qu'avatar, il efface toute friction d'identification : pas d'âge, pas de genre, pas de skin à choisir. Du mouvement pur, et une liberté totale d'exploration.</p>
            </div>

            <div class="pp-block">
                <h3 class="pp-heading">De SplatGate à Paper Plane</h3>
                <p>Ce projet s'appuie sur toute l'expertise en Gaussian Splatting que j'ai construite avec SplatGate. Paper Plane, c'est mon premier pas dans l'écosystème Arrival.Space, un nouveau terrain pour pousser les limites du web immersif, du spatial computing et du vibe coding.</p>
            </div>

            <!-- Tags compacts + VR gradient -->
            <div class="pp-tags">
                <span class="pp-tag">🧊 Gaussian Splatting</span>
                <span class="pp-tag">🌐 Web Immersif</span>
                <span class="pp-tag pp-tag-vr"><span class="pp-vr-gradient">Full VR available Experience -ᯅ-</span> ✔️</span>
            </div>
        </div>

        <!-- CTAs - 3 boutons sur une ligne -->
        <div class="pp-cta-row">
            <a href="https://arrival.space/83754293_5074?gate=608f2483-802b-4fa7-95bb-920bed1431ad" target="_blank" class="pp-cta-primary">
                Explorer les univers gagnants <i class="fa-regular fa-paper-plane"></i>
            </a>
            <a href="https://arrival.space/82998375_8446" target="_blank" class="pp-cta-secondary">
                Essayer Paper Plane
            </a>
            <a href="https://buymeacoffee.com/presentcomposedesign" target="_blank" class="pp-cta-support">
                <i class="fa-solid fa-heart pp-heart-pulse"></i> Soutenir / Remercier
            </a>
        </div>

        <!-- Partenaires -->
        <div class="pp-partners">
            <span class="pp-partners-label">Organisé par</span>
            <div class="pp-partners-logos">
                <a href="https://arrival.space/" target="_blank" class="pp-partner-block" title="Arrival.Space">
                    <img decoding="async"
                        src="https://presentcomposedesign.fr/wp-content/uploads/2026/03/arrival-space-logo-white.svg"
                        alt="Arrival.Space"
                        class="pp-partner-logo"
                    />
                    <span class="pp-partner-name">Arrival.Space</span>
                </a>
                <span class="pp-partners-sep">&</span>
                <a href="https://www.choice.love/" target="_blank" class="pp-partner-block" title="CHOICE DAO">
                    <img decoding="async"
                        src="https://presentcomposedesign.fr/wp-content/uploads/2026/03/choice-DAO-logo-1.svg"
                        alt="CHOICE DAO"
                        class="pp-partner-logo"
                    />
                    <span class="pp-partner-name">CHOICE DAO</span>
                </a>
            </div>
        </div>

    </div>
</div>
<style>
    /* Animations */
    @keyframes ppHoloFlow {
        0% { background-position: 0% 50%; }
        100% { background-position: 200% 50%; }
    }
    @keyframes ppBronzeFlow {
        0% { background-position: 0% 50%; }
        100% { background-position: 200% 50%; }
    }
    @keyframes ppHeartBeat {
        0% { transform: scale(1); }
        50% { transform: scale(1.15); }
        100% { transform: scale(1); }
    }

    .pp-desc-container {
        width: 100vw;
        position: relative;
        left: 50%;
        right: 50%;
        margin-left: -50vw;
        margin-right: -50vw;
        padding: 0;
        background: linear-gradient(175deg, #0a0e1a 0%, #111827 50%, #0f172a 100%);
        box-sizing: border-box;
        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, sans-serif;
        overflow: hidden;
    }
    .pp-desc-wrap {
        max-width: 900px;
        margin: 0 auto;
        padding: 60px 40px 50px;
    }

    /* Badge lauréat */
    .pp-award-row {
        display: flex;
        justify-content: center;
        margin-bottom: 4px;
    }
    .pp-laurel-badge {
        display: flex;
        flex-direction: column;
        align-items: center;
        gap: 8px;
    }
    .pp-laurel-img {
        width: 160px;
        height: auto;
        filter: drop-shadow(0 0 14px rgba(184, 122, 94, 0.35));
    }
    .pp-award-label {
        font-size: 11px;
        font-weight: 600;
        color: #C4836A;
        letter-spacing: 2.5px;
        text-transform: uppercase;
        text-align: center;
        opacity: 0.85;
    }

    /* Titre & sous-titre */
    .pp-title {
        font-size: 46px;
        font-weight: 800;
        color: #ffffff;
        margin: 0 0 10px 0;
        line-height: 1.1;
        text-align: center;
    }
    .pp-title-icon {
        font-size: 36px;
        vertical-align: middle;
        margin-left: 6px;
        color: #00BFFF;
    }
    .pp-subtitle {
        font-size: 18px;
        color: #94a3b8;
        margin: 0 0 30px 0;
        line-height: 1.5;
        text-align: center;
    }
    .pp-divider {
        width: 60px;
        height: 3px;
        background: linear-gradient(90deg, #00BFFF, #1E90FF);
        border-radius: 2px;
        margin: 0 auto 40px;
    }

    /* Contenu */
    .pp-content {
        margin-bottom: 36px;
    }
    .pp-block {
        margin-bottom: 28px;
    }
    .pp-heading {
        font-size: 18px;
        font-weight: 700;
        color: #e2e8f0;
        margin: 0 0 8px 0;
    }
    .pp-block p {
        font-size: 15px;
        color: #94a3b8;
        line-height: 1.7;
        margin: 0;
    }

    /* Tags compacts */
    .pp-tags {
        display: flex;
        flex-wrap: wrap;
        gap: 8px;
        margin-top: 28px;
        justify-content: center;
    }
    .pp-tag {
        display: inline-flex;
        align-items: center;
        padding: 2px 10px;
        font-size: 11px;
        line-height: 1.4;
        font-weight: 500;
        color: #94a3b8;
        background: rgba(255, 255, 255, 0.03);
        border: 1px solid rgba(255, 255, 255, 0.06);
        border-radius: 20px;
        letter-spacing: 0.3px;
        white-space: nowrap;
    }
    .pp-tag-vr {
        gap: 4px;
    }
    .pp-vr-gradient {
        background: linear-gradient(120deg, #00F7AD, #0010EF 50%, #00F7AD);
        background-size: 200% 200%;
        animation: ppHoloFlow 5s linear infinite;
        -webkit-background-clip: text;
        -webkit-text-fill-color: transparent;
        background-clip: text;
        font-weight: 600;
    }

    /* CTAs - 3 boutons sur une ligne */
    .pp-cta-row {
        display: flex;
        justify-content: center;
        align-items: center;
        gap: 12px;
        flex-wrap: nowrap;
        margin-bottom: 50px;
    }

    /* CTA principal - contour gradient animé bronze/or */
    .pp-cta-primary {
        position: relative;
        display: inline-flex;
        align-items: center;
        gap: 8px;
        padding: 12px 24px;
        background: rgba(255, 255, 255, 0.03);
        backdrop-filter: blur(10px);
        -webkit-backdrop-filter: blur(10px);
        color: #D4967A;
        text-decoration: none;
        font-size: 14px;
        font-weight: 700;
        border-radius: 10px;
        border: 2px solid transparent;
        transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.2s;
        white-space: nowrap;
    }
    .pp-cta-primary::before {
        content: "";
        position: absolute;
        inset: 0;
        border-radius: 10px;
        padding: 2px;
        background: linear-gradient(120deg, #C9A84C, #B87A5E 30%, #D4967A 60%, #C9A84C);
        background-size: 200% 200%;
        animation: ppBronzeFlow 4s linear infinite;
        -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
        -webkit-mask-composite: xor;
        mask-composite: exclude;
        z-index: -1;
    }
    .pp-cta-primary:hover {
        transform: translateY(-2px);
        box-shadow: 0 5px 20px rgba(184, 122, 94, 0.25);
    }

    .pp-cta-secondary {
        display: inline-flex;
        align-items: center;
        padding: 12px 24px;
        background: transparent;
        color: #00BFFF;
        text-decoration: none;
        font-size: 14px;
        font-weight: 700;
        border-radius: 10px;
        border: 2px solid rgba(0, 191, 255, 0.4);
        transition: all 0.3s ease;
        white-space: nowrap;
    }
    .pp-cta-secondary:hover {
        border-color: #00BFFF;
        background: rgba(0, 191, 255, 0.08);
        transform: translateY(-2px);
    }
    .pp-cta-support {
        display: inline-flex;
        align-items: center;
        gap: 8px;
        padding: 12px 24px;
        background: rgba(255, 255, 255, 0.03);
        color: #fff;
        text-decoration: none;
        font-size: 14px;
        font-weight: 700;
        border-radius: 10px;
        border: 2px solid rgba(255, 255, 255, 0.12);
        transition: all 0.3s ease;
        backdrop-filter: blur(10px);
        -webkit-backdrop-filter: blur(10px);
        white-space: nowrap;
    }
    .pp-cta-support:hover {
        transform: translateY(-2px);
        background: rgba(255, 255, 255, 0.08);
        border-color: rgba(255, 255, 255, 0.25);
        box-shadow: 0 0 20px rgba(255, 64, 129, 0.2);
    }
    .pp-heart-pulse {
        color: #ff4081;
        animation: ppHeartBeat 2s infinite ease-in-out;
    }

    /* Partenaires */
    .pp-partners {
        text-align: center;
        padding-top: 30px;
        border-top: 1px solid rgba(255, 255, 255, 0.06);
    }
    .pp-partners-label {
        display: block;
        font-size: 11px;
        font-weight: 600;
        color: #64748b;
        letter-spacing: 2px;
        text-transform: uppercase;
        margin-bottom: 18px;
    }
    .pp-partners-logos {
        display: flex;
        align-items: center;
        justify-content: center;
        gap: 20px;
    }
    .pp-partner-block {
        display: flex;
        flex-direction: column;
        align-items: center;
        gap: 8px;
        text-decoration: none;
        opacity: 0.7;
        transition: opacity 0.3s;
    }
    .pp-partner-block:hover {
        opacity: 1;
    }
    .pp-partner-name {
        font-size: 12px;
        font-weight: 200;
        color: #94a3b8;
        letter-spacing: 0.5px;
    }
    .pp-partner-logo {
        height: 22px;
        width: auto;
        object-fit: contain;
    }
    .pp-partners-sep {
        color: #64748b;
        font-size: 20px;
        font-weight: 200;
        align-self: center;
        padding: 0 10px;
    }

    /* Responsive */
    @media (max-width: 768px) {
        .pp-desc-wrap {
            padding: 40px 20px 36px;
        }
        .pp-title {
            font-size: 30px;
        }
        .pp-title-icon {
            font-size: 24px;
        }
        .pp-subtitle {
            font-size: 16px;
        }
        .pp-cta-row {
            flex-wrap: wrap;
            flex-direction: column;
            align-items: center;
        }
        .pp-cta-primary,
        .pp-cta-secondary,
        .pp-cta-support {
            width: 100%;
            max-width: 300px;
            text-align: center;
            justify-content: center;
        }
        .pp-partners-logos {
            flex-direction: column;
            width: 80px;
            gap: 14px;
        }
        .pp-partners-sep {
            display: none;
        }
        .pp-laurel-img {
            width: 120px;
        }
    }
</style>
				</div>
				</div>
					</div>
		</header>
					</div>
		</section>
				</div>
		<p>Cet article <a href="https://presentcomposedesign.fr/paper-plane-mode_for_arrival-space/">Paper plane</a> est apparu en premier sur <a href="https://presentcomposedesign.fr">Présent Composé design</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Labs</title>
		<link>https://presentcomposedesign.fr/labs/</link>
		
		<dc:creator><![CDATA[admin]]></dc:creator>
		<pubDate>Mon, 16 Mar 2026 15:19:07 +0000</pubDate>
				<category><![CDATA[Design produit]]></category>
		<guid isPermaLink="false">https://presentcomposedesign.fr/?p=35979</guid>

					<description><![CDATA[<p>Cet article <a href="https://presentcomposedesign.fr/labs/">Labs</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="35979" class="elementor elementor-35979">
				<div class="elementor-element elementor-element-720b5ed 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="720b5ed" data-element_type="container" data-e-type="container">
					<div class="e-con-inner">
					</div>
				</div>
		<div class="elementor-element elementor-element-6c1039a 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="6c1039a" data-element_type="container" data-e-type="container">
					<div class="e-con-inner">
				<div class="elementor-element elementor-element-dc74ee0 elementor-widget elementor-widget-html" data-id="dc74ee0" data-element_type="widget" data-e-type="widget" data-widget_type="html.default">
				<div class="elementor-widget-container">
					<!DOCTYPE html>
<html>
<head>
  <style>
    * { margin: 0; padding: 0; outline: none !important; box-sizing: border-box; }
    html, body, #root { width: 100%; height: 100%; overflow: hidden; background: #000; }
    canvas { display: block; outline: none !important; }
    #canvas { position: absolute; top: 0; left: 0; }
  </style>

  <script type="importmap">
  {
    "imports": {
      "three": "https://cdn.jsdelivr.net/npm/three@0.183.2/build/three.module.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/"
    }
  }
  </script>

</head>
<body>
  <div id="root"></div>
  <script type="module" src="/scene.js"></script>
</body>
</html>				</div>
				</div>
					</div>
				</div>
				</div>
		<p>Cet article <a href="https://presentcomposedesign.fr/labs/">Labs</a> est apparu en premier sur <a href="https://presentcomposedesign.fr">Présent Composé design</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Formations</title>
		<link>https://presentcomposedesign.fr/formations/</link>
		
		<dc:creator><![CDATA[admin]]></dc:creator>
		<pubDate>Sat, 14 Mar 2026 12:46:14 +0000</pubDate>
				<category><![CDATA[Design produit]]></category>
		<guid isPermaLink="false">https://presentcomposedesign.fr/?p=35941</guid>

					<description><![CDATA[<p>Formations / Présent Composé Design ✓ Guide débloqué ! ✕🎨 Formation Découverte IA Débloquez le guide Entrez vos coordonnées pour accéder au guide sélectionné. PrénomNomEmailTéléphone (optionnel)Vous êtes· Choisissez ·Étudiant·ePro du design / créatifPro (autre secteur)Entrepreneur·eCurieux·se / DécouverteSecteur d&#8217;activitéCode d&#8217;accès (fourni par le formateur)Accéder au guide → 🔒 Données utilisées uniquement pour cette formation. Formations Présent Composé Design / Alban Desbarax Formations Présent Composé Design / Alban Desbarax Ateliers &#038; Ressources 🎨 TOP 10 Applications IA Génératives 2026 Les meilleures apps gratuites (téléphone &#038; desktop) pour générer des images et vidéos facilement avec l&#8217;IA. Guide interactif avec démos vidéo. GRATUIT · Guide interactif · 11 apps testées 💻 TOP 25 Applications Open Source à installer en 2026 La sélection essentielle des logiciels gratuits &#038; open source pour la création 3D, audio, vidéo, design, impression 3D, code créatif et productivité. GRATUIT · 25 logiciels · 7 catégories · Liens de téléchargement 🎬 Storyboard &#38; virtual production IA TOOLBOX 20 outils IA et 100 termes techniques pour la création de storyboards. Glossaire interactif, prompts prêts à l&#8217;emploi, liens et demos. GRATUIT · 20 outils · 100 termes · 2 onglets ⚡ Claude Vibecoding — Guide complet Maîtriser Claude dans le navigateur et dans VS Code. Workflows, prompts-clés, pièges à éviter, méthodes d&#8217;itération rapide pour créatifs qui codent. GRATUIT · 15 fiches · Navigateur · VS Code · Prompts à copier ← Retour aux formations ★ Incontournable / Assistant IA quotidien ★Mistral AI (Le Chat)Web · iOS · Android Assistant IA français polyvalent : texte, recherche, résumé, analyse. Idéal pour la productivité quotidienne. TexteRechercheAnalyse▶ DemoAccéder → 🖼️ Images / Génération &#038; édition 1ChatGPT + DALL·EWeb · iOS · Android Génération d&#8217;images réalistes via GPT-4o. ~3 images/jour en gratuit. Texte lisible dans les images. ImagesTexte▶ DemoAccéder → 2Google Gemini + Veo 3.1Web · iOS · Android IA multimodale Google. Images (Nano Banana) et vidéos HD avec audio natif via Flow. Gratuit et puissant. ImagesVidéosAudio▶ DemoAccéder → 3Canva IAWeb · iOS · Android Suite de design avec images (Média Magique) et vidéos IA (Veo-3). Intuitive, idéale pour débutants. ImagesVidéosDesign▶ DemoAccéder → 4Adobe FireflyWeb · Desktop Générateur images/vidéos premium d&#8217;Adobe. 25 crédits gratuits/mois. Remplissage génératif et retouche. ImagesVidéosRetouche▶ DemoAccéder → 🎬 Vidéos / Génération &#038; animation 5Luma AI (Dream Machine)Web · iOS · Android Génération vidéo réaliste à partir de texte ou d&#8217;images. Capture 3D photoréaliste avec téléphone. Vidéos3D▶ DemoAccéder → 6Runway (Gen-4)Web · iOS Outil cinématographique. Vidéo à partir de texte, images ou clips. Productions réelles. VidéosCinéma▶ DemoAccéder → 7Leonardo AIWeb · iOS · Android Générateur d&#8217;images avec nombreux modèles. 150 tokens/jour gratuits. Assets visuels. ImagesAssets▶ DemoAccéder → 8IdeogramWeb · iOS · Android Maîtrise du texte dans les images. ~40 images/semaine gratuits. Résultats créatifs. ImagesTypographie▶ DemoAccéder → 9Microsoft CopilotWeb · iOS · Android DALL·E 3 gratuit et illimité via Microsoft. Aucune inscription nécessaire. ImagesIllimité▶ DemoAccéder → 10PikaWeb · iOS Vidéos courtes créatives pour réseaux sociaux. Interface simple. VidéosSocial▶ DemoAccéder → ← Retour aux formations 💻 TOP 25 Applications Open Source &#038; Gratuites · PC 2026 🎮 3D · Moteurs · VR / XR Blender Suite 3D complète : modélisation, animation, rendu, compositing. Le standard open source. 3DAnimationRendu▶ DemoTélécharger ↓ Roblox Studio Créez vos propres jeux et expériences 3D. Plateforme massive de création interactive. Jeux3D▶ DemoTélécharger ↓ Unity / Unreal Engine Les deux moteurs de jeu de référence. Unity (C#) et Unreal (Blueprints/C++). Gratuits pour débuter. Jeux3DXR▶ DemoTélécharger ↓ Babylon.js Moteur 3D web open source par Microsoft. Créez des expériences 3D/WebXR dans le navigateur. Web3DWebXR▶ DemoTélécharger ↓ SideQuest Plateforme de sideloading pour Meta Quest. Accédez à des apps VR non officielles. VRQuest▶ DemoTélécharger ↓ 🎵 Audio · Musique · Son Reaper DAW professionnel ultra-léger. Licence d&#8217;évaluation illimitée. Plugins VST, MIDI, multitrack. DAWAudio▶ DemoTélécharger ↓ Ableton Live Lite DAW référence pour la musique électronique et le live. Version Lite gratuite avec instruments. DAWLive▶ DemoTélécharger ↓ Max MSP Environnement de programmation visuelle pour audio, MIDI, vidéo et médias interactifs. AudioVisuel▶ DemoTélécharger ↓ 🎬 Vidéo · Montage · Compositing DaVinci Resolve Suite pro complète : montage, étalonnage, VFX, mixage audio. Gratuit et très puissant. MontageVFXColor▶ DemoTélécharger ↓ CapCut Montage vidéo simple et efficace. Templates, effets, sous-titres auto. Idéal réseaux sociaux. MontageSocial▶ DemoTélécharger ↓ Natron Compositing node-based open source. Alternative à After Effects/Nuke pour le VFX. VFXCompositing▶ DemoTélécharger ↓ VLC Lecteur multimédia universel. Lit tous les formats, convertit, streame. Incontournable. LecteurConversion▶ DemoTélécharger ↓ 🎨 Design · Image · UI Figma Design d&#8217;interfaces, prototypage, collaboration temps réel. Version gratuite très généreuse. UI/UXPrototypage▶ DemoTélécharger ↓ Affinity Suite créative (Photo, Designer, Publisher). Alternative premium à Adobe, licence perpétuelle. DesignPhoto▶ DemoTélécharger ↓ XnView Visionneuse et convertisseur d&#8217;images. Supporte 500+ formats. Batch processing puissant. ImagesBatch▶ DemoTélécharger ↓ 🖨️ Impression 3D · Fabrication Bambu Studio Slicer officiel Bambu Lab. Profils optimisés, multi-couleur, interface moderne. SlicerFDM▶ DemoTélécharger ↓ Orca Slicer Fork communautaire de Bambu Studio. Compatible toutes imprimantes, très personnalisable. SlicerOpen Source▶ DemoTélécharger ↓ Cura (UltiMaker) Slicer universel le plus utilisé au monde. Marketplace de plugins, profils communautaires. SlicerUniversel▶ DemoTélécharger ↓ ⚡ Code créatif · IA générative TouchDesigner Programmation visuelle temps réel pour installations interactives, VJing, mapping. InteractifTemps réel▶ DemoTélécharger ↓ Processing Langage de programmation créative. Idéal pour apprendre le code par le visuel. CodeArt▶ DemoTélécharger ↓ Arduino Plateforme électronique open source. IDE + cartes pour prototypage interactif. ÉlectroniqueIoT▶ DemoTélécharger ↓ ComfyUI Interface node-based pour Stable Diffusion. Workflows visuels pour la génération d&#8217;images IA. IAStable Diffusion▶ DemoTélécharger ↓ 🔧 Outils · Productivité · Communication Visual Studio Code Éditeur de code le plus populaire. Extensions, Git, terminal intégré, IA (Copilot). CodeIDE▶ DemoTélécharger ↓ OpenOffice Suite bureautique complète et gratuite. Writer, Calc, Impress. Alternative à Microsoft Office. BureauDocs▶ DemoTélécharger ↓ Discord Plateforme de communication. Salons vocaux/texte, partage d&#8217;écran, bots, communautés. ChatCommunauté▶ DemoTélécharger ↓ ← Retour aux formations 🎬 STORYBOARD &#038; VIRTUAL PRODUCTION IA TOOLBOX e.classList.remove(&#8216;active&#8217;));this.classList.add(&#8216;active&#8217;);document.getElementById(&#8216;sb-outils&#8217;).classList.add(&#8216;active&#8217;) »>🛠 Outils (20) e.classList.remove(&#8216;active&#8217;));this.classList.add(&#8216;active&#8217;);document.getElementById(&#8216;sb-glossaire&#8217;).classList.add(&#8216;active&#8217;) »>📖 Glossaire (100) 🖼️ Génération d&#8217;images 1ComfyUIGratuit Interface node-based pour Stable Diffusion et Flux. Workflows visuels puissants pour la génération d&#8217;images IA. Personnalisation totale, ControlNet, IP-Adapter, cohérence entre panels. Workflow visuelStable DiffusionControlNetCohérence▶ DemoAccéder →this.style.outline= »,1000) » data-p= »ControlNet pose + IP-Adapter style: Generate storyboard panel,</p>
<p>Cet article <a href="https://presentcomposedesign.fr/formations/">Formations</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="35941" class="elementor elementor-35941">
				<div class="elementor-element elementor-element-25c13e0 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="25c13e0" data-element_type="container" data-e-type="container">
				<div class="elementor-element elementor-element-4ebd579 elementor-widget__width-inherit elementor-widget elementor-widget-html" data-id="4ebd579" data-element_type="widget" data-e-type="widget" data-widget_type="html.default">
				<div class="elementor-widget-container">
					
<!--
╔═══════════════════════════════════════════════════════════════╗
║ Formations / Présent Composé Design, © All rights reserved.   ║
║---------------------------------------------------------------║
║ 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/                         ║
╚═══════════════════════════════════════════════════════════════╝

@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%#*++==------===**%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#++-::::::::::::::::::::::-=*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#+-::::::::::::::::::::::::::::::::=*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@#+::::::::::::::::::::::::::::::::::::::::=%@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@%+:::::::::::::::::=++***+=-::::::::::::::::::::=%@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@#=::::::::::::::::+%@@@@@@@@@@%+-:::::::::::::::::::=#@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@#-::::::::::::::::#@@@@@@@@@@@@@@@@%-:::::::::::::::::::=#@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@%=::::::::::::::::*@@@@@@**==-=+##@@@@@*-:::::::::::::::::::=%@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@=:::::::::::::::::%@@@@@+::::::::::-*@@@@#-::::::::::::::::::::=@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@#::::::::::::::::::%@@@@=::::::::::::::-%@@@#-:::::::::::::::::::::#@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@=::::::::::::::::::*@@@@=::::::::::::::::-%@@@*::::::::::::::::::::::=@@@@@@@@@@@@@@@
@@@@@@@@@@@@@%-::::::::::::::::::-@@@@=::::::::::::::::::-%@@@-::::::::::::::::::::::-%@@@@@@@@@@@@@
@@@@@@@@@@@@#::::::::::::::::::::*@@@#::::::::::::::::::::=@@@*::::::::::::::::::::::::*@@@@@@@@@@@@
@@@@@@@@@@@#:::::::::::::::::::::%@@@+::::::::::::::::::::-@@@%:::::::::::::::::::::::::*@@@@@@@@@@@
@@@@@@@@@@+::::::::::::::::::::::@@@@-:::::::::::::::::::::%@@@::::::::::::::::::::::::::*@@@@@@@@@@
@@@@@@@@@*:::::::::::::::::::::::@@@@=:::::::::::::::::::::@@@@:::::::::::::::::::::::::::*@@@@@@@@@
@@@@@@@@%::::::::::::::::::::::::@@@@*:::::::::::::::::::::@@@%::::::::::::::::::::::::::::*@@@@@@@@
@@@@@@@%:::::::::::::::::::::::::@@@@%::::::::::::::::::::+@@@*:::::::::::::::::::::::::::::%@@@@@@@
@@@@@@@::::::::::::::::::::::::::@@@@@*::::::::::::::::::-@@@@::::::::::::::::::::::::::::::-@@@@@@@
@@@@@@=::::::::::::::::::::::::::@@@@@@*::::::::::::::::-@@@@+:::::::::::::::::::::::::::::::*@@@@@@
@@@@@%:::::::::::::::::::::::::::@@@@@@@*::::::::::::::-%@@@#:::::::::::::::::::::::::::::::::*@@@@@
@@@@@::::::::::::::::::::::::::::@@@@@@@@@*::::::::::+@@@@@*::::::::::::::::::::::::::::::::::-@@@@@
@@@@*::::::::::::::::::::::::::::@@@#=@@@@@@%#*+=**%@@@@@@=::::::::::::::::::::::::::::::::::::*@@@@
@@@@:::::::::::::::::::::::::::::@@@#::*@@@@@@@@@@@@@@@@*:::::::::::::::::::::::::::::::::::::::@@@@
@@@*:::::::::::::::::::::::::::::@@@#::::=*@@@@@@@@@%#=:::::::::::::::::::::::::::::::::::::::::#@@@
@@@=:::::::::::::::::::::::::::::@@@#::::::::-==+=-:::::::::::::::::::::::::::::::::::::::::::::=@@@
@@@::::::::::::::::::::::::::::::@@@#:::::::::::::::::::::::::::::::::::::::::::::::::--:::::::::@@@
@@*::::::::::::::::::::::::::::::@@@#:::::::::+######*:::::::::::::::::::::::::::::::+@=:::::::::#@@
@@*::::::::::::::::::::::::::::::@@@#:::--::::===+*+==:::::=-:::::::=-::::::-::::::::+@=:::::::::+@@
@@:::::::::::::::::::::::::::::::@@@#::+@#@@::-#@@%@%=::=@@@@+:::+@@@@@+-::@@@@%*-::#@@%#:::::::::@@
@@:::::::::::::::::+*##%%#*=-::::@@@#::+@@=::-%@:::-*@=:%@-:=:::*@*:::-@*::@@-:=@%::=*@*=:::::::::@@
@@::::::::::::::+%@@@@@@@@@@@%*-:@@@#::+@*:::*@@@@@@@@#:+@%*-:::@@@@@@@@@-:@%:::#@:::+@=::::::::::@@
@%::::::::::::*@@@@@@@@@@@@@@@@@#+@@#::+@+:::+@=-------:::=%@*::@%-------::@%:::#@:::+@=::::::::::%@
@%::::::::::-@@@@@@++-::::-=#@@@@@+*#::+@+:::-@%=-:+%%-:-=::#@::*@*-::+%=::@%:::#@:::+@=::::::::::#@
@%:::::::::=@@@@%+::::::::::::#@@@@**::+@+::::-#@@@@%=::#@%%@+:::+@@@@@*:::@%:::#@:::+@=::::::::::%@
@%::::::::-@@@@+:::::::::::::::-@@@@+:::-::::::::--:::::::=-:::::::-=-:::::--:::-=----=-::::::::::%@
@@:::::::-@@@@*:::::::::::::::::-%%%*:::::::::::::::::::::::::::::::::::::::::::+%%%%%%#::::::::::@@
@@:::::::*@@@#:::::::+#@%*-::::::=+*#*=+#*=-:::::+#%%#-::::::=#%%#=::::-#@@#-::::=##%#+-::::::::::@@
@@-::::::@@@@-:::::-%@#+*%@*:::::%@#+#@@+*@*::::%@+==#@*::::%@#+=%@*-::%@-=*=:::*@*+=+@%-::::::::-@@
@@+:::::-@@@@::::::#@=::::#@=::::%@::=@*::#@-::*@=::::-@+::*@*::::*@*::*@*-::::-@@####%@#::::::::*@@
@@#:::::-@@@*::::::@@:::::=@*::::%@::=@*::#@-::%@::::::@*::%@-::::-@%:::=%@%-::*@#******+::::::::*@@
@@@-::::-@@@%::::::#@+::::%@-::::%@::=@*::#@-::@@-::::=@-::*@*::::*@*:::-::@%::=@*::::-=-::::::::@@@
@@@+:::::@@@@-::::::%@%*#@@+:::::%@::=@*::#@-::@@@#=+#@+::::*@%*+@@*:::#@*+@*:::*@%*+%@%::::::::-@@@
@@@#:::::#@@@+:::::::=*##+-::::::+*::-*=::+*:::@%=*%#*-::::::=*%#*-:::::=*#+:::::-*##*=--=::::::*@@@
@@@@-::::=@@@@-::::::::::::::::::=**+::::::::::@%:::::::::::::::::::::::-==++**##%%@@@@@@@-::::-@@@@
@@@@*:::::*@@@@-::::::::::::::::+@@@#::::::::::::::::::::-==++**##=-#@@@@@@@@@@@@@@@@@@@@@=::::*@@@@
@@@@@-:::::#@@@@+-:::::::::::::*@@@@::::::-==++--##%@@@@@@@@@#*#@@%%@@@@@@@@@@@@@@@@@@@@@+:::-@@@@@
@@@@@%::::::*@@@@@+-:::::::::+@@@@%:=@@@@@@@@@@==@@@**%@@@@*-#@*-@@*@@@*-+**@@@:%@@==@++@@*:::%@@@@@
@@@@@@*::::::=@@@@@@@%%**##@@@@@@=::=@@@@#-:--=-=@@=+@+=@@@:%@@@=+@-*@*-@@@+:@@=#@:*@@%-@@#::+@@@@@@
@@@@@@@-:::::::+%@@@@@@@@@@@@@@#-::::@@@+-%@@@*--@@:@@=*@@@%=*%@@@@*=@-*@@@*:+@=+=#@@@%:@@%:-@@@@@@@
@@@@@@@@-::::::::=*%@@@@@@@##-:::::::@@@:#@@@@@*:@@:%==@@@@@@%+-%@@#-@*+@@@:+:@*:*@@@@@:%@@-@@@@@@@@
@@@@@@@@#-:::::::::::::-:::::::::::::@@%:%@@@@@*-@@-#@@@#-@*%@@@-#@@:@@#=+:%@-#@:@@@@@@*=@@%@@@@@@@@
@@@@@@@@@*:::::::::::::::::::::::::::#@@--@@@@%:*@@%*+*=*@@=+@@@=+@@-#@@@@@@@=#@#@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@*-:::::::::::::::::::::::::#@@@+-==-:#@@@@@@@@@@@@#++*#@@@@@@@#@@@%:@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@*-::::::::::::::::::::::::+@@@@@%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@-=-:+**++==--::%@@@@@@@@@@@
@@@@@@@@@@@@#-:::::::::::::::::::::::=@@@@@@@@@@@@@@@@@@@@@%%##**++=---::::::::::::::::%@@@@@@@@@@@@
@@@@@@@@@@@@@@-::::::::::::::::::::::-@@@@@@%###**++=--::::::::::::::::::::::::::::::-%@@@@@@@@@@@@@
@@@@@@@@@@@@@@@+::::::::::::::::::::::--::::::::::::::::::::::::::::::::::::::::::::+@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@%-::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::-%@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@+-:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::+@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@%=::::::::::::::::::::::::::::::::::::::::::::::::::::::::::+%@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@%=::::::::::::::::::::::::::::::::::::::::::::::::::::::=%@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@%=-:::::::::::::::::::::::::::::::::::::::::::::::::+%@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@*-:::::::::::::::::::::::::::::::::::::::::::::#@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@%+-:::::::::::::::::::::::::::::::::::::::+%@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%*=-:::::::::::::::::::::::::::::::+#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%**=-:::::::::::::::::::::=+#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%**++=====+++*##@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@

@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#****+++++****##%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@**+-::::::::::::::::::::==*#%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*+-::::::::::::::::::::::::::::::==#%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@#+-::::::::::::::::::::::::::::::::::::::=*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@*-::::::::::::::::::::::::::::::::::::::::::::=*%@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@#+:::::::::::::::++****%%@@@@@%%##**=--:::::::::::::=#@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@*=:::::::::::::+*%@@@@@@@@@@@@@@@@@@@@@@@#*=-:::::::::::=#@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@#=:::::::::::+*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#=-::::::::::=#@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@%=::::::::::+%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*=::::::::::=%@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@*::::::::::*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#+-:::::::::*%@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@%=::::::::=*@@@@@@@@@@@@@@%#*********#%%@@@@@@@@@@@@@@@@@@@%+-::::::::=%@@@@@@@@@@@@@@
@@@@@@@@@@@@@#:::::::::#@@@@@@@@@@@**+-::::::::::::::::-+#%@%@@@@@@@@@@@@@@%+-::::::::*@@@@@@@@@@@@@
@@@@@@@@@@@@+::::::::+@@@@@@@@@@*-::::::::::::::::::::::#@@@##%%@@@@@@@@@@@@@@+::::::::*%@@@@@@@@@@@
@@@@@@@@@@@=:::::::=#@@@@@@@@@+::::::::::::::::::::::::-*@@@@@@#%%@@@@@@@@@@@@@*-:::::::=%@@@@@@@@@@
@@@@@@@@@@=:::::::*@@@@@@@@@%=::::::::::::::::::::::::::*@@@@@@@@##%@@@@@@@@@@@@%+:::::::=%@@@@@@@@@
@@@@@@@@%=:::::::#@@@@@@@@@%:::::::::::::::::::::::::::+@@@@@@@@@@@%%@@@@@@@@@@@@@*-::::::-%@@@@@@@@
@@@@@@@@=::::::-#@@@@@@@@@%-:::::::::::::::::::::::::==#@@@@@@@@@@@@%#@@@@@@@@@@@@@#-::::::=%@@@@@@@
@@@@@@@=::::::-%@@@@@@@@@@=:::::::::::::::::::::::::*=#%@@@@@@@@@@@@@%#@@@@@@@@@@@@@#-::::::*@@@@@@@
@@@@@@*::::::-#@@@@@@@@@@*::::::::::::::::::::::::*=#+%@@@%@@@@@@@@@@@%%@@@@@@@@@@@@@%-::::::*@@@@@@
@@@@@%:::::::#@@@@@@@@@@%:::::::::::::::::::---:::-:-+*@%%@@@@@@@@@@@@@*%@@@@@@@@@@@@@%-::::::%@@@@@
@@@@@-::::::#@@@@@@@@@@@-::::::::::::::::::::+=#-=#*#=+%%@@@@@@@@@@@@@@@%@@@@@@@@@@@@@@#-:::::-%@@@@
@@@@*::::::*@@@@@@@@@@@#:::::::::::::::::::::-++=#==#+-%%%@@@@@@@@@@@@@@%@@@@@@@@@@@@@@@*::::::*@@@@
@@@%::::::-@@@@@@@@@@@@=::::::::::::::::::::::::+=++#%=%*@@@@@@@@@@@@@@@@*@@@@@@@@@@@@@@@-::::::%@@@
@@@*::::::#@@@@@@@@@@@%:::::::::::::::::::::::::+#:+@%+%@@@@@@@@@@@@@@@@@@#@@@@@@@@@@@@@@%-:::::*@@@
@@%::::::=@@@@@@@@@@@@*::::::::::::::::::::::::-#*-#+=@@%@%@@@@@@@@@@@@@@@#@@@@@@@@@@@@@@@*::::::%@@
@@*::::::%@@@@@@@@@@@@=:::::::::::::::::::::::+=#@++=+%@@@@@@@@@@@@@@#@@@+-%@@@@@@@@@@@@@@@-:::::*@@
@@::::::*@@@@@@@@@@@@@-:::::::::::::::::::::::-**+@+*=%@%@@@@@@@@@@@#@@%=::*@@@@@@@@@@@@@@@*::::::%@
@%::::::#@@@@@@@@@@@@@::::::::::::::::::::::::::==#@%+@%+*%%@@@@@@@#==+::=-#@@@@@@@@@@@@@@@#::::::*@
@*:::::-@@@@@@@@@@@@@@::::::::::::::::::::::::::::***%-@*@%%@@@@@@@%%=+*%%%@@@@@@@@@@@@@@@@@-:::::+@
@=:::::*@@@@@@@@@@@@@@-::::::::::::::::::::::::::-+@+=+*%*%@@@@@@@%@@@%*=:*@@@@@@@@@@@@@@@@@+:::::=@
%::::::*@@@@@@@@@@@@@@*::::::::::::::::::::::-=-=+#**#=%+@@*@@@@@@@@@@@+=**%@@@@@@@@@@@@@@@@#::::::%
%::::::%@@@@@@@@@@@@@@#:::::::::::::::::::+#@++@%@@#*+*+#=@@@@@@@@@@@@@@*%@#%@@@@@@@@@@@@@@@#::::::#
*::::::@@@@@@@@@@@@@@@@-:::::::::::::::::*@@@:::%@@@@*---**@@@@@@@@@@@@@@@@@@%@@@@@@@@@@@@@@%::::::*
*::::::@@@@@@@@@@@@@@@@+:::::::::::::::::@@@@%::*@@@@@+-:=%+@@@@@@@@@@@@@@@@@@%@@@@@@@@@@@@@@::::::*
*:::::-@@@@@@@@@@@@@@@@#:::::::::::::::::%=%@@-:+@@@@@*::%*@@@@@@@@@@@@@@@@@@@@%@@@@@@@@@@@@@-:::::*
*:::::=@@@@@@@@@@@@@@@@@+::::::::::::::::*:-@*::*-*@@@@-:-=%@%@@@@@@@@@@@@@@@@@#@@@@@@@@@@@@@::::::*
*:::::-@@@@@@@@@@@@@@@@@%-:::::::::::::::*+:%@@:::-@@@@-:=+%%@%@@@@@@@@@@@*@@@@@@@@@@@@@@@@@@::::::*
*::::::%@@@@@@@@@@@@@@@@@*::::::::::::::::*=-@*::::%@@@+:+*@@@%@@@@@@@@@@@%@@%#@@@@@@@@@@@@@%::::::*
#::::::%@@@@@@@@@@@@@@@@@%-::::::::::::::::#+**:::::%@@*:-=%@*@@@@@@@@@@@#**%@@@@@@@@@@@@@@@%::::::#
@::::::*@@@@@@@@@@@@@@@@@@*::::::::::::::::-%@@@@@**@@%=::=+@@@@@%@@@@@@@@@%@@@@@@@@@@@@@@@@*::::::@
@-:::::*@@@@@@@@@@@@@@@@@@@=:::::::::::::::::#@@@@@@@@%:::+=%@@@@@@@@@@@@@@@%@@@@@@@@@@@@@@@*:::::-@
@+:::::=@@@@@@@@@@@@@@@@@@@%-:::::::::::::::::*%@@@*%@-:::-*=*%@@@@@@@@@@@@@%@@@@@@@@@@@@@@@::::::*@
@*::::::%@@@@@@@@@@@@@@@@@@@*:::::::::::::::::::=%%+#@=:::=*++@@@@@@@@@@@@@#@@@@@@@@@@@@@@@%::::::*@
@@-:::::*@@@@@@@@@@@@@@@@@@@#::::::::::::::::----:=###::::=**%%@@@@@@@@@@@+%@@@@@@@@@@@@@@@+:::::-@@
@@+:::::-%@@@@@@@@@@@@@@@@@@@-:::::::::::::=@@@@@+::::::::-*#%%%@@@@@@@@@%*%@@@@@@@@@@@@@@%::::::*@@
@@#::::::*@@@@@@@@@@@@@@@@@@@+::::::::::::::%@@@@@-:::::::+%@%%%@@@@@@@@@%@@@@@@@@@@@@@@@@+::::::%@@
@@@+::::::%@@@@@@@@@@@@@@@@@@*::::::::::::::*@@@@@-:::::::-+#@%%@%@@@@@@@%@@@@@@@@@@@@@@@%::::::=@@@
@@@%-:::::*@@@@@@@@@@@@@@@@@@*::::::::::::::#@@@@@::::::::::*@%**%@@@@@@@#@@@@@@@@@@@@@@@=::::::%@@@
@@@@*::::::%@@@@@@@@@@@@@@@@@*::::::::::::::*@@@@@#-::::::::===+-%%@@@@@@%%@@@@@@@@@@@@@*::::::*@@@@
@@@@@-:::::-%@@@@@@@@@@@@@@@@+::::::::::::::=#%@@@@*--#=::::::--:=@@%%%@@%@@@@@@@@@@@@@#::::::-@@@@@
@@@@@#-:::::-%@@@@@@@@@@@@@@@=::::::::::::::==@%@%%%#+%%*:::::::-=#%%*%@#*@@@@@@@@@@@@%:::::::#@@@@@
@@@@@@*::::::*%@@@@@@@@@@**+-:::::::::::::::@#@@%@@@%#%@*::::-::::==::%%*@@@@@@@@@@@@%=::::::*@@@@@@
@@@@@@@+::::::*@@@@@@@@*::::::::::::::::::-##%%@#@@@@%%%*-==*%:-::==+**@@@@@@@@@@@@@%=::::::=@@@@@@@
@@@@@@@@-::::::*%@@@@*::::::::::::::::::::::%%%%%@@@@@@@#%@@%%@@@@@@@@@@@@@@@@@@@@@%=::::::=@@@@@@@@
@@@@@@@@%-::::::=%@@%:::::::::::::::::::::#@%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#:::::::-@@@@@@@@@
@@@@@@@@@@-::::::-%@#::::::::::::::::::::+@@@@@%@@@@@@@@@@@%%@@@@@@@@@@@@@@@@@@@@*:::::::=@@@@@@@@@@
@@@@@@@@@@@-:::::::*@-:::::::::::::::::::-%@@@@@@@@@@@@@@@@*@@@@@@@@@@@@@@@@@@@@=:::::::=@@@@@@@@@@@
@@@@@@@@@@@@+:::::::==:::::::::::::::::::-%@@@@@@@@@@@@@@@%@@@@@@@@@@@@@@@@@@@#-:::::::+@@@@@@@@@@@@
@@@@@@@@@@@@@*-:::::::::::::::::::::::::::=@@@@@@@@@@@@@@%#@@@@@@@@@@@@@@@@@%=::::::::#@@@@@@@@@@@@@
@@@@@@@@@@@@@@#-:::::::::::::::::::::::::::*@@@@@@@@@@@@#@@@@@@@@@@@@@@@@@@+::::::::=#@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@*-::::::::::::::::::::::::::*%@@@@@@@@@@*@@@@@@@@@@@@@@@@+:::::::::*@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@%=:::::::::::::::::::::::::::*%@@@@@@@@#@@@@@@@@@@@@@*=:::::::::=%@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@#-:::::::::::::::::::::::::::*%@@@@@@@%#@@@@@@@@*+-:::::::::=#@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@#-:::::::::::::::::::::::::::*%@@@@@@@%@@@@*+:::::::::::=#@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@#=-::::::::::::::::::::::::::+%@@@@%**--::::::::::::+#@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@%*-:::::::::::::::::::::::::::::::::::::::::::::*%@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@#*-:::::::::::::::::::::::::::::::::::::::+%@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@##=-:::::::::::::::::::::::::::::::+*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@##*=-::::::::::::::::::::-+**%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%##****+++++****%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
-->
<!DOCTYPE html><html lang="fr"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Formations / Présent Composé Design</title><link rel="icon" href="https://presentcomposedesign.fr/wp-content/uploads/2024/03/cropped-logo_PCd_2024-32x32.png" sizes="32x32"><link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet"><script type="module" src="https://unpkg.com/@splinetool/viewer@1.12.69/build/spline-viewer.js"></script>
<style>
:root{--pcd:#1001EF;--pcd-dk:#0C01B8;--pcd-lt:#E8E5FF;--pcd-glow:rgba(16,1,239,.15);--pcd-or:#FF6B35;--pcd-or-lt:#FFF3ED;--pcd-or-bd:#FFD0B5;--pcd-bg:#F4F3FA;--pcd-w:#FFF;--pcd-tx:#1A1A2E;--pcd-txm:#4A4A66;--pcd-txl:#8888AA;--pcd-bd:#DDDDE8;--pcd-ok:#10B981;--pcd-r:14px}
#pcd,#pcd *,#pcd *::before,#pcd *::after{margin:0;padding:0;box-sizing:border-box;border:none;outline:none;text-decoration:none;font-style:normal;list-style:none;background:none;letter-spacing:normal;text-transform:none;text-shadow:none;box-shadow:none;float:none;max-width:none;min-height:0}
#pcd{font-family:'DM Sans',sans-serif!important;background:var(--pcd-bg)!important;color:var(--pcd-tx)!important;line-height:1.6!important;font-size:16px!important;text-align:left!important;min-height:100vh;overflow-x:hidden}
#pcd .H{position:relative;overflow:hidden;min-height:480px}
#pcd .H spline-viewer{position:absolute;inset:0;width:100%;height:100%;z-index:0;pointer-events:auto}
#pcd .H::after{content:'';position:absolute;bottom:0;left:0;right:0;height:4px;background:linear-gradient(90deg,var(--pcd-or),#FFD700,var(--pcd-or));z-index:3}
#pcd .Hi{max-width:900px;margin:0 auto;padding:24px 32px;display:flex;align-items:center;justify-content:space-between;gap:24px;position:relative;z-index:2}
#pcd .Hl{display:flex;align-items:center;gap:14px}
/* Logos: hidden on screen by default, shown on print */
#pcd .Lp{width:64px;height:64px;flex-shrink:0;border-radius:10px;overflow:hidden;box-shadow:0 2px 10px rgba(0,0,0,.3);background:#fff;display:none}
#pcd .Lp.show-screen{display:block}
#pcd .Lp img{width:100%;height:100%;object-fit:contain;display:block}
#pcd .Ht h1{color:#FFF!important;font-size:34px!important;font-weight:700!important;line-height:1.2!important;margin:0!important;text-shadow:0 2px 16px rgba(0,0,0,.4)}
#pcd .Ht p{color:rgba(255,255,255,.8)!important;font-size:14px!important;margin:6px 0 0!important;text-shadow:0 1px 8px rgba(0,0,0,.4)}
#pcd .Ll{width:72px;height:72px;background:rgba(255,255,255,.15);backdrop-filter:blur(8px);border:1px solid rgba(255,255,255,.2)!important;border-radius:12px;display:none;align-items:center;justify-content:center;flex-shrink:0;overflow:hidden}
#pcd .Ll.show-screen{display:flex}
#pcd .Ll img{width:100%;height:100%;object-fit:contain;padding:5px;display:block}
#pcd .land{max-width:900px;margin:0 auto;padding:40px 24px}
#pcd .land-title{font-size:14px!important;font-weight:700!important;text-transform:uppercase;letter-spacing:1.5px;color:var(--pcd)!important;margin-bottom:20px}
#pcd .fcard{background:var(--pcd-w);border:1.5px solid var(--pcd-bd)!important;border-radius:var(--pcd-r);padding:28px 70px 28px 24px;cursor:pointer;transition:transform .2s,box-shadow .2s,border-color .2s;position:relative;overflow:hidden}
#pcd .fcard:hover{transform:translateY(-3px);box-shadow:0 12px 35px rgba(16,1,239,.1)!important;border-color:var(--pcd)!important}
#pcd .fcard::before{content:'';position:absolute;top:0;left:0;right:0;height:4px;background:linear-gradient(90deg,var(--pcd),var(--pcd-or))}
#pcd .fcard h2{font-size:22px!important;font-weight:700!important;color:var(--pcd-tx)!important;margin-bottom:6px!important}
#pcd .fcard .fdesc{font-size:14.5px!important;color:var(--pcd-txm)!important;line-height:1.5!important;margin-bottom:12px!important}
#pcd .fcard .fbadge{display:inline-block;background:var(--pcd-lt);color:var(--pcd)!important;font-size:11px!important;font-weight:600!important;padding:4px 12px;border-radius:6px}
#pcd .fcard .farrow{position:absolute;right:24px;top:50%;transform:translateY(-50%);width:32px;height:32px;background:var(--pcd)!important;border-radius:50%;display:flex;align-items:center;justify-content:center}
#pcd .fcard .farrow svg{width:14px;height:14px;fill:none;stroke:#fff;stroke-width:2.5}
#pcd .fo{position:fixed;inset:0;background:rgba(10,10,26,.88);backdrop-filter:blur(14px);z-index:99999;display:none;align-items:center;justify-content:center;padding:20px;animation:pF .4s}
#pcd .fo.on{display:flex}
@keyframes pF{from{opacity:0}to{opacity:1}}
#pcd .fc{background:#fff;border-radius:22px;padding:36px 32px;max-width:440px;width:100%;box-shadow:0 30px 80px rgba(0,0,0,.5);animation:pU .5s;position:relative;overflow:hidden}
#pcd .fc::before{content:'';position:absolute;top:0;left:0;right:0;height:4px;background:linear-gradient(90deg,var(--pcd),var(--pcd-or))}
@keyframes pU{from{opacity:0;transform:translateY(30px)}to{opacity:1;transform:translateY(0)}}
#pcd .fc h2{font-size:20px!important;font-weight:700!important;color:#0A0A1A!important;margin:4px 0!important}
#pcd .fs{color:var(--pcd-txm)!important;font-size:13px!important;margin-bottom:20px!important;line-height:1.5!important}
#pcd .fs strong{color:var(--pcd)!important}
#pcd .fg{margin-bottom:12px}
#pcd .fg label{display:block;font-size:11px!important;font-weight:600!important;color:var(--pcd-txm)!important;margin-bottom:3px!important;text-transform:uppercase;letter-spacing:.5px}
#pcd .fg label .op{font-weight:400!important;text-transform:none;color:var(--pcd-txl)!important;font-size:10px!important}
#pcd .fg input,#pcd .fg select{width:100%;padding:10px 12px;border:1.5px solid var(--pcd-bd)!important;border-radius:8px;font-family:'DM Sans',sans-serif!important;font-size:13.5px!important;color:var(--pcd-tx)!important;background:#FAFAFF!important;height:auto;-webkit-appearance:none}
#pcd .fg select{-webkit-appearance:menulist;appearance:menulist}
#pcd .fg input:focus,#pcd .fg select:focus{border-color:var(--pcd)!important;box-shadow:0 0 0 3px var(--pcd-glow)!important}
#pcd .fg input::placeholder{color:#BBB!important}
#pcd .fr{display:flex;gap:10px}
#pcd .fr .fg{flex:1}
#pcd .sf{max-height:0;overflow:hidden;transition:max-height .3s,opacity .3s;opacity:0}
#pcd .sf.v{max-height:70px;opacity:1}
#pcd .fb{display:inline-flex;align-items:center;gap:5px;background:var(--pcd-lt);border:1px solid rgba(16,1,239,.15)!important;border-radius:7px;padding:6px 12px;margin-bottom:16px;font-size:11px!important;color:var(--pcd)!important;font-weight:600!important}
#pcd .btn{width:100%;padding:13px;border:none!important;border-radius:9px;background:var(--pcd)!important;color:#fff!important;font-family:'DM Sans',sans-serif!important;font-size:14px!important;font-weight:600!important;cursor:pointer;margin-top:4px;display:block;text-align:center}
#pcd .btn:hover{transform:translateY(-1px);box-shadow:0 6px 24px rgba(16,1,239,.35)!important;background:var(--pcd-dk)!important}
#pcd .fn{text-align:center;font-size:10px!important;color:var(--pcd-txl)!important;margin-top:12px!important;line-height:1.5!important}
#pcd .fcl{position:absolute;top:14px;right:14px;width:28px;height:28px;border-radius:50%;background:rgba(0,0,0,.06)!important;cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:16px!important;color:var(--pcd-txl)!important}
#pcd .guide{max-width:900px;margin:0 auto;padding:0 24px 40px;display:none}
#pcd .guide.on{display:block}
#pcd .sl{display:flex;align-items:center;gap:8px;padding:22px 0 10px;font-size:11px!important;font-weight:700!important;text-transform:uppercase;letter-spacing:1.2px;color:var(--pcd-or)!important}
#pcd .sl::after{content:'';flex:1;height:1.5px;background:linear-gradient(90deg,var(--pcd-or-bd),transparent)}
#pcd .st{display:flex;align-items:center;gap:8px;padding:24px 0 12px;font-size:11px!important;font-weight:700!important;text-transform:uppercase;letter-spacing:1.2px;color:var(--pcd)!important}
#pcd .st::after{content:'';flex:1;height:2px;background:linear-gradient(90deg,var(--pcd),transparent)}
#pcd .ac{background:var(--pcd-w);border:1px solid var(--pcd-bd)!important;border-radius:var(--pcd-r);padding:16px 18px;margin-bottom:8px;display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap;transition:all .25s;position:relative}
#pcd .ac:hover{box-shadow:0 8px 28px rgba(16,1,239,.08)!important;border-color:rgba(16,1,239,.25)!important}
#pcd .ac.mc{background:var(--pcd-or-lt);border:1.5px solid var(--pcd-or-bd)!important}
#pcd .ac.mc:hover{border-color:var(--pcd-or)!important;box-shadow:0 8px 25px rgba(255,107,53,.12)!important}
#pcd .an{width:18px;flex-shrink:0;font-family:'Space Mono',monospace!important;font-size:11px!important;font-weight:700!important;color:var(--pcd-txl)!important;text-align:center;padding-top:10px}
#pcd .mc .an{color:var(--pcd-or)!important;font-size:14px!important}
#pcd .ai{width:44px;height:44px;border-radius:10px;flex-shrink:0;overflow:hidden;background:#F0F0F5;box-shadow:0 2px 6px rgba(0,0,0,.08);display:flex;align-items:center;justify-content:center}
#pcd .ai img{width:30px;height:30px;object-fit:contain;display:block}
#pcd .ab{flex:1;min-width:0}
#pcd .ah{display:flex;align-items:baseline;gap:7px;flex-wrap:wrap;margin-bottom:2px}
#pcd .nm{font-size:15px!important;font-weight:700!important;color:var(--pcd-tx)!important}
#pcd .mc .nm{color:#B83800!important}
#pcd .pl{font-size:9.5px!important;color:var(--pcd-txl)!important;background:rgba(0,0,0,.04)!important;padding:1px 6px;border-radius:3px}
#pcd .ad{font-size:13.5px!important;color:var(--pcd-txm)!important;line-height:1.45!important;margin-bottom:6px!important}
#pcd .af{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:6px}
#pcd .at{display:flex;gap:4px;flex-wrap:wrap}
#pcd .tg{font-size:10.5px!important;font-weight:600!important;padding:2px 8px;border-radius:4px;display:inline-block}
#pcd .ctas{display:flex;gap:6px;align-items:center}
#pcd .cta-d,#pcd .cta-a{font-size:10.5px!important;font-weight:600!important;padding:4px 10px;border-radius:6px;cursor:pointer;display:inline-flex;align-items:center;gap:3px;transition:all .15s;text-decoration:none!important}
#pcd .cta-d{background:var(--pcd-lt)!important;color:var(--pcd)!important;border:1px solid rgba(16,1,239,.15)!important}
#pcd .cta-d:hover{background:var(--pcd)!important;color:#fff!important}
#pcd .cta-a{background:var(--pcd)!important;color:#fff!important}
#pcd .cta-a:hover{background:var(--pcd-dk)!important}
#pcd .mc .cta-d{background:var(--pcd-or-lt)!important;color:#CC4400!important;border:1px solid var(--pcd-or-bd)!important}
#pcd .mc .cta-d:hover{background:var(--pcd-or)!important;color:#fff!important}
#pcd .mc .cta-a{background:var(--pcd-or)!important}
#pcd .mc .cta-a:hover{background:#CC4400!important}
/* Video: full width inside card, smooth expand */

#pcd .vp{width:100%;flex-basis:100%;max-height:0;overflow:hidden;border-radius:10px;transition:max-height .4s ease,margin .4s ease;margin:0}
#pcd .vp.open{max-height:280px;margin-top:10px}
#pcd .vp iframe{width:100%;height:260px;display:block;border:none!important;border-radius:10px}

#pcd .sb-tabs{display:flex;gap:0;margin:0 0 20px;border-radius:12px;overflow:hidden;border:2px solid #1001EF}
#pcd .sb-tab{flex:1;padding:14px 20px;text-align:center;font-size:15px!important;font-weight:700!important;cursor:pointer;transition:all .2s;background:#fff;color:#1001EF}
#pcd .sb-tab.active{background:#1001EF;color:#fff}
#pcd .sb-tab:hover:not(.active){background:#f0f0ff}
#pcd .sb-panel{display:none}
#pcd .sb-panel.active{display:block}
#pcd .T{position:fixed;top:20px;right:20px;background:var(--pcd-ok)!important;color:#fff!important;padding:12px 20px;border-radius:10px;font-weight:600!important;font-size:13px!important;box-shadow:0 8px 30px rgba(16,185,129,.4)!important;z-index:100000;display:none}
#pcd .T.on{display:block;animation:pSI .4s}
@keyframes pSI{from{opacity:0;transform:translateX(40px)}to{opacity:1;transform:translateX(0)}}
#pcd .AP{max-width:900px;margin:30px auto 0;padding:0 24px 40px;display:none}
#pcd .AP.on{display:block}
#pcd .AC{background:#fff;border:1px solid var(--pcd-bd)!important;border-radius:var(--pcd-r);padding:20px}
#pcd .AC h3{font-size:13px!important;font-weight:700!important;color:var(--pcd)!important;margin-bottom:2px!important}
#pcd .AC .as{font-size:11px!important;color:var(--pcd-txl)!important;margin-bottom:14px!important}
#pcd .tb{width:100%;border-collapse:collapse;font-size:11px!important}
#pcd .tb th{text-align:left;padding:6px;border-bottom:2px solid var(--pcd-bd)!important;font-weight:600!important;color:var(--pcd-txm)!important;font-size:9px!important;text-transform:uppercase;background:none!important}
#pcd .tb td{padding:7px 6px;border-bottom:1px solid #F0F0F5!important;color:var(--pcd-tx)!important;background:none!important}
#pcd .tb tr:last-child td{border-bottom:none!important}
#pcd .bx{margin-top:12px;padding:8px 16px;border-radius:7px;font-family:'DM Sans',sans-serif!important;font-size:11px!important;font-weight:600!important;cursor:pointer;display:inline-block}
#pcd .bx-b{border:1.5px solid var(--pcd)!important;background:#fff!important;color:var(--pcd)!important;margin-right:6px}
#pcd .bx-b:hover{background:var(--pcd)!important;color:#fff!important}
#pcd .bx-g{border:1.5px solid #555!important;background:#fff!important;color:#555!important;margin-right:6px}
#pcd .bx-g:hover{background:#555!important;color:#fff!important}
#pcd .bx-r{border:1.5px solid #E55!important;background:#fff!important;color:#E55!important}
#pcd .bx-r:hover{background:#E55!important;color:#fff!important}
#pcd .cb{display:inline-block;background:var(--pcd-lt);color:var(--pcd)!important;font-size:10px!important;font-weight:700!important;padding:2px 8px;border-radius:20px;margin-left:5px}



@keyframes pSH{0%,100%{transform:translateX(0)}20%{transform:translateX(-6px)}40%{transform:translateX(6px)}60%{transform:translateX(-4px)}80%{transform:translateX(4px)}}
@media(max-width:1024px){
  #pcd .H{min-height:320px}
  #pcd .Ht h1{font-size:26px!important}
  #pcd .Ht p{font-size:12px!important}
}
@media(max-width:600px){
  #pcd .H{min-height:220px}
  #pcd .Hi{flex-wrap:wrap;padding:16px;gap:12px}
  #pcd .Ht h1{font-size:20px!important}
  #pcd .Ht p{font-size:11px!important}
  #pcd .ac{padding:12px 10px;gap:8px}
  #pcd .an{display:none}
  #pcd .fc{padding:24px 18px}
  #pcd .fr{flex-direction:column;gap:0}
  #pcd .fcard .farrow{display:none}
#pcd .vp.open{max-height:200px}
#pcd .vp iframe{height:180px}
}

#pcd .cat{display:flex;align-items:center;gap:8px;padding:18px 0 8px;font-size:11px!important;font-weight:700!important;text-transform:uppercase;letter-spacing:1px;color:var(--pcd)!important}
#pcd .cat::after{content:'';flex:1;height:1.5px;background:linear-gradient(90deg,var(--pcd-bd),transparent)}
#pcd .cat .cat-icon{font-size:14px;font-style:normal!important}
#pcd .guide-back{display:inline-flex;align-items:center;gap:4px;font-size:12px!important;font-weight:600!important;color:var(--pcd)!important;cursor:pointer;padding:16px 0 8px;text-decoration:none!important}
#pcd .guide-back:hover{color:var(--pcd-dk)!important}

#pcd .coffee{display:inline-flex;align-items:center;gap:6px;background:linear-gradient(135deg,#FF813F,#FF6B35);color:#fff!important;font-size:12px!important;font-weight:600!important;padding:8px 16px;border-radius:8px;text-decoration:none!important;margin-top:12px;box-shadow:0 4px 12px rgba(255,107,53,.3)}
#pcd .pc-cta{display:inline-flex;align-items:center;gap:4px;background:#00f7ad;color:#001028!important;border:none!important;padding:6px 14px;font-size:9px!important;font-weight:700!important;border-radius:6px;text-decoration:none!important}
#pcd .pc-thx{opacity:.8;font-size:7.5px!important;color:rgba(255,255,255,.7)!important}

#pcd .coffee:hover{transform:translateY(-1px);box-shadow:0 6px 20px rgba(255,107,53,.4)!important}


/* ── PRINT STYLES ── */

#pcd.spline-full{position:relative}
#pcd.spline-full>.H{position:fixed;inset:0;min-height:100vh!important;z-index:0}
#pcd.spline-full .land,#pcd.spline-full .guide,#pcd.spline-full .AP{position:relative;z-index:1}
#pcd.spline-full .fcard,#pcd.spline-full .ac,#pcd.spline-full .AC,#pcd.spline-full .fc{background:rgba(255,255,255,.92)!important;backdrop-filter:blur(12px)!important;-webkit-backdrop-filter:blur(12px)!important}
#pcd.spline-full .fo{z-index:99999}
#pcd .pc-li{width:28px!important;height:28px!important;border-radius:50%!important;display:inline-flex!important;align-items:center!important;justify-content:center!important;font-weight:700!important;font-size:12px!important;font-family:serif!important;padding:0!important;text-align:center!important}
#pcd .print-contact{display:none}

#pcd .print-hdr{display:none}

#pcd .print-footer{display:none}
@media print{
  @page{size:A4;margin:0;@bottom-center{content:counter(page)" / "counter(pages);font-family:"DM Sans",sans-serif;font-size:7pt;color:#bbb}}
  *{-webkit-print-color-adjust:exact!important;print-color-adjust:exact!important}
  html,body{overflow:visible!important;width:100%!important;height:auto!important}
  ::-webkit-scrollbar{display:none!important}
  header:not(.H),footer:not(.print-footer),nav,.site-header,.site-footer,.site-navigation,.wp-block-navigation,.menu-toggle,#header-menu-toggle,.entry-footer,.post-navigation,.sidebar,#wpadminbar,.cookie-notice,.popup-overlay{display:none!important}
  #pcd{background:#fff!important;font-size:14px!important;width:100%!important;max-width:100%!important;margin:0!important;padding:0!important;float:none!important;position:static!important;overflow:visible!important}
  #pcd *{box-shadow:none!important;text-shadow:none!important;float:none!important}
  #pcd .H,#pcd .fo,#pcd .T,.bx,#pcd .AP,#pcd .guide-back,#pcd .vp,#pcd .sb-tabs{display:none!important}
  #pcd .land{display:none!important}
  #pcd .sb-panel{display:block!important}
  /* ── PRINT HEADER: full width gradient ── */
  #pcd .print-hdr{display:block!important;position:relative;width:100%;height:110px;overflow:hidden;margin:0 0 12px;-webkit-print-color-adjust:exact!important;print-color-adjust:exact!important}
  #pcd .ph-bg{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
  #pcd .ph-over{position:relative;z-index:1;display:flex!important;align-items:center;gap:14px;padding:20px 28px;height:100%}
  #pcd .ph-logo{width:48px;height:48px;border-radius:10px;object-fit:contain;padding:0;filter:brightness(0) invert(1)}
  #pcd .ph-title{color:#fff!important;font-size:20px!important;font-weight:700!important}
  #pcd .ph-sub{color:rgba(255,255,255,.85)!important;font-size:11px!important;margin-top:2px}
  /* ── GUIDES ── */
  #pcd .land{max-width:100%!important;padding:12px 20px!important}
  #pcd .fcard{break-inside:avoid;border:1px solid #ddd!important;margin-bottom:8px!important}
  #pcd .Lp,#pcd .Ll{display:none!important}
  #pcd .guide.on,[id="pcd-guide-os"][style*="block"],[id="pcd-guide-sb"][style*="block"]{display:block!important;max-width:100%!important;padding:0 20px!important}
  /* ── CARDS ── */
  #pcd .ac{break-inside:avoid;box-shadow:none!important;border:1px solid #E0E0EC!important;border-radius:10px!important;margin-bottom:6px!important;padding:12px 14px!important;background:#fff!important;display:flex!important;gap:10px!important}
  #pcd .ac:hover{transform:none!important}
  #pcd .mc{background:#FFF3ED!important;border:1.5px solid #FFD0B5!important}
  #pcd .an{width:18px!important;font-size:11px!important;padding-top:8px!important}
  #pcd .ai{width:38px!important;height:38px!important;min-width:38px!important;border-radius:10px!important;display:flex!important;align-items:center!important;justify-content:center!important;-webkit-print-color-adjust:exact!important;print-color-adjust:exact!important}
  #pcd .ai img{width:26px!important;height:26px!important}
  #pcd .ab{flex:1!important;min-width:0!important}
  #pcd .nm{font-size:13px!important;font-weight:700!important;color:#1A1A2E!important}
  #pcd .mc .nm{color:#B83800!important}
  #pcd .pl{font-size:8px!important}
  #pcd .ad{font-size:10.5px!important;color:#4A4A66!important;line-height:1.4!important;margin-bottom:5px!important}
  #pcd .tg{font-size:8.5px!important;display:inline-block!important;padding:2px 7px!important;border-radius:4px!important;-webkit-print-color-adjust:exact!important;print-color-adjust:exact!important}
  #pcd .af{display:flex!important;justify-content:space-between!important;align-items:center!important}
  #pcd .ctas{display:flex!important;gap:5px!important}
  #pcd .cta-d{display:inline-flex!important;font-size:8.5px!important;padding:3px 8px!important;border:1px solid #ddd!important;border-radius:5px!important;color:#555!important;background:#fff!important}
  #pcd .cta-a{display:inline-flex!important;font-size:8.5px!important;padding:3px 10px!important;background:#1001EF!important;color:#fff!important;border-radius:5px!important;text-decoration:none!important;-webkit-print-color-adjust:exact!important;print-color-adjust:exact!important}
  /* ── CATEGORIES ── */
  #pcd .cat,#pcd .sl,#pcd .st{break-after:avoid;padding-top:14px!important;display:flex!important;font-size:10px!important}
  #pcd .cat::after,#pcd .sl::after,#pcd .st::after{-webkit-print-color-adjust:exact!important;print-color-adjust:exact!important}
  /* ── CONTACT BANNER: full width ── */
  /* Contact banner (last page, full width, mirrors header) */
  #pcd .print-contact{display:block!important;break-inside:avoid;margin:20px 0 0;width:100%;position:relative;page-break-before:auto;overflow:hidden;z-index:999;page-break-inside:avoid;-webkit-print-color-adjust:exact!important;print-color-adjust:exact!important}
  #pcd .pc-bg{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
  #pcd .pc-inner{position:relative;z-index:1;background:rgba(26,26,46,.48)!important;padding:22px 28px 22px 32px!important;margin-bottom:-2mm;-webkit-print-color-adjust:exact!important;print-color-adjust:exact!important;border-radius:0!important;padding:20px 28px;color:#fff!important;display:flex!important;align-items:center;gap:16px}
  #pcd .pc-logo{width:64px;height:64px;flex-shrink:0;border-radius:10px;overflow:hidden;border:none;display:block;text-decoration:none}
  #pcd .pc-logo img{width:100%;height:100%;object-fit:contain;filter:brightness(0) invert(1)}
  #pcd .pc-info{flex:1}
  #pcd .pc-name{font-size:13px!important;font-weight:700!important;color:#fff!important}
  #pcd .pc-role{font-size:9px!important;color:#99AABB!important;margin-top:2px!important}
  #pcd .pc-phrase{font-size:8.5px!important;color:#00f7ad!important;font-style:italic!important;margin-top:5px!important}
  #pcd .pc-links{display:flex!important;gap:6px;flex-wrap:wrap;margin-top:8px}
  #pcd .pc-links a{color:#fff!important;font-size:8px!important;max-width:200px!important;overflow:hidden!important;text-overflow:ellipsis!important;white-space:nowrap!important;padding:3px 10px;background:rgba(255,255,255,.1)!important;border:1px solid rgba(255,255,255,.2)!important;border-radius:5px;text-decoration:none!important;-webkit-print-color-adjust:exact!important;print-color-adjust:exact!important}
  #pcd .pc-coffee{background:rgba(0,247,173,.15)!important;color:#00f7ad!important;border-color:rgba(0,247,173,.3)!important}
  
  /* ── FOOTER: full width, stuck at bottom ── */
  /* Page footer (repeats every page) */
  #pcd .print-footer{display:block!important;position:fixed;bottom:5mm;left:0;right:0;text-align:center;font-family:'DM Sans',sans-serif;font-size:6.5pt;color:#888;border-top:1.5px solid #1001EF!important;padding:6px 20px 0;margin:0;background:#fff!important;-webkit-print-color-adjust:exact!important;print-color-adjust:exact!important}
  #pcd .pf-b{color:#1001EF;font-weight:700;font-size:7.5pt}
  #pcd .pf-i{font-size:6.5pt;color:#aaa;margin-top:1px}
  /* ── LINKS ── */
  #pcd .eml{font-size:8px!important;display:inline!important}
  #pcd .pc-links .eml{font-size:8px!important}
  #pcd a{color:#1001EF!important}
  #pcd a[href]:after{content:none!important}
  #pcd .cta-a[href]:after,#pcd .cta-d[href]:after{content:none!important}
}

@media(max-width:600px){
  #pcd .tb{font-size:9px!important;display:block;overflow-x:auto;white-space:nowrap}
  #pcd .tb th,#pcd .tb td{padding:4px 3px!important;font-size:8px!important}
  #pcd .AC{padding:12px!important;overflow-x:auto}
  #pcd .cta-a,#pcd .cta-d{font-size:9px!important;padding:3px 6px!important}
  #pcd .af{flex-direction:column;align-items:flex-start!important;gap:6px!important}
}
#pcd .rulebox{background:#F3EEFF;border-left:3px solid #7C3AED;border-radius:0 8px 8px 0;padding:8px 12px;margin-top:8px;font-size:12px!important;color:#2D1B69!important;line-height:1.5!important}
#pcd .rulebox strong{color:#7C3AED!important;font-weight:700!important}
#pcd .rulebox ul{margin:4px 0 0 14px}
#pcd .rulebox ul li{list-style:disc;margin-bottom:3px}
#pcd .warnbox{background:#FFF7ED;border-left:3px solid var(--pcd-or);border-radius:0 8px 8px 0;padding:8px 12px;margin-top:8px;font-size:12px!important;color:#7C3004!important;line-height:1.5!important}
#pcd .warnbox strong{color:var(--pcd-or)!important;font-weight:700!important}
#pcd .warnbox ul{margin:4px 0 0 14px}
#pcd .warnbox ul li{list-style:disc;margin-bottom:3px}
#pcd .ac.star{background:#F3EEFF;border:1.5px solid #D8C5FF!important}
#pcd .ac.star:hover{border-color:#7C3AED!important;box-shadow:0 8px 25px rgba(124,58,237,.15)!important}
#pcd .ac.star .an{color:#7C3AED!important;font-size:14px!important}
#pcd .ac.star .nm{color:#4C1D95!important}
@media print{
  #pcd .rulebox,.warnbox{font-size:9.5px!important;padding:5px 8px!important;-webkit-print-color-adjust:exact!important;print-color-adjust:exact!important}
  #pcd .ac.star{background:#F3EEFF!important;border:1.5px solid #D8C5FF!important;-webkit-print-color-adjust:exact!important;print-color-adjust:exact!important}
}
</style></head><body><div id="pcd">

<!-- ╔═══════════════════════════════════════════════════════════════╗
     ║  🔧 CONFIG — TOUT SE MODIFIE ICI                            ║
     ╚═══════════════════════════════════════════════════════════════╝ -->
<script>
var C={
  LOGO_PCD:"https://presentcomposedesign.fr/wp-content/uploads/2024/03/cropped-logo_PCd_2024.png",
  LOGO_PCD_ON_SCREEN:false,  // true = visible à l'écran, false = print seulement

  LOGO_LIEU:"https://presentcomposedesign.fr/wp-content/uploads/2026/03/Logo_studio_m_wiki.png",
  LOGO_LIEU_ON_SCREEN:false, // true = visible à l'écran, false = print seulement

  TITRE:"Formations",
  CREDIT:"Présent Composé Design / Alban Desbarax",
  FAVICON:"https://presentcomposedesign.fr/wp-content/uploads/2024/03/cropped-logo_PCd_2024-32x32.png",
  SPLINE:"https://prod.spline.design/SsCdd-1WulmAbnAK/scene.splinecode",
  SPLINE_MODE:"header", // "header" | "fullscreen" | "header-footer"

  /* ── VIDEOS DEMO YOUTUBE ─────────────────────────────────────
   *  id = ID YouTube (ex: "UIZAiXYceBI"). Autoplay muet au hover.
   *       Clic sur la carte = active le son.
   *       Clic "▶ Demo" = ouvre YouTube plein écran avec son.
   *  q  = recherche YouTube (fallback si id est vide)
   *
   *  ▶ POUR TROUVER UN ID : ouvrir TROUVER_LES_IDS_YOUTUBE.html
   *    L'ID est le code après "v=" dans youtube.com/watch?v=XXXXX
   * ────────────────────────────────────────────────────────── */
  VIDEOS:{
    mistral: {id:"",q:"Mistral AI Le Chat demo tutoriel"},
    chatgpt: {id:"",q:"ChatGPT DALL-E image generation demo 2025"},
    gemini:  {id:"UIZAiXYceBI",q:"Google Gemini Veo 3 video demo"},
    canva:   {id:"",q:"Canva AI image video generator demo"},
    firefly: {id:"Sp6K3qpVFO0",q:"Adobe Firefly demo tutorial"},
    luma:    {id:"",q:"Luma AI Dream Machine video demo"},
    runway:  {id:"",q:"Runway Gen-4 AI video demo"},
    leonardo:{id:"",q:"Leonardo AI image generation tutorial"},
    ideogram:{id:"",q:"Ideogram AI text image demo"},
    copilot: {id:"S7xTBa93TX8",q:"Microsoft Copilot image generator demo"},
    pika:    {id:"",q:"Pika AI video generation demo"}
  }
};
document.addEventListener('DOMContentLoaded',function(){
  var lp=document.getElementById('pcd-lp'),ll=document.getElementById('pcd-ll');
  lp.querySelector('img').src=C.LOGO_PCD;
  ll.querySelector('img').src=C.LOGO_LIEU;
  if(C.LOGO_PCD_ON_SCREEN) lp.classList.add('show-screen');
  if(C.LOGO_LIEU_ON_SCREEN) ll.classList.add('show-screen');
  document.getElementById('pcd-t').textContent=C.TITRE;
  document.getElementById('pcd-c').textContent=C.CREDIT;
  var f=document.querySelector('link[rel="icon"]');if(f)f.href=C.FAVICON;
  document.getElementById('pcd-spline').setAttribute('url',C.SPLINE);

/* Apply Spline mode */
if(C.SPLINE_MODE==='fullscreen'){document.getElementById('pcd').classList.add('spline-full')}

});
</script>

<div class="T" id="pcd-toast">✓ Guide débloqué !</div>
<div class="fo" id="pcd-fo"><div class="fc"><div class="fcl" onclick="document.getElementById('pcd-fo').classList.remove('on')">✕</div><div class="fb" id="pcd-fo-badge">🎨 Formation Découverte IA</div><h2 id="pcd-fo-title">Débloquez le guide</h2><p class="fs" id="pcd-fo-desc">Entrez vos coordonnées pour accéder au <strong>guide sélectionné</strong>.</p><div class="fr"><div class="fg"><label>Prénom</label><input type="text" id="pcd-fn" placeholder="Votre prénom"></div><div class="fg"><label>Nom</label><input type="text" id="pcd-ln" placeholder="Votre nom"></div></div><div class="fg"><label>Email</label><input type="email" id="pcd-em" placeholder="votre@email.com"></div><div class="fg"><label>Téléphone <span class="op">(optionnel)</span></label><input type="tel" id="pcd-ph" placeholder="06 12 34 56 78"></div><div class="fg"><label>Vous êtes</label><select id="pcd-pr" onchange="var f=document.getElementById('pcd-sf');this.value==='pro_autre'?f.classList.add('v'):f.classList.remove('v')"><option value="">· Choisissez ·</option><option value="etudiant">Étudiant·e</option><option value="pro_design">Pro du design / créatif</option><option value="pro_autre">Pro (autre secteur)</option><option value="entrepreneur">Entrepreneur·e</option><option value="curieux">Curieux·se / Découverte</option></select></div><div class="fg sf" id="pcd-sf"><label>Secteur d'activité</label><input type="text" id="pcd-sc" placeholder="Ex : architecture, santé…"></div><div class="fg"><label>Code d'accès <span class="op">(fourni par le formateur)</span></label><input type="text" id="pcd-code" placeholder="1 2 3 4" maxlength="4" onkeydown="if(event.key==='Enter')pSub()" style="text-transform:uppercase;letter-spacing:4px;font-family:'Space Mono',monospace!important;font-size:18px!important;text-align:center"></div><button class="btn" id="pcd-sub" onclick="pSub()">Accéder au guide →</button><p class="fn">🔒 Données utilisées uniquement pour cette formation.</p></div></div>

<header class="H"><spline-viewer id="pcd-spline" loading="lazy" events-target="none"></spline-viewer><div class="Hi"><div class="Hl"><div class="Lp" id="pcd-lp"><img alt="PCD"></div><div class="Ht"><h1 id="pcd-t">Formations</h1><p id="pcd-c">Présent Composé Design / Alban Desbarax</p></div></div><div class="Ll" id="pcd-ll"><img alt=""></div></div></header>

<script>
var currentGuide='ia';
function openForm(g){
  currentGuide=g;
  var fo=document.getElementById('pcd-fo');
  var badge=document.getElementById('pcd-fo-badge');
  var title=document.getElementById('pcd-fo-title');
  var desc=document.getElementById('pcd-fo-desc');
  if(g==='ia'){
    badge.textContent='\u{1F3A8} Formation D\u00e9couverte IA';
    title.textContent='D\u00e9bloquez le guide IA';
    desc.innerHTML='Entrez vos coordonn\u00e9es pour acc\u00e9der au <strong>Top 10 des apps IA gratuites</strong>.';
  }else if(g==='sb'){
    badge.textContent='\u{1F3AC} Storyboard & virtual production IA TOOLBOX';
    title.textContent='D\u00e9bloquez la toolbox';
    desc.innerHTML='Acc\u00e9dez aux <strong>20 outils IA + 100 termes</strong> du storyboarder.';
  }else if(g==='vc'){
    badge.textContent='\u26A1 Claude Vibecoding';
    title.textContent='D\u00e9bloquez le guide Vibecoding';
    desc.innerHTML='Acc\u00e9dez au <strong>guide complet Claude Vibecoding</strong> — navigateur, VS Code, workflows et prompts.';
  }else{
    badge.textContent='\u{1F4BB} Applications Open Source';
    title.textContent='D\u00e9bloquez le guide';
    desc.innerHTML='Entrez vos coordonn\u00e9es pour acc\u00e9der au <strong>Top 25 des logiciels open source</strong>.';
  }
  fo.classList.add('on');
}
function showLanding(){
  document.getElementById('pcd-guide').classList.remove('on');
  var os=document.getElementById('pcd-guide-os');if(os)os.style.display='none';
  var sb=document.getElementById('pcd-guide-sb');if(sb)sb.style.display='none';
  var vc=document.getElementById('pcd-guide-vc');if(vc)vc.style.display='none';

  document.getElementById('pcd-land').style.display='block';
}


/* Fix Demo buttons: set real hrefs for PDF print */
document.querySelectorAll('#pcd-guide .cta-d').forEach(function(btn){
  var card=btn.closest('.ac[data-v]');
  if(!card)return;
  var key=card.getAttribute('data-v');
  var v=C.VIDEOS[key];
  if(!v)return;
  if(v.id){btn.href='https://www.youtube.com/watch?v='+v.id}
  else{btn.href='https://www.youtube.com/results?search_query='+encodeURIComponent(v.q)}
  btn.setAttribute('target','_blank');
});
/* OS guide Demo buttons already have real hrefs */

/* Video hover: autoplay for all cards */
document.querySelectorAll('.ac[data-v]').forEach(function(card){
  var key=card.getAttribute('data-v'),vp=card.querySelector('.vp');
  if(!vp)return;
  var v=C.VIDEOS?C.VIDEOS[key]:null;
  var demoBtn=card.querySelector('.cta-d');
  var demoHref=demoBtn?demoBtn.getAttribute('href'):'';
  var vid='';
  if(v&&v.id){vid=v.id}
  else if(demoHref){var m=demoHref.match(/watch\?v=([^&]+)/);if(m)vid=m[1]}
  var searchQ=(v&&v.q)?v.q:'';
  if(!searchQ&&demoHref){var sq=demoHref.match(/search_query=([^&]+)/);if(sq)searchQ=decodeURIComponent(sq[1]).replace(/\+/g,' ')}
  if(!vid)return;
  var timer=null;
  card.addEventListener('mouseenter',function(){
    timer=setTimeout(function(){
      var src='https://www.youtube.com/embed/'+vid+'?autoplay=1&mute=1&rel=0&modestbranding=1&controls=0';
      vp.innerHTML='<iframe src="'+src+'" allow="autoplay;encrypted-media" allowfullscreen loading="lazy"></iframe>';
      vp.classList.add('open');
    },300);
  });
  card.addEventListener('mouseleave',function(){
    clearTimeout(timer);
    vp.classList.remove('open');
    setTimeout(function(){vp.innerHTML=''},400);
  });
});
document.querySelectorAll('.eml').forEach(function(el){el.textContent=String.fromCharCode(99,111,110,116,97,99,116,64,112,114,101,115,101,110,116,99,111,109,112,111,115,101,100,101,115,105,103,110,46,102,114)});
</script>

<div class="print-hdr" id="pcd-print-hdr">
<img decoding="async" src="https://presentcomposedesign.fr/wp-content/uploads/2026/03/abstract-gradient-background-Formation-page@1-2048x1111abstract-gradient-background-Formation_PresentComposedesign_header.png" class="ph-bg" alt="">
<div class="ph-over">
<a href="https://presentcomposedesign.fr" target="_blank"><img decoding="async" src="https://presentcomposedesign.fr/wp-content/uploads/2025/07/Present-Compose-design_logo-image.svg" class="ph-logo" alt="PCD"></a>
<div class="ph-text">
<div class="ph-title">Formations</div>
<div class="ph-sub">Présent Composé Design / Alban Desbarax</div>
</div>
</div>
</div>
<section class="land" id="pcd-land"><div class="land-title">Ateliers & Ressources</div><div class="fcard" onclick="openForm('ia')"><h2>🎨 TOP 10 Applications IA Génératives 2026</h2><p class="fdesc">Les meilleures apps gratuites (téléphone & desktop) pour générer des images et vidéos facilement avec l'IA. Guide interactif avec démos vidéo.</p><span class="fbadge">GRATUIT · Guide interactif · 11 apps testées</span><div class="farrow"><svg viewBox="0 0 16 16"><path d="M5 11L11 5M11 5H6M11 5V10"/></svg></div></div><div class="fcard" onclick="openForm('os')" style="margin-top:12px"><h2>💻 TOP 25 Applications Open Source à installer en 2026</h2><p class="fdesc">La sélection essentielle des logiciels gratuits & open source pour la création 3D, audio, vidéo, design, impression 3D, code créatif et productivité.</p><span class="fbadge">GRATUIT · 25 logiciels · 7 catégories · Liens de téléchargement</span><div class="farrow"><svg viewBox="0 0 16 16"><path d="M5 11L11 5M11 5H6M11 5V10"/></svg></div></div><div class="fcard" onclick="openForm('sb')" style="margin-top:12px"><h2>🎬 Storyboard &amp; virtual production IA TOOLBOX</h2><p class="fdesc">20 outils IA et 100 termes techniques pour la création de storyboards. Glossaire interactif, prompts prêts à l'emploi, liens et demos.</p><span class="fbadge">GRATUIT · 20 outils · 100 termes · 2 onglets</span><div class="farrow"><svg viewBox="0 0 16 16"><path d="M5 11L11 5M11 5H6M11 5V10"/></svg></div></div><div class="fcard" onclick="openForm('vc')" style="margin-top:12px;border-color:#D8C5FF;position:relative;overflow:hidden"><div style="position:absolute;top:0;left:0;right:0;height:4px;background:linear-gradient(90deg,#7C3AED,#FF6B35)"></div><h2>⚡ Claude Vibecoding — Guide complet</h2><p class="fdesc">Maîtriser Claude dans le navigateur et dans VS Code. Workflows, prompts-clés, pièges à éviter, méthodes d'itération rapide pour créatifs qui codent.</p><span class="fbadge" style="background:#F3EEFF;color:#7C3AED">GRATUIT · 15 fiches · Navigateur · VS Code · Prompts à copier</span><div class="farrow" style="background:#7C3AED"><svg viewBox="0 0 16 16"><path d="M5 11L11 5M11 5H6M11 5V10"/></svg></div></div></section>

<section class="guide" id="pcd-guide">
<div class="guide-back" onclick="showLanding()">← Retour aux formations</div>
<div class="sl">★ Incontournable / Assistant IA quotidien</div>
<div class="ac mc" data-v="mistral"><div class="an">★</div><div class="ai" style="background:linear-gradient(135deg,#FF6B35,#FF8F5E);"><img decoding="async" src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" alt="" onerror="this.style.display='none';this.parentElement.textContent='M'"></div><div class="ab"><div class="ah"><span class="nm">Mistral AI (Le Chat)</span><span class="pl">Web · iOS · Android</span></div><p class="ad">Assistant IA français polyvalent : texte, recherche, résumé, analyse. Idéal pour la productivité quotidienne.</p><div class="af"><div class="at"><span class="tg" style="background:#FFF3ED;color:#CC4400;">Texte</span><span class="tg" style="background:#FFF3ED;color:#CC4400;">Recherche</span><span class="tg" style="background:#FFF3ED;color:#CC4400;">Analyse</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=Mistral+AI+Le+Chat+demo+tutoriel" target="_blank">▶ Demo</a><a class="cta-a" href="https://chat.mistral.ai" target="_blank">Accéder →</a></div></div></div><div class="vp"></div></div>
<div class="cat"><span class="cat-icon">🖼️</span> Images / Génération & édition</div>
<div class="ac" data-v="chatgpt"><div class="an">1</div><div class="ai" style="background:#E6F7F1;"><img decoding="async" src="https://www.google.com/s2/favicons?domain=chatgpt.com&sz=64" alt="" onerror="this.parentElement.style.background='#10A37F';this.parentElement.textContent='G';this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">ChatGPT + DALL·E</span><span class="pl">Web · iOS · Android</span></div><p class="ad">Génération d'images réalistes via GPT-4o. ~3 images/jour en gratuit. Texte lisible dans les images.</p><div class="af"><div class="at"><span class="tg" style="background:#E6F7F1;color:#0D8C6C;">Images</span><span class="tg" style="background:#E6F7F1;color:#0D8C6C;">Texte</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=ChatGPT+DALL-E+image+generation+demo+2025" target="_blank">▶ Demo</a><a class="cta-a" href="https://chatgpt.com" target="_blank">Accéder →</a></div></div></div><div class="vp"></div></div>
<div class="ac" data-v="gemini"><div class="an">2</div><div class="ai" style="background:#E8F0FE;"><img decoding="async" src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" alt="" onerror="this.parentElement.style.background='#4285F4';this.parentElement.textContent='G';this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Google Gemini + Veo 3.1</span><span class="pl">Web · iOS · Android</span></div><p class="ad">IA multimodale Google. Images (Nano Banana) et vidéos HD avec audio natif via Flow. Gratuit et puissant.</p><div class="af"><div class="at"><span class="tg" style="background:#E8F0FE;color:#1A73E8;">Images</span><span class="tg" style="background:#E8F0FE;color:#1A73E8;">Vidéos</span><span class="tg" style="background:#E8F0FE;color:#1A73E8;">Audio</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/watch?v=UIZAiXYceBI" target="_blank">▶ Demo</a><a class="cta-a" href="https://gemini.google.com" target="_blank">Accéder →</a></div></div></div><div class="vp"></div></div>
<div class="ac" data-v="canva"><div class="an">3</div><div class="ai" style="background:#E5F9FA;"><img decoding="async" src="https://www.google.com/s2/favicons?domain=canva.com&sz=64" alt="" onerror="this.parentElement.style.background='#00C4CC';this.parentElement.textContent='C';this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Canva IA</span><span class="pl">Web · iOS · Android</span></div><p class="ad">Suite de design avec images (Média Magique) et vidéos IA (Veo-3). Intuitive, idéale pour débutants.</p><div class="af"><div class="at"><span class="tg" style="background:#E5F9FA;color:#00A5AB;">Images</span><span class="tg" style="background:#E5F9FA;color:#00A5AB;">Vidéos</span><span class="tg" style="background:#E5F9FA;color:#00A5AB;">Design</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=Canva+AI+image+video+generator+demo" target="_blank">▶ Demo</a><a class="cta-a" href="https://www.canva.com" target="_blank">Accéder →</a></div></div></div><div class="vp"></div></div>
<div class="ac" data-v="firefly"><div class="an">4</div><div class="ai" style="background:#FFF0EB;"><img decoding="async" src="https://www.google.com/s2/favicons?domain=firefly.adobe.com&sz=64" alt="" onerror="this.parentElement.style.background='#FF4500';this.parentElement.textContent='A';this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Adobe Firefly</span><span class="pl">Web · Desktop</span></div><p class="ad">Générateur images/vidéos premium d'Adobe. 25 crédits gratuits/mois. Remplissage génératif et retouche.</p><div class="af"><div class="at"><span class="tg" style="background:#FFF0EB;color:#CC3700;">Images</span><span class="tg" style="background:#FFF0EB;color:#CC3700;">Vidéos</span><span class="tg" style="background:#FFF0EB;color:#CC3700;">Retouche</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/watch?v=Sp6K3qpVFO0" target="_blank">▶ Demo</a><a class="cta-a" href="https://firefly.adobe.com" target="_blank">Accéder →</a></div></div></div><div class="vp"></div></div>
<div class="cat"><span class="cat-icon">🎬</span> Vidéos / Génération & animation</div>
<div class="ac" data-v="luma"><div class="an">5</div><div class="ai" style="background:#F0EAFF;"><img decoding="async" src="https://www.google.com/s2/favicons?domain=lumalabs.ai&sz=64" alt="" onerror="this.parentElement.style.background='#7C3AED';this.parentElement.textContent='L';this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Luma AI (Dream Machine)</span><span class="pl">Web · iOS · Android</span></div><p class="ad">Génération vidéo réaliste à partir de texte ou d'images. Capture 3D photoréaliste avec téléphone.</p><div class="af"><div class="at"><span class="tg" style="background:#F0EAFF;color:#6D28D9;">Vidéos</span><span class="tg" style="background:#F0EAFF;color:#6D28D9;">3D</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=Luma+AI+Dream+Machine+video+demo" target="_blank">▶ Demo</a><a class="cta-a" href="https://lumalabs.ai" target="_blank">Accéder →</a></div></div></div><div class="vp"></div></div>
<div class="ac" data-v="runway"><div class="an">6</div><div class="ai" style="background:#E6F0FF;"><img decoding="async" src="https://www.google.com/s2/favicons?domain=runwayml.com&sz=64" alt="" onerror="this.parentElement.style.background='#0066FF';this.parentElement.textContent='R';this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Runway (Gen-4)</span><span class="pl">Web · iOS</span></div><p class="ad">Outil cinématographique. Vidéo à partir de texte, images ou clips. Productions réelles.</p><div class="af"><div class="at"><span class="tg" style="background:#E6F0FF;color:#0052CC;">Vidéos</span><span class="tg" style="background:#E6F0FF;color:#0052CC;">Cinéma</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=Runway+Gen-4+AI+video+demo" target="_blank">▶ Demo</a><a class="cta-a" href="https://runwayml.com" target="_blank">Accéder →</a></div></div></div><div class="vp"></div></div>
<div class="ac" data-v="leonardo"><div class="an">7</div><div class="ai" style="background:#F0EAFF;"><img decoding="async" src="https://www.google.com/s2/favicons?domain=leonardo.ai&sz=64" alt="" onerror="this.parentElement.style.background='#8B5CF6';this.parentElement.textContent='L';this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Leonardo AI</span><span class="pl">Web · iOS · Android</span></div><p class="ad">Générateur d'images avec nombreux modèles. 150 tokens/jour gratuits. Assets visuels.</p><div class="af"><div class="at"><span class="tg" style="background:#F0EAFF;color:#7C3AED;">Images</span><span class="tg" style="background:#F0EAFF;color:#7C3AED;">Assets</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=Leonardo+AI+image+generation+tutorial" target="_blank">▶ Demo</a><a class="cta-a" href="https://leonardo.ai" target="_blank">Accéder →</a></div></div></div><div class="vp"></div></div>
<div class="ac" data-v="ideogram"><div class="an">8</div><div class="ai" style="background:#FFF0F3;"><img decoding="async" src="https://www.google.com/s2/favicons?domain=ideogram.ai&sz=64" alt="" onerror="this.parentElement.style.background='#E11D48';this.parentElement.textContent='I';this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Ideogram</span><span class="pl">Web · iOS · Android</span></div><p class="ad">Maîtrise du texte dans les images. ~40 images/semaine gratuits. Résultats créatifs.</p><div class="af"><div class="at"><span class="tg" style="background:#FFF0F3;color:#BE123C;">Images</span><span class="tg" style="background:#FFF0F3;color:#BE123C;">Typographie</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=Ideogram+AI+text+image+demo" target="_blank">▶ Demo</a><a class="cta-a" href="https://ideogram.ai" target="_blank">Accéder →</a></div></div></div><div class="vp"></div></div>
<div class="ac" data-v="copilot"><div class="an">9</div><div class="ai" style="background:#E6F2FF;"><img decoding="async" src="https://www.google.com/s2/favicons?domain=copilot.microsoft.com&sz=64" alt="" onerror="this.parentElement.style.background='#0078D4';this.parentElement.textContent='C';this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Microsoft Copilot</span><span class="pl">Web · iOS · Android</span></div><p class="ad">DALL·E 3 gratuit et illimité via Microsoft. Aucune inscription nécessaire.</p><div class="af"><div class="at"><span class="tg" style="background:#E6F2FF;color:#005EA6;">Images</span><span class="tg" style="background:#E6F2FF;color:#005EA6;">Illimité</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/watch?v=S7xTBa93TX8" target="_blank">▶ Demo</a><a class="cta-a" href="https://copilot.microsoft.com" target="_blank">Accéder →</a></div></div></div><div class="vp"></div></div>
<div class="ac" data-v="pika"><div class="an">10</div><div class="ai" style="background:#FEF3C7;"><img decoding="async" src="https://www.google.com/s2/favicons?domain=pika.art&sz=64" alt="" onerror="this.parentElement.style.background='#F59E0B';this.parentElement.textContent='P';this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Pika</span><span class="pl">Web · iOS</span></div><p class="ad">Vidéos courtes créatives pour réseaux sociaux. Interface simple.</p><div class="af"><div class="at"><span class="tg" style="background:#FEF3C7;color:#D97706;">Vidéos</span><span class="tg" style="background:#FEF3C7;color:#D97706;">Social</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=Pika+AI+video+generation+demo" target="_blank">▶ Demo</a><a class="cta-a" href="https://pika.art" target="_blank">Accéder →</a></div></div></div><div class="vp"></div></div>
</section>


<section class="guide" id="pcd-guide-os" style="display:none">
<div class="guide-back" onclick="showLanding()">← Retour aux formations</div>
<div class="sl">💻 TOP 25 Applications Open Source & Gratuites · PC 2026</div>
<div class="cat"><span class="cat-icon">🎮</span> 3D · Moteurs · VR / XR</div>
<div class="ac" data-v="blender.org"><div class="ai" style="background:#E8E5FF;"><img decoding="async" src="https://www.google.com/s2/favicons?domain=blender.org&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Blender</span></div><p class="ad">Suite 3D complète : modélisation, animation, rendu, compositing. Le standard open source.</p><div class="af"><div class="at"><span class="tg" style="background:#E8E5FF;color:#1001EF;">3D</span><span class="tg" style="background:#E8E5FF;color:#1001EF;">Animation</span><span class="tg" style="background:#E8E5FF;color:#1001EF;">Rendu</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=Blender+3D+tutorial+d%C3%A9butant+2025" target="_blank">▶ Demo</a><a class="cta-a" href="https://www.blender.org/download/" target="_blank">Télécharger ↓</a></div></div></div><div class="vp"></div></div>
<div class="ac" data-v="roblox.com"><div class="ai" style="background:#E8E5FF;"><img decoding="async" src="https://www.google.com/s2/favicons?domain=roblox.com&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Roblox Studio</span></div><p class="ad">Créez vos propres jeux et expériences 3D. Plateforme massive de création interactive.</p><div class="af"><div class="at"><span class="tg" style="background:#E8E5FF;color:#1001EF;">Jeux</span><span class="tg" style="background:#E8E5FF;color:#1001EF;">3D</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=Roblox+Studio+tutorial+d%C3%A9butant+2025" target="_blank">▶ Demo</a><a class="cta-a" href="https://www.roblox.com/create" target="_blank">Télécharger ↓</a></div></div></div><div class="vp"></div></div>
<div class="ac" data-v="unity.com"><div class="ai" style="background:#E8E5FF;"><img decoding="async" src="https://www.google.com/s2/favicons?domain=unity.com&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Unity / Unreal Engine</span></div><p class="ad">Les deux moteurs de jeu de référence. Unity (C#) et Unreal (Blueprints/C++). Gratuits pour débuter.</p><div class="af"><div class="at"><span class="tg" style="background:#E8E5FF;color:#1001EF;">Jeux</span><span class="tg" style="background:#E8E5FF;color:#1001EF;">3D</span><span class="tg" style="background:#E8E5FF;color:#1001EF;">XR</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=Unity+vs+Unreal+Engine+tutorial+d%C3%A9butant+2025" target="_blank">▶ Demo</a><a class="cta-a" href="https://unity.com/download" target="_blank">Télécharger ↓</a></div></div></div><div class="vp"></div></div>
<div class="ac" data-v="babylonjs.com"><div class="ai" style="background:#E8E5FF;"><img decoding="async" src="https://www.google.com/s2/favicons?domain=babylonjs.com&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Babylon.js</span></div><p class="ad">Moteur 3D web open source par Microsoft. Créez des expériences 3D/WebXR dans le navigateur.</p><div class="af"><div class="at"><span class="tg" style="background:#E8E5FF;color:#1001EF;">Web3D</span><span class="tg" style="background:#E8E5FF;color:#1001EF;">WebXR</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=Babylon.js+tutorial+getting+started+2025" target="_blank">▶ Demo</a><a class="cta-a" href="https://www.babylonjs.com/" target="_blank">Télécharger ↓</a></div></div></div><div class="vp"></div></div>
<div class="ac" data-v="sidequestvr.com"><div class="ai" style="background:#E8E5FF;"><img decoding="async" src="https://www.google.com/s2/favicons?domain=sidequestvr.com&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">SideQuest</span></div><p class="ad">Plateforme de sideloading pour Meta Quest. Accédez à des apps VR non officielles.</p><div class="af"><div class="at"><span class="tg" style="background:#E8E5FF;color:#1001EF;">VR</span><span class="tg" style="background:#E8E5FF;color:#1001EF;">Quest</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=SideQuest+Meta+Quest+tutorial+2025" target="_blank">▶ Demo</a><a class="cta-a" href="https://sidequestvr.com/" target="_blank">Télécharger ↓</a></div></div></div><div class="vp"></div></div>
<div class="cat"><span class="cat-icon">🎵</span> Audio · Musique · Son</div>
<div class="ac" data-v="reaper.fm"><div class="ai" style="background:#FFF3ED;"><img decoding="async" src="https://www.google.com/s2/favicons?domain=reaper.fm&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Reaper</span></div><p class="ad">DAW professionnel ultra-léger. Licence d'évaluation illimitée. Plugins VST, MIDI, multitrack.</p><div class="af"><div class="at"><span class="tg" style="background:#FFF3ED;color:#CC4400;">DAW</span><span class="tg" style="background:#FFF3ED;color:#CC4400;">Audio</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=Reaper+DAW+tutorial+d%C3%A9butant+2025" target="_blank">▶ Demo</a><a class="cta-a" href="https://www.reaper.fm/download.php" target="_blank">Télécharger ↓</a></div></div></div><div class="vp"></div></div>
<div class="ac" data-v="ableton.com"><div class="ai" style="background:#FFF3ED;"><img decoding="async" src="https://www.google.com/s2/favicons?domain=ableton.com&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Ableton Live Lite</span></div><p class="ad">DAW référence pour la musique électronique et le live. Version Lite gratuite avec instruments.</p><div class="af"><div class="at"><span class="tg" style="background:#FFF3ED;color:#CC4400;">DAW</span><span class="tg" style="background:#FFF3ED;color:#CC4400;">Live</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=Ableton+Live+Lite+tutorial+d%C3%A9butant+2025" target="_blank">▶ Demo</a><a class="cta-a" href="https://www.ableton.com/en/products/live-lite/" target="_blank">Télécharger ↓</a></div></div></div><div class="vp"></div></div>
<div class="ac" data-v="cycling74.com"><div class="ai" style="background:#FFF3ED;"><img decoding="async" src="https://www.google.com/s2/favicons?domain=cycling74.com&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Max MSP</span></div><p class="ad">Environnement de programmation visuelle pour audio, MIDI, vidéo et médias interactifs.</p><div class="af"><div class="at"><span class="tg" style="background:#FFF3ED;color:#CC4400;">Audio</span><span class="tg" style="background:#FFF3ED;color:#CC4400;">Visuel</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=Max+MSP+tutorial+d%C3%A9butant+2025" target="_blank">▶ Demo</a><a class="cta-a" href="https://cycling74.com/downloads" target="_blank">Télécharger ↓</a></div></div></div><div class="vp"></div></div>
<div class="cat"><span class="cat-icon">🎬</span> Vidéo · Montage · Compositing</div>
<div class="ac" data-v="blackmagicdesign.com"><div class="ai" style="background:#E6F7F1;"><img decoding="async" src="https://www.google.com/s2/favicons?domain=blackmagicdesign.com&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">DaVinci Resolve</span></div><p class="ad">Suite pro complète : montage, étalonnage, VFX, mixage audio. Gratuit et très puissant.</p><div class="af"><div class="at"><span class="tg" style="background:#E6F7F1;color:#0D8C6C;">Montage</span><span class="tg" style="background:#E6F7F1;color:#0D8C6C;">VFX</span><span class="tg" style="background:#E6F7F1;color:#0D8C6C;">Color</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=DaVinci+Resolve+tutorial+d%C3%A9butant+2025" target="_blank">▶ Demo</a><a class="cta-a" href="https://www.blackmagicdesign.com/products/davinciresolve" target="_blank">Télécharger ↓</a></div></div></div><div class="vp"></div></div>
<div class="ac" data-v="capcut.com"><div class="ai" style="background:#E6F7F1;"><img decoding="async" src="https://www.google.com/s2/favicons?domain=capcut.com&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">CapCut</span></div><p class="ad">Montage vidéo simple et efficace. Templates, effets, sous-titres auto. Idéal réseaux sociaux.</p><div class="af"><div class="at"><span class="tg" style="background:#E6F7F1;color:#0D8C6C;">Montage</span><span class="tg" style="background:#E6F7F1;color:#0D8C6C;">Social</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=CapCut+tutorial+montage+d%C3%A9butant+2025" target="_blank">▶ Demo</a><a class="cta-a" href="https://www.capcut.com/" target="_blank">Télécharger ↓</a></div></div></div><div class="vp"></div></div>
<div class="ac" data-v="natrongithub.github.io"><div class="ai" style="background:#E6F7F1;"><img decoding="async" src="https://www.google.com/s2/favicons?domain=natrongithub.github.io&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Natron</span></div><p class="ad">Compositing node-based open source. Alternative à After Effects/Nuke pour le VFX.</p><div class="af"><div class="at"><span class="tg" style="background:#E6F7F1;color:#0D8C6C;">VFX</span><span class="tg" style="background:#E6F7F1;color:#0D8C6C;">Compositing</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=Natron+compositing+tutorial+2025" target="_blank">▶ Demo</a><a class="cta-a" href="https://natrongithub.github.io/" target="_blank">Télécharger ↓</a></div></div></div><div class="vp"></div></div>
<div class="ac" data-v="videolan.org"><div class="ai" style="background:#E6F7F1;"><img decoding="async" src="https://www.google.com/s2/favicons?domain=videolan.org&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">VLC</span></div><p class="ad">Lecteur multimédia universel. Lit tous les formats, convertit, streame. Incontournable.</p><div class="af"><div class="at"><span class="tg" style="background:#E6F7F1;color:#0D8C6C;">Lecteur</span><span class="tg" style="background:#E6F7F1;color:#0D8C6C;">Conversion</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=VLC+media+player+astuces+2025" target="_blank">▶ Demo</a><a class="cta-a" href="https://www.videolan.org/vlc/" target="_blank">Télécharger ↓</a></div></div></div><div class="vp"></div></div>
<div class="cat"><span class="cat-icon">🎨</span> Design · Image · UI</div>
<div class="ac" data-v="figma.com"><div class="ai" style="background:#F0EAFF;"><img decoding="async" src="https://www.google.com/s2/favicons?domain=figma.com&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Figma</span></div><p class="ad">Design d'interfaces, prototypage, collaboration temps réel. Version gratuite très généreuse.</p><div class="af"><div class="at"><span class="tg" style="background:#F0EAFF;color:#7C3AED;">UI/UX</span><span class="tg" style="background:#F0EAFF;color:#7C3AED;">Prototypage</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=Figma+tutorial+d%C3%A9butant+UI+design+2025" target="_blank">▶ Demo</a><a class="cta-a" href="https://www.figma.com/downloads/" target="_blank">Télécharger ↓</a></div></div></div><div class="vp"></div></div>
<div class="ac" data-v="affinity.serif.com"><div class="ai" style="background:#F0EAFF;"><img decoding="async" src="https://www.google.com/s2/favicons?domain=affinity.serif.com&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Affinity</span></div><p class="ad">Suite créative (Photo, Designer, Publisher). Alternative premium à Adobe, licence perpétuelle.</p><div class="af"><div class="at"><span class="tg" style="background:#F0EAFF;color:#7C3AED;">Design</span><span class="tg" style="background:#F0EAFF;color:#7C3AED;">Photo</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=Affinity+Designer+Photo+tutorial+2025" target="_blank">▶ Demo</a><a class="cta-a" href="https://affinity.serif.com/" target="_blank">Télécharger ↓</a></div></div></div><div class="vp"></div></div>
<div class="ac" data-v="xnview.com"><div class="ai" style="background:#F0EAFF;"><img decoding="async" src="https://www.google.com/s2/favicons?domain=xnview.com&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">XnView</span></div><p class="ad">Visionneuse et convertisseur d'images. Supporte 500+ formats. Batch processing puissant.</p><div class="af"><div class="at"><span class="tg" style="background:#F0EAFF;color:#7C3AED;">Images</span><span class="tg" style="background:#F0EAFF;color:#7C3AED;">Batch</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=XnView+tutorial+batch+2025" target="_blank">▶ Demo</a><a class="cta-a" href="https://www.xnview.com/en/" target="_blank">Télécharger ↓</a></div></div></div><div class="vp"></div></div>
<div class="cat"><span class="cat-icon">🖨️</span> Impression 3D · Fabrication</div>
<div class="ac" data-v="bambulab.com"><div class="ai" style="background:#FEF3C7;"><img decoding="async" src="https://www.google.com/s2/favicons?domain=bambulab.com&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Bambu Studio</span></div><p class="ad">Slicer officiel Bambu Lab. Profils optimisés, multi-couleur, interface moderne.</p><div class="af"><div class="at"><span class="tg" style="background:#FEF3C7;color:#D97706;">Slicer</span><span class="tg" style="background:#FEF3C7;color:#D97706;">FDM</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=Bambu+Studio+slicer+tutorial+2025" target="_blank">▶ Demo</a><a class="cta-a" href="https://bambulab.com/en/download/studio" target="_blank">Télécharger ↓</a></div></div></div><div class="vp"></div></div>
<div class="ac" data-v="github.com"><div class="ai" style="background:#FEF3C7;"><img decoding="async" src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Orca Slicer</span></div><p class="ad">Fork communautaire de Bambu Studio. Compatible toutes imprimantes, très personnalisable.</p><div class="af"><div class="at"><span class="tg" style="background:#FEF3C7;color:#D97706;">Slicer</span><span class="tg" style="background:#FEF3C7;color:#D97706;">Open Source</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=Orca+Slicer+tutorial+2025" target="_blank">▶ Demo</a><a class="cta-a" href="https://github.com/SoftFever/OrcaSlicer/releases" target="_blank">Télécharger ↓</a></div></div></div><div class="vp"></div></div>
<div class="ac" data-v="ultimaker.com"><div class="ai" style="background:#FEF3C7;"><img decoding="async" src="https://www.google.com/s2/favicons?domain=ultimaker.com&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Cura (UltiMaker)</span></div><p class="ad">Slicer universel le plus utilisé au monde. Marketplace de plugins, profils communautaires.</p><div class="af"><div class="at"><span class="tg" style="background:#FEF3C7;color:#D97706;">Slicer</span><span class="tg" style="background:#FEF3C7;color:#D97706;">Universel</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=Cura+slicer+tutorial+d%C3%A9butant+2025" target="_blank">▶ Demo</a><a class="cta-a" href="https://ultimaker.com/software/ultimaker-cura/" target="_blank">Télécharger ↓</a></div></div></div><div class="vp"></div></div>
<div class="cat"><span class="cat-icon">⚡</span> Code créatif · IA générative</div>
<div class="ac" data-v="derivative.ca"><div class="ai" style="background:#FFF0F3;"><img decoding="async" src="https://www.google.com/s2/favicons?domain=derivative.ca&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">TouchDesigner</span></div><p class="ad">Programmation visuelle temps réel pour installations interactives, VJing, mapping.</p><div class="af"><div class="at"><span class="tg" style="background:#FFF0F3;color:#BE123C;">Interactif</span><span class="tg" style="background:#FFF0F3;color:#BE123C;">Temps réel</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=TouchDesigner+tutorial+d%C3%A9butant+2025" target="_blank">▶ Demo</a><a class="cta-a" href="https://derivative.ca/download" target="_blank">Télécharger ↓</a></div></div></div><div class="vp"></div></div>
<div class="ac" data-v="processing.org"><div class="ai" style="background:#FFF0F3;"><img decoding="async" src="https://www.google.com/s2/favicons?domain=processing.org&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Processing</span></div><p class="ad">Langage de programmation créative. Idéal pour apprendre le code par le visuel.</p><div class="af"><div class="at"><span class="tg" style="background:#FFF0F3;color:#BE123C;">Code</span><span class="tg" style="background:#FFF0F3;color:#BE123C;">Art</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=Processing+creative+coding+tutorial+2025" target="_blank">▶ Demo</a><a class="cta-a" href="https://processing.org/download" target="_blank">Télécharger ↓</a></div></div></div><div class="vp"></div></div>
<div class="ac" data-v="arduino.cc"><div class="ai" style="background:#FFF0F3;"><img decoding="async" src="https://www.google.com/s2/favicons?domain=arduino.cc&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Arduino</span></div><p class="ad">Plateforme électronique open source. IDE + cartes pour prototypage interactif.</p><div class="af"><div class="at"><span class="tg" style="background:#FFF0F3;color:#BE123C;">Électronique</span><span class="tg" style="background:#FFF0F3;color:#BE123C;">IoT</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=Arduino+tutorial+d%C3%A9butant+2025" target="_blank">▶ Demo</a><a class="cta-a" href="https://www.arduino.cc/en/software" target="_blank">Télécharger ↓</a></div></div></div><div class="vp"></div></div>
<div class="ac" data-v="comfy.org"><div class="ai" style="background:#FFF0F3;"><img decoding="async" src="https://www.google.com/s2/favicons?domain=comfy.org&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">ComfyUI</span></div><p class="ad">Interface node-based pour Stable Diffusion. Workflows visuels pour la génération d'images IA.</p><div class="af"><div class="at"><span class="tg" style="background:#FFF0F3;color:#BE123C;">IA</span><span class="tg" style="background:#FFF0F3;color:#BE123C;">Stable Diffusion</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=ComfyUI+tutorial+d%C3%A9butant+2025" target="_blank">▶ Demo</a><a class="cta-a" href="https://github.com/comfyanonymous/ComfyUI" target="_blank">Télécharger ↓</a></div></div></div><div class="vp"></div></div>
<div class="cat"><span class="cat-icon">🔧</span> Outils · Productivité · Communication</div>
<div class="ac" data-v="code.visualstudio.com"><div class="ai" style="background:#E6F2FF;"><img decoding="async" src="https://www.google.com/s2/favicons?domain=code.visualstudio.com&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Visual Studio Code</span></div><p class="ad">Éditeur de code le plus populaire. Extensions, Git, terminal intégré, IA (Copilot).</p><div class="af"><div class="at"><span class="tg" style="background:#E6F2FF;color:#005EA6;">Code</span><span class="tg" style="background:#E6F2FF;color:#005EA6;">IDE</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=Visual+Studio+Code+tutorial+d%C3%A9butant+2025" target="_blank">▶ Demo</a><a class="cta-a" href="https://code.visualstudio.com/" target="_blank">Télécharger ↓</a></div></div></div><div class="vp"></div></div>
<div class="ac" data-v="openoffice.org"><div class="ai" style="background:#E6F2FF;"><img decoding="async" src="https://www.google.com/s2/favicons?domain=openoffice.org&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">OpenOffice</span></div><p class="ad">Suite bureautique complète et gratuite. Writer, Calc, Impress. Alternative à Microsoft Office.</p><div class="af"><div class="at"><span class="tg" style="background:#E6F2FF;color:#005EA6;">Bureau</span><span class="tg" style="background:#E6F2FF;color:#005EA6;">Docs</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=OpenOffice+tutorial+2025" target="_blank">▶ Demo</a><a class="cta-a" href="https://www.openoffice.org/download/" target="_blank">Télécharger ↓</a></div></div></div><div class="vp"></div></div>
<div class="ac" data-v="discord.com"><div class="ai" style="background:#E6F2FF;"><img decoding="async" src="https://www.google.com/s2/favicons?domain=discord.com&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Discord</span></div><p class="ad">Plateforme de communication. Salons vocaux/texte, partage d'écran, bots, communautés.</p><div class="af"><div class="at"><span class="tg" style="background:#E6F2FF;color:#005EA6;">Chat</span><span class="tg" style="background:#E6F2FF;color:#005EA6;">Communauté</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=Discord+tutorial+d%C3%A9butant+2025" target="_blank">▶ Demo</a><a class="cta-a" href="https://discord.com/download" target="_blank">Télécharger ↓</a></div></div></div><div class="vp"></div></div>

</section>






<section class="guide" id="pcd-guide-sb" style="display:none">
<div class="guide-back" onclick="showLanding()">← Retour aux formations</div>
<div class="sl">🎬 STORYBOARD & VIRTUAL PRODUCTION IA TOOLBOX</div>
<div class="sb-tabs">
<div class="sb-tab active" onclick="document.querySelectorAll('.sb-tab,.sb-panel').forEach(e=>e.classList.remove('active'));this.classList.add('active');document.getElementById('sb-outils').classList.add('active')">🛠 Outils (20)</div>
<div class="sb-tab" onclick="document.querySelectorAll('.sb-tab,.sb-panel').forEach(e=>e.classList.remove('active'));this.classList.add('active');document.getElementById('sb-glossaire').classList.add('active')">📖 Glossaire (100)</div>
</div>
<div class="sb-panel active" id="sb-outils">
<div class="cat"><span class="cat-icon">🖼️</span> Génération d'images</div>
<div class="ac" data-v="www.comfy.org"><div class="an">1</div><div class="ai" style="background:#E8F0FE"><img decoding="async" src="https://www.google.com/s2/favicons?domain=www.comfy.org&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">ComfyUI</span><span class="pl" style="background:#e8f5ed;color:#27ae60;padding:2px 6px;border-radius:3px;font-size:9px">Gratuit</span></div><p class="ad">Interface node-based pour Stable Diffusion et Flux. Workflows visuels puissants pour la génération d'images IA. Personnalisation totale, ControlNet, IP-Adapter, cohérence entre panels.</p><div class="af"><div class="at"><span class="tg" style="background:#E8F0FE;color:#1A73E8">Workflow visuel</span><span class="tg" style="background:#E8F0FE;color:#1A73E8">Stable Diffusion</span><span class="tg" style="background:#E8F0FE;color:#1A73E8">ControlNet</span><span class="tg" style="background:#E8F0FE;color:#1A73E8">Cohérence</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=ComfyUI" target="_blank">▶ Demo</a><a class="cta-a" href="https://www.comfy.org/" target="_blank">Accéder →</a></div></div><div style="background:#1e1a15;border-radius:8px;padding:8px 12px;margin-top:8px;font-size:10px;color:#e8dcc8;line-height:1.5;font-family:monospace;cursor:pointer" onclick="event.stopPropagation();navigator.clipboard.writeText(this.dataset.p);this.style.outline='2px solid #27ae60';setTimeout(()=>this.style.outline='',1000)" data-p="ControlNet pose + IP-Adapter style: Generate storyboard panel, plan rapproché, dutch angle 15°, hard side lighting, clair-obscur, background defocused urban décor, cinematic 16:9, high contrast B&amp;W storyboard sketch style" title="Copier"><span style="font-size:8px;color:#888;display:block;margin-bottom:3px">PROMPT · clic = copier</span>ControlNet pose + IP-Adapter style: Generate storyboard panel, plan rapproché, dutch angle 15°, hard side lighting, clair-obscur, background defocused urban déc...</div></div><div class="vp"></div></div>
<div class="ac" data-v="labs.google"><div class="an">2</div><div class="ai" style="background:#E8F0FE"><img decoding="async" src="https://www.google.com/s2/favicons?domain=labs.google&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Google Flow</span><span class="pl" style="background:#e8f5ed;color:#27ae60;padding:2px 6px;border-radius:3px;font-size:9px">Gratuit</span></div><p class="ad">Outil Google pour créer des storyboards et moodboards visuels avec l'IA. Génération d'images à partir de prompts textuels, idéal pour la pré-production et la visualisation rapide de concepts narratifs.</p><div class="af"><div class="at"><span class="tg" style="background:#E8F0FE;color:#1A73E8">Storyboard</span><span class="tg" style="background:#E8F0FE;color:#1A73E8">Moodboard</span><span class="tg" style="background:#E8F0FE;color:#1A73E8">Google AI</span><span class="tg" style="background:#E8F0FE;color:#1A73E8">Pré-production</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=Google+Flow" target="_blank">▶ Demo</a><a class="cta-a" href="https://labs.google/flow/about" target="_blank">Accéder →</a></div></div><div style="background:#1e1a15;border-radius:8px;padding:8px 12px;margin-top:8px;font-size:10px;color:#e8dcc8;line-height:1.5;font-family:monospace;cursor:pointer" onclick="event.stopPropagation();navigator.clipboard.writeText(this.dataset.p);this.style.outline='2px solid #27ae60';setTimeout(()=>this.style.outline='',1000)" data-p="Cinematic storyboard panel, establishing shot, rain-soaked alley at night, low key lighting, hard light from neon creating clair-obscur, foreground bokeh, deep depth of field, 2.39:1 cinémascope" title="Copier"><span style="font-size:8px;color:#888;display:block;margin-bottom:3px">PROMPT · clic = copier</span>Cinematic storyboard panel, establishing shot, rain-soaked alley at night, low key lighting, hard light from neon creating clair-obscur, foreground bokeh, deep ...</div></div><div class="vp"></div></div>
<div class="ac" data-v="stability.ai"><div class="an">3</div><div class="ai" style="background:#E8F0FE"><img decoding="async" src="https://www.google.com/s2/favicons?domain=stability.ai&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Stable Diffusion</span><span class="pl" style="background:#e8f5ed;color:#27ae60;padding:2px 6px;border-radius:3px;font-size:9px">Gratuit</span></div><p class="ad">Modèle open-source de génération d'images. Utilisable localement ou via ComfyUI. Parfait pour créer des planches cohérentes avec des LoRA personnalisés (personnages, univers visuels constants).</p><div class="af"><div class="at"><span class="tg" style="background:#E8F0FE;color:#1A73E8">Cohérence personnage</span><span class="tg" style="background:#E8F0FE;color:#1A73E8">Style graphique</span><span class="tg" style="background:#E8F0FE;color:#1A73E8">Batch génération</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=Stable+Diffusion" target="_blank">▶ Demo</a><a class="cta-a" href="https://stability.ai" target="_blank">Accéder →</a></div></div><div style="background:#1e1a15;border-radius:8px;padding:8px 12px;margin-top:8px;font-size:10px;color:#e8dcc8;line-height:1.5;font-family:monospace;cursor:pointer" onclick="event.stopPropagation();navigator.clipboard.writeText(this.dataset.p);this.style.outline='2px solid #27ae60';setTimeout(()=>this.style.outline='',1000)" data-p="Storyboard frame, plan américain shot, male detective character, over-the-shoulder framing, contre-plongée angle, rim light on shoulders, soft light fill, background slightly defocused, cinematic 16:9, desaturated color palette, pencil sketch style" title="Copier"><span style="font-size:8px;color:#888;display:block;margin-bottom:3px">PROMPT · clic = copier</span>Storyboard frame, plan américain shot, male detective character, over-the-shoulder framing, contre-plongée angle, rim light on shoulders, soft light fill, backg...</div></div><div class="vp"></div></div>
<div class="ac" data-v="openai.com"><div class="an">4</div><div class="ai" style="background:#E8F0FE"><img decoding="async" src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">DALL·E 3</span><span class="pl" style="background:#fff8e8;color:#d4891a;padding:2px 6px;border-radius:3px;font-size:9px">Freemium</span></div><p class="ad">Accessible directement via ChatGPT. Excellente compréhension des instructions en langage naturel. Utile pour générer rapidement des thumbnails de séquences et tester des compositions.</p><div class="af"><div class="at"><span class="tg" style="background:#E8F0FE;color:#1A73E8">Prototypage rapide</span><span class="tg" style="background:#E8F0FE;color:#1A73E8">Composition</span><span class="tg" style="background:#E8F0FE;color:#1A73E8">Intégré ChatGPT</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=DALL·E+3" target="_blank">▶ Demo</a><a class="cta-a" href="https://openai.com/dall-e-3" target="_blank">Accéder →</a></div></div><div style="background:#1e1a15;border-radius:8px;padding:8px 12px;margin-top:8px;font-size:10px;color:#e8dcc8;line-height:1.5;font-family:monospace;cursor:pointer" onclick="event.stopPropagation();navigator.clipboard.writeText(this.dataset.p);this.style.outline='2px solid #27ae60';setTimeout(()=>this.style.outline='',1000)" data-p="Storyboard thumbnail, plan d&#x27;ensemble of a forest clearing at golden hour, high key natural lighting, rule of thirds composition with protagonist in left third, symmetrical tree framing, cinematic flat 1.85:1 aspect ratio, warm color temperature, impressionist style" title="Copier"><span style="font-size:8px;color:#888;display:block;margin-bottom:3px">PROMPT · clic = copier</span>Storyboard thumbnail, plan d&#x27;ensemble of a forest clearing at golden hour, high key natural lighting, rule of thirds composition with protagonist in left third,...</div></div><div class="vp"></div></div>
<div class="ac" data-v="blackforestlabs.ai"><div class="an">5</div><div class="ai" style="background:#E8F0FE"><img decoding="async" src="https://www.google.com/s2/favicons?domain=blackforestlabs.ai&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Flux (Black Forest Labs)</span><span class="pl" style="background:#fff8e8;color:#d4891a;padding:2px 6px;border-radius:3px;font-size:9px">Freemium</span></div><p class="ad">Modèle nouvelle génération aux rendus photographiques exceptionnels. Très précis sur les compositions complexes et le rendu des textures. Idéal pour des panneaux de storyboard ultra-détaillés.</p><div class="af"><div class="at"><span class="tg" style="background:#E8F0FE;color:#1A73E8">Rendu photoréaliste</span><span class="tg" style="background:#E8F0FE;color:#1A73E8">Texture & matière</span><span class="tg" style="background:#E8F0FE;color:#1A73E8">Composition précise</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=Flux+(Black+Forest+Labs)" target="_blank">▶ Demo</a><a class="cta-a" href="https://blackforestlabs.ai" target="_blank">Accéder →</a></div></div><div style="background:#1e1a15;border-radius:8px;padding:8px 12px;margin-top:8px;font-size:10px;color:#e8dcc8;line-height:1.5;font-family:monospace;cursor:pointer" onclick="event.stopPropagation();navigator.clipboard.writeText(this.dataset.p);this.style.outline='2px solid #27ae60';setTimeout(()=>this.style.outline='',1000)" data-p="Cinematic storyboard panel, bird&#x27;s eye view of a city intersection during golden hour, very long shot establishing geography, leading lines converging to center, silhouette figures, contre-jour sunlight, warm backlight, cinematic 2.35:1 scope format, photorealistic" title="Copier"><span style="font-size:8px;color:#888;display:block;margin-bottom:3px">PROMPT · clic = copier</span>Cinematic storyboard panel, bird&#x27;s eye view of a city intersection during golden hour, very long shot establishing geography, leading lines converging to center...</div></div><div class="vp"></div></div>
<div class="cat"><span class="cat-icon">🎬</span> Génération vidéo</div>
<div class="ac" data-v="runwayml.com"><div class="an">6</div><div class="ai" style="background:#F0EAFF"><img decoding="async" src="https://www.google.com/s2/favicons?domain=runwayml.com&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Runway Gen-4</span><span class="pl" style="background:#fff8e8;color:#d4891a;padding:2px 6px;border-radius:3px;font-size:9px">Freemium</span></div><p class="ad">Transforme une image fixe en clip vidéo avec contrôle des mouvements de caméra. Permet de créer une animatique directement depuis ses panels de storyboard en quelques clics.</p><div class="af"><div class="at"><span class="tg" style="background:#F0EAFF;color:#7C3AED">Image-to-video</span><span class="tg" style="background:#F0EAFF;color:#7C3AED">Animatique</span><span class="tg" style="background:#F0EAFF;color:#7C3AED">Mouvement caméra</span><span class="tg" style="background:#F0EAFF;color:#7C3AED">Durée plan</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=Runway+Gen-4" target="_blank">▶ Demo</a><a class="cta-a" href="https://runwayml.com" target="_blank">Accéder →</a></div></div><div style="background:#1e1a15;border-radius:8px;padding:8px 12px;margin-top:8px;font-size:10px;color:#e8dcc8;line-height:1.5;font-family:monospace;cursor:pointer" onclick="event.stopPropagation();navigator.clipboard.writeText(this.dataset.p);this.style.outline='2px solid #27ae60';setTimeout(()=>this.style.outline='',1000)" data-p="Camera motion: slow dolly in toward subject, subtle tilt up, soft focus pull (rack focus) from foreground to background. Lighting remains constant high key. No jump cut. Smooth tracking movement, 4 seconds duration." title="Copier"><span style="font-size:8px;color:#888;display:block;margin-bottom:3px">PROMPT · clic = copier</span>Camera motion: slow dolly in toward subject, subtle tilt up, soft focus pull (rack focus) from foreground to background. Lighting remains constant high key. No ...</div></div><div class="vp"></div></div>
<div class="ac" data-v="sora.com"><div class="an">7</div><div class="ai" style="background:#F0EAFF"><img decoding="async" src="https://www.google.com/s2/favicons?domain=sora.com&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Sora (OpenAI)</span><span class="pl" style="background:#fce8e8;color:#c0392b;padding:2px 6px;border-radius:3px;font-size:9px">Payant</span></div><p class="ad">Génération vidéo longue durée depuis texte ou image. Excellent pour simuler des plans-séquences complexes, des mouvements fluides et des transitions entre scènes-clés d'une séquence narrative.</p><div class="af"><div class="at"><span class="tg" style="background:#F0EAFF;color:#7C3AED">Plan-séquence</span><span class="tg" style="background:#F0EAFF;color:#7C3AED">Text-to-video</span><span class="tg" style="background:#F0EAFF;color:#7C3AED">Simulation mouvement</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=Sora+(OpenAI)" target="_blank">▶ Demo</a><a class="cta-a" href="https://sora.com" target="_blank">Accéder →</a></div></div><div style="background:#1e1a15;border-radius:8px;padding:8px 12px;margin-top:8px;font-size:10px;color:#e8dcc8;line-height:1.5;font-family:monospace;cursor:pointer" onclick="event.stopPropagation();navigator.clipboard.writeText(this.dataset.p);this.style.outline='2px solid #27ae60';setTimeout(()=>this.style.outline='',1000)" data-p="A continuous plan-séquence starting from a bird&#x27;s eye view of a rooftop, camera slowly arcs downward transitioning to eye level, following a woman walking, steadicam movement, dissolve to interior scene, low key dramatic lighting, cinematic 2.39:1 aspect ratio, golden hour ambiance" title="Copier"><span style="font-size:8px;color:#888;display:block;margin-bottom:3px">PROMPT · clic = copier</span>A continuous plan-séquence starting from a bird&#x27;s eye view of a rooftop, camera slowly arcs downward transitioning to eye level, following a woman walking, stea...</div></div><div class="vp"></div></div>
<div class="ac" data-v="klingai.com"><div class="an">8</div><div class="ai" style="background:#F0EAFF"><img decoding="async" src="https://www.google.com/s2/favicons?domain=klingai.com&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Kling AI</span><span class="pl" style="background:#fff8e8;color:#d4891a;padding:2px 6px;border-radius:3px;font-size:9px">Freemium</span></div><p class="ad">Génération vidéo de haute qualité avec un excellent contrôle du mouvement des sujets. Idéal pour simuler les mouvements de personnages dans une scène, utile pour valider un blocking avant tournage.</p><div class="af"><div class="at"><span class="tg" style="background:#F0EAFF;color:#7C3AED">Blocking acteurs</span><span class="tg" style="background:#F0EAFF;color:#7C3AED">Mouvement sujet</span><span class="tg" style="background:#F0EAFF;color:#7C3AED">Image-to-video</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=Kling+AI" target="_blank">▶ Demo</a><a class="cta-a" href="https://klingai.com" target="_blank">Accéder →</a></div></div><div style="background:#1e1a15;border-radius:8px;padding:8px 12px;margin-top:8px;font-size:10px;color:#e8dcc8;line-height:1.5;font-family:monospace;cursor:pointer" onclick="event.stopPropagation();navigator.clipboard.writeText(this.dataset.p);this.style.outline='2px solid #27ae60';setTimeout(()=>this.style.outline='',1000)" data-p="Two-shot scene, blocking: character A enters frame from left foreground, character B remains static in background right, camera pans slightly left to follow action, maintaining over-the-shoulder framing, eye level angle, soft fill light, natural lighting interior" title="Copier"><span style="font-size:8px;color:#888;display:block;margin-bottom:3px">PROMPT · clic = copier</span>Two-shot scene, blocking: character A enters frame from left foreground, character B remains static in background right, camera pans slightly left to follow act...</div></div><div class="vp"></div></div>
<div class="cat"><span class="cat-icon">📐</span> Storyboard dédié</div>
<div class="ac" data-v="wonderunit.com"><div class="an">9</div><div class="ai" style="background:#E6F7F1"><img decoding="async" src="https://www.google.com/s2/favicons?domain=wonderunit.com&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Storyboarder (Wonder Unit)</span><span class="pl" style="background:#e8f5ed;color:#27ae60;padding:2px 6px;border-radius:3px;font-size:9px">Gratuit</span></div><p class="ad">Logiciel gratuit dédié au storyboard avec intégration IA. Génère automatiquement des panels à partir d'un script, avec des champs pour action, dialogue, durée et notes de réalisation.</p><div class="af"><div class="at"><span class="tg" style="background:#E6F7F1;color:#0D8C6C">Script-to-storyboard</span><span class="tg" style="background:#E6F7F1;color:#0D8C6C">Shot list auto</span><span class="tg" style="background:#E6F7F1;color:#0D8C6C">Export animatique</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=Storyboarder+(Wonder+Unit)" target="_blank">▶ Demo</a><a class="cta-a" href="https://wonderunit.com/storyboarder" target="_blank">Accéder →</a></div></div><div style="background:#1e1a15;border-radius:8px;padding:8px 12px;margin-top:8px;font-size:10px;color:#e8dcc8;line-height:1.5;font-family:monospace;cursor:pointer" onclick="event.stopPropagation();navigator.clipboard.writeText(this.dataset.p);this.style.outline='2px solid #27ae60';setTimeout(()=>this.style.outline='',1000)" data-p="SCÈNE 12 — INT. BUREAU — NUIT | Plan : Gros plan (GP) | Angle : Eye level | Mouvement : Push in lent | Durée : 3s | Action : Le personnage lit une lettre, regard descendant. Lumière : Key light latérale gauche, low key, clair-obscur marqué. | Dialogue : — | SFX : Bruissement de papier V.O." title="Copier"><span style="font-size:8px;color:#888;display:block;margin-bottom:3px">PROMPT · clic = copier</span>SCÈNE 12 — INT. BUREAU — NUIT | Plan : Gros plan (GP) | Angle : Eye level | Mouvement : Push in lent | Durée : 3s | Action : Le personnage lit une lettre, regar...</div></div><div class="vp"></div></div>
<div class="ac" data-v="boords.com"><div class="an">10</div><div class="ai" style="background:#E6F7F1"><img decoding="async" src="https://www.google.com/s2/favicons?domain=boords.com&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Boords</span><span class="pl" style="background:#fce8e8;color:#c0392b;padding:2px 6px;border-radius:3px;font-size:9px">Payant</span></div><p class="ad">Outil de storyboard professionnel avec génération IA intégrée. Idéal pour les agences et réalisateurs. Permet de générer une séquence de panels cohérents et de les exporter en animatique avec timing.</p><div class="af"><div class="at"><span class="tg" style="background:#E6F7F1;color:#0D8C6C">Panels cohérents</span><span class="tg" style="background:#E6F7F1;color:#0D8C6C">Export PDF/vidéo</span><span class="tg" style="background:#E6F7F1;color:#0D8C6C">Collaboration</span><span class="tg" style="background:#E6F7F1;color:#0D8C6C">Animatique</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=Boords" target="_blank">▶ Demo</a><a class="cta-a" href="https://boords.com" target="_blank">Accéder →</a></div></div><div style="background:#1e1a15;border-radius:8px;padding:8px 12px;margin-top:8px;font-size:10px;color:#e8dcc8;line-height:1.5;font-family:monospace;cursor:pointer" onclick="event.stopPropagation();navigator.clipboard.writeText(this.dataset.p);this.style.outline='2px solid #27ae60';setTimeout(()=>this.style.outline='',1000)" data-p="Generate storyboard sequence: Scene opens with establishing shot (plan général) of empty parking garage, cut to plan moyen of protagonist entering, then close-up (gros plan) on hand gripping car keys, smash cut to exterior establishing shot. Low key lighting throughout, hard shadows, 1.85:1 flat format." title="Copier"><span style="font-size:8px;color:#888;display:block;margin-bottom:3px">PROMPT · clic = copier</span>Generate storyboard sequence: Scene opens with establishing shot (plan général) of empty parking garage, cut to plan moyen of protagonist entering, then close-u...</div></div><div class="vp"></div></div>
<div class="ac" data-v="scenaristai.com"><div class="an">11</div><div class="ai" style="background:#E6F7F1"><img decoding="async" src="https://www.google.com/s2/favicons?domain=scenaristai.com&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Scenarist AI</span><span class="pl" style="background:#fff8e8;color:#d4891a;padding:2px 6px;border-radius:3px;font-size:9px">Freemium</span></div><p class="ad">Plateforme tout-en-un : écriture du script, découpage technique et génération des panels. Transforme un traitement narratif en storyboard illustré automatiquement avec numérotation des scènes et plans.</p><div class="af"><div class="at"><span class="tg" style="background:#E6F7F1;color:#0D8C6C">Script → Storyboard</span><span class="tg" style="background:#E6F7F1;color:#0D8C6C">Découpage auto</span><span class="tg" style="background:#E6F7F1;color:#0D8C6C">Numéro de plan</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=Scenarist+AI" target="_blank">▶ Demo</a><a class="cta-a" href="https://scenaristai.com" target="_blank">Accéder →</a></div></div><div style="background:#1e1a15;border-radius:8px;padding:8px 12px;margin-top:8px;font-size:10px;color:#e8dcc8;line-height:1.5;font-family:monospace;cursor:pointer" onclick="event.stopPropagation();navigator.clipboard.writeText(this.dataset.p);this.style.outline='2px solid #27ae60';setTimeout(()=>this.style.outline='',1000)" data-p="Generate a storyboard from this treatment: ACT I — The protagonist arrives at a deserted train station (establishing shot, wide). She notices a letter on a bench (insert, extreme close-up). She reads it, reaction shot (gros plan, eye level). BEAT: she looks up — empty platform (pan left, plan d&#x27;ensemble). Cut to black." title="Copier"><span style="font-size:8px;color:#888;display:block;margin-bottom:3px">PROMPT · clic = copier</span>Generate a storyboard from this treatment: ACT I — The protagonist arrives at a deserted train station (establishing shot, wide). She notices a letter on a benc...</div></div><div class="vp"></div></div>
<div class="cat"><span class="cat-icon">✍️</span> Écriture & Structure narrative</div>
<div class="ac" data-v="claude.ai"><div class="an">12</div><div class="ai" style="background:#FFF3ED"><img decoding="async" src="https://www.google.com/s2/favicons?domain=claude.ai&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Claude (Anthropic)</span><span class="pl" style="background:#fff8e8;color:#d4891a;padding:2px 6px;border-radius:3px;font-size:9px">Freemium</span></div><p class="ad">Excellent pour la structure narrative, les beat sheets et les descriptions de plans. Génère des découpages techniques précis, des dialogues et des notes de réalisation détaillées. Idéal pour construire la colonne vertébrale d'un storyboard.</p><div class="af"><div class="at"><span class="tg" style="background:#FFF3ED;color:#CC4400">Beat sheet</span><span class="tg" style="background:#FFF3ED;color:#CC4400">Découpage technique</span><span class="tg" style="background:#FFF3ED;color:#CC4400">Logline</span><span class="tg" style="background:#FFF3ED;color:#CC4400">Notes de réalisation</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=Claude+(Anthropic)" target="_blank">▶ Demo</a><a class="cta-a" href="https://claude.ai" target="_blank">Accéder →</a></div></div><div style="background:#1e1a15;border-radius:8px;padding:8px 12px;margin-top:8px;font-size:10px;color:#e8dcc8;line-height:1.5;font-family:monospace;cursor:pointer" onclick="event.stopPropagation();navigator.clipboard.writeText(this.dataset.p);this.style.outline='2px solid #27ae60';setTimeout(()=>this.style.outline='',1000)" data-p="Crée un découpage technique storyboard pour la séquence du climax (Acte III) d&#x27;un thriller urbain. Pour chaque plan indique : numéro de plan, type de plan (GP/PM/PA/PE), angle (plongée/contre-plongée/eye level), mouvement caméra (travelling/pan/dolly), transition vers le plan suivant (coupe franche/match cut/dissolve), durée estimée, et une brève description de l&#x27;action et du dialogue. Ambiance : low key, clair-obscur, 2.39:1." title="Copier"><span style="font-size:8px;color:#888;display:block;margin-bottom:3px">PROMPT · clic = copier</span>Crée un découpage technique storyboard pour la séquence du climax (Acte III) d&#x27;un thriller urbain. Pour chaque plan indique : numéro de plan, type de plan (GP/P...</div></div><div class="vp"></div></div>
<div class="ac" data-v="chatgpt.com"><div class="an">13</div><div class="ai" style="background:#FFF3ED"><img decoding="async" src="https://www.google.com/s2/favicons?domain=chatgpt.com&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">ChatGPT (OpenAI)</span><span class="pl" style="background:#fff8e8;color:#d4891a;padding:2px 6px;border-radius:3px;font-size:9px">Freemium</span></div><p class="ad">Fort en génération de synopses, loglines et arcs narratifs. Utile pour développer la structure en 3 actes, générer des beat sheets et rédiger des notes de plateau. Compatible avec DALL·E pour une chaîne texte → image.</p><div class="af"><div class="at"><span class="tg" style="background:#FFF3ED;color:#CC4400">Synopsis</span><span class="tg" style="background:#FFF3ED;color:#CC4400">Arc narratif</span><span class="tg" style="background:#FFF3ED;color:#CC4400">Beat sheet</span><span class="tg" style="background:#FFF3ED;color:#CC4400">Chaîne texte→image</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=ChatGPT+(OpenAI)" target="_blank">▶ Demo</a><a class="cta-a" href="https://chatgpt.com" target="_blank">Accéder →</a></div></div><div style="background:#1e1a15;border-radius:8px;padding:8px 12px;margin-top:8px;font-size:10px;color:#e8dcc8;line-height:1.5;font-family:monospace;cursor:pointer" onclick="event.stopPropagation();navigator.clipboard.writeText(this.dataset.p);this.style.outline='2px solid #27ae60';setTimeout(()=>this.style.outline='',1000)" data-p="Rédige un beat sheet en 12 points pour un court-métrage de 15 minutes (genre : drame psychologique). Identifie : l&#x27;exposition, le turning point de l&#x27;Acte I, la montée en tension, le nœud dramatique central, le climax et la résolution. Pour chaque beat, suggère un type de plan dominant (plan général, gros plan, etc.) et une ambiance lumineuse (high key / low key)." title="Copier"><span style="font-size:8px;color:#888;display:block;margin-bottom:3px">PROMPT · clic = copier</span>Rédige un beat sheet en 12 points pour un court-métrage de 15 minutes (genre : drame psychologique). Identifie : l&#x27;exposition, le turning point de l&#x27;Acte I, la ...</div></div><div class="vp"></div></div>
<div class="ac" data-v="gemini.google.com"><div class="an">14</div><div class="ai" style="background:#FFF3ED"><img decoding="async" src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Gemini (Google)</span><span class="pl" style="background:#fff8e8;color:#d4891a;padding:2px 6px;border-radius:3px;font-size:9px">Freemium</span></div><p class="ad">Capable d'analyser une image et de décrire techniquement un plan (type de plan, angle, lumière) — très utile pour reverse-engineer des références visuelles et les adapter à son storyboard. Multimodal natif.</p><div class="af"><div class="at"><span class="tg" style="background:#FFF3ED;color:#CC4400">Analyse image</span><span class="tg" style="background:#FFF3ED;color:#CC4400">Référence visuelle</span><span class="tg" style="background:#FFF3ED;color:#CC4400">Multimodal</span><span class="tg" style="background:#FFF3ED;color:#CC4400">Description de plan</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=Gemini+(Google)" target="_blank">▶ Demo</a><a class="cta-a" href="https://gemini.google.com" target="_blank">Accéder →</a></div></div><div style="background:#1e1a15;border-radius:8px;padding:8px 12px;margin-top:8px;font-size:10px;color:#e8dcc8;line-height:1.5;font-family:monospace;cursor:pointer" onclick="event.stopPropagation();navigator.clipboard.writeText(this.dataset.p);this.style.outline='2px solid #27ae60';setTimeout(()=>this.style.outline='',1000)" data-p="Analyse this reference image and describe it using storyboard technical vocabulary: identify the shot type (plan général/moyen/rapproché/gros plan), camera angle (plongée/contre-plongée/eye level/dutch angle), lighting setup (high key/low key, key light direction, fill light, rim light), aspect ratio, and suggest a camera movement (pan/tilt/dolly/steadicam) that would work for the next shot." title="Copier"><span style="font-size:8px;color:#888;display:block;margin-bottom:3px">PROMPT · clic = copier</span>Analyse this reference image and describe it using storyboard technical vocabulary: identify the shot type (plan général/moyen/rapproché/gros plan), camera angl...</div></div><div class="vp"></div></div>
<div class="cat"><span class="cat-icon">🎯</span> Cohérence visuelle</div>
<div class="ac" data-v="leonardo.ai"><div class="an">15</div><div class="ai" style="background:#FEF3C7"><img decoding="async" src="https://www.google.com/s2/favicons?domain=leonardo.ai&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Leonardo.Ai</span><span class="pl" style="background:#fff8e8;color:#d4891a;padding:2px 6px;border-radius:3px;font-size:9px">Freemium</span></div><p class="ad">Spécialisé dans la cohérence de personnages entre les panels. Fonctionnalité « Character Reference » pour garder le même visage/costume sur tous les plans. Indispensable pour les storyboards avec protagonistes récurrents.</p><div class="af"><div class="at"><span class="tg" style="background:#FEF3C7;color:#D97706">Cohérence personnage</span><span class="tg" style="background:#FEF3C7;color:#D97706">Character reference</span><span class="tg" style="background:#FEF3C7;color:#D97706">Angles multiples</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=Leonardo.Ai" target="_blank">▶ Demo</a><a class="cta-a" href="https://leonardo.ai" target="_blank">Accéder →</a></div></div><div style="background:#1e1a15;border-radius:8px;padding:8px 12px;margin-top:8px;font-size:10px;color:#e8dcc8;line-height:1.5;font-family:monospace;cursor:pointer" onclick="event.stopPropagation();navigator.clipboard.writeText(this.dataset.p);this.style.outline='2px solid #27ae60';setTimeout(()=>this.style.outline='',1000)" data-p="[Character reference attached] Same character, storyboard panel sequence: Panel 1 — plan moyen, profil gauche, eye level, natural soft light. Panel 2 — gros plan, three-quarter view, contre-plongée, hard key light from right creating clair-obscur. Panel 3 — très gros plan eyes only, eye level, rim light. Consistent art style, cinematic 16:9." title="Copier"><span style="font-size:8px;color:#888;display:block;margin-bottom:3px">PROMPT · clic = copier</span>[Character reference attached] Same character, storyboard panel sequence: Panel 1 — plan moyen, profil gauche, eye level, natural soft light. Panel 2 — gros pla...</div></div><div class="vp"></div></div>
<div class="ac" data-v="firefly.adobe.com"><div class="an">16</div><div class="ai" style="background:#FEF3C7"><img decoding="async" src="https://www.google.com/s2/favicons?domain=firefly.adobe.com&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Adobe Firefly</span><span class="pl" style="background:#fff8e8;color:#d4891a;padding:2px 6px;border-radius:3px;font-size:9px">Freemium</span></div><p class="ad">Intégré à Photoshop et Premiere. Génère et retouche des panels directement dans le workflow Adobe. La fonction Generative Fill permet d'ajuster un cadrage, ajouter un décor ou modifier une composition après génération.</p><div class="af"><div class="at"><span class="tg" style="background:#FEF3C7;color:#D97706">Workflow Adobe</span><span class="tg" style="background:#FEF3C7;color:#D97706">Retouche panel</span><span class="tg" style="background:#FEF3C7;color:#D97706">Générative Fill</span><span class="tg" style="background:#FEF3C7;color:#D97706">Décor</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=Adobe+Firefly" target="_blank">▶ Demo</a><a class="cta-a" href="https://firefly.adobe.com" target="_blank">Accéder →</a></div></div><div style="background:#1e1a15;border-radius:8px;padding:8px 12px;margin-top:8px;font-size:10px;color:#e8dcc8;line-height:1.5;font-family:monospace;cursor:pointer" onclick="event.stopPropagation();navigator.clipboard.writeText(this.dataset.p);this.style.outline='2px solid #27ae60';setTimeout(()=>this.style.outline='',1000)" data-p="Extend this storyboard panel to cinémascope 2.39:1 ratio by generating background on both sides. Maintain: low key lighting, hard light source from left, clair-obscur atmosphere, industrial interior décor, night time, consistent with existing foreground elements." title="Copier"><span style="font-size:8px;color:#888;display:block;margin-bottom:3px">PROMPT · clic = copier</span>Extend this storyboard panel to cinémascope 2.39:1 ratio by generating background on both sides. Maintain: low key lighting, hard light source from left, clair-...</div></div><div class="vp"></div></div>
<div class="cat"><span class="cat-icon">🎵</span> Son, V.O. & Soundtrack</div>
<div class="ac" data-v="elevenlabs.io"><div class="an">17</div><div class="ai" style="background:#FFE8F0"><img decoding="async" src="https://www.google.com/s2/favicons?domain=elevenlabs.io&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">ElevenLabs</span><span class="pl" style="background:#fff8e8;color:#d4891a;padding:2px 6px;border-radius:3px;font-size:9px">Freemium</span></div><p class="ad">Génération de voix off (V.O.) ultra-réalistes à partir de texte. Permet d'ajouter des dialogues et narrations à une animatique pour tester le rythme des plans et la durée des séquences avant tournage.</p><div class="af"><div class="at"><span class="tg" style="background:#FFE8F0;color:#BE123C">Voix off (V.O.)</span><span class="tg" style="background:#FFE8F0;color:#BE123C">Dialogue</span><span class="tg" style="background:#FFE8F0;color:#BE123C">Test de rythme</span><span class="tg" style="background:#FFE8F0;color:#BE123C">Durée des plans</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=ElevenLabs" target="_blank">▶ Demo</a><a class="cta-a" href="https://elevenlabs.io" target="_blank">Accéder →</a></div></div><div style="background:#1e1a15;border-radius:8px;padding:8px 12px;margin-top:8px;font-size:10px;color:#e8dcc8;line-height:1.5;font-family:monospace;cursor:pointer" onclick="event.stopPropagation();navigator.clipboard.writeText(this.dataset.p);this.style.outline='2px solid #27ae60';setTimeout(()=>this.style.outline='',1000)" data-p="[V.O. Narrateur — Ton : grave, posé, 90 mots/min] « La ville ne dort jamais. Mais cette nuit-là, quelque chose avait changé. » → Durée cible : 4 secondes → correspond à un plan d&#x27;ensemble avec panoramique lent. [SFX à ajouter : bruit de circulation lointain, vent]" title="Copier"><span style="font-size:8px;color:#888;display:block;margin-bottom:3px">PROMPT · clic = copier</span>[V.O. Narrateur — Ton : grave, posé, 90 mots/min] « La ville ne dort jamais. Mais cette nuit-là, quelque chose avait changé. » → Durée cible : 4 secondes → corr...</div></div><div class="vp"></div></div>
<div class="ac" data-v="suno.com"><div class="an">18</div><div class="ai" style="background:#FFE8F0"><img decoding="async" src="https://www.google.com/s2/favicons?domain=suno.com&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Suno AI</span><span class="pl" style="background:#fff8e8;color:#d4891a;padding:2px 6px;border-radius:3px;font-size:9px">Freemium</span></div><p class="ad">Génération musicale à partir d'un prompt textuel. Crée une bande sonore temporaire pour accompagner l'animatique, tester l'émotion de chaque séquence et valider le rythme des transitions avant la production finale.</p><div class="af"><div class="at"><span class="tg" style="background:#FFE8F0;color:#BE123C">Bande son temp</span><span class="tg" style="background:#FFE8F0;color:#BE123C">Ambiance séquence</span><span class="tg" style="background:#FFE8F0;color:#BE123C">Rythme transition</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=Suno+AI" target="_blank">▶ Demo</a><a class="cta-a" href="https://suno.com" target="_blank">Accéder →</a></div></div><div style="background:#1e1a15;border-radius:8px;padding:8px 12px;margin-top:8px;font-size:10px;color:#e8dcc8;line-height:1.5;font-family:monospace;cursor:pointer" onclick="event.stopPropagation();navigator.clipboard.writeText(this.dataset.p);this.style.outline='2px solid #27ae60';setTimeout(()=>this.style.outline='',1000)" data-p="Cinematic score for a thriller storyboard sequence. Act II rising tension, building from sparse low-key piano to intense orchestral swell reaching climax at 1min20. Tempo matches smash cut editing rhythm. Dark, suspenseful, Hans Zimmer style. No lyrics. Duration: 2 minutes." title="Copier"><span style="font-size:8px;color:#888;display:block;margin-bottom:3px">PROMPT · clic = copier</span>Cinematic score for a thriller storyboard sequence. Act II rising tension, building from sparse low-key piano to intense orchestral swell reaching climax at 1mi...</div></div><div class="vp"></div></div>
<div class="ac" data-v="elevenlabs.io"><div class="an">19</div><div class="ai" style="background:#FFE8F0"><img decoding="async" src="https://www.google.com/s2/favicons?domain=elevenlabs.io&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">ElevenLabs SFX</span><span class="pl" style="background:#fff8e8;color:#d4891a;padding:2px 6px;border-radius:3px;font-size:9px">Freemium</span></div><p class="ad">Génération de SFX (effets sonores) depuis un prompt. Utile pour enrichir l'animatique avec des sons d'ambiance correspondant aux décors et actions de chaque panel — bruitage, atmosphères, impacts.</p><div class="af"><div class="at"><span class="tg" style="background:#FFE8F0;color:#BE123C">SFX</span><span class="tg" style="background:#FFE8F0;color:#BE123C">Ambiance décor</span><span class="tg" style="background:#FFE8F0;color:#BE123C">Bruitage</span><span class="tg" style="background:#FFE8F0;color:#BE123C">Animatique sonore</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=ElevenLabs+SFX" target="_blank">▶ Demo</a><a class="cta-a" href="https://elevenlabs.io/sound-effects" target="_blank">Accéder →</a></div></div><div style="background:#1e1a15;border-radius:8px;padding:8px 12px;margin-top:8px;font-size:10px;color:#e8dcc8;line-height:1.5;font-family:monospace;cursor:pointer" onclick="event.stopPropagation();navigator.clipboard.writeText(this.dataset.p);this.style.outline='2px solid #27ae60';setTimeout(()=>this.style.outline='',1000)" data-p="SFX for storyboard panel: establishing shot of rainy city alley, night scene. Sound: distant traffic, rain on pavement, dripping water from rooftop, occasional distant siren. Duration: 6 seconds, seamlessly loopable, low key urban ambiance." title="Copier"><span style="font-size:8px;color:#888;display:block;margin-bottom:3px">PROMPT · clic = copier</span>SFX for storyboard panel: establishing shot of rainy city alley, night scene. Sound: distant traffic, rain on pavement, dripping water from rooftop, occasional ...</div></div><div class="vp"></div></div>
<div class="ac" data-v="github.com"><div class="an">20</div><div class="ai" style="background:#FEF3C7"><img decoding="async" src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Fooocus</span><span class="pl" style="background:#e8f5ed;color:#27ae60;padding:2px 6px;border-radius:3px;font-size:9px">Gratuit</span></div><p class="ad">Interface simplifiée pour Stable Diffusion / Flux. Génération d'images de haute qualité sans configuration complexe. ControlNet et IP-Adapter intégrés pour la cohérence visuelle entre panels.</p><div class="af"><div class="at"><span class="tg" style="background:#FEF3C7;color:#D97706">Workflow simplifié</span><span class="tg" style="background:#FEF3C7;color:#D97706">Cohérence totale</span><span class="tg" style="background:#FEF3C7;color:#D97706">ControlNet</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=Fooocus+tutorial" target="_blank">▶ Demo</a><a class="cta-a" href="https://github.com/lllyasviel/Fooocus" target="_blank">Accéder →</a></div></div></div><div class="vp"></div></div>


</div>
<div class="sb-panel" id="sb-glossaire">
<div class="cat"><span class="cat-icon">📖</span> Structure du récit</div>
<div class="ac"><div class="an">01</div><div class="ai" style="background:#FFF3ED"><span style="font-size:20px">📖</span></div><div class="ab"><div class="ah"><span class="nm">Structure du récit</span><span class="pl">17 termes</span></div><p class="ad">Cliquez sur un terme pour le copier dans vos prompts IA.</p><div class="af"><div class="at" style="flex-wrap:wrap"><span class="tg" style="background:#FFF3ED;color:#CC4400;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Acte I');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Acte I</span><span class="tg" style="background:#FFF3ED;color:#CC4400;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Acte II');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Acte II</span><span class="tg" style="background:#FFF3ED;color:#CC4400;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Acte III');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Acte III</span><span class="tg" style="background:#FFF3ED;color:#CC4400;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Arc narratif');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Arc narratif</span><span class="tg" style="background:#FFF3ED;color:#CC4400;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Beat');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Beat</span><span class="tg" style="background:#FFF3ED;color:#CC4400;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Beat sheet');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Beat sheet</span><span class="tg" style="background:#FFF3ED;color:#CC4400;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Climax');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Climax</span><span class="tg" style="background:#FFF3ED;color:#CC4400;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Dénouement');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Dénouement</span><span class="tg" style="background:#FFF3ED;color:#CC4400;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Exposition');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Exposition</span><span class="tg" style="background:#FFF3ED;color:#CC4400;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Intrigue');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Intrigue</span><span class="tg" style="background:#FFF3ED;color:#CC4400;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Logline');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Logline</span><span class="tg" style="background:#FFF3ED;color:#CC4400;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Montée en tension');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Montée en tension</span><span class="tg" style="background:#FFF3ED;color:#CC4400;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Nœud dramatique');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Nœud dramatique</span><span class="tg" style="background:#FFF3ED;color:#CC4400;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Point de bascule');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Point de bascule</span><span class="tg" style="background:#FFF3ED;color:#CC4400;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Prologue');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Prologue</span><span class="tg" style="background:#FFF3ED;color:#CC4400;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Résolution');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Résolution</span><span class="tg" style="background:#FFF3ED;color:#CC4400;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Scène-clé');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Scène-clé</span></div></div></div></div>
<div class="cat"><span class="cat-icon">🎥</span> Cadrage & Plans</div>
<div class="ac"><div class="an">02</div><div class="ai" style="background:#E8F0FE"><span style="font-size:20px">🎥</span></div><div class="ab"><div class="ah"><span class="nm">Cadrage & Plans</span><span class="pl">14 termes</span></div><p class="ad">Cliquez sur un terme pour le copier dans vos prompts IA.</p><div class="af"><div class="at" style="flex-wrap:wrap"><span class="tg" style="background:#E8F0FE;color:#1A73E8;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Amorce');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Amorce</span><span class="tg" style="background:#E8F0FE;color:#1A73E8;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Cadre');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Cadre</span><span class="tg" style="background:#E8F0FE;color:#1A73E8;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Champ');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Champ</span><span class="tg" style="background:#E8F0FE;color:#1A73E8;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Contre-champ');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Contre-champ</span><span class="tg" style="background:#E8F0FE;color:#1A73E8;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Establishing shot');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Establishing shot</span><span class="tg" style="background:#E8F0FE;color:#1A73E8;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Gros plan (GP)');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Gros plan (GP)</span><span class="tg" style="background:#E8F0FE;color:#1A73E8;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Hors-champ');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Hors-champ</span><span class="tg" style="background:#E8F0FE;color:#1A73E8;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Insert');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Insert</span><span class="tg" style="background:#E8F0FE;color:#1A73E8;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Over-the-shoulder (OTS)');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Over-the-shoulder (OTS)</span><span class="tg" style="background:#E8F0FE;color:#1A73E8;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Plan américain (PA)');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Plan américain (PA)</span><span class="tg" style="background:#E8F0FE;color:#1A73E8;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Plan densemble (PE)');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Plan d'ensemble (PE)</span><span class="tg" style="background:#E8F0FE;color:#1A73E8;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Plan général (PG)');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Plan général (PG)</span><span class="tg" style="background:#E8F0FE;color:#1A73E8;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Plan moyen (PM)');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Plan moyen (PM)</span><span class="tg" style="background:#E8F0FE;color:#1A73E8;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Plan poitrine (PP)');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Plan poitrine (PP)</span></div></div></div></div>
<div class="cat"><span class="cat-icon">📐</span> Angles & Axes de caméra</div>
<div class="ac"><div class="an">03</div><div class="ai" style="background:#F0EAFF"><span style="font-size:20px">📐</span></div><div class="ab"><div class="ah"><span class="nm">Angles & Axes de caméra</span><span class="pl">9 termes</span></div><p class="ad">Cliquez sur un terme pour le copier dans vos prompts IA.</p><div class="af"><div class="at" style="flex-wrap:wrap"><span class="tg" style="background:#F0EAFF;color:#7C3AED;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Angle dattaque');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Angle d'attaque</span><span class="tg" style="background:#F0EAFF;color:#7C3AED;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Birds eye view');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Bird's eye view</span><span class="tg" style="background:#F0EAFF;color:#7C3AED;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Caméra subjective');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Caméra subjective</span><span class="tg" style="background:#F0EAFF;color:#7C3AED;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Contre-plongée');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Contre-plongée</span><span class="tg" style="background:#F0EAFF;color:#7C3AED;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Dutch angle');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Dutch angle</span><span class="tg" style="background:#F0EAFF;color:#7C3AED;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Eye level');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Eye level</span><span class="tg" style="background:#F0EAFF;color:#7C3AED;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Plongée');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Plongée</span><span class="tg" style="background:#F0EAFF;color:#7C3AED;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Profil');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Profil</span><span class="tg" style="background:#F0EAFF;color:#7C3AED;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Top shot');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Top shot</span></div></div></div></div>
<div class="cat"><span class="cat-icon">🎬</span> Mouvements de caméra</div>
<div class="ac"><div class="an">04</div><div class="ai" style="background:#E6F7F1"><span style="font-size:20px">🎬</span></div><div class="ab"><div class="ah"><span class="nm">Mouvements de caméra</span><span class="pl">12 termes</span></div><p class="ad">Cliquez sur un terme pour le copier dans vos prompts IA.</p><div class="af"><div class="at" style="flex-wrap:wrap"><span class="tg" style="background:#E6F7F1;color:#0D8C6C;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Arc');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Arc</span><span class="tg" style="background:#E6F7F1;color:#0D8C6C;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Caméra portée');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Caméra portée</span><span class="tg" style="background:#E6F7F1;color:#0D8C6C;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Dolly in');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Dolly in</span><span class="tg" style="background:#E6F7F1;color:#0D8C6C;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Dolly out');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Dolly out</span><span class="tg" style="background:#E6F7F1;color:#0D8C6C;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Filé (swish pan)');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Filé (swish pan)</span><span class="tg" style="background:#E6F7F1;color:#0D8C6C;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Panoramique (pan)');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Panoramique (pan)</span><span class="tg" style="background:#E6F7F1;color:#0D8C6C;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Plan-séquence');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Plan-séquence</span><span class="tg" style="background:#E6F7F1;color:#0D8C6C;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Push in');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Push in</span><span class="tg" style="background:#E6F7F1;color:#0D8C6C;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Rack focus');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Rack focus</span><span class="tg" style="background:#E6F7F1;color:#0D8C6C;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Steadicam');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Steadicam</span><span class="tg" style="background:#E6F7F1;color:#0D8C6C;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Tilt down');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Tilt down</span><span class="tg" style="background:#E6F7F1;color:#0D8C6C;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Tilt up');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Tilt up</span></div></div></div></div>
<div class="cat"><span class="cat-icon">✂️</span> Transitions</div>
<div class="ac"><div class="an">05</div><div class="ai" style="background:#FEF3C7"><span style="font-size:20px">✂️</span></div><div class="ab"><div class="ah"><span class="nm">Transitions</span><span class="pl">9 termes</span></div><p class="ad">Cliquez sur un terme pour le copier dans vos prompts IA.</p><div class="af"><div class="at" style="flex-wrap:wrap"><span class="tg" style="background:#FEF3C7;color:#D97706;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Coupe franche');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Coupe franche</span><span class="tg" style="background:#FEF3C7;color:#D97706;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Cut to black');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Cut to black</span><span class="tg" style="background:#FEF3C7;color:#D97706;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Dissolve (fondu enchaîné)');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Dissolve (fondu enchaîné)</span><span class="tg" style="background:#FEF3C7;color:#D97706;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Ellipse');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Ellipse</span><span class="tg" style="background:#FEF3C7;color:#D97706;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Fade in');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Fade in</span><span class="tg" style="background:#FEF3C7;color:#D97706;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Fade out');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Fade out</span><span class="tg" style="background:#FEF3C7;color:#D97706;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Flash cut');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Flash cut</span><span class="tg" style="background:#FEF3C7;color:#D97706;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Jump cut');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Jump cut</span><span class="tg" style="background:#FEF3C7;color:#D97706;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Match cut');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Match cut</span></div></div></div></div>
<div class="cat"><span class="cat-icon">🎭</span> Composition & Mise en scène</div>
<div class="ac"><div class="an">06</div><div class="ai" style="background:#FFE8F0"><span style="font-size:20px">🎭</span></div><div class="ab"><div class="ah"><span class="nm">Composition & Mise en scène</span><span class="pl">9 termes</span></div><p class="ad">Cliquez sur un terme pour le copier dans vos prompts IA.</p><div class="af"><div class="at" style="flex-wrap:wrap"><span class="tg" style="background:#FFE8F0;color:#BE123C;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Action line (axe des 180°)');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Action line (axe des 180°)</span><span class="tg" style="background:#FFE8F0;color:#BE123C;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Background (arrière-plan)');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Background (arrière-plan)</span><span class="tg" style="background:#FFE8F0;color:#BE123C;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Blocking (placement des acteurs)');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Blocking (placement des acteurs)</span><span class="tg" style="background:#FFE8F0;color:#BE123C;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Foreground (premier plan)');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Foreground (premier plan)</span><span class="tg" style="background:#FFE8F0;color:#BE123C;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Golden ratio');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Golden ratio</span><span class="tg" style="background:#FFE8F0;color:#BE123C;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Lead room');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Lead room</span><span class="tg" style="background:#FFE8F0;color:#BE123C;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Lignes de force');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Lignes de force</span><span class="tg" style="background:#FFE8F0;color:#BE123C;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Look room (nose room)');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Look room (nose room)</span><span class="tg" style="background:#FFE8F0;color:#BE123C;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Profondeur de champ');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Profondeur de champ</span></div></div></div></div>
<div class="cat"><span class="cat-icon">💡</span> Lumière & Ambiance</div>
<div class="ac"><div class="an">07</div><div class="ai" style="background:#E5F9FA"><span style="font-size:20px">💡</span></div><div class="ab"><div class="ah"><span class="nm">Lumière & Ambiance</span><span class="pl">9 termes</span></div><p class="ad">Cliquez sur un terme pour le copier dans vos prompts IA.</p><div class="af"><div class="at" style="flex-wrap:wrap"><span class="tg" style="background:#E5F9FA;color:#00A5AB;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Contre-jour');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Contre-jour</span><span class="tg" style="background:#E5F9FA;color:#00A5AB;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Clair-obscur');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Clair-obscur</span><span class="tg" style="background:#E5F9FA;color:#00A5AB;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Fill light');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Fill light</span><span class="tg" style="background:#E5F9FA;color:#00A5AB;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Hard light');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Hard light</span><span class="tg" style="background:#E5F9FA;color:#00A5AB;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('High key');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">High key</span><span class="tg" style="background:#E5F9FA;color:#00A5AB;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Key light');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Key light</span><span class="tg" style="background:#E5F9FA;color:#00A5AB;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Low key');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Low key</span><span class="tg" style="background:#E5F9FA;color:#00A5AB;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Lumière naturelle');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Lumière naturelle</span><span class="tg" style="background:#E5F9FA;color:#00A5AB;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Rim light');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Rim light</span></div></div></div></div>
<div class="cat"><span class="cat-icon">📝</span> Annotations & Format</div>
<div class="ac"><div class="an">08</div><div class="ai" style="background:#E6F2FF"><span style="font-size:20px">📝</span></div><div class="ab"><div class="ah"><span class="nm">Annotations & Format</span><span class="pl">12 termes</span></div><p class="ad">Cliquez sur un terme pour le copier dans vos prompts IA.</p><div class="af"><div class="at" style="flex-wrap:wrap"><span class="tg" style="background:#E6F2FF;color:#005EA6;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Action');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Action</span><span class="tg" style="background:#E6F2FF;color:#005EA6;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Animatique');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Animatique</span><span class="tg" style="background:#E6F2FF;color:#005EA6;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Case (panel)');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Case (panel)</span><span class="tg" style="background:#E6F2FF;color:#005EA6;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Décor');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Décor</span><span class="tg" style="background:#E6F2FF;color:#005EA6;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Description de plan');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Description de plan</span><span class="tg" style="background:#E6F2FF;color:#005EA6;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Dialogue');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Dialogue</span><span class="tg" style="background:#E6F2FF;color:#005EA6;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Durée');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Durée</span><span class="tg" style="background:#E6F2FF;color:#005EA6;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Flèche de mouvement');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Flèche de mouvement</span><span class="tg" style="background:#E6F2FF;color:#005EA6;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Miniature (thumbnail)');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Miniature (thumbnail)</span><span class="tg" style="background:#E6F2FF;color:#005EA6;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Note de réalisation');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Note de réalisation</span><span class="tg" style="background:#E6F2FF;color:#005EA6;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Numéro de plan');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Numéro de plan</span><span class="tg" style="background:#E6F2FF;color:#005EA6;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Numéro de scène');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Numéro de scène</span></div></div></div></div>
<div class="cat"><span class="cat-icon">📏</span> Formats & Ratio</div>
<div class="ac"><div class="an">09</div><div class="ai" style="background:#F5F0FF"><span style="font-size:20px">📏</span></div><div class="ab"><div class="ah"><span class="nm">Formats & Ratio</span><span class="pl">9 termes</span></div><p class="ad">Cliquez sur un terme pour le copier dans vos prompts IA.</p><div class="af"><div class="at" style="flex-wrap:wrap"><span class="tg" style="background:#F5F0FF;color:#6D28D9;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('1.33:1 (4:3)');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">1.33:1 (4:3)</span><span class="tg" style="background:#F5F0FF;color:#6D28D9;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('1.78:1 (16:9)');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">1.78:1 (16:9)</span><span class="tg" style="background:#F5F0FF;color:#6D28D9;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('1.85:1 (Flat)');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">1.85:1 (Flat)</span><span class="tg" style="background:#F5F0FF;color:#6D28D9;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('2.35:1 (Scope)');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">2.35:1 (Scope)</span><span class="tg" style="background:#F5F0FF;color:#6D28D9;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('2.39:1 (Cinémascope)');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">2.39:1 (Cinémascope)</span><span class="tg" style="background:#F5F0FF;color:#6D28D9;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Format carré (1:1)');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Format carré (1:1)</span><span class="tg" style="background:#F5F0FF;color:#6D28D9;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Format vertical (9:16)');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Format vertical (9:16)</span><span class="tg" style="background:#F5F0FF;color:#6D28D9;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Letterbox');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Letterbox</span><span class="tg" style="background:#F5F0FF;color:#6D28D9;cursor:pointer;margin:2px" onclick="event.stopPropagation();navigator.clipboard.writeText('Masque (matte)');this.style.outline='2px solid #10B981';setTimeout(()=>this.style.outline='',800)">Masque (matte)</span></div></div></div></div>

</div>
</section>
<div class="print-contact" id="pcd-print-contact">
<img decoding="async" src="https://presentcomposedesign.fr/wp-content/uploads/2026/03/abstract-gradient-background-Formation-page@1-2048x1111abstract-gradient-background-Formation_PresentComposedesign_footer.png" class="pc-bg" alt="">
<div class="pc-inner">
<a href="https://presentcomposedesign.fr" target="_blank" class="pc-logo"><img decoding="async" src="https://presentcomposedesign.fr/wp-content/uploads/2025/07/Present-Compose-design_logo-image.svg" alt="PCD"></a>
<div class="pc-info">
<div class="pc-name">Alban Desbarax · Présent Composé Design</div>
<div class="pc-role">Designer 360° · Directeur Artistique · Enseignant · Toulouse</div>
<div class="pc-phrase">Ma sélection d'outils et applications préconisés pour les formations universitaires et professionnelles dans les secteurs créatifs</div>
<div class="pc-links">
<a href="https://fr.linkedin.com/in/alban-desbarax-present-compose-design" target="_blank" class="pc-li" title="LinkedIn">in</a>
<a href="#" onclick="window.location=String.fromCharCode(109,97,105,108,116,111,58)+String.fromCharCode(99,111,110,116,97,99,116,64)+String.fromCharCode(112,114,101,115,101,110,116,99,111,109,112,111,115,101,100,101,115,105,103,110,46,102,114);return false" class="pc-email">&#9993; <span class="eml"></span></a>
<a href="tel:+33683949667">&#9742; +33 6 83 94 96 67</a>
<a href="https://buymeacoffee.com/presentcomposedesign" class="pc-thx" target="_blank">&#9829; Remercier / Soutenir</a>
<a href="https://presentcomposedesign.fr/contact/" class="pc-cta" target="_blank">&#8599; Mettre en place une formation</a>
</div>
</div>
</div>
</div>
<div class="print-footer"><div class="pf-b"><a href="https://presentcomposedesign.fr" target="_blank">Présent Composé Design</a> / Alban Desbarax</div><div class="pf-i">12, bis rue Louis Plana 31500 Toulouse · +33 (0) 683 949 667 · <span class="eml"></span> · SIREN : 789 583 242 000 35</div></div>
<section class="guide" id="pcd-guide-vc" style="display:none">
<div class="guide-back" onclick="showLanding()">← Retour aux formations</div>
<div class="sl">⚡ CLAUDE VIBECODING — Guide complet</div>
<div class="cat"><span class="cat-icon">🌊</span> C'est quoi le Vibecoding ?</div>
<div class="ac star"><div class="an">★</div><div class="ai" style="background:#F3EEFF"><span style="font-size:22px">🌊</span></div><div class="ab"><div class="ah"><span class="nm">Le concept — Définition</span><span class="pl" style="background:#D8C5FF;color:#4C1D95;padding:2px 6px;border-radius:3px;font-size:9px">Essentiel</span></div><p class="ad">Terme popularisé par Andrej Karpathy (ex-OpenAI, Tesla AI) en 2025. Vibecoder c'est <strong>déléguer l'écriture du code à Claude</strong> en pilotant par l'intention et le résultat visuel — sans lire chaque ligne. On décrit, Claude génère, on teste, on itère. La machine code, toi tu diriges.</p><div class="rulebox"><strong>La boucle du vibecoding</strong><ul><li><strong>Tu décris</strong> ce que tu veux voir / ce que tu veux que ça fasse.</li><li><strong>Claude génère</strong> — tu testes dans le navigateur ou VS Code.</li><li><strong>Si ça marche</strong> → tu construis dessus. <strong>Si ça casse</strong> → tu colles l'erreur et tu itères.</li><li>Tu n'as pas besoin de tout comprendre — mais tu dois <strong>valider</strong> chaque étape avant de continuer.</li></ul></div></div><div class="vp"></div></div>
<div class="ac"><div class="an">01</div><div class="ai" style="background:#F3EEFF"><span style="font-size:22px">🧠</span></div><div class="ab"><div class="ah"><span class="nm">Mindset — Ce qu'il faut accepter</span></div><p class="ad">Le vibecoding demande un changement de posture : tu passes de <em>«&nbsp;je code&nbsp;»</em> à <em>«&nbsp;je dirige un développeur qui ne dort jamais&nbsp;»</em>. Ce n'est pas parce que tu ne comprends pas une ligne que c'est mal écrit. Et ce n'est pas parce que Claude génère que le code est bon — <strong>c'est toi le chef de projet</strong>.</p><div class="af"><div class="at"><span class="tg" style="background:#F3EEFF;color:#7C3AED">Itération rapide</span><span class="tg" style="background:#F3EEFF;color:#7C3AED">Intention &gt; syntaxe</span><span class="tg" style="background:#F3EEFF;color:#7C3AED">Test &amp; correction</span><span class="tg" style="background:#F3EEFF;color:#7C3AED">Tu valides</span></div></div><div class="rulebox"><strong>Les 3 règles du bon vibecoder</strong><ul><li>Toujours <strong>tester avant de continuer</strong> — ne jamais empiler du code non vérifié.</li><li><strong>Committer ou sauvegarder</strong> avant chaque grosse intervention de Claude.</li><li>Si Claude tourne en rond après 3 tentatives → <strong>reformuler ou changer d'approche</strong>.</li></ul></div></div><div class="vp"></div></div>
<div class="cat"><span class="cat-icon">🌐</span> Claude.ai — Dans le navigateur</div>
<div class="ac"><div class="an">02</div><div class="ai" style="background:#F0F0FF"><img decoding="async" src="https://www.google.com/s2/favicons?domain=claude.ai&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">claude.ai — Les 3 fonctions clés</span><span class="pl" style="background:#E8E5FF;color:#1001EF;padding:2px 6px;border-radius:3px;font-size:9px">Freemium</span></div><p class="ad">La plateforme chat officielle. Trois fonctions à activer pour vibecoder efficacement : <strong>Projects</strong> (contexte persistant entre sessions), <strong>Artifacts</strong> (code HTML/JSX prévisualisable en live dans le panneau droit), et la <strong>recherche web</strong> pour les docs de frameworks récents. Demander explicitement un Artifact — Claude ne le fait pas toujours seul.</p><div class="af"><div class="at"><span class="tg" style="background:#E8E5FF;color:#1001EF">Projects</span><span class="tg" style="background:#E8E5FF;color:#1001EF">Artifacts</span><span class="tg" style="background:#E8E5FF;color:#1001EF">Recherche web</span><span class="tg" style="background:#E8E5FF;color:#1001EF">Mémoire contexte</span></div><div class="ctas"><a class="cta-a" href="https://claude.ai" target="_blank">Accéder →</a></div></div><div class="rulebox"><strong>Artifacts — comment en tirer le maximum</strong><ul><li>Préciser dans le prompt : <em>«&nbsp;génère le résultat dans un Artifact HTML complet et fonctionnel&nbsp;»</em>.</li><li>Artifact HTML = s'affiche et s'exécute en direct → parfait pour prototyper une interface ou une animation.</li><li>Artifact React/JSX → composants interactifs prévisualisés sans aucun setup local.</li><li>Le bouton <strong>Edit</strong> dans l'Artifact permet à Claude de modifier le code sans toucher à la conversation.</li></ul></div></div><div class="vp"></div></div>
<div class="ac"><div class="an">03</div><div class="ai" style="background:#F0F0FF"><img decoding="async" src="https://www.google.com/s2/favicons?domain=claude.ai&sz=64" alt="" onerror="this.style.display='none'"></div><div class="ab"><div class="ah"><span class="nm">Projects — Contexte persistant entre sessions</span><span class="pl" style="background:#E8E5FF;color:#1001EF;padding:2px 6px;border-radius:3px;font-size:9px">Pro</span></div><p class="ad">Les Projects stockent un contexte permanent : charte graphique, stack technique, règles de code, conventions de nommage. Claude s'en souvient à chaque nouvelle conversation dans ce projet — sans avoir à tout réexpliquer à chaque session.</p><div class="af"><div class="at"><span class="tg" style="background:#E8E5FF;color:#1001EF">Instructions permanentes</span><span class="tg" style="background:#E8E5FF;color:#1001EF">Multi-sessions</span><span class="tg" style="background:#E8E5FF;color:#1001EF">Fichiers de référence</span></div><div class="ctas"><a class="cta-a" href="https://claude.ai/projects" target="_blank">Ouvrir →</a></div></div><div style="background:#1e1a15;border-radius:8px;padding:8px 12px;margin-top:8px;font-size:10px;color:#e8dcc8;line-height:1.5;font-family:monospace;cursor:pointer" onclick="event.stopPropagation();navigator.clipboard.writeText(this.dataset.p);this.style.outline='2px solid #27ae60';setTimeout(()=>this.style.outline='',1000)" data-p="[INSTRUCTIONS PROJECT] Stack : HTML/CSS vanilla/JS ES6+. Style : variables CSS dans :root, BEM pour les classes, mobile-first. Code : commenté en français. Réponses : Artifact HTML complet. Avant toute modification : rappeler ce qui existait, ce qui change et pourquoi." title="Copier"><span style="font-size:8px;color:#888;display:block;margin-bottom:3px">TEMPLATE INSTRUCTIONS PROJECT · clic = copier</span>[INSTRUCTIONS PROJECT] Stack : HTML/CSS vanilla/JS ES6+. Style : variables CSS :root, BEM, mobile-first. Code : commenté en français. Réponses : Artifact HTML complet. Avant toute modification : rappeler ce qui existait…</div></div><div class="vp"></div></div>
<div class="ac"><div class="an">04</div><div class="ai" style="background:#F3EEFF"><span style="font-size:22px">🎯</span></div><div class="ab"><div class="ah"><span class="nm">Structure d'un bon prompt de code</span></div><p class="ad">Un prompt efficace n'est pas une phrase — c'est une <strong>mini-spec</strong> : <em>quoi</em> (la tâche), <em>comment</em> (contraintes techniques), <em>format</em> (ce qu'on attend en sortie), <em>ne pas faire</em> (erreurs à éviter). Plus c'est précis, moins il y a d'itérations.</p><div class="af"><div class="at"><span class="tg" style="background:#F3EEFF;color:#7C3AED">Rôle + Contexte</span><span class="tg" style="background:#F3EEFF;color:#7C3AED">Contraintes tech</span><span class="tg" style="background:#F3EEFF;color:#7C3AED">Format de sortie</span><span class="tg" style="background:#F3EEFF;color:#7C3AED">Interdictions</span></div></div><div style="background:#1e1a15;border-radius:8px;padding:8px 12px;margin-top:8px;font-size:10px;color:#e8dcc8;line-height:1.5;font-family:monospace;cursor:pointer" onclick="event.stopPropagation();navigator.clipboard.writeText(this.dataset.p);this.style.outline='2px solid #27ae60';setTimeout(()=>this.style.outline='',1000)" data-p="Tu es un développeur frontend expert en HTML/CSS vanilla et JavaScript ES6+. Tâche : Crée [DÉCRIRE ICI]. Contraintes : CSS pur, variables CSS dans :root, mobile-first, pas de librairies. Format : Artifact HTML unique, autonome, fonctionnel, commenté en français. Ne pas faire : animations inutiles, JS si CSS suffit, !important sauf exception justifiée." title="Copier"><span style="font-size:8px;color:#888;display:block;margin-bottom:3px">TEMPLATE PROMPT DÉMARRAGE · clic = copier</span>Tu es un développeur frontend expert HTML/CSS vanilla JS ES6+. Tâche : [DÉCRIRE]. Contraintes : CSS pur, variables :root, mobile-first. Format : Artifact HTML complet commenté. Ne pas faire : animations inutiles, JS si CSS suffit…</div></div><div class="vp"></div></div>
<div class="cat"><span class="cat-icon">💻</span> Claude Code — VS Code &amp; Terminal</div>
<div class="ac"><div class="an">05</div><div class="ai" style="background:#1A1A2E"><img decoding="async" src="https://www.google.com/s2/favicons?domain=code.visualstudio.com&sz=64" alt="" onerror="this.style.display='none'" style="filter:brightness(0) invert(1);width:26px;height:26px"></div><div class="ab"><div class="ah"><span class="nm">Claude Code — Installation &amp; commandes</span><span class="pl" style="background:#E8E5FF;color:#1001EF;padding:2px 6px;border-radius:3px;font-size:9px">Pro</span></div><p class="ad">Agent CLI qui s'intègre dans le terminal et dans VS Code. Il peut <strong>lire, écrire, renommer et exécuter</strong> des fichiers de façon autonome. Installation via npm — nécessite un compte Claude Pro ou un accès API Anthropic.</p><div class="af"><div class="at"><span class="tg" style="background:#F3EEFF;color:#7C3AED">CLI + VS Code</span><span class="tg" style="background:#F3EEFF;color:#7C3AED">Agentic</span><span class="tg" style="background:#F3EEFF;color:#7C3AED">Lire / Écrire / Exécuter</span></div><div class="ctas"><a class="cta-d" href="https://www.youtube.com/results?search_query=claude+code+vscode+2025" target="_blank">▶ Demo</a><a class="cta-a" href="https://docs.anthropic.com/fr/docs/claude-code/overview" target="_blank">Doc →</a></div></div><div style="background:#1e1a15;border-radius:8px;padding:8px 12px;margin-top:8px;font-size:10px;color:#e8dcc8;line-height:1.5;font-family:monospace;cursor:pointer" onclick="event.stopPropagation();navigator.clipboard.writeText(this.dataset.p);this.style.outline='2px solid #27ae60';setTimeout(()=>this.style.outline='',1000)" data-p="# Installation Claude Code&#10;npm install -g @anthropic-ai/claude-code&#10;&#10;# Lancer dans ton projet&#10;cd mon-projet/ &amp;&amp; claude&#10;&#10;# Commandes dans le chat Claude Code :&#10;/help     → liste toutes les commandes&#10;/clear    → vide le contexte (résout Request too large)&#10;/compact  → résume et compresse l'historique&#10;/status   → affiche les fichiers ouverts&#10;@fichier  → référencer un fichier spécifique&#10;Ctrl+C    → interrompre une action en cours" title="Copier"><span style="font-size:8px;color:#888;display:block;margin-bottom:3px">COMMANDES CLAUDE CODE · clic = copier</span>npm install -g @anthropic-ai/claude-code → cd mon-projet/ &amp;&amp; claude → /clear · /compact · /status · @fichier · Ctrl+C pour interrompre…</div></div><div class="vp"></div></div>
<div class="ac star"><div class="an">★</div><div class="ai" style="background:#FFF7ED"><span style="font-size:22px">⚠️</span></div><div class="ab"><div class="ah"><span class="nm">Résoudre "Request too large (max 32MB)"</span><span class="pl" style="background:#FFE8CC;color:#B84A00;padding:2px 6px;border-radius:3px;font-size:9px">Problème fréquent</span></div><p class="ad">Cette erreur n'est <strong>pas liée à la taille de tes fichiers</strong> — elle vient de l'accumulation de l'<strong>historique de conversation</strong> envoyée à l'API. À chaque message, Claude Code ré-envoie tout le contexte : messages précédents + fichiers référencés. Cela dépasse 32 MB sur les sessions longues.</p><div class="warnbox"><strong>5 solutions, dans l'ordre à essayer</strong><ul><li><strong>/clear</strong> dans le chat Claude Code → vide l'historique, repart de zéro dans la même fenêtre.</li><li><strong>/compact</strong> → Claude résume la conversation et reprend à partir de ce résumé.</li><li><strong>Nouveau chat</strong> (+) → conversation fraîche, contexte vide.</li><li><strong>@fichier précis</strong> → référencer uniquement le fichier en cours, pas tout le dossier.</li><li><strong>Découper les tâches</strong> → une fonctionnalité = une conversation.</li></ul></div></div><div class="vp"></div></div>
<div class="ac"><div class="an">06</div><div class="ai" style="background:#1A1A2E"><span style="font-size:20px;display:block;text-align:center;padding:12px 0">🤖</span></div><div class="ab"><div class="ah"><span class="nm">Mode agentic — Laisser Claude agir seul</span></div><p class="ad">En mode agentic, Claude Code <strong>planifie, exécute et vérifie</strong> les modifications de fichiers. Il peut créer des dossiers, installer des dépendances npm et exécuter des scripts. Puissant, mais à cadrer : donner un périmètre clair pour éviter les modifications non souhaitées.</p><div class="af"><div class="at"><span class="tg" style="background:#F3EEFF;color:#7C3AED">Plan → Exécute → Vérifie</span><span class="tg" style="background:#F3EEFF;color:#7C3AED">Périmètre défini</span><span class="tg" style="background:#F3EEFF;color:#7C3AED">Git avant tout</span></div></div><div style="background:#1e1a15;border-radius:8px;padding:8px 12px;margin-top:8px;font-size:10px;color:#e8dcc8;line-height:1.5;font-family:monospace;cursor:pointer" onclick="event.stopPropagation();navigator.clipboard.writeText(this.dataset.p);this.style.outline='2px solid #27ae60';setTimeout(()=>this.style.outline='',1000)" data-p="Périmètre : travaille uniquement dans /src/components/. Ne touche pas à /public/, package.json ni aux fichiers de config. Tâche : [DÉCRIRE ICI]. Avant d'écrire : 1. Liste les fichiers à modifier. 2. Explique la stratégie en 3 étapes max. 3. Attends ma validation. Après chaque modification : confirme ce qui a été fait et ce qui reste." title="Copier"><span style="font-size:8px;color:#888;display:block;margin-bottom:3px">PROMPT AGENTIC SÉCURISÉ · clic = copier</span>Périmètre : /src/components/ uniquement. Ne pas toucher à /public/, package.json. Avant d'écrire : liste les fichiers, stratégie 3 étapes max, attendre validation. Confirmer après chaque étape…</div></div><div class="vp"></div></div>
<div class="cat"><span class="cat-icon">⚡</span> Workflows pratiques</div>
<div class="ac"><div class="an">07</div><div class="ai" style="background:#FFF3ED"><span style="font-size:22px">🎨</span></div><div class="ab"><div class="ah"><span class="nm">Workflow HTML/CSS/JS — Prototype rapide</span></div><p class="ad">Le workflow le plus simple : tout dans un seul fichier HTML, prévisualisé en Artifact ou ouvert dans le navigateur. Idéal pour les animations CSS, les layouts créatifs, les landing pages et les composants standalone.</p><div class="af"><div class="at"><span class="tg" style="background:#FFF3ED;color:#CC4400">Single file</span><span class="tg" style="background:#FFF3ED;color:#CC4400">CSS vars</span><span class="tg" style="background:#FFF3ED;color:#CC4400">Artifact preview</span><span class="tg" style="background:#FFF3ED;color:#CC4400">Itération live</span></div></div><div style="background:#1e1a15;border-radius:8px;padding:8px 12px;margin-top:8px;font-size:10px;color:#e8dcc8;line-height:1.5;font-family:monospace;cursor:pointer" onclick="event.stopPropagation();navigator.clipboard.writeText(this.dataset.p);this.style.outline='2px solid #27ae60';setTimeout(()=>this.style.outline='',1000)" data-p="Crée une page HTML complète (fichier unique, HTML+CSS+JS inlinés) pour [DÉCRIRE]. Variables CSS dans :root : --clr-primary: #1001EF; --clr-accent: #FF6B35; --clr-bg: #F4F3FA; --radius: 12px; --font: 'DM Sans', sans-serif. Importer DM Sans depuis Google Fonts. Mobile-first. Micro-interactions CSS au hover. Commenter chaque section. Livrer dans un Artifact HTML." title="Copier"><span style="font-size:8px;color:#888;display:block;margin-bottom:3px">PROMPT DÉMARRAGE HTML/CSS · clic = copier</span>Crée une page HTML complète (single file) pour [DÉCRIRE]. Variables CSS :root avec couleurs PCD. Mobile-first. Micro-interactions CSS au hover. Livrer dans un Artifact HTML commenté…</div></div><div class="vp"></div></div>
<div class="ac"><div class="an">08</div><div class="ai" style="background:#F0FFF4"><span style="font-size:22px">⚛️</span></div><div class="ab"><div class="ah"><span class="nm">Workflow React — Composants &amp; Design System</span></div><p class="ad">Pour un projet React, Claude Code génère des composants cohérents, des hooks personnalisés et un design system depuis une description en langage naturel. La clé : définir le design system dès le départ et le maintenir en référence constante.</p><div class="af"><div class="at"><span class="tg" style="background:#F0FFF4;color:#059669">Composants</span><span class="tg" style="background:#F0FFF4;color:#059669">Hooks</span><span class="tg" style="background:#F0FFF4;color:#059669">Design system</span><span class="tg" style="background:#F0FFF4;color:#059669">Tailwind</span></div></div><div style="background:#1e1a15;border-radius:8px;padding:8px 12px;margin-top:8px;font-size:10px;color:#e8dcc8;line-height:1.5;font-family:monospace;cursor:pointer" onclick="event.stopPropagation();navigator.clipboard.writeText(this.dataset.p);this.style.outline='2px solid #27ae60';setTimeout(()=>this.style.outline='',1000)" data-p="Stack : React 18 + Tailwind CSS 3 + Vite. Pas de librairie UI externe. Design system : couleurs primary #1001EF, accent #FF6B35, bg #F4F3FA. Radius : rounded-xl cartes, rounded-full boutons. Motion : transition-all duration-200 ease-out. Tâche : Crée le composant [NOM] qui [DÉCRIRE]. Props TypeScript typées, export default nommé, JSDoc sur les props principales." title="Copier"><span style="font-size:8px;color:#888;display:block;margin-bottom:3px">PROMPT COMPOSANT REACT · clic = copier</span>Stack React 18 + Tailwind + Vite. Design system défini. Crée le composant [NOM] — props TypeScript typées, export default, JSDoc…</div></div><div class="vp"></div></div>
<div class="ac"><div class="an">09</div><div class="ai" style="background:#F3EEFF"><span style="font-size:22px">🔁</span></div><div class="ab"><div class="ah"><span class="nm">Itérer sans tout casser — Le prompt d'amélioration</span></div><p class="ad">Le piège classique : demander à Claude de <em>«&nbsp;modifier&nbsp;»</em> sans préciser le périmètre et se retrouver avec tout le code réécrit. Un bon prompt d'itération isole chirurgicalement ce qui change, en préservant le reste.</p><div class="af"><div class="at"><span class="tg" style="background:#F3EEFF;color:#7C3AED">Modification ciblée</span><span class="tg" style="background:#F3EEFF;color:#7C3AED">Préserver l'existant</span><span class="tg" style="background:#F3EEFF;color:#7C3AED">Diff minimal</span></div></div><div style="background:#1e1a15;border-radius:8px;padding:8px 12px;margin-top:8px;font-size:10px;color:#e8dcc8;line-height:1.5;font-family:monospace;cursor:pointer" onclick="event.stopPropagation();navigator.clipboard.writeText(this.dataset.p);this.style.outline='2px solid #27ae60';setTimeout(()=>this.style.outline='',1000)" data-p="Modification ciblée uniquement — ne pas réécrire le fichier entier. Ce qui existe et doit rester intact : [DÉCRIRE]. Ce qui doit changer : [DÉCRIRE précisément]. Toucher uniquement à [NOM DE LA FONCTION/SECTION]. Conserver les noms existants. Ajouter un commentaire // modifié le [DATE]. Si changement structurel nécessaire, m'en informer avant d'agir. Livrer uniquement le diff, pas le fichier entier." title="Copier"><span style="font-size:8px;color:#888;display:block;margin-bottom:3px">PROMPT D'ITÉRATION CIBLÉE · clic = copier</span>Modification ciblée — ne pas réécrire le fichier entier. Ce qui reste intact : [X]. Ce qui change : [Y]. Toucher uniquement à [FONCTION]. Livrer uniquement le diff…</div></div><div class="vp"></div></div>
<div class="cat"><span class="cat-icon">🎯</span> Prompts-clés à copier</div>
<div class="ac"><div class="an">10</div><div class="ai" style="background:#FFF3ED"><span style="font-size:22px">🐛</span></div><div class="ab"><div class="ah"><span class="nm">Le prompt de debug</span></div><p class="ad">Ne pas juste coller l'erreur brute — Claude a besoin du contexte pour diagnostiquer efficacement. Ce template structure le bug report pour obtenir un diagnostic précis et une correction propre du premier coup.</p><div style="background:#1e1a15;border-radius:8px;padding:8px 12px;margin-top:8px;font-size:10px;color:#e8dcc8;line-height:1.5;font-family:monospace;cursor:pointer" onclick="event.stopPropagation();navigator.clipboard.writeText(this.dataset.p);this.style.outline='2px solid #27ae60';setTimeout(()=>this.style.outline='',1000)" data-p="Debug — ne pas corriger tout de suite, analyser d'abord. Erreur : [COLLER L'ERREUR ICI]. Fichier : [NOM]. Comportement attendu : [CE QUI DEVRAIT SE PASSER]. Comportement obtenu : [CE QUI SE PASSE]. Ce que j'ai déjà essayé : [OU INDIQUER RIEN]. Consigne : 1. Cause racine (pas le symptôme). 2. Explication en une phrase. 3. Correction minimale. 4. Effets de bord potentiels." title="Copier"><span style="font-size:8px;color:#888;display:block;margin-bottom:3px">PROMPT DE DEBUG · clic = copier</span>Debug — analyser d'abord. Erreur : [COLLER]. Comportement attendu vs obtenu. 1. Cause racine. 2. Explication courte. 3. Correction minimale. 4. Effets de bord…</div></div><div class="vp"></div></div>
<div class="ac"><div class="an">11</div><div class="ai" style="background:#E6F7F1"><span style="font-size:22px">🧹</span></div><div class="ab"><div class="ah"><span class="nm">Le prompt de refactoring &amp; documentation</span></div><p class="ad">Une fois que le code fonctionne en vibecoding, il faut le nettoyer et le documenter. Ce prompt transforme du code qui "vibe" en code qui dure — sans changer le comportement visible.</p><div style="background:#1e1a15;border-radius:8px;padding:8px 12px;margin-top:8px;font-size:10px;color:#e8dcc8;line-height:1.5;font-family:monospace;cursor:pointer" onclick="event.stopPropagation();navigator.clipboard.writeText(this.dataset.p);this.style.outline='2px solid #27ae60';setTimeout(()=>this.style.outline='',1000)" data-p="Refactoring et documentation — le comportement visible ne doit PAS changer. Fichier : @[NOM]. Objectifs : 1. Supprimer le code mort et les console.log. 2. Regrouper les constantes en tête de fichier. 3. Découper les fonctions de plus de 30 lignes. 4. Ajouter JSDoc sur chaque fonction. 5. Ajouter des commentaires de section // ── NOM ──. 6. Corriger les noms non descriptifs. Livraison : fichier complet + liste des changements." title="Copier"><span style="font-size:8px;color:#888;display:block;margin-bottom:3px">PROMPT REFACTORING · clic = copier</span>Refactoring — comportement visible inchangé. Supprimer code mort, grouper constantes, découper fonctions longues, JSDoc, commentaires de section. Fichier complet + liste des changements…</div></div><div class="vp"></div></div>
<div class="ac"><div class="an">12</div><div class="ai" style="background:#FFF0F3"><span style="font-size:22px">🔍</span></div><div class="ab"><div class="ah"><span class="nm">Le prompt d'audit UI/UX</span></div><p class="ad">Claude peut auditer une interface et lister les problèmes UX, d'accessibilité et de cohérence visuelle. Idéal après une session de vibecoding intense pour remettre de l'ordre avant de livrer.</p><div style="background:#1e1a15;border-radius:8px;padding:8px 12px;margin-top:8px;font-size:10px;color:#e8dcc8;line-height:1.5;font-family:monospace;cursor:pointer" onclick="event.stopPropagation();navigator.clipboard.writeText(this.dataset.p);this.style.outline='2px solid #27ae60';setTimeout(()=>this.style.outline='',1000)" data-p="Audit complet — @[FICHIER]. 1. COHÉRENCE VISUELLE : couleurs design system, typographie, espacements. 2. ACCESSIBILITÉ WCAG AA : contrastes, alt, navigation clavier. 3. UX &amp; INTERACTIONS : états hover/focus/active, feedback utilisateur. 4. PERFORMANCE : CSS inutilisé, animations sans transform. 5. MOBILE : touch targets ≥ 44px, textes lisibles à 320px. Livrer liste priorisée : 🔴 Critique · 🟡 Important · 🟢 Amélioration." title="Copier"><span style="font-size:8px;color:#888;display:block;margin-bottom:3px">PROMPT AUDIT UI/UX · clic = copier</span>Audit en 5 axes : Cohérence visuelle, Accessibilité WCAG AA, UX &amp; Interactions, Performance, Mobile. Liste priorisée 🔴 Critique · 🟡 Important · 🟢 Amélioration…</div></div><div class="vp"></div></div>
<div class="cat"><span class="cat-icon">⚠️</span> Pièges &amp; Antipatterns</div>
<div class="ac"><div class="an">13</div><div class="ai" style="background:#FFF7ED"><span style="font-size:22px">🌀</span></div><div class="ab"><div class="ah"><span class="nm">La boucle infinie — Quand Claude tourne en rond</span></div><p class="ad">Symptôme : Claude génère une correction, tu testes, ça ne marche toujours pas, tu recolles l'erreur, Claude génère une autre correction légèrement différente… La conversation s'allonge, le contexte se dégrade, les réponses empirent.</p><div class="warnbox"><strong>Sortir de la boucle</strong><ul><li><strong>Ne pas continuer dans la même conversation</strong> — le contexte long dégrade la qualité.</li><li><strong>/clear</strong> ou nouveau chat, puis reformuler le problème entièrement.</li><li>Réduire le scope : isoler le minimum de code qui reproduit le bug.</li><li>Changer d'angle : <em>«&nbsp;explique-moi pourquoi X ne fonctionne pas&nbsp;»</em> plutôt que <em>«&nbsp;corrige X&nbsp;»</em>.</li><li>Si le bug persiste → MDN, Stack Overflow, puis revenir avec la réponse trouvée.</li></ul></div></div><div class="vp"></div></div>
<div class="ac"><div class="an">14</div><div class="ai" style="background:#FFF7ED"><span style="font-size:22px">🧱</span></div><div class="ab"><div class="ah"><span class="nm">La dette technique du vibecoding</span></div><p class="ad">Vibecoder vite crée de la dette : code dupliqué, fonctions trop longues, pas de gestion d'erreur, dépendances npm non voulues. Sans hygiène régulière, le projet devient ingérable même pour Claude.</p><div class="warnbox"><strong>Prévenir la dette technique</strong><ul><li>Un refactoring léger tous les 3-4 features ajoutées (utiliser le prompt de refactoring ci-dessus).</li><li>Préciser <em>«&nbsp;sans nouvelles dépendances npm&nbsp;»</em> pour garder le projet léger.</li><li>Vérifier le <code style="font-family:monospace;background:#f0f0f0;padding:1px 4px;border-radius:3px">package.json</code> après chaque session agentic — Claude peut installer des libs sans prévenir.</li><li>Committer avec Git avant chaque grosse intervention de Claude Code.</li></ul></div></div><div class="vp"></div></div>
<div class="ac"><div class="an">15</div><div class="ai" style="background:#FFF7ED"><span style="font-size:22px">🚫</span></div><div class="ab"><div class="ah"><span class="nm">Les antipatterns les plus fréquents</span></div><p class="ad">Les erreurs classiques qui freinent ou cassent un projet vibecoding — et comment les éviter dès le départ.</p><div class="warnbox"><strong>Antipatterns à bannir</strong><ul><li>❌ <strong>«&nbsp;Fais-le plus beau&nbsp;»</strong> sans critères → Claude improvise et casse la cohérence.</li><li>❌ <strong>Attacher tous les fichiers du projet à chaque message</strong> → context overflow garanti.</li><li>❌ <strong>Demander plusieurs choses à la fois</strong> → résultats mitigés, difficiles à tester séparément.</li><li>❌ <strong>Ne pas tester entre deux prompts</strong> → les erreurs s'accumulent et deviennent impossibles à tracer.</li><li>❌ <strong>Faire confiance à Claude sur les chiffres et URLs</strong> → toujours vérifier les données factuelles.</li><li>❌ <strong>Vibecoder en production directement</strong> → utiliser une branche Git ou un environnement de dev.</li></ul></div></div><div class="vp"></div></div>
</section>
<div class="AP" id="pcd-ap"><div class="AC"><h3>📋 Participants <span class="cb" id="pcd-cb">0</span></h3><p class="as">Visible uniquement sur votre navigateur</p><div id="pcd-pl"></div><div id="pcd-ab" style="display:none"><button class="bx bx-b" onclick="pExp()">📥 CSV</button><button class="bx bx-g" onclick="pPrint()">🖨 PDF</button><button class="bx bx-r" onclick="pClr()">🗑 Reset</button></div></div></div>
</div>

<script>

var P=[];try{P=JSON.parse(localStorage.getItem('pcd_participants')||'[]')}catch(e){}


/* ── Demo button: opens YouTube (full page with sound) ── */
function pDm(k){
  var v=C.VIDEOS[k]; if(!v) return false;
  if(v.id) window.open('https://www.youtube.com/watch?v='+v.id,'_blank');
  else window.open('https://www.youtube.com/results?search_query='+encodeURIComponent(v.q),'_blank');
  return false;
}

/* ── Form ── */
function pGenCode(fn,ln){
  fn=(fn||'').toLowerCase().replace(/[^a-z]/g,'');
  ln=(ln||'').toLowerCase().replace(/[^a-z]/g,'');
  var a=fn.charAt(0),b=fn.charAt(1),c=ln.charAt(0),d=ln.charAt(1);
  var s='';
  if(a)s+=(a.charCodeAt(0)-96);
  if(b)s+=(b.charCodeAt(0)-96);
  if(c)s+=(c.charCodeAt(0)-96);
  if(d)s+=(d.charCodeAt(0)-96);
  return s.substring(0,4);
}
function pSub(){
  var fn=document.getElementById('pcd-fn').value.trim();
  var ln=document.getElementById('pcd-ln').value.trim();
  var em=document.getElementById('pcd-em').value.trim();
  var ph=document.getElementById('pcd-ph').value.trim();
  var pr=document.getElementById('pcd-pr').value;
  var sc=document.getElementById('pcd-sc').value.trim();
  var code=document.getElementById('pcd-code').value.trim().toUpperCase();
  if(!fn||!ln||!em||!code){
    var b=document.getElementById('pcd-sub');b.style.animation='pSH .4s';
    setTimeout(function(){b.style.animation=''},500);
    alert('Merci de remplir tous les champs obligatoires (prénom, nom, email et code).');return;
  }
  if(!em.includes('@')||!em.includes('.')){alert('Email invalide.');return;}
  /* Master key check (obfuscated) */
  var mk=String.fromCharCode(50,75,49,54);var mk2=String.fromCharCode(70,79,82,77);var mk3=String.fromCharCode(50,48,50,54);
  var expected=pGenCode(fn,ln);
  if(code!==expected&&code!==mk&&code!==mk2&&code!==mk3){
    alert('Code d\'acc\u00e8s incorrect. Votre code personnel est bas\u00e9 sur votre pr\u00e9nom et nom. Demandez-le \u00e0 votre formateur si besoin.');return;
  }
  var isAdmin=(code===mk||code===mk2||code===mk3);
  P.push({firstName:fn,lastName:ln,email:em,phone:ph,profile:pr,sector:pr==='pro_autre'?sc:'',date:new Date().toLocaleString('fr-FR'),guide:currentGuide});
  try{localStorage.setItem('pcd_participants',JSON.stringify(P))}catch(e){}
  document.getElementById('pcd-fo').classList.remove('on');
  document.getElementById('pcd-land').style.display='none';
  if(currentGuide==='ia'){document.getElementById('pcd-guide').classList.add('on')}
  else if(currentGuide==='sb'){document.getElementById('pcd-guide-sb').style.display='block'}
  else if(currentGuide==='vc'){document.getElementById('pcd-guide-vc').style.display='block'}
  else{document.getElementById('pcd-guide-os').style.display='block'}
  var t=document.getElementById('pcd-toast');t.classList.add('on');
  setTimeout(function(){t.classList.remove('on')},3500);
  pUA();
  setTimeout(function(){var ap=document.getElementById('pcd-ap');if(ap&&ap.classList.contains('on'))ap.scrollIntoView({behavior:'smooth',block:'end'})},500);
}

/* ── Admin ── */
function pUA(){if(!P.length){document.getElementById('pcd-ap').classList.remove('on');return}document.getElementById('pcd-ap').classList.add('on');document.getElementById('pcd-ab').style.display='block';document.getElementById('pcd-cb').textContent=P.length;var L={'etudiant':'Étudiant·e','pro_design':'Pro design','pro_autre':'Pro autre','entrepreneur':'Entrepreneur','curieux':'Curieux·se','':'—'};var h='<table class="tb"><thead><tr><th>Prénom</th><th>Nom</th><th>Email</th><th>Tél.</th><th>Profil</th><th>Secteur</th><th>Date</th></tr></thead><tbody>';P.forEach(function(p){h+='<tr><td>'+pE(p.firstName)+'</td><td>'+pE(p.lastName)+'</td><td>'+pE(p.email)+'</td><td>'+pE(p.phone||'—')+'</td><td>'+(L[p.profile]||'—')+'</td><td>'+pE(p.sector||'—')+'</td><td>'+pE(p.date)+'</td></tr>'});h+='</tbody></table>';document.getElementById('pcd-pl').innerHTML=h}
function pE(t){var d=document.createElement('div');d.textContent=t;return d.innerHTML}
function pExp(){if(!P.length)return;var c='Prénom,Nom,Email,Téléphone,Profil,Secteur,Date\n';P.forEach(function(p){c+='"'+p.firstName+'","'+p.lastName+'","'+p.email+'","'+(p.phone||'')+'","'+(p.profile||'')+'","'+(p.sector||'')+'","'+p.date+'"\n'});var b=new Blob(['\uFEFF'+c],{type:'text/csv;charset=utf-8;'}),a=document.createElement('a');a.href=URL.createObjectURL(b);a.download='participants_formation_ia_pcd.csv';a.click()}
function pPrint(){window.print()}
function pClr(){if(!confirm('Supprimer tous les participants ?'))return;P=[];try{localStorage.removeItem('pcd_participants')}catch(e){}pUA()}
if(P.length>0)pUA();

/* Fix Demo buttons: set real hrefs for PDF print */
document.querySelectorAll('#pcd-guide .cta-d').forEach(function(btn){
  var card=btn.closest('.ac[data-v]');
  if(!card)return;
  var key=card.getAttribute('data-v');
  var v=C.VIDEOS[key];
  if(!v)return;
  if(v.id){btn.href='https://www.youtube.com/watch?v='+v.id}
  else{btn.href='https://www.youtube.com/results?search_query='+encodeURIComponent(v.q)}
  btn.setAttribute('target','_blank');
});
/* OS guide Demo buttons already have real hrefs */

/* Video hover: autoplay for all cards */
document.querySelectorAll('.ac[data-v]').forEach(function(card){
  var key=card.getAttribute('data-v'),vp=card.querySelector('.vp');
  if(!vp)return;
  var v=C.VIDEOS?C.VIDEOS[key]:null;
  var demoBtn=card.querySelector('.cta-d');
  var demoHref=demoBtn?demoBtn.getAttribute('href'):'';
  var vid='';
  if(v&&v.id){vid=v.id}
  else if(demoHref){var m=demoHref.match(/watch\?v=([^&]+)/);if(m)vid=m[1]}
  var searchQ=(v&&v.q)?v.q:'';
  if(!searchQ&&demoHref){var sq=demoHref.match(/search_query=([^&]+)/);if(sq)searchQ=decodeURIComponent(sq[1]).replace(/\+/g,' ')}
  if(!vid)return;
  var timer=null;
  card.addEventListener('mouseenter',function(){
    timer=setTimeout(function(){
      var src='https://www.youtube.com/embed/'+vid+'?autoplay=1&mute=1&rel=0&modestbranding=1&controls=0';
      vp.innerHTML='<iframe src="'+src+'" allow="autoplay;encrypted-media" allowfullscreen loading="lazy"></iframe>';
      vp.classList.add('open');
    },300);
  });
  card.addEventListener('mouseleave',function(){
    clearTimeout(timer);
    vp.classList.remove('open');
    setTimeout(function(){vp.innerHTML=''},400);
  });
});
document.querySelectorAll('.eml').forEach(function(el){el.textContent=String.fromCharCode(99,111,110,116,97,99,116,64,112,114,101,115,101,110,116,99,111,109,112,111,115,101,100,101,115,105,103,110,46,102,114)});

/* Hide repeating footer when contact banner is visible (last page) */
window.addEventListener('beforeprint',function(){
  var pf=document.querySelector('.print-footer');
  var pc=document.querySelector('.print-contact');
  if(pf&&pc){pf.style.display='none'}
});
window.addEventListener('afterprint',function(){
  var pf=document.querySelector('.print-footer');
  if(pf){pf.style.display=''}
});
</script>
</body>
</html>				</div>
				</div>
				</div>
				</div>
		<p>Cet article <a href="https://presentcomposedesign.fr/formations/">Formations</a> est apparu en premier sur <a href="https://presentcomposedesign.fr">Présent Composé design</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Canyon-sound</title>
		<link>https://presentcomposedesign.fr/canyon-sound/</link>
		
		<dc:creator><![CDATA[admin]]></dc:creator>
		<pubDate>Mon, 19 Jan 2026 09:44:07 +0000</pubDate>
				<category><![CDATA[Design produit]]></category>
		<guid isPermaLink="false">https://presentcomposedesign.fr/?p=35701</guid>

					<description><![CDATA[<p>Canyon Ambix Sound Expérience sonore Ambix</p>
<p>Cet article <a href="https://presentcomposedesign.fr/canyon-sound/">Canyon-sound</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="35701" class="elementor elementor-35701">
				<div class="elementor-element elementor-element-cbfcc19 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="cbfcc19" data-element_type="container" data-e-type="container">
					<div class="e-con-inner">
				<div class="elementor-element elementor-element-80547c5 elementor-widget elementor-widget-html" data-id="80547c5" data-element_type="widget" data-e-type="widget" data-widget_type="html.default">
				<div class="elementor-widget-container">
					<!DOCTYPE html>
<html>
<head>
    <title>Canyon Ambix Sound</title>
    <script>
        // Initialiser l'AudioContext et démarrer la lecture
        window.addEventListener('load', () => {
            const audioContext = new (window.AudioContext || window.webkitAudioContext)();
            const audioElement = new Audio('https://presentcomposedesign.fr/wp-content/uploads/2026/01/CANYON-AMBIX.wav');

            // Configurer les paramètres audio
            audioElement.loop = true;
            audioElement.volume = 0.6;

            // Créer les nœuds audio pour le contrôle du volume
            const sourceNode = audioContext.createMediaElementSource(audioElement);
            const gainNode = audioContext.createGain();
            gainNode.gain.value = 0.6;

            // Connecter les nœuds
            sourceNode.connect(gainNode);
            gainNode.connect(audioContext.destination);

            // Fonction pour démarrer la lecture
            const startPlayback = () => {
                audioElement.play().catch(error => {
                    console.log("Erreur de lecture :", error);
                });
            };

            // Tentative de démarrage automatique
            startPlayback();

            // Écouter les interactions utilisateur pour reprendre la lecture si nécessaire
            document.body.addEventListener('click', () => {
                if (audioContext.state === 'suspended') {
                    audioContext.resume().then(() => {
                        startPlayback();
                    });
                }
            });
        });
    </script>
</head>
<body>
    <p style="text-align: center; margin-top: 50px;">Expérience sonore Ambix</p>
</body>
</html>
				</div>
				</div>
					</div>
				</div>
				</div>
		<p>Cet article <a href="https://presentcomposedesign.fr/canyon-sound/">Canyon-sound</a> est apparu en premier sur <a href="https://presentcomposedesign.fr">Présent Composé design</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Curiosity</title>
		<link>https://presentcomposedesign.fr/curiosity/</link>
		
		<dc:creator><![CDATA[admin]]></dc:creator>
		<pubDate>Thu, 25 Dec 2025 18:47:39 +0000</pubDate>
				<category><![CDATA[[ia]]]></category>
		<category><![CDATA[Design graphique]]></category>
		<category><![CDATA[Design produit]]></category>
		<guid isPermaLink="false">https://presentcomposedesign.fr/?p=35140</guid>

					<description><![CDATA[<p>Curiosity Viewer ● Curiosity Chat 🧠 Curiosity 🗑️ ⚙️ 👋 Bonjour, Je suis Curiosity, votre oracle culturel français. Posez-moi n&#8217;importe quelle question ! 🎤 ▶️ ⚙️ Réglages Avancés ✕ 🎨 Apparence Thème Choisissez le thème d&#8217;affichage Auto ☀️ Clair 🌙 Sombre Affichage du personnage Montrer/cacher Curiosity Afficher le personnage Curiosity Taille de la fenêtre Ajuster la taille selon vos préférences 100% 🎤 Synthèse Vocale Voix française Sélectionnez la voix de lecture Vitesse de lecture 1.0x Lecture automatique des réponses Lire les sources à voix haute 💬 Conversation Mode de réponse Longueur des réponses de Curiosity Concis (2 phrases)Détaillé (5 phrases)Complet (10+ phrases) Historique conservé Nombre d&#8217;échanges mémorisés 10 Sauvegarder l&#8217;historique (localStorage) 🔧 Avancé URL de l&#8217;API Ne modifiez que si nécessaire Délai d&#8217;affichage des yeux Durée d&#8217;affichage des recherches (secondes) 8s Effet parallax sur le personnage Mode debug (console) 💾 Sauvegarder les paramètres</p>
<p>Cet article <a href="https://presentcomposedesign.fr/curiosity/">Curiosity</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="35140" class="elementor elementor-35140">
				<div class="elementor-element elementor-element-11b78ac 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="11b78ac" data-element_type="container" data-e-type="container">
					<div class="e-con-inner">
				<div class="elementor-element elementor-element-cfacc9d elementor-widget elementor-widget-html" data-id="cfacc9d" data-element_type="widget" data-e-type="widget" data-widget_type="html.default">
				<div class="elementor-widget-container">
					<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Curiosity Viewer</title>
    <style>
        :root {
            --phi: 1.618;
            --viewer-size: min(500px, 80vmin);
        }

        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        body {
            display: flex;
            align-items: center;
            justify-content: center;
            min-height: 100vh;
            background: #f5f7fa;
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
        }

        .curiosity-viewer {
            position: relative;
            width: var(--viewer-size);
            height: var(--viewer-size);
            transition: transform 0.1s ease-out;
        }

        /* Base character image */
        .character-base {
            width: 100%;
            height: 100%;
            position: absolute;
            top: 0;
            left: 0;
        }

        .character-base img {
            width: 100%;
            height: 100%;
            object-fit: contain;
            filter: drop-shadow(0 20px 40px rgba(0,0,0,0.15));
        }

        /* Screen container with perfect SVG mask */
        .screen-container {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            -webkit-mask-image: url('https://presentcomposedesign.fr/wp-content/uploads/2025/12/Curiosity_mask-screen-transparent_PresentComposedesign.svg');
            mask-image: url('https://presentcomposedesign.fr/wp-content/uploads/2025/12/Curiosity_mask-screen-transparent_PresentComposedesign.svg');
            -webkit-mask-size: 100% 100%;
            mask-size: 100% 100%;
            -webkit-mask-repeat: no-repeat;
            mask-repeat: no-repeat;
            -webkit-mask-position: center;
            mask-position: center;
            display: grid;
            grid-template-columns: 1fr 1fr;
            gap: 0;
            padding: 0;
            background: #000;
        }

        /* Left Eye - Code */
        .left-eye {
            width: 100%;
            height: 100%;
            background: #000;
            overflow: hidden;
            display: flex;
            flex-direction: column;
            justify-content: flex-start;
            padding: 8%;
            font-family: 'Courier New', monospace;
            font-size: 7px;
            line-height: 1.5;
            color: #fff;
            opacity: 0;
            transition: opacity 0.3s;
        }

        .left-eye.active {
            opacity: 1;
            animation: scrollCode 6s linear infinite;
        }

        @keyframes scrollCode {
            0% { transform: translateY(0); }
            100% { transform: translateY(-30%); }
        }

        .code-line {
            white-space: nowrap;
            text-shadow: 0 0 8px #fff;
            margin-bottom: 1px;
        }

        /* Right Eye - Preview */
        .right-eye {
            width: 100%;
            height: 100%;
            background: #fff;
            overflow: hidden;
            position: relative;
        }

        .eye-preview {
            width: 100%;
            height: 100%;
            display: none;
        }

        .eye-preview.active {
            display: block;
        }

        .eye-preview iframe {
            width: 400%;
            height: 400%;
            border: none;
            transform: scale(0.25);
            transform-origin: top left;
            pointer-events: none;
        }

        .preview-placeholder {
            width: 100%;
            height: 100%;
            display: flex;
            align-items: center;
            justify-content: center;
            color: #fff;
            font-family: monospace;
            font-size: 8px;
            text-align: center;
            background: #000;
        }

        /* Status Indicator */
        .status-indicator {
            position: absolute;
            bottom: 8%;
            right: 12%;
            width: calc(var(--viewer-size) * 0.08);
            height: calc(var(--viewer-size) * 0.08);
            border-radius: 50%;
            background: #4ade80;
            box-shadow: 0 0 0 calc(var(--viewer-size) * 0.02) rgba(74,222,128,0.3);
            z-index: 100;
            transition: all 0.3s ease;
        }

        .status-indicator.thinking {
            background: #fbbf24;
            box-shadow: 0 0 0 calc(var(--viewer-size) * 0.02) rgba(251,191,36,0.3);
            animation: pulse 1.5s infinite;
        }

        .status-indicator.error {
            background: #f87171;
            box-shadow: 0 0 0 calc(var(--viewer-size) * 0.02) rgba(248,113,113,0.3);
        }

        @keyframes pulse {
            0%, 100% { transform: scale(1); }
            50% { transform: scale(1.2); }
        }

        /* Responsive */
        @media (max-width: 768px) {
            :root {
                --viewer-size: min(320px, 85vmin);
            }
            .left-eye {
                font-size: 6px;
            }
        }
    </style>
</head>
<body>
    <div class="curiosity-viewer" id="viewer">
        <!-- Character Base -->
        <div class="character-base">
            <img decoding="async" id="characterImg" 
                 src="https://presentcomposedesign.fr/wp-content/uploads/2025/12/Curiosity_OFF_PresentComposedesign.png"
                 alt="Curiosity">
        </div>

        <!-- Eyes Screen -->
        <div class="screen-container">
            <!-- Left Eye -->
            <div class="left-eye" id="leftEye"></div>
            
            <!-- Right Eye -->
            <div class="right-eye">
                <div id="rightEye" class="eye-preview">
                    <div class="preview-placeholder">●</div>
                </div>
            </div>
        </div>

        <!-- Status -->
        <div id="statusIndicator" class="status-indicator"></div>
    </div>

    <script>
        const STATES = {
            OFF: 'https://presentcomposedesign.fr/wp-content/uploads/2025/12/Curiosity_OFF_PresentComposedesign.png',
            ON: 'https://presentcomposedesign.fr/wp-content/uploads/2025/12/Curiosity_ON_PresentComposedesign.png',
            THINKING: 'https://presentcomposedesign.fr/wp-content/uploads/2025/12/Curiosity_requesting-sample_PresentComposedesign.png',
            GOOD: 'https://presentcomposedesign.fr/wp-content/uploads/2025/12/Curiosity_good_PresentComposedesign.png',
            BAD: 'https://presentcomposedesign.fr/wp-content/uploads/2025/12/Curiosity_bad_PresentComposedesign.png'
        };

        // API pour contrôler le viewer depuis l'extérieur
        window.CuriosityViewer = {
            setState(state) {
                document.getElementById('characterImg').src = STATES[state] || STATES.ON;
            },

            setStatus(status) {
                const indicator = document.getElementById('statusIndicator');
                indicator.className = 'status-indicator ' + (status || '');
            },

            showCode(query) {
                const leftEye = document.getElementById('leftEye');
                const code = [
                    `> web_search("${query}")`,
                    `> Connecting...`,
                    `> fetch wikipedia.org`,
                    `> fetch meteofrance.com`,
                    `> fetch coingecko.com`,
                    `> Parsing HTML...`,
                    `> const data = await parse()`,
                    `> Extracting content...`,
                    `> Processing 12 sources`,
                    `> Analyzing data...`,
                    `> Building response...`,
                    `> ✓ Complete`,
                    ``,
                    `// ${new Date().toLocaleTimeString()}`,
                    `// Query: "${query}"`,
                    `// Confidence: 96%`
                ];
                
                leftEye.innerHTML = code.map(line => 
                    `<div class="code-line">${line}</div>`
                ).join('');
                
                leftEye.classList.add('active');
            },

            showPreview(url) {
                const rightEye = document.getElementById('rightEye');
                if (url && url.startsWith('http')) {
                    rightEye.innerHTML = `<iframe src="${url}" sandbox="allow-same-origin"></iframe>`;
                    rightEye.classList.add('active');
                }
            },

            clearEyes() {
                const leftEye = document.getElementById('leftEye');
                const rightEye = document.getElementById('rightEye');
                
                leftEye.classList.remove('active');
                rightEye.classList.remove('active');
                
                setTimeout(() => {
                    leftEye.innerHTML = '';
                    rightEye.innerHTML = '<div class="preview-placeholder">●</div>';
                }, 300);
            }
        };

        // Parallax
        document.addEventListener('mousemove', e => {
            const x = (e.clientX / window.innerWidth - 0.5) * 12;
            const y = (e.clientY / window.innerHeight - 0.5) * 12;
            document.getElementById('viewer').style.transform = `translate(${x}px, ${y}px)`;
        });

        // Init
        window.CuriosityViewer.setState('OFF');
        setTimeout(() => {
            window.CuriosityViewer.setState('ON');
        }, 1000);

        // Écouter les messages du chat
        window.addEventListener('message', (event) => {
            const { action, data } = event.data;
            
            switch(action) {
                case 'setState':
                    window.CuriosityViewer.setState(data);
                    break;
                case 'setStatus':
                    window.CuriosityViewer.setStatus(data);
                    break;
                case 'showCode':
                    window.CuriosityViewer.showCode(data);
                    break;
                case 'showPreview':
                    window.CuriosityViewer.showPreview(data);
                    break;
                case 'clearEyes':
                    window.CuriosityViewer.clearEyes();
                    break;
            }
        });
    </script>
</body>
</html>				</div>
				</div>
				<div class="elementor-element elementor-element-d5da671 elementor-widget elementor-widget-html" data-id="d5da671" data-element_type="widget" data-e-type="widget" data-widget_type="html.default">
				<div class="elementor-widget-container">
					<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Curiosity Chat</title>
    <style>
        /* ===================================
           CURIOSITY CHAT v3.2 - Golden Ratio
           © Présent Composé Design
           =================================== */
        
        :root {
            --phi: 1.618;
            --unit: 20px;
            
            /* Espacements selon le nombre d'or */
            --space-xxs: calc(var(--unit) / var(--phi) / var(--phi) / var(--phi));
            --space-xs: calc(var(--unit) / var(--phi) / var(--phi));
            --space-s: calc(var(--unit) / var(--phi));
            --space-m: var(--unit);
            --space-l: calc(var(--unit) * var(--phi));
            --space-xl: calc(var(--unit) * var(--phi) * var(--phi));
            --space-xxl: calc(var(--unit) * var(--phi) * var(--phi) * var(--phi));
            
            /* Tailles selon le nombre d'or */
            --text-xs: calc(var(--unit) / var(--phi) / var(--phi));
            --text-s: calc(var(--unit) / var(--phi));
            --text-m: var(--unit);
            --text-l: calc(var(--unit) * var(--phi));
            --text-xl: calc(var(--unit) * var(--phi) * var(--phi));
            
            --border-radius-s: calc(var(--unit) / var(--phi));
            --border-radius-m: var(--unit);
            --border-radius-l: calc(var(--unit) * var(--phi));
            
            --bg-light: #F5F7FA;
            --bg-dark: #0a0e27;
            --card-light: rgba(255,255,255,0.98);
            --card-dark: rgba(15,20,40,0.98);
            --text-light: #1a1a2e;
            --text-dark: #e8e9f3;
            --accent: #667eea;
            --accent-secondary: #764ba2;
            --success: #4ade80;
            --warning: #fbbf24;
            --error: #f87171;
            
            --chat-width: calc(600px);
            --chat-max-width: calc(var(--chat-width) * var(--phi));
        }

        * {
            box-sizing: border-box;
            margin: 0;
            padding: 0;
        }

        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', sans-serif;
            -webkit-font-smoothing: antialiased;
            -moz-osx-font-smoothing: grayscale;
            line-height: var(--phi);
            transition: background-color 0.3s ease;
            min-height: 100vh;
            display: flex;
            align-items: center;
            justify-content: center;
            padding: var(--space-m);
        }

        body.light-mode {
            background: var(--bg-light);
            color: var(--text-light);
        }

        body.dark-mode {
            background: var(--bg-dark);
            color: var(--text-dark);
        }

        /* Main Chat Container */
        .chat-container {
            width: 100%;
            max-width: var(--chat-max-width);
        }

        .chat-interface {
            width: 100%;
            backdrop-filter: blur(calc(var(--space-m) * var(--phi)));
            -webkit-backdrop-filter: blur(calc(var(--space-m) * var(--phi)));
            border-radius: var(--border-radius-l);
            padding: var(--space-l);
            box-shadow: 0 var(--space-m) var(--space-xl) rgba(0,0,0,0.15);
            transition: all 0.3s ease;
        }

        .light-mode .chat-interface {
            background: var(--card-light);
            border: 1px solid rgba(0,0,0,0.06);
        }

        .dark-mode .chat-interface {
            background: var(--card-dark);
            border: 1px solid rgba(255,255,255,0.1);
        }

        /* Header */
        .chat-header {
            display: flex;
            justify-content: space-between;
            align-items: center;
            margin-bottom: var(--space-l);
            padding-bottom: var(--space-s);
            border-bottom: 1px solid rgba(102,126,234,0.1);
        }

        .chat-title {
            font-size: var(--text-xl);
            font-weight: 800;
            background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
            -webkit-background-clip: text;
            -webkit-text-fill-color: transparent;
            background-clip: text;
            letter-spacing: -0.02em;
        }

        .header-actions {
            display: flex;
            gap: var(--space-xs);
        }

        .icon-btn {
            width: calc(var(--space-l) * var(--phi));
            height: calc(var(--space-l) * var(--phi));
            border-radius: 50%;
            border: none;
            background: rgba(102,126,234,0.1);
            color: var(--accent);
            cursor: pointer;
            display: grid;
            place-items: center;
            transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
            font-size: var(--text-m);
        }

        .icon-btn.danger {
            background: rgba(248,113,113,0.1);
            color: var(--error);
        }

        .icon-btn:hover {
            transform: scale(1.1) rotate(10deg);
            background: rgba(102,126,234,0.2);
        }

        .icon-btn.danger:hover {
            background: rgba(248,113,113,0.2);
        }

        .icon-btn:active {
            transform: scale(0.95);
        }

        /* Messages Area */
        .messages-container {
            height: calc(var(--space-xxl) * 3);
            overflow-y: auto;
            margin-bottom: var(--space-l);
            padding: var(--space-xs);
            scrollbar-width: thin;
            scrollbar-color: var(--accent) transparent;
        }

        .messages-container::-webkit-scrollbar {
            width: calc(var(--space-xs) / 2);
        }

        .messages-container::-webkit-scrollbar-thumb {
            background: var(--accent);
            border-radius: calc(var(--border-radius-s) / 2);
        }

        .message {
            margin-bottom: var(--space-m);
            padding: var(--space-s) var(--space-m);
            border-radius: var(--border-radius-m);
            max-width: 85%;
            animation: slideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
            word-wrap: break-word;
            line-height: var(--phi);
        }

        @keyframes slideIn {
            from {
                opacity: 0;
                transform: translateY(var(--space-s));
            }
            to {
                opacity: 1;
                transform: translateY(0);
            }
        }

        .message.user {
            background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
            color: white;
            margin-left: auto;
            text-align: right;
            box-shadow: 0 var(--space-xs) var(--space-m) rgba(102,126,234,0.2);
        }

        .message.bot {
            background: rgba(102,126,234,0.08);
            border: 1px solid rgba(102,126,234,0.15);
        }

        .dark-mode .message.bot {
            background: rgba(102,126,234,0.12);
            border-color: rgba(102,126,234,0.25);
        }

        .message-sources {
            font-size: var(--text-xs);
            opacity: 0.7;
            margin-top: var(--space-xs);
            padding-top: var(--space-xs);
            border-top: 1px solid rgba(102,126,234,0.2);
            font-style: italic;
            display: flex;
            flex-wrap: wrap;
            gap: var(--space-xs);
        }

        .source-link {
            color: var(--accent);
            text-decoration: none;
            transition: all 0.2s;
            padding: var(--space-xxs) var(--space-xs);
            background: rgba(102,126,234,0.1);
            border-radius: var(--border-radius-s);
        }

        .source-link:hover {
            background: rgba(102,126,234,0.2);
            transform: translateY(-1px);
        }

        .read-sources-btn {
            font-size: var(--text-xs);
            padding: var(--space-xxs) var(--space-xs);
            border: 1px solid var(--accent);
            background: transparent;
            color: var(--accent);
            border-radius: var(--border-radius-s);
            cursor: pointer;
            transition: all 0.2s;
            margin-top: var(--space-xxs);
        }

        .read-sources-btn:hover {
            background: var(--accent);
            color: white;
        }

        /* Input Area */
        .input-area {
            display: flex;
            gap: var(--space-s);
            align-items: center;
        }

        .input-field {
            flex: 1;
            padding: var(--space-s) var(--space-m);
            border-radius: calc(var(--space-xxl));
            border: 2px solid transparent;
            background: rgba(102,126,234,0.08);
            font-size: var(--text-m);
            transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
            outline: none;
            font-family: inherit;
            line-height: var(--phi);
        }

        .light-mode .input-field {
            color: var(--text-light);
        }

        .dark-mode .input-field {
            color: var(--text-dark);
        }

        .input-field::placeholder {
            opacity: 0.5;
        }

        .input-field:focus {
            border-color: var(--accent);
            background: rgba(102,126,234,0.12);
            box-shadow: 0 0 0 calc(var(--space-xs) / 2) rgba(102,126,234,0.1);
        }

        .action-btn {
            width: calc(var(--space-l) * var(--phi));
            height: calc(var(--space-l) * var(--phi));
            border-radius: 50%;
            border: none;
            background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
            color: white;
            cursor: pointer;
            display: grid;
            place-items: center;
            font-size: var(--text-l);
            transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
            box-shadow: 0 var(--space-xs) var(--space-m) rgba(102,126,234,0.3);
        }

        .action-btn:hover {
            transform: translateY(calc(var(--space-xxs) * -1));
            box-shadow: 0 var(--space-s) var(--space-l) rgba(102,126,234,0.4);
        }

        .action-btn:active {
            transform: scale(0.95);
        }

        .action-btn.listening {
            animation: pulse 1.5s infinite;
        }

        @keyframes pulse {
            0%, 100% { transform: scale(1); }
            50% { transform: scale(1.15); }
        }

        /* Settings Overlay */
        .settings-overlay {
            position: fixed;
            inset: 0;
            background: rgba(0,0,0,0.6);
            backdrop-filter: blur(var(--space-xs));
            z-index: 999;
            opacity: 0;
            pointer-events: none;
            transition: opacity 0.3s ease;
        }

        .settings-overlay.active {
            opacity: 1;
            pointer-events: all;
        }

        /* Settings Panel */
        .settings-panel {
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%) scale(0.9);
            width: min(calc(var(--chat-width) * 0.8), 90vw);
            max-height: 85vh;
            overflow-y: auto;
            padding: var(--space-xl);
            border-radius: var(--border-radius-l);
            backdrop-filter: blur(calc(var(--space-l) * var(--phi)));
            box-shadow: 0 var(--space-l) var(--space-xxl) rgba(0,0,0,0.3);
            z-index: 1000;
            opacity: 0;
            pointer-events: none;
            transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
        }

        .settings-panel.active {
            opacity: 1;
            pointer-events: all;
            transform: translate(-50%, -50%) scale(1);
        }

        .light-mode .settings-panel {
            background: var(--card-light);
            border: 1px solid rgba(0,0,0,0.1);
        }

        .dark-mode .settings-panel {
            background: var(--card-dark);
            border: 1px solid rgba(255,255,255,0.15);
        }

        .settings-header {
            display: flex;
            justify-content: space-between;
            align-items: center;
            margin-bottom: var(--space-xl);
            padding-bottom: var(--space-m);
            border-bottom: 2px solid rgba(102,126,234,0.2);
        }

        .settings-title {
            font-size: var(--text-l);
            font-weight: 800;
            background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
            -webkit-background-clip: text;
            -webkit-text-fill-color: transparent;
        }

        .close-btn {
            width: calc(var(--space-l) * var(--phi));
            height: calc(var(--space-l) * var(--phi));
            border-radius: 50%;
            border: none;
            background: rgba(248,113,113,0.1);
            color: var(--error);
            cursor: pointer;
            display: grid;
            place-items: center;
            font-size: var(--text-l);
            transition: all 0.2s;
        }

        .close-btn:hover {
            background: rgba(248,113,113,0.2);
            transform: scale(1.15) rotate(90deg);
        }

        .settings-section {
            margin-bottom: var(--space-l);
        }

        .section-title {
            font-size: var(--text-m);
            font-weight: 700;
            color: var(--accent);
            margin-bottom: var(--space-m);
            display: flex;
            align-items: center;
            gap: var(--space-s);
        }

        .setting-row {
            margin-bottom: var(--space-m);
        }

        .setting-label {
            display: block;
            font-size: var(--text-s);
            font-weight: 600;
            margin-bottom: var(--space-xs);
            opacity: 0.9;
        }

        .setting-description {
            font-size: var(--text-xs);
            opacity: 0.6;
            margin-bottom: var(--space-xs);
        }

        .setting-select,
        .setting-input {
            width: 100%;
            padding: var(--space-s) var(--space-m);
            border-radius: var(--border-radius-m);
            border: 1px solid rgba(102,126,234,0.2);
            background: rgba(102,126,234,0.08);
            font-size: var(--text-s);
            cursor: pointer;
            outline: none;
            font-family: inherit;
            transition: all 0.2s;
        }

        .light-mode .setting-select,
        .light-mode .setting-input {
            color: var(--text-light);
        }

        .dark-mode .setting-select,
        .dark-mode .setting-input {
            color: var(--text-dark);
        }

        .setting-select:hover,
        .setting-input:hover {
            border-color: var(--accent);
        }

        .setting-select:focus,
        .setting-input:focus {
            border-color: var(--accent);
            background: rgba(102,126,234,0.12);
        }

        .toggle-group {
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(calc(var(--space-xxl) * 1.5), 1fr));
            gap: var(--space-xs);
        }

        .toggle-btn {
            padding: var(--space-s);
            border-radius: var(--border-radius-m);
            border: 2px solid transparent;
            background: rgba(102,126,234,0.08);
            cursor: pointer;
            transition: all 0.2s;
            font-weight: 600;
            font-size: var(--text-s);
            text-align: center;
        }

        .light-mode .toggle-btn {
            color: var(--text-light);
        }

        .dark-mode .toggle-btn {
            color: var(--text-dark);
        }

        .toggle-btn.active {
            border-color: var(--accent);
            background: rgba(102,126,234,0.2);
            color: var(--accent);
            transform: scale(1.02);
        }

        .toggle-btn:hover:not(.active) {
            background: rgba(102,126,234,0.15);
        }

        .checkbox-wrapper {
            display: flex;
            align-items: center;
            gap: var(--space-s);
            padding: var(--space-s);
            background: rgba(102,126,234,0.05);
            border-radius: var(--border-radius-m);
            cursor: pointer;
            transition: all 0.2s;
        }

        .checkbox-wrapper:hover {
            background: rgba(102,126,234,0.1);
        }

        .checkbox-wrapper input[type="checkbox"] {
            width: calc(var(--space-m) * var(--phi));
            height: calc(var(--space-m) * var(--phi));
            cursor: pointer;
        }

        .slider-container {
            display: flex;
            align-items: center;
            gap: var(--space-m);
        }

        .slider {
            flex: 1;
            height: var(--space-xs);
            border-radius: calc(var(--space-xs) / 2);
            background: rgba(102,126,234,0.2);
            outline: none;
            cursor: pointer;
        }

        .slider-value {
            min-width: calc(var(--space-xl));
            text-align: center;
            font-weight: 700;
            color: var(--accent);
        }

        /* Responsive */
        @media (max-width: 768px) {
            :root {
                --unit: 16px;
            }

            .chat-interface {
                padding: var(--space-m);
            }

            .settings-panel {
                padding: var(--space-l);
            }

            .toggle-group {
                grid-template-columns: 1fr;
            }
        }

        /* Accessibility */
        @media (prefers-reduced-motion: reduce) {
            *, *::before, *::after {
                animation-duration: 0.01ms !important;
                animation-iteration-count: 1 !important;
                transition-duration: 0.01ms !important;
            }
        }

        .sr-only {
            position: absolute;
            width: 1px;
            height: 1px;
            padding: 0;
            margin: -1px;
            overflow: hidden;
            clip: rect(0,0,0,0);
            white-space: nowrap;
            border: 0;
        }
    </style>
</head>
<body class="light-mode">
    <div class="chat-container">
        <article class="chat-interface">
            <header class="chat-header">
                <h1 class="chat-title">🧠 Curiosity</h1>
                <div class="header-actions">
                    <button class="icon-btn danger" onclick="clearConversation()" 
                            aria-label="Effacer" title="Effacer la conversation">🗑️</button>
                    <button class="icon-btn" onclick="openSettings()" 
                            aria-label="Paramètres" title="Paramètres">⚙️</button>
                </div>
            </header>

            <div id="messages" class="messages-container" role="log" aria-live="polite">
                <div class="message bot">
                    👋 <strong>Bonjour,</strong> Je suis Curiosity, votre oracle culturel français. 
                    <br>Posez-moi n'importe quelle question !
                </div>
            </div>

            <form class="input-area" onsubmit="event.preventDefault(); sendMessage();">
                <input type="text" id="inputField" class="input-field" 
                       placeholder="Posez votre question..." 
                       aria-label="Question">
                <button type="button" class="action-btn" id="micBtn" 
                        onclick="toggleVoice()" aria-label="Vocal">🎤</button>
                <button type="submit" class="action-btn" aria-label="Envoyer">▶️</button>
            </form>
        </article>
    </div>

    <!-- Settings Overlay -->
    <div class="settings-overlay" id="settingsOverlay" onclick="closeSettings()"></div>
    
    <!-- Settings Panel -->
    <aside class="settings-panel" id="settingsPanel" role="dialog" aria-modal="true">
        <header class="settings-header">
            <h2 class="settings-title">⚙️ Réglages Avancés</h2>
            <button class="close-btn" onclick="closeSettings()" aria-label="Fermer">✕</button>
        </header>

        <!-- SECTION: Apparence -->
        <section class="settings-section">
            <h3 class="section-title">🎨 Apparence</h3>
            
            <div class="setting-row">
                <label class="setting-label">Thème</label>
                <div class="setting-description">Choisissez le thème d'affichage</div>
                <div class="toggle-group">
                    <button class="toggle-btn" id="autoThemeBtn" onclick="setTheme('auto')">Auto</button>
                    <button class="toggle-btn active" id="lightThemeBtn" onclick="setTheme('light')">☀️ Clair</button>
                    <button class="toggle-btn" id="darkThemeBtn" onclick="setTheme('dark')">🌙 Sombre</button>
                </div>
            </div>

            <div class="setting-row">
                <label class="setting-label">Affichage du personnage</label>
                <div class="setting-description">Montrer/cacher Curiosity</div>
                <div class="checkbox-wrapper" onclick="toggleViewerDisplay(this)">
                    <input type="checkbox" id="showViewer" checked>
                    <label for="showViewer">Afficher le personnage Curiosity</label>
                </div>
            </div>

            <div class="setting-row">
                <label class="setting-label">Taille de la fenêtre</label>
                <div class="setting-description">Ajuster la taille selon vos préférences</div>
                <div class="slider-container">
                    <input type="range" class="slider" id="chatSize" min="0.8" max="1.5" step="0.1" value="1" 
                           oninput="updateChatSize(this.value)">
                    <span class="slider-value" id="chatSizeValue">100%</span>
                </div>
            </div>
        </section>

        <!-- SECTION: Voix -->
        <section class="settings-section">
            <h3 class="section-title">🎤 Synthèse Vocale</h3>
            
            <div class="setting-row">
                <label class="setting-label" for="voiceSelect">Voix française</label>
                <div class="setting-description">Sélectionnez la voix de lecture</div>
                <select id="voiceSelect" class="setting-select"></select>
            </div>

            <div class="setting-row">
                <label class="setting-label">Vitesse de lecture</label>
                <div class="slider-container">
                    <input type="range" class="slider" id="speechRate" min="0.5" max="2" step="0.1" value="1" 
                           oninput="updateSpeechRate(this.value)">
                    <span class="slider-value" id="speechRateValue">1.0x</span>
                </div>
            </div>

            <div class="setting-row">
                <div class="checkbox-wrapper" onclick="toggleAutoSpeak(this)">
                    <input type="checkbox" id="autoSpeak" checked>
                    <label for="autoSpeak">Lecture automatique des réponses</label>
                </div>
            </div>

            <div class="setting-row">
                <div class="checkbox-wrapper" onclick="toggleReadSources(this)">
                    <input type="checkbox" id="readSources">
                    <label for="readSources">Lire les sources à voix haute</label>
                </div>
            </div>
        </section>

        <!-- SECTION: Conversation -->
        <section class="settings-section">
            <h3 class="section-title">💬 Conversation</h3>
            
            <div class="setting-row">
                <label class="setting-label" for="modeSelect">Mode de réponse</label>
                <div class="setting-description">Longueur des réponses de Curiosity</div>
                <select id="modeSelect" class="setting-select">
                    <option value="concise">Concis (2 phrases)</option>
                    <option value="detailed">Détaillé (5 phrases)</option>
                    <option value="comprehensive">Complet (10+ phrases)</option>
                </select>
            </div>

            <div class="setting-row">
                <label class="setting-label">Historique conservé</label>
                <div class="setting-description">Nombre d'échanges mémorisés</div>
                <div class="slider-container">
                    <input type="range" class="slider" id="historySize" min="3" max="20" step="1" value="10" 
                           oninput="updateHistorySize(this.value)">
                    <span class="slider-value" id="historySizeValue">10</span>
                </div>
            </div>

            <div class="setting-row">
                <div class="checkbox-wrapper" onclick="togglePersistence(this)">
                    <input type="checkbox" id="persistence" checked>
                    <label for="persistence">Sauvegarder l'historique (localStorage)</label>
                </div>
            </div>
        </section>

        <!-- SECTION: Avancé -->
        <section class="settings-section">
            <h3 class="section-title">🔧 Avancé</h3>
            
            <div class="setting-row">
                <label class="setting-label" for="apiUrl">URL de l'API</label>
                <div class="setting-description">Ne modifiez que si nécessaire</div>
                <input type="text" id="apiUrl" class="setting-input" 
                       value="https://curiosity-silk.vercel.app/api/chat" 
                       placeholder="URL de l'API">
            </div>

            <div class="setting-row">
                <label class="setting-label">Délai d'affichage des yeux</label>
                <div class="setting-description">Durée d'affichage des recherches (secondes)</div>
                <div class="slider-container">
                    <input type="range" class="slider" id="eyesDelay" min="3" max="15" step="1" value="8" 
                           oninput="updateEyesDelay(this.value)">
                    <span class="slider-value" id="eyesDelayValue">8s</span>
                </div>
            </div>

            <div class="setting-row">
                <div class="checkbox-wrapper" onclick="toggleParallax(this)">
                    <input type="checkbox" id="parallax" checked>
                    <label for="parallax">Effet parallax sur le personnage</label>
                </div>
            </div>

            <div class="setting-row">
                <div class="checkbox-wrapper" onclick="toggleDebug(this)">
                    <input type="checkbox" id="debug">
                    <label for="debug">Mode debug (console)</label>
                </div>
            </div>
        </section>

        <button class="action-btn" style="width: 100%; border-radius: var(--border-radius-m); margin-top: var(--space-l);" 
                onclick="saveSettings()">
            💾 Sauvegarder les paramètres
        </button>
    </aside>

    <script>
        const API_URL = 'https://curiosity-silk.vercel.app/api/chat';
        
        let config = {
            theme: 'light',
            showViewer: true,
            chatSize: 1,
            speechRate: 1,
            autoSpeak: true,
            readSources: false,
            mode: 'concise',
            historySize: 10,
            persistence: true,
            apiUrl: API_URL,
            eyesDelay: 8,
            parallax: true,
            debug: false
};
let recognition, isListening = false, history = [], voices = [], selectedVoice;
    let viewerWindow = null;

    // ============================================
    // INIT & PERSISTENCE
    // ============================================
    function loadSettings() {
        try {
            const saved = localStorage.getItem('curiosity-config');
            if (saved) {
                config = { ...config, ...JSON.parse(saved) };
                applySettings();
            }
        } catch (e) {
            console.warn('Could not load settings:', e);
        }
    }

    function saveSettings() {
        try {
            localStorage.setItem('curiosity-config', JSON.stringify(config));
            alert('✅ Paramètres sauvegardés !');
            applySettings();
        } catch (e) {
            console.error('Could not save settings:', e);
            alert('❌ Erreur lors de la sauvegarde');
        }
    }

    function applySettings() {
        // Thème
        setTheme(config.theme);
        
        // Taille chat
        updateChatSize(config.chatSize);
        document.getElementById('chatSize').value = config.chatSize;
        
        // Voix
        document.getElementById('speechRate').value = config.speechRate;
        document.getElementById('speechRateValue').textContent = config.speechRate + 'x';
        
        // Checkboxes
        document.getElementById('autoSpeak').checked = config.autoSpeak;
        document.getElementById('readSources').checked = config.readSources;
        document.getElementById('persistence').checked = config.persistence;
        document.getElementById('showViewer').checked = config.showViewer;
        document.getElementById('parallax').checked = config.parallax;
        document.getElementById('debug').checked = config.debug;
        
        // Selects
        document.getElementById('modeSelect').value = config.mode;
        document.getElementById('apiUrl').value = config.apiUrl;
        
        // Sliders
        document.getElementById('historySize').value = config.historySize;
        document.getElementById('historySizeValue').textContent = config.historySize;
        document.getElementById('eyesDelay').value = config.eyesDelay;
        document.getElementById('eyesDelayValue').textContent = config.eyesDelay + 's';
    }

    function loadConversation() {
        if (!config.persistence) return;
        
        try {
            const savedHistory = localStorage.getItem('curiosity-history');
            const savedMessages = localStorage.getItem('curiosity-messages');
            
            if (savedHistory) {
                history = JSON.parse(savedHistory);
            }
            
            if (savedMessages) {
                document.getElementById('messages').innerHTML = savedMessages;
            }
        } catch (e) {
            if (config.debug) console.warn('Could not load conversation:', e);
        }
    }

    function saveConversation() {
        if (!config.persistence) return;
        
        try {
            localStorage.setItem('curiosity-history', JSON.stringify(history));
            localStorage.setItem('curiosity-messages', document.getElementById('messages').innerHTML);
        } catch (e) {
            if (config.debug) console.warn('Could not save conversation:', e);
        }
    }

    function clearConversation() {
        if (confirm('Effacer toute la conversation ?')) {
            history = [];
            localStorage.removeItem('curiosity-history');
            localStorage.removeItem('curiosity-messages');
            
            document.getElementById('messages').innerHTML = `
                <div class="message bot">
                    👋 <strong>Bonjour !</strong> Je suis Curiosity, votre oracle français. Météo, heure, Bitcoin, recettes, histoire... Posez-moi n'importe quelle question !
                </div>
            `;
        }
    }

    // ============================================
    // THEME
    // ============================================
    function detectTheme() {
        return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
    }

    function setTheme(mode) {
        config.theme = mode;
        document.querySelectorAll('.toggle-btn').forEach(b => b.classList.remove('active'));
        document.getElementById(mode + 'ThemeBtn').classList.add('active');
        document.body.className = (mode === 'auto' ? detectTheme() : mode) + '-mode';
    }

    window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
        if(config.theme === 'auto') setTheme('auto');
    });

    // ============================================
    // VOICE RECOGNITION
    // ============================================
    if('webkitSpeechRecognition' in window || 'SpeechRecognition' in window) {
        const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
        recognition = new SR();
        recognition.lang = 'fr-FR';
        recognition.onresult = e => {
            document.getElementById('inputField').value = e.results[0][0].transcript;
            sendMessage();
        };
        recognition.onend = () => stopListening();
    }

    function toggleVoice() {
        if(!recognition) return alert('❌ Non supporté');
        isListening ? stopListening() : startListening();
    }

    function startListening() {
        isListening = true;
        document.getElementById('micBtn').classList.add('listening');
        recognition.start();
    }

    function stopListening() {
        isListening = false;
        document.getElementById('micBtn').classList.remove('listening');
        if(recognition) recognition.stop();
    }

    // ============================================
    // VOICE SYNTHESIS
    // ============================================
    function loadVoices() {
        voices = speechSynthesis.getVoices().filter(v => v.lang.startsWith('fr'));
        const sel = document.getElementById('voiceSelect');
        sel.innerHTML = '';
        voices.forEach((v, i) => {
            const opt = document.createElement('option');
            opt.value = i;
            opt.textContent = `${v.name} (${v.lang})`;
            sel.appendChild(opt);
        });
        const pref = voices.findIndex(v => v.name.includes('Thomas') || v.name.includes('French'));
        if(pref !== -1) {
            sel.value = pref;
            selectedVoice = voices[pref];
        }
    }

    speechSynthesis.onvoiceschanged = loadVoices;
    loadVoices();
    document.getElementById('voiceSelect').onchange = e => selectedVoice = voices[e.target.value];

    function cleanTextForSpeech(text) {
        // Supprimer les caractères markdown: ** // _ ~~
        return text
            .replace(/\*\*/g, '')
            .replace(/\*/g, '')
            .replace(/\_\_/g, '')
            .replace(/\_/g, '')
            .replace(/\~\~/g, '')
            .replace(/\`\`\`/g, '')
            .replace(/\`/g, '')
            .replace(/\#\#\#/g, '')
            .replace(/\#\#/g, '')
            .replace(/\#/g, '')
            .replace(/\//g, '')
            .trim();
    }

    function speak(text, includeSources = false) {
        if (!config.autoSpeak) return;
        if (!('speechSynthesis' in window)) return;

        const cleanText = cleanTextForSpeech(text);
        
        const utterance = new SpeechSynthesisUtterance(cleanText);
        utterance.lang = 'fr-FR';
        utterance.rate = config.speechRate;
        utterance.pitch = 1.0;
        if(selectedVoice) utterance.voice = selectedVoice;
        
        speechSynthesis.speak(utterance);
    }

    function speakSources(sources) {
        if (!config.readSources || !sources || !sources.length) return;
        
        const sourcesText = `Sources consultées: ${sources.map((s, i) => {
            const domain = s.match(/https?:\/\/([^\/]+)/)?.[1] || s;
            return `source ${i + 1}: ${domain}`;
        }).join(', ')}`;
        
        setTimeout(() => {
            speak(sourcesText);
        }, 1000);
    }

    // ============================================
    // VIEWER COMMUNICATION
    // ============================================
    function sendToViewer(action, data) {
        if (!config.showViewer) return;
        
        // Si viewer dans iframe
        const viewerFrame = parent.document.querySelector('#curiosity-viewer');
        if (viewerFrame && viewerFrame.contentWindow) {
            viewerFrame.contentWindow.postMessage({ action, data }, '*');
        }
        
        // Si viewer dans window séparée
        if (viewerWindow && !viewerWindow.closed) {
            viewerWindow.postMessage({ action, data }, '*');
        }
    }

    // ============================================
    // MESSAGES
    // ============================================
    function addMsg(text, type, sources) {
        const msgs = document.getElementById('messages');
        const div = document.createElement('div');
        div.className = `message ${type}`;
        let html = text;
        
        if(sources?.length) {
            const sourceLinks = sources.map((s, i) => {
                const domain = s.match(/https?:\/\/([^\/]+)/)?.[1] || `Source ${i + 1}`;
                return `<a href="${s}" class="source-link" target="_blank" rel="noopener">${domain}</a>`;
            }).join('');
            
            html += `<div class="message-sources">
                📚 ${sourceLinks}
                ${!config.readSources ? '<button class="read-sources-btn" onclick="speakSources(' + JSON.stringify(sources) + ')">🔊 Lire les sources</button>' : ''}
            </div>`;
        }
        
        div.innerHTML = html;
        msgs.appendChild(div);
        msgs.scrollTop = msgs.scrollHeight;
        
        saveConversation();
    }

    // ============================================
    // SEND MESSAGE
    // ============================================
    async function sendMessage() {
        const input = document.getElementById('inputField');
        const msg = input.value.trim();
        if(!msg) return;

        addMsg(msg, 'user');
        history.push({role: 'user', content: msg});
        if(history.length > config.historySize * 2) {
            history = history.slice(-config.historySize * 2);
        }
        input.value = '';

        // Viewer
        sendToViewer('setState', 'THINKING');
        sendToViewer('setStatus', 'thinking');
        sendToViewer('showCode', msg);

        try {
            const maxSentences = config.mode === 'concise' ? 2 : config.mode === 'detailed' ? 5 : 10;
            
            const res = await fetch(config.apiUrl, {
                method: 'POST',
                headers: {'Content-Type': 'application/json'},
                body: JSON.stringify({
                    question: msg,
                    max_sentences: maxSentences,
                    history: history.slice(-config.historySize)
                })
            });

            if(!res.ok) throw new Error();

            const data = await res.json();
            history.push({role: 'assistant', content: data.answer});

            addMsg(data.answer, 'bot', data.sources);

            // Aperçu dans l'œil droit
            if (data.sources && data.sources.length > 0) {
                sendToViewer('showPreview', data.sources[0]);
                
                setTimeout(() => {
                    sendToViewer('clearEyes');
                }, config.eyesDelay * 1000);
            }

            // Lecture vocale
            speak(data.answer);
            if (config.readSources) {
                speakSources(data.sources);
            }

            // Viewer
            sendToViewer('setState', 'GOOD');
            sendToViewer('setStatus', '');

        } catch(err) {
            if (config.debug) console.error(err);
            addMsg('❌ Erreur. Réessayez.', 'bot');
            sendToViewer('setState', 'BAD');
            sendToViewer('setStatus', 'error');
            sendToViewer('clearEyes');
        }
    }

    // ============================================
    // SETTINGS
    // ============================================
    function openSettings() {
        document.getElementById('settingsPanel').classList.add('active');
        document.getElementById('settingsOverlay').classList.add('active');
    }

    function closeSettings() {
        document.getElementById('settingsPanel').classList.remove('active');
        document.getElementById('settingsOverlay').classList.remove('active');
    }

    function updateChatSize(value) {
        config.chatSize = parseFloat(value);
        document.getElementById('chatSizeValue').textContent = Math.round(value * 100) + '%';
        document.documentElement.style.setProperty('--chat-width', `${600 * value}px`);
    }

    function updateSpeechRate(value) {
        config.speechRate = parseFloat(value);
        document.getElementById('speechRateValue').textContent = value + 'x';
    }

    function updateHistorySize(value) {
        config.historySize = parseInt(value);
        document.getElementById('historySizeValue').textContent = value;
    }

    function updateEyesDelay(value) {
        config.eyesDelay = parseInt(value);
        document.getElementById('eyesDelayValue').textContent = value + 's';
    }

    function toggleViewerDisplay(wrapper) {
        config.showViewer = wrapper.querySelector('input').checked;
    }

    function toggleAutoSpeak(wrapper) {
        config.autoSpeak = wrapper.querySelector('input').checked;
    }

    function toggleReadSources(wrapper) {
        config.readSources = wrapper.querySelector('input').checked;
    }

    function togglePersistence(wrapper) {
        config.persistence = wrapper.querySelector('input').checked;
    }

    function toggleParallax(wrapper) {
        config.parallax = wrapper.querySelector('input').checked;
        sendToViewer('toggleParallax', config.parallax);
    }

    function toggleDebug(wrapper) {
        config.debug = wrapper.querySelector('input').checked;
    }

    // ============================================
    // INIT
    // ============================================
    loadSettings();
    loadConversation();
    setTheme(config.theme);

    // Initialiser le viewer
    sendToViewer('setState', 'OFF');
    setTimeout(() => {
        sendToViewer('setState', 'ON');
        sendToViewer('setStatus', '');
    }, 1000);
</script>
</body>
</html>				</div>
				</div>
					</div>
				</div>
				</div>
		<p>Cet article <a href="https://presentcomposedesign.fr/curiosity/">Curiosity</a> est apparu en premier sur <a href="https://presentcomposedesign.fr">Présent Composé design</a>.</p>
]]></content:encoded>
					
		
		
			</item>
	</channel>
</rss>
