<!doctype html>
<html lang="fr">
  <head>
    <meta charset="UTF-8" />
    <!-- Sécurité : CSP + en-têtes complémentaires appliqués côté client.
         Complète sanitizeEditorialHtml (allowlist tags/attributs/URL) en
         verrouillant script-src, frame-src (embeds) et object-src.
         NB: 'unsafe-inline' reste nécessaire pour Vite/GTM/JSON-LD ; on
         compense en interdisant object/base et en restreignant frame-src. -->
    <meta http-equiv="Content-Security-Policy" content="default-src 'self'; base-uri 'self'; object-src 'none'; form-action 'self'; frame-ancestors 'self'; upgrade-insecure-requests; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.googletagmanager.com https://www.google-analytics.com https://ssl.google-analytics.com https://pagead2.googlesyndication.com https://*.supabase.co https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: blob: https:; media-src 'self' blob: https:; connect-src 'self' https: wss:; frame-src 'self' https://www.youtube.com https://www.youtube-nocookie.com https://player.vimeo.com https://w.soundcloud.com https://open.spotify.com https://embed.music.apple.com https://bandcamp.com https://www.tiktok.com https://www.instagram.com https://www.facebook.com https://www.google.com https://td.doubleclick.net https://challenges.cloudflare.com; worker-src 'self' blob:; manifest-src 'self'" />
    <meta http-equiv="X-Content-Type-Options" content="nosniff" />
    <meta name="referrer" content="strict-origin-when-cross-origin" />
    <meta http-equiv="Permissions-Policy" content="geolocation=(), microphone=(), camera=(), payment=(self &quot;https://js.stripe.com&quot;)" />
    <!-- Lot 16 — Google Tag Manager déféré hors de la fenêtre LCP.
         Le bootstrap GTM (gtm.js) est exécuté après le premier paint via
         requestIdleCallback (fallback setTimeout 2 s + première interaction),
         libérant main thread et bande passante pour l'image hero + les polices.
         Le dataLayer est initialisé immédiatement pour ne perdre aucun event
         poussé pendant le boot React. -->
    <script>
      window.dataLayer = window.dataLayer || [];
      (function(){
        var booted = false;
        function boot(){
          if (booted) return; booted = true;
          window.dataLayer.push({'gtm.start': new Date().getTime(), event:'gtm.js'});
          var f = document.getElementsByTagName('script')[0];
          var j = document.createElement('script');
          j.async = true;
          j.src = 'https://www.googletagmanager.com/gtm.js?id=GTM-M6RH386D';
          f.parentNode.insertBefore(j, f);
        }
        function schedule(){
          if ('requestIdleCallback' in window) {
            requestIdleCallback(boot, { timeout: 3000 });
          } else {
            setTimeout(boot, 2000);
          }
        }
        if (document.readyState === 'complete') schedule();
        else window.addEventListener('load', schedule, { once: true });
        ['pointerdown','keydown','scroll','touchstart'].forEach(function(ev){
          window.addEventListener(ev, boot, { once: true, passive: true });
        });
      })();
    </script>
    <!-- SPA route-change pageview: push an event into dataLayer on every client navigation -->
    <script>(function(){var push=function(){window.dataLayer=window.dataLayer||[];window.dataLayer.push({event:'spa_pageview',page_path:location.pathname+location.search,page_location:location.href,page_title:document.title});};var wrap=function(type){var orig=history[type];history[type]=function(){var r=orig.apply(this,arguments);window.dispatchEvent(new Event('locationchange'));return r;};};wrap('pushState');wrap('replaceState');window.addEventListener('popstate',function(){window.dispatchEvent(new Event('locationchange'));});window.addEventListener('locationchange',push);})();</script>
    <!-- Favicon: Google uses /favicon.ico by default — cache-busted v3 -->
    <link rel="icon" href="/favicon.ico?v=3" sizes="48x48" />
    <link rel="icon" type="image/png" href="/favicon.png?v=3" sizes="any" />
    <link rel="icon" type="image/png" href="/favicon-dark.png?v=3" media="(prefers-color-scheme: light)" />
    <link rel="icon" type="image/png" href="/favicon-white.png?v=3" media="(prefers-color-scheme: dark)" />
    <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png?v=3" />
    <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png?v=3" />
    <link rel="icon" type="image/png" sizes="96x96" href="/logo-512.png?v=3" />
    <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png?v=3" />
    <link rel="shortcut icon" href="/favicon.ico?v=3" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
    
    <!-- Security Headers -->
    <meta http-equiv="X-Content-Type-Options" content="nosniff" />
    <meta http-equiv="Referrer-Policy" content="strict-origin-when-cross-origin" />
    <meta http-equiv="Permissions-Policy" content="camera=(), microphone=(), geolocation=(self), payment=()" />
    
    <!-- Google Discover & Crawl Directives -->
    <meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1" />
    <!-- SEO leak guard — synchronously force noindex on private routes BEFORE
         DOMContentLoaded. Must stay inline & non-deferred: the React guard
         (src/components/seo/NoindexRouteGuard.tsx) runs after hydration,
         which is too late for SPA crawlers and for the admin-noindex Playwright
         smoke test (which reads <meta name="robots"> at DCL). Keep both layers:
         this one covers initial paint, the React guard covers client routing. -->
    <script>
      (function(){
        try {
          var p = location.pathname || '/';
          var PRIVATE = ['/admin','/espace-membre','/espace-pro','/account','/dashboard','/checkout','/login','/signup','/forgot-password','/reset-password','/unsubscribe','/newsletter/preferences','/newsletter/confirm','/newsletter/unsubscribe'];
          var isPrivate = PRIVATE.some(function(prefix){ return p === prefix || p.indexOf(prefix + '/') === 0; });
          if (!isPrivate) return;
          var NOINDEX = 'noindex, nofollow, noarchive, nosnippet, max-image-preview:none, max-snippet:0, max-video-preview:0';
          var meta = document.querySelector('meta[name="robots"]');
          if (!meta) { meta = document.createElement('meta'); meta.setAttribute('name','robots'); document.head.appendChild(meta); }
          meta.setAttribute('content', NOINDEX);
          var g = document.querySelector('meta[name="googlebot"]');
          if (!g) { g = document.createElement('meta'); g.setAttribute('name','googlebot'); document.head.appendChild(g); }
          g.setAttribute('content', NOINDEX);
        } catch (e) { /* never break boot */ }
      })();
    </script>
    
    <title>Pull Up Mag — Webzine reggae, urban, electro, rock</title>
    <meta name="description" content="Pull Up Mag, le webzine musical : actus, chroniques, interviews et vidéos reggae, urban, electro, rock et world music." />
    
    <!-- Critical CSS inlined to avoid render-blocking -->
    <style>
      /* Lot 14 — Self-hosted woff2 files to remove the fonts.gstatic cross-origin
         handshake from the LCP window. Subset: Latin + common punctuation. */
      @font-face {
        font-family: 'Inter';
        font-style: normal;
        font-weight: 300 700;
        font-display: swap;
        src: url('/fonts/inter-latin.woff2') format('woff2');
        unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
      }
      @font-face {
        font-family: 'Bebas Neue';
        font-style: normal;
        font-weight: 400;
        font-display: swap;
        src: url('/fonts/bebasneue-latin.woff2') format('woff2');
        unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
      }
      /* Source Serif 4 is article-body only — loaded on demand by ArticlePage
         to keep the critical font payload at ~2 woff2 files. See
         src/utils/loadArticleFont.ts. */
    </style>

    <!-- Preload critical font files (same-origin, no cross-origin handshake) -->
    <link rel="preload" href="/fonts/inter-latin.woff2" as="font" type="font/woff2" crossorigin />
    <link rel="preload" href="/fonts/bebasneue-latin.woff2" as="font" type="font/woff2" crossorigin />

    <!-- Preload LCP logo (used in initial loader and fallback) -->
    <link rel="preload" as="image" href="/logo_pullupmag.png" fetchpriority="high" />
    
    <!-- Hreflang for multilingual SEO (FR only — EN version not yet published) -->
    <link rel="alternate" hreflang="fr-FR" href="https://pullupmag.fr/" />
    <link rel="alternate" hreflang="x-default" href="https://pullupmag.fr/" />
    <link rel="canonical" href="https://pullupmag.fr/" />
    <link rel="sitemap" type="application/xml" href="https://pullupmag.fr/sitemap-index.xml" />
    
    <!-- DNS Prefetch & Preconnect for faster API calls.
         Lot 14 — kept only the two origins consumed in the LCP window
         (Supabase API + R2 CDN). YouTube/Spotify preconnects moved out of
         the head: they only matter on video/article routes and were stealing
         TLS handshake slots from the critical path. -->
    <link rel="preconnect" href="https://bzizwhjyualqtvdiairc.supabase.co" crossorigin />
    <link rel="dns-prefetch" href="https://bzizwhjyualqtvdiairc.supabase.co" />
    <link rel="dns-prefetch" href="https://api.radioking.io" />
    <link rel="dns-prefetch" href="https://cdn.pullupmag.fr" />
    <link rel="preconnect" href="https://cdn.pullupmag.fr" crossorigin />
    <link rel="dns-prefetch" href="https://www.youtube.com" />
    <link rel="dns-prefetch" href="https://i.ytimg.com" />
    <link rel="dns-prefetch" href="https://open.spotify.com" />
    <link rel="dns-prefetch" href="https://i.scdn.co" />


    <!-- Lot 20 + Lot 21 — Early-fire homepage-data fetch in parallel with the
         entry chunk download, with sessionStorage stale-while-revalidate so
         intra-session returns to the home paint *instantly* from cache while
         a background fetch refreshes it. anon key is publishable by design. -->
    <script>
      (function(){
        try {
          var p = location.pathname;
          if (p !== '/' && p !== '' && p !== '/index') return;
          if (location.search.indexOf('cat=') !== -1) return; // skip filtered variants
          var KEY = 'pum:home:v1';
          var MAX_AGE = 5 * 60 * 1000; // 5 min — match React Query gcTime
          // 1) Synchronous cache read — exposed to useHomepageData as initialData.
          try {
            var raw = sessionStorage.getItem(KEY);
            if (raw) {
              var parsed = JSON.parse(raw);
              if (parsed && parsed.ts && (Date.now() - parsed.ts) < MAX_AGE && parsed.data) {
                window.__pumHomepageDataCached = parsed.data;
                window.__pumHomepageDataCachedTs = parsed.ts;
              }
            }
          } catch (e) { /* quota / parse error — ignore */ }
          // 2) Always fire network revalidation in parallel with the bundle.
          var ANON = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImJ6aXp3aGp5dWFscXR2ZGlhaXJjIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzA0ODgxNzgsImV4cCI6MjA4NjA2NDE3OH0.ltnhvJ3FZgiNYeruNd3m1qSIG1MnCYotnJrxROQTqgQ';
          window.__pumHomepageDataPromise = fetch(
            'https://bzizwhjyualqtvdiairc.supabase.co/functions/v1/homepage-data',
            { method: 'POST', headers: { 'Authorization': 'Bearer ' + ANON, 'apikey': ANON, 'Content-Type': 'application/json' }, body: '{}' }
          ).then(function(r){ return r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status)); })
           .then(function(data){
             try { sessionStorage.setItem(KEY, JSON.stringify({ ts: Date.now(), data: data })); } catch (e) {}
             return data;
           });
        } catch (e) { /* ignore — React Query will fall back to the standard path */ }
      })();
    </script>
    <!-- Module preload -->
    <!-- modulepreload removed: /src/main.tsx doesn't exist in production builds -->
    
    
    <!-- Speculation Rules are injected at runtime by
         <SpeculationRulesInjector /> so the `perf_speculation_rules_enabled`
         feature flag (managed in /admin/feature-flags) can toggle them
         without a redeploy. -->

    <meta name="theme-color" content="#1A8754" />
    <!-- Manifest is injected by VitePWA (generated /manifest.webmanifest) -->
    <link rel="alternate" type="application/rss+xml" title="Pull Up Mag - Flux RSS" href="https://pullupmag.fr/rss.xml" />
    <meta name="application-name" content="Pull Up Mag" />
    <meta name="apple-mobile-web-app-title" content="Pull Up Mag" />
    <meta name="apple-mobile-web-app-capable" content="yes" />
    <meta name="mobile-web-app-capable" content="yes" />
    <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
    <meta name="format-detection" content="telephone=no" />

    <!-- Open Graph (Facebook/WhatsApp/LinkedIn/X) - used by scrapers (no JS) -->
    <meta property="og:title" content="Pull Up Mag — Webzine reggae, urban, electro, rock" />
    <meta property="og:description" content="Pull Up Mag, le webzine musical : actus, chroniques, interviews et vidéos reggae, urban, electro, rock et world music." />
    <meta property="og:type" content="website" />
    <meta property="og:url" content="https://pullupmag.fr/" />
    <meta property="og:site_name" content="Pull Up Mag" />
    <meta property="og:image" content="https://pullupmag.fr/og-image-default.webp" />
    <meta property="og:image:alt" content="Pull Up Mag — Webzine musical" />
    <meta property="og:image:width" content="1200" />
    <meta property="og:image:height" content="630" />
    <meta property="og:locale" content="fr_FR" />
    <meta property="og:locale:alternate" content="en_US" />

    <!-- X / Twitter Cards -->
    <meta name="twitter:card" content="summary_large_image" />
    <meta name="twitter:domain" content="pullupmag.fr" />
    <meta name="twitter:site" content="@pullupmag" />
    <meta name="twitter:creator" content="@pullupmag" />
    <meta name="twitter:title" content="Pull Up Mag — Webzine reggae, urban, electro, rock" />
    <meta name="twitter:description" content="Pull Up Mag, le webzine musical : actus, chroniques, interviews et vidéos reggae, urban, electro, rock et world music." />
    <meta name="twitter:image" content="https://pullupmag.fr/og-image-default.webp" />
    <meta name="twitter:image:alt" content="Pull Up Mag — Webzine musical" />

    <!-- Structured Data in static HTML for bots that don't execute JS -->
    <script type="application/ld+json">
    {
      "@context": "https://schema.org",
      "@type": "NewsMediaOrganization",
      "@id": "https://pullupmag.fr/#organization",
      "name": "Pull Up Mag",
      "url": "https://pullupmag.fr",
      "logo": {
        "@type": "ImageObject",
        "url": "https://pullupmag.fr/logo-512.png",
        "width": 512,
        "height": 512
      },
      "image": "https://pullupmag.fr/logo-512.png",
      "description": "Le webzine musical de référence en Europe, dédié au reggae, urban, electro, rock et world music. Fondé en 2018.",
      "foundingDate": "2018",
      "publishingPrinciples": "https://pullupmag.fr/a-propos",
      "masthead": "https://pullupmag.fr/a-propos",
      "ethicsPolicy": "https://pullupmag.fr/mentions-legales",
      "actionableFeedbackPolicy": "https://pullupmag.fr/contact",
      "noBylinesPolicy": "https://pullupmag.fr/a-propos",
      "knowsAbout": ["Reggae", "Dancehall", "Hip-Hop", "R&B", "Afrobeats", "Electronic Music", "Rock", "World Music", "Music Festivals", "Concert Reviews", "Album Reviews"],
      "areaServed": ["Europe", "Africa", "Caribbean"],
      "sameAs": [
        "https://www.facebook.com/pullupmag",
        "https://www.instagram.com/pullupmag",
        "https://x.com/pullupmag",
        "https://www.youtube.com/@pullupmag",
        "https://www.tiktok.com/@pullupmag"
      ],
      "contactPoint": {"@type": "ContactPoint", "contactType": "customer service", "email": "contact@pullupmag.fr"}
    }
    </script>
    <script type="application/ld+json">
    {
      "@context": "https://schema.org",
      "@type": "WebSite",
      "name": "Pull Up Mag",
      "url": "https://pullupmag.fr",
      "description": "Le webzine musical dédié au reggae, urban, electro, rock et world music.",
      "inLanguage": "fr-FR",
      "potentialAction": {
        "@type": "SearchAction",
        "target": {"@type": "EntryPoint", "urlTemplate": "https://pullupmag.fr/recherche?q={search_term_string}"},
        "query-input": "required name=search_term_string"
      }
    }
    </script>
    <script type="application/ld+json">
    {
      "@context": "https://schema.org",
      "@type": "SiteNavigationElement",
      "name": "Navigation principale",
      "hasPart": [
        {"@type": "WebPage", "name": "Reggae", "url": "https://pullupmag.fr/reggae"},
        {"@type": "WebPage", "name": "Urban", "url": "https://pullupmag.fr/urban"},
        {"@type": "WebPage", "name": "Electro", "url": "https://pullupmag.fr/electro"},
        {"@type": "WebPage", "name": "Rock", "url": "https://pullupmag.fr/rock"},
        {"@type": "WebPage", "name": "World", "url": "https://pullupmag.fr/world"},
        {"@type": "WebPage", "name": "Radio", "url": "https://pullupmag.fr/radio"},
        {"@type": "WebPage", "name": "Agenda", "url": "https://pullupmag.fr/agenda"},
        {"@type": "WebPage", "name": "Artistes", "url": "https://pullupmag.fr/artistes"},
        {"@type": "WebPage", "name": "Albums", "url": "https://pullupmag.fr/albums"}
      ]
    }
    </script>
    <script type="module" crossorigin src="/assets/index-CBXzOTM0.js"></script>
    <link rel="modulepreload" crossorigin href="/assets/react-vendor-BOqul0sQ.js">
    <link rel="modulepreload" crossorigin href="/assets/supabase-DPvvkuYv.js">
    <link rel="modulepreload" crossorigin href="/assets/radix-core-CL5Rfs_B.js">
    <link rel="modulepreload" crossorigin href="/assets/tanstack-query-Of-PVrTu.js">
    <link rel="modulepreload" crossorigin href="/assets/framer-motion-Dtp5K6-h.js">
    <link rel="modulepreload" crossorigin href="/assets/radix-forms-DK3bIvII.js">
    <link rel="preload" as="style" crossorigin href="/assets/index-Pm8_JtDJ.css" onload="this.onload=null;this.rel='stylesheet'" /><noscript><link rel="stylesheet" crossorigin href="/assets/index-Pm8_JtDJ.css" /></noscript>
  <link rel="manifest" href="/manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script></head>

  <body style="background-color: #0a0a0a; margin: 0;">
    <!-- Google Tag Manager (noscript) -->
    <noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-M6RH386D"
    height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
    <!-- End Google Tag Manager (noscript) -->
    <div id="root">
      <!-- Static H1 for SEO crawlers (replaced once React hydrates the route).
           Visually hidden but readable by bots & screen readers. -->
      <h1 id="seo-h1" style="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;">
        Pull Up Mag — Webzine Musical : Reggae, Urban, Electro, Rock & World
      </h1>
      <!-- Skeleton shell — renders immediately before React hydrates -->
      <div id="initial-loader" style="
        position: fixed;
        inset: 0;
        background-color: #0a0a0a;
        z-index: 9999;
        overflow: hidden;
      ">
        <!-- Header skeleton -->
        <div style="height: 80px; border-bottom: 1px solid rgba(255,255,255,0.08); display: flex; align-items: center; padding: 0 16px;">
          <img src="/logo_pullupmag.png" alt="" width="140" height="40" decoding="async" fetchpriority="high" style="height: 40px; width: auto; opacity: 0.8;" />
          <div style="flex: 1;"></div>
          <div style="width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.06);"></div>
        </div>
        <!-- Content skeleton -->
        <div style="max-width: 1200px; margin: 0 auto; padding: 32px 16px;">
          <div style="display: grid; grid-template-columns: 1fr; gap: 24px;">
            <div style="aspect-ratio: 16/9; border-radius: 16px; background: rgba(255,255,255,0.04);"></div>
            <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 16px;">
              <div style="aspect-ratio: 4/3; border-radius: 12px; background: rgba(255,255,255,0.04);"></div>
              <div style="aspect-ratio: 4/3; border-radius: 12px; background: rgba(255,255,255,0.04);"></div>
              <div style="aspect-ratio: 4/3; border-radius: 12px; background: rgba(255,255,255,0.04);"></div>
              <div style="aspect-ratio: 4/3; border-radius: 12px; background: rgba(255,255,255,0.04);"></div>
            </div>
          </div>
        </div>
        <!-- Subtle shimmer animation -->
        <style>
          #initial-loader > div:first-child ~ div div {
            position: relative;
            overflow: hidden;
          }
          @keyframes shimmer {
            0% { transform: translateX(-100%); }
            100% { transform: translateX(100%); }
          }
        </style>
      </div>
    </div>
    <!-- Boot watchdog — filet de sécurité pour l'écran noir en production.
         Si le bundle JS ne charge pas (chunk introuvable après un déploiement,
         MIME type html, erreur module) ou si React n'a pas hydraté au bout
         de 12 s, on purge Service Worker + Cache Storage et on recharge
         une seule fois avec cache-bust. Guard par sessionStorage pour éviter
         toute boucle. Ignoré en dev / iframe (preview Lovable). -->
    <script>
      (function(){
        try {
          if (window.self !== window.top) return;
        } catch(e) { return; }
        var host = location.hostname;
        if (host === 'localhost' || host === '127.0.0.1' ||
            host.indexOf('lovableproject.com') !== -1 ||
            host.indexOf('lovable.app') !== -1) return;
        var FLAG = 'pum_boot_recover_done';
        var recovered = false;
        try { recovered = sessionStorage.getItem(FLAG) === '1'; } catch(e){}
        function recover(reason){
          if (recovered) return;
          try { sessionStorage.setItem(FLAG,'1'); } catch(e){}
          recovered = true;
          console.warn('[boot-watchdog] recovering:', reason);
          var done = function(){
            var u = new URL(location.href);
            u.searchParams.set('__pum_recover', Date.now().toString(36));
            location.replace(u.toString());
          };
          var tasks = [];
          try {
            if ('serviceWorker' in navigator) {
              tasks.push(navigator.serviceWorker.getRegistrations()
                .then(function(rs){ return Promise.all(rs.map(function(r){ return r.unregister().catch(function(){}); })); })
                .catch(function(){}));
            }
          } catch(e){}
          try {
            if ('caches' in window) {
              tasks.push(caches.keys().then(function(ks){
                return Promise.all(ks.map(function(k){ return caches.delete(k).catch(function(){}); }));
              }).catch(function(){}));
            }
          } catch(e){}
          Promise.all(tasks).then(done, done);
          setTimeout(done, 2500);
        }
        // 1) Script load errors (module MIME mismatch, 404, etc.)
        window.addEventListener('error', function(ev){
          var t = ev && ev.target;
          if (t && t.tagName === 'SCRIPT' && t.src && t.src.indexOf('/assets/') !== -1) {
            recover('script-error:' + t.src);
          }
        }, true);
        // 2) Watchdog — if React never mounts, the initial-loader stays in DOM.
        setTimeout(function(){
          try {
            var loader = document.getElementById('initial-loader');
            var root = document.getElementById('root');
            // React hydrate remplace/enlève #initial-loader; sa présence après
            // 12 s indique un boot cassé.
            if (loader && root && root.contains(loader)) {
              recover('watchdog-timeout');
            }
          } catch(e){}
        }, 12000);
      })();
    </script>
  <!-- Cloudflare Pages Analytics --><script defer src='https://static.cloudflareinsights.com/beacon.min.js' data-cf-beacon='{"token": "eed2f3792f3046289c11118c54a25f82"}'></script><!-- Cloudflare Pages Analytics --></body>
</html>
