PageSourceSearch

https://olud.ai/site-nav-v6.js?v=1785914784

js olud.ai collected 2026-10-05 12:22:59 UTC 552,370 bytes, 7,303 lines download raw bytes

1/* BUILD-V166-WEBMCP */
2
3/* ═══ [26/08/2026] Google Ads — AW-17973607590, avec Consent Mode v2 ═══════
4   Pose ici parce que site-nav-v6.js est charge par CHAQUE page du site :
5   une seule installation couvre les 10 000+ fiches, sans toucher un
6   generateur. Meme raisonnement que la charte CSS plus bas.
7
8   CONSENTEMENT : le tag se charge toujours, mais Google demarre en
9   « denied » — aucun cookie publicitaire n'est ecrit tant que le visiteur
10   n'a pas accepte. C'est le Consent Mode v2, la methode officielle de
11   Google et la seule conforme au RGPD pour un site francais. Le bandeau
12   existant ecrit son choix dans localStorage.osai_consent ('accepted',
13   'declined' ou 'custom') : on le relit au chargement, et setCookieChoice()
14   previent Google en direct quand le visiteur tranche.
15
16   Le texte du bandeau disait « No advertising » : c'etait vrai avant ce
17   jour, ca ne l'est plus. Il a ete corrige dans index.html — un
18   consentement obtenu sur une information fausse ne vaut rien. */
19(function () {
20  var ID = 'AW-17973607590';
21  window.dataLayer = window.dataLayer || [];
22  function gtag() { dataLayer.push(arguments); }
23  window.gtag = window.gtag || gtag;
24
25  var choix = null;
26  try { choix = localStorage.getItem('osai_consent'); } catch (e) {}
27  var ok = (choix === 'accepted');
28
29  /* Par defaut TOUT est refuse : c'est ce qui rend l'installation legale
30     avant le clic. On n'accorde que ce que le visiteur a accepte. */
31  gtag('consent', 'default', {
32    ad_storage: ok ? 'granted' : 'denied',
33    ad_user_data: ok ? 'granted' : 'denied',
34    ad_personalization: ok ? 'granted' : 'denied',
35    analytics_storage: ok ? 'granted' : 'denied',
36    wait_for_update: 500
37  });
38
39  var sc = document.createElement('script');
40  sc.async = true;
41  sc.src = 'https://www.googletagmanager.com/gtag/js?id=' + ID;
42  (document.head || document.documentElement).appendChild(sc);
43
44  gtag('js', new Date());
45  gtag('config', ID);
46
47  /* Le bandeau appelle setCookieChoice() : on s'y greffe pour prevenir
48     Google a la seconde ou le visiteur tranche, sans attendre un
49     rechargement de page. */
50  var poser = function () {
51    var origine = window.setCookieChoice;
52    if (typeof origine !== 'function' || origine.__osaiConsent) return;
53    window.setCookieChoice = function (c) {
54      try {
55        var g = (c === 'accepted') ? 'granted' : 'denied';
56        gtag('consent', 'update', {
57          ad_storage: g, ad_user_data: g, ad_personalization: g, analytics_storage: g
58        });
59      } catch (e) {}
60      return origine.apply(this, arguments);
61    };
62    window.setCookieChoice.__osaiConsent = true;
63  };
64  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', poser);
65  else poser();
66  setTimeout(poser, 1200);   /* le bandeau peut etre defini apres nous */
67})();
68/* ════════════════════════════════════════════════════════
69   olud.ai — Navigation & Footer partagés
70   Un seul fichier pour le menu + pied de page de TOUTES les pages.
71   Pour modifier le menu : éditer CE fichier uniquement.
72   
73   Usage dans chaque page :
74   1. Mettre <div id="site-nav"></div> en haut du body (à la place du <nav>)
75   2. Mettre <div id="site-footer"></div> en bas (à la place du <footer>)
76   3. Ajouter avant </body> : <script src="/site-nav.js"></script>
77   ════════════════════════════════════════════════════════ */
78(function(){
79  /* ═══ V132 — PERSISTANCE DE LANGUE ═══
80     Un visiteur qui a choisi le français retombait en anglais dès qu'il
81     revenait sur une URL sans préfixe. Désormais : le choix est mémorisé
82     (localStorage), et à l'arrivée sur une page anglaise on redirige vers la
83     version localisée — MAIS uniquement si elle existe vraiment (signal fiable :
84     la balise <link rel="alternate" hreflang="xx"> que seules les pages
85     traduites portent — jamais vers une fiche projet inexistante en /fr/). */
86  (function(){
87    try {
88      var onPrefix = /^\/(fr|it|es|de|pt)(\/|$)/.test(location.pathname);
89      if (onPrefix) return;                                  // déjà localisé : rien à faire
90      var pref = localStorage.getItem('osai_lang');
91      if (!pref || pref === 'en') return;                    // pas de préférence, ou anglais choisi
92      if (sessionStorage.getItem('osai_lang_stay') === '1') return;  // « rester en anglais » pour cette session
93      var alt = document.querySelector('link[rel="alternate"][hreflang="' + pref + '"]');
94      if (!alt) return;                                      // pas de version traduite de CETTE page
95      var href = alt.getAttribute('href') || '';
96      if (!href) return;
97      var target = href.replace(/^https?:\/\/[^\/]+/, '');
98      if (target && target !== location.pathname) {
99        location.replace(target + location.search + location.hash);
100      }
101    } catch(e){}
102  })();
103
104  // ═══ Google Analytics 4 ═══
105  // N'installe GA que si AUCUN analytics n'est déjà présent sur la page
106  // (évite tout double comptage avec un snippet gtag existant, GTM ou Cloudflare Zaraz).
107  // Garantit que TOUTES les pages (y compris les nouvelles) sont suivies.
108  // Expose window.osaiTrack(nom, params) pour les événements custom.
109  (function(){
110    var GA_ID = 'G-WBRQRVBJ07';
111    /* ── [31/08/2026] Le garde-fou se retournait contre lui-meme ─────────────
112       La condition etait « n'installer GA que si aucun analytics n'est deja
113       la », testee sur window.gtag et window.dataLayer. Elle protegeait d'un
114       double comptage. Mais depuis l'ajout de Google Ads plus haut dans ce
115       fichier, gtag et dataLayer EXISTENT toujours quand on arrive ici : la
116       condition etait donc systematiquement fausse, et GA4 n'etait plus
117       configure nulle part — sauf sur l'accueil, seule page a porter encore
118       son propre extrait en dur.
119
120       Constat de Romain le 31/08 : 283 sessions sur Clarity, 41 sur GA. Les
121       10 000 fiches, les outils, les alternatives et les classements
122       n'etaient plus mesures du tout.
123
124       On teste desormais ce qu'il fallait tester depuis le debut : est-ce que
125       CET identifiant-la est deja configure ? Un meme gtag.js sert plusieurs
126       identifiants — c'est prevu par Google — donc quand la balise Ads a deja
127       charge le script, il suffit d'ajouter la configuration GA4. */
128    var dejaConfigure = false;
129    try {
130      var dl = window.dataLayer || [];
131      for (var i = 0; i < dl.length; i++) {
132        if (dl[i] && dl[i][0] === 'config' && dl[i][1] === GA_ID) { dejaConfigure = true; break; }
133      }
134    } catch(e){}
135
136    if (!dejaConfigure && !window.zaraz) {
137      window.dataLayer = window.dataLayer || [];
138      if (typeof window.gtag !== 'function') {
139        window.gtag = function(){ window.dataLayer.push(arguments); };
140      }
141      /* Charger gtag.js seulement s'il ne l'est pas deja pour un autre ID. */
142      var dejaCharge = false;
143      var sc = document.querySelectorAll('script[src*="googletagmanager.com/gtag/js"]');
144      if (sc.length) dejaCharge = true;
145      if (!dejaCharge) {
146        var gs = document.createElement('script');
147        gs.async = true;
148        gs.src = 'https://www.googletagmanager.com/gtag/js?id=' + GA_ID;
149        document.head.appendChild(gs);
150        window.gtag('js', new Date());
151      }
152      window.gtag('config', GA_ID);
153    }
154    window.osaiTrack = function(name, params){
155      params = params || {};
156      try {
157        if (typeof window.gtag === 'function') { window.gtag('event', name, params); }
158        else if (window.dataLayer && window.dataLayer.push) {
159          var o = { event: name };
160          for (var k in params) { if (Object.prototype.hasOwnProperty.call(params, k)) o[k] = params[k]; }
161          window.dataLayer.push(o);
162        } else if (window.zaraz && window.zaraz.track) { window.zaraz.track(name, params); }
163      } catch(e){}
164    };
165  })();
166
167  // ═══ Microsoft Clarity (cartes de chaleur + rejeu de sessions) ═══
168  // Chargé une seule fois, pour TOUTES les pages, à côté de GA. Idempotent :
169  // ne réinstalle pas Clarity s'il est déjà présent sur la page.
170  (function(){
171    if (window.clarity) return;
172    (function(c,l,a,r,i,t,y){
173      c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
174      t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
175      y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
176    })(window, document, "clarity", "script", "xr0c6wbj7q");
177  })();
178
179  // ═══ i18n du nav/footer (FR/IT/ES/DE/PT) ═══
180  // Détecte la langue depuis le préfixe d'URL (/fr/, /it/, /es/, /de/, /pt/).
181  // localizeNav() traduit les libellés APRÈS injection. Gardé : sur les pages
182  // anglaises, on ne touche à RIEN (zéro risque pour le trafic actuel).
183  var NAV_LANG = (function(){
184    var m = location.pathname.match(/^\/(fr|it|es|de|pt)(\/|$)/);
185    return m ? m[1] : 'en';
186  })();
187  var LP = (NAV_LANG === 'en') ? '' : '/' + NAV_LANG;   // préfixe langue ('' ou '/fr') — défini tôt car utilisé par le localisateur de liens
188  try {
189    if (!document.querySelector('link[rel="icon"]')) {
190      var fav = document.createElement('link'); fav.rel = 'icon'; fav.type = 'image/svg+xml';
191      fav.href = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI2NCIgaGVpZ2h0PSI2NCIgdmlld0JveD0iMCAwIDY0IDY0Ij48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImZnIiB4MT0iMCIgeTE9IjEiIHgyPSIxIiB5Mj0iMCI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM0I1N0ZGIi8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjQTI1N0ZGIi8+PC9saW5lYXJHcmFkaWVudD48bWFzayBpZD0iZm4iIG1hc2tVbml0cz0idXNlclNwYWNlT25Vc2UiIHg9IjAiIHk9IjAiIHdpZHRoPSI2NCIgaGVpZ2h0PSI2NCI+PHJlY3Qgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0IiBmaWxsPSIjZmZmIi8+PGNpcmNsZSBjeD0iNTEuMDkiIGN5PSIxNi4zNSIgcj0iOC4yNyIgZmlsbD0iIzAw
191MCIvPjwvbWFzaz48L2RlZnM+PHJlY3Qgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0IiByeD0iMTQiIGZpbGw9IiMwQjBFMUEiLz48ZyBtYXNrPSJ1cmwoI2ZuKSI+PGNpcmNsZSBjeD0iMzIiIGN5PSIzMiIgcj0iMjAuNjAiIGZpbGw9Im5vbmUiIHN0cm9rZT0idXJsKCNmZykiIHN0cm9rZS13aWR0aD0iMTAuODEiLz48L2c+PGNpcmNsZSBjeD0iNTEuMDkiIGN5PSIxNi4zNSIgcj0iNS41NyIgZmlsbD0idXJsKCNmZykiLz48L3N2Zz4=';
192      document.head.appendChild(fav);
193    }
194    if (!document.querySelector('link[href*="family=Poppins"][href*="display=swap"]')) {
195      var fl = document.createElement('link'); fl.rel = 'stylesheet';
196      fl.href = 'https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700;800&family=DM+Mono:wght@400;500&display=swap';
197      document.head.appendChild(fl);
198    }
199  } catch(e){}
200  try { /* ═══ V130 — la couche sociale des fiches MODÈLES : cœur (favoris) + partage.
201   Les fiches /model/ sont générées sans ces boutons ; le nav les leur offre. ═══ */
202(function(){
203  var mpath = location.pathname.replace(/^\/(fr|it|es|de|pt)(?=\/)/, '');
204  if (!/^\/model\/[^\/]+\.html$/.test(mpath)) return;
205  /* le cœur a besoin de favorites.js — certaines fiches modèles ne l'embarquent pas */
206  if (typeof window.favBtnHtml !== 'function' && !document.querySelector('script[src*="favorites.js"]')) {
207    var fs2 = document.createElement('script'); fs2.src = '/favorites.js'; document.head.appendChild(fs2);
208  }
209  var bootTries = 0;
210  function boot(){
211    var h1 = document.querySelector('main h1') || document.querySelector('h1')
212          || document.querySelector('.hero h1, .hero-main h1, header h1');
213    if (document.getElementById('osaiModelActions')) return;
214    if (!h1) { if (++bootTries < 4) setTimeout(boot, 700); return; }
215    var name = h1.textContent.trim();
216    var url = 'https://olud.ai' + mpath;
217    var row = document.createElement('div');
218    row.id = 'osaiModelActions';
219    row.style.cssText = 'display:flex;align-items:center;gap:10px;margin:12px 0 4px';
220    var share = document.createElement('button');
221    share.type = 'button';
222    share.style.cssText = 'display:inline-flex;align-items:center;gap:7px;height:40px;padding:0 15px;border-radius:11px;'
223      + 'border:1px solid var(--border2,rgba(0,0,0,.15));background:var(--surface2,transparent);color:var(--txt2,#444);'
224      + 'font:inherit;font-size:.82rem;font-weight:600;cursor:pointer';
225    share.innerHTML = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="m8.6 13.5 6.8 4M15.4 6.5l-6.8 4"/></svg><span>' + (typeof window.osaiT === 'function' ? window.osaiT('Share') : 'Share') + '</span>';
226    share.addEventListener('click', function(e){
227      if (typeof window.shareModel === 'function') window.shareModel(e, name, url);
228    });
229    row.appendChild(share);
230    function addHeart(tries){
231      if (typeof window.favBtnHtml === 'function') {
232        var wrap = document.createElement('span');
233        wrap.innerHTML = window.favBtnHtml(name, 'AI model', 'model', url);
234        row.appendChild(wrap);
235        if (typeof window.favPaintAll === 'function') try { window.favPaintAll(); } catch(e){}
236      } else if (tries > 0) setTimeout(function(){ addHeart(tries - 1); }, 600);
237    }
238    addHeart(4);
239    h1.insertAdjacentElement('afterend', row);
240  }
241  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function(){ setTimeout(boot, 300); });
242  else setTimeout(boot, 300);
243  setTimeout(boot, 1600);                              /* filet : contenus tardifs */
244})();
245
246
247  /* ═══ V135 — ASSISTANT DE SITE (sans IA, sans coût) ═══
248     Une bulle d'aide qui répond à partir d'une base Q/R + recherche par
249     mots-clés. Aucune requête réseau, aucune clé, aucun dérapage possible.
250     Bilingue EN/FR (EN par défaut pour les autres langues). ═══ */
251  try {
252  (function(){
253    if (document.getElementById('osaiHelpBtn')) return;
254    var LG = (typeof NAV_LANG !== 'undefined' ? NAV_LANG : 'en');
255    var docL = (document.documentElement.lang || '').slice(0,2);
256    var L  = (LG === 'fr' || docL === 'fr') ? 'fr' : 'en';
257    /* [01/08] tt() ne connaissait que deux langues : sur les pages allemande,
258       espagnole, italienne et portugaise, TOUT l'assistant restait en anglais
259       - titre, salutation, suggestions, note - alors que le modele, lui,
260       repond desormais dans la langue de la question. Une meme conversation
261       melangeait donc deux langues. On passe par le dictionnaire du site pour
262       les cinq langues ; le francais garde sa formulation ecrite a la main. */
263    function tt(en, fr){
264      if (L === 'fr') return fr;
265      if (typeof window.osaiT === 'function') { var t = window.osaiT(en); if (t && t !== en) return t; }
266      return en;
267    }
268
269    /* ── base de connaissances : mots-clés déclencheurs → réponse ── */
270    var KB = [
271      { k:['best model','which model','recommend model','model for images','model for pictures','picture','pictures','image generation','generate image','generate pictures','create image','create picture','best image','video model','generate video','best video','voice model','speech model','text to speech','best for images','meilleur modele','quel modele','modele pour image','generer image','generer video','creer image','meilleure image'],
272        a:{en:'For the best model at a given task, check our human-preference leaderboards. For images, video and speech, the Media Leaderboard ranks models by blind human votes (open-source and proprietary side by side). For text/coding, see the benchmarks leaderboard.',
273           fr:"Pour le meilleur modèle sur une tâche donnée, consultez nos classements par préférence humaine. Pour l'image, la vidéo et la voix, le Media Leaderboard classe les modèles par votes humains à l'aveugle (open-source et propriétaire côte à côte). Pour le texte/code, voyez le classement des benchmarks."},
274        links:[{l:tt('Media leaderboard','Classement média'),u:'/media-leaderboard.html'},{l:tt('Benchmarks','Benchmarks'),u:'/leaderboard.html'}] },
275      { id:'about', k:['opensourceai','about the site','what is this site','presentation','sert le site','a quoi sert'],
276        a:{en:'olud.ai is the AI hub: thousands of open-source projects tracked for health and momentum, model benchmarks, live pricing for open and commercial models, and human-preference leaderboards — all in six languages.',
277           fr:"olud.ai est le hub de l'IA : des milliers de projets open source suivis pour leur santé et leur dynamique, des benchmarks de modèles, les prix en direct des modèles ouverts et commerciaux, et des classements par préférence humaine — le tout en six langues."},
278        links:[{l:'Explore projects',u:'/projects.html'},{l:'Live terminal',u:'/dashboard.html'}] },
279      { id:'plans', k:['plan','plans','price','pricing','cost','subscribe','what are the plans','quels sont les plans','les plans','the plans','tarif','tarifs','prix','abonnement','combien'],
280        a:{en:'olud.ai is currently free to use — paid plans are on pause while we focus on the product. If the platform is useful to you, you can support its development with a voluntary contribution.',
281           fr:"olud.ai est actuellement gratuit — les offres payantes sont en pause, cap sur le produit. Si la plateforme vous est utile, vous pouvez soutenir son développement par une contribution volontaire."},
282        links:[{l:tt('Support olud.ai','Soutenir olud.ai'),u:'/support.html'}] },
283      { k:['dashboard','widget','my dashboard','tableau','bord','personnalis'],
284        a:{en:'My Dashboard lets you compose your own view of the ecosystem: pick from 14 widgets (watchlist health, emerging, price drops, benchmarks, industry projects…), arrange them, and your layout follows your account everywhere.',
285           fr:"My Dashboard vous laisse composer votre propre vue de l'écosystème : choisissez parmi 14 widgets (santé de la watchlist, émergents, baisses de prix, benchmarks, projets par industrie…), organisez-les, et votre disposition vous suit partout."},
286        links:[{l:'My Dashboard',u:'/my-dashboard.html'}] },
287      { id:'follow', k:['follow','watch','watchlist','alert','alerts','follow a project','suivre un projet','suivre','suivi','alerte','alertes','notification'],
288        a:{en:'Open any project page and click Follow to add it to your watchlist. Then the bell icon turns on email alerts for health changes, new releases and star spikes. You can follow up to 10 projects.',
289           fr:"Ouvrez une fiche projet et cliquez sur Suivre pour l'ajouter à votre watchlist. La cloche active ensuite les alertes email (changements de santé, nouvelles versions, pics d'étoiles). Vous pouvez suivre jusqu'à 10 projets."},
290        links:[{l:tt('Projects','Projets'),u:'/projects.html'}] },
291      { k:['health','score','sante','santé','thriving','risk'],
292        a:{en:'Every tracked project gets a daily health score (0–100) from four signals: activity, momentum, community and maintenance. Green means thriving, amber means slowing, red means at risk.',
293           fr:"Chaque projet suivi reçoit un score de santé quotidien (0–100) basé sur quatre signaux : activité, dynamique, communauté et maintenance. Vert = prospère, ambre = ralentit, rouge = à risque."},
294        links:[{l:tt('Health monitor','Moniteur de santé'),u:'/health.html'}] },
295      { k:['emerging','new project','trending','discover','emergent','émergent','nouveau','decouverte','découverte'],
296        a:{en:'The Emerging page surfaces projects our engine just detected gaining traction — before they hit the mainstream. It refreshes with the morning scan.',
297           fr:"La page Émergents fait remonter les projets que notre moteur vient de détecter en train de décoller — avant qu'ils ne deviennent connus. Elle se rafraîchit avec le scan du matin."},
298        links:[{l:tt('Emerging','Émergents'),u:'/emerging.html'}] },
299      { k:['stack','builder','tools','stack builder','outil','construire'],
300        a:{en:'The Stack Builder asks what you want to do (chat, RAG, image generation…) and assembles a compatible open-source stack, with a docker-compose file and compatibility scoring.',
301           fr:"Le Stack Builder vous demande ce que vous voulez faire (chat, RAG, génération d'images…) et assemble une stack open-source compatible, avec un f
301ichier docker-compose et un score de compatibilité."},
302        links:[{l:'Stack Builder',u:'/stack-builder.html'}] },
303      { k:['benchmark','benchmarks','intelligence index','coding benchmark','best coding model','smartest model','meilleur modele intelligence'],
304        a:{en:'We track model benchmarks (intelligence, coding, math) from Artificial Analysis, plus a media leaderboard with human-preference ELO rankings for image, video and speech models.',
305           fr:"Nous suivons les benchmarks de modèles (intelligence, code, maths) via Artificial Analysis, plus un classement média avec des scores ELO par préférence humaine pour l'image, la vidéo et la voix."},
306        links:[{l:tt('Leaderboard','Classement'),u:'/leaderboard.html'},{l:'Media ELO',u:'/media-leaderboard.html'}] },
307      { k:['price','pricing','model price','cost model','token','prix modele','prix modèle','openrouter'],
308        a:{en:'The pricing page shows live model prices per 1M tokens (input and output), and the terminal highlights the biggest price drops and rises each day.',
309           fr:"La page pricing montre les prix des modèles en direct par 1M de tokens (entrée et sortie), et le terminal met en avant les plus grosses baisses et hausses de prix chaque jour."},
310        links:[{l:'Pricing',u:'/pricing.html'},{l:tt('Terminal','Terminal'),u:'/dashboard.html'}] },
311      { k:['api','developer','key','endpoint','integrate','integration','développeur','clé','cle'],
312        a:{en:'The public Developer API is paused for now: olud.ai focuses on the free platform. Keys already issued keep working as before.',
313           fr:"L'API développeur publique est en pause pour le moment : olud.ai se concentre sur la plateforme gratuite. Les clés déjà émises continuent de fonctionner."},
314        links:[{l:tt('Contact us','Nous contacter'),u:'/contact.html'}] },
315      { k:['claim','maintainer','my project','owner','revendiquer','mainteneur','proprietaire','propriétaire'],
316        a:{en:'If you maintain a project we list, open its page and click "Claim this project". You prove ownership via GitHub, then you appear as the verified maintainer.',
317           fr:'Si vous maintenez un projet que nous listons, ouvrez sa fiche et cliquez sur « Réclamer ce projet ». Vous prouvez la propriété via GitHub, puis vous apparaissez comme mainteneur vérifié.'},
318        links:[] },
319      { id:'signup', k:['create account','create an account','make an account','open an account','get started','join the site','sign up','signup','register','how do i sign','creer un compte','créer un compte','inscription','inscrire','nouveau compte'],
320        a:{en:'Creating an account is free, in three steps: 1) open the Account page (avatar, top right); 2) enter your email — or continue with Google/GitHub; 3) click the confirmation link we email you. You are in: dashboard, watchlist, collections and follows are unlocked.',
321           fr:"Créer un compte est gratuit, en trois étapes : 1) ouvrez la page Compte (avatar, en haut à droite) ; 2) entrez votre email — ou continuez avec Google/GitHub ; 3) cliquez le lien de confirmation reçu par email. C'est fait : dashboard, watchlist, collections et suivi sont débloqués."},
322        links:[{l:tt('Create your account','Créer votre compte'),u:'/account.html'}] },
323      { k:['sign in','log in','login','connexion','connecter','me connecter'],
324        a:{en:'Click the avatar top-right → enter your email (or Google/GitHub). No password to remember: we email you a sign-in link.',
325           fr:"Cliquez sur l'avatar en haut à droite → entrez votre email (ou Google/GitHub). Pas de mot de passe à retenir : nous vous envoyons un lien de connexion par email."},
326        links:[{l:tt('Sign in','Se connecter'),u:'/account.html'}] },
327      { k:['account settings','configure account','account page','settings','your account section','parametrer','paramétrer','parametres','paramètres','regler mon compte','régler','section compte'],
328        a:{en:'The Account page groups everything: your public profile (handle, bio, links, public/private toggle), the alerts you receive, models you follow (with prices), your watchlist, favorites, stacks and collections, and Your data (export or delete). Each section saves in place.',
329           fr:"La page Compte regroupe tout : votre profil public (handle, bio, liens, visible/privé), les alertes que vous recevez, les modèles suivis (avec leurs prix), votre watchlist, favoris, stacks et collections, et Vos données (export ou suppression). Chaque section s'enregistre sur place."},
330        links:[{l:tt('Open your account','Ouvrir votre compte'),u:'/account.html'}] },
331      { k:['public profile','profile','handle','bio','my page','profil public','profil','ma page','pseudo'],
332        a:{en:'In Account → "Your public profile": pick a handle, add a bio and links, and choose Public (listed in the members directory, your stacks and collections visible) or Private (only you). Your public page lives at /u/your-handle.',
333           fr:"Dans Compte → « Votre profil public » : choisissez un handle, ajoutez une bi
333o et des liens, puis Public (visible dans l'annuaire des membres, vos stacks et collections affichées) ou Privé (vous seul). Votre page publique vit sur /u/votre-handle."},
334        links:[{l:tt('Set up your profile','Configurer votre profil'),u:'/account.html'}] },
335      { k:['alert settings','configure alerts','email alerts settings','mute','threshold','regler alertes','régler les alertes','parametrer alertes','seuil','couper les alertes'],
336        a:{en:'In Account → "Alerts you receive" you control the global switches (new members activity, price moves, new projects). Per-project alerts are set with the bell on each project page or in My Dashboard: health changes, releases, and a star-spike threshold you choose.',
337           fr:"Dans Compte → « Vos alertes », vous contrôlez les interrupteurs globaux (activité des membres suivis, mouvements de prix, nouveaux projets). Les alertes par projet se règlent avec la cloche sur chaque fiche ou dans My Dashboard : santé, releases, et un seuil de pic d'étoiles à votre choix."},
338        links:[{l:tt('Alert settings','Régler les alertes'),u:'/account.html'}] },
339      { k:['api key','my key','webhook','webhooks','developer section','cle api','clé api','ma cle'],
340        a:{en:'API keys and webhooks already issued keep working, but this area is closed to new sign-ups for now. If your key misbehaves, write to us.',
341           fr:"Les clés API et webhooks déjà émis continuent de fonctionner, mais cet espace est fermé aux nouvelles inscriptions pour le moment. Un souci avec votre clé ? Écrivez-nous."},
342        links:[{l:tt('Contact us','Nous contacter'),u:'/contact.html'}] },
343      { k:['cancel','unsubscribe','billing','invoice','manage subscription','upgrade','downgrade','annuler','resilier','résilier','facture','abonnement gerer','gérer abonnement','changer de plan'],
344        a:{en:'There is nothing to upgrade right now: olud.ai is free and paid plans are on pause. Existing subscribers keep their access and can manage billing from Account → the secure Stripe portal.',
345           fr:"Rien à débloquer pour l'instant : olud.ai est gratuit, les offres payantes sont en pause. Les abonnés existants gardent leur accès et gèrent leur facturation depuis Compte → portail Stripe sécurisé."},
346        links:[{l:tt('Your account','Votre compte'),u:'/account.html'},{l:tt('Support olud.ai','Soutenir olud.ai'),u:'/support.html'}] },
347      { k:['delete account','export data','my data','privacy','gdpr','supprimer compte','supprimer mon compte','exporter','mes donnees','mes données','rgpd'],
348        a:{en:'In Account → "Your data": export everything you own (watchlist, collections, profile) or delete your account permanently. Deletion is immediate and irreversible.',
349           fr:"Dans Compte → « Vos données » : exportez tout ce qui vous appartient (watchlist, collections, profil) ou supprimez définitivement votre compte. La suppression est immédiate et irréversible."},
350        links:[{l:tt('Your data','Vos données'),u:'/account.html'}] },
351      { k:['language','translate','french','english','langue','traduction','francais','français'],
352        a:{en:'The site is available in English, French, German, Spanish, Italian and Portuguese. Use the language switcher in the top bar — your choice is remembered for next time.',
353           fr:"Le site existe en anglais, français, allemand, espagnol, italien et portugais. Utilisez le sélecteur de langue dans la barre du haut — votre choix est mémorisé pour la prochaine fois."},
354        links:[] },
355      { k:['report','weekly','portfolio','rapport','hebdo','hebdomadaire'],
356        a:{en:'The weekly portfolio report was part of the paid plans, which are currently on pause. You can still follow projects from your account and get alerts.',
357           fr:"Le rapport de portefeuille hebdomadaire faisait partie des offres payantes, actuellement en pause. Vous pouvez toujours suivre des projets depuis votre compte et recevoir des alertes."},
358        links:[{l:tt('Support olud.ai','Soutenir olud.ai'),u:'/support.html'}] },
359      { k:['contact','help','support','email','human','aide','contacter'],
360        a:{en:'For anything I cannot answer, email [email protected] and a human will get back to you.',
361           fr:"Pour tout ce à quoi je ne peux pas répondre, écrivez à [email protected] et un humain vous répondra."},
362        links:[] }
363    ];
364
365    var SUGGEST = [
366      { t: tt('What is olud.ai?','C\u2019est quoi olud.ai ?'),   id:'about' },
367      { t: tt('How do I create an account?','Comment créer un compte ?'),  id:'signup' },
368      { t: tt('What are the plans?','Quels sont les plans ?'),             id:'plans' },
369      { t: tt('How do I follow a project?','Comment suivre un projet ?'),  id:'follow' }
370    ];
371    function kbById(id){ for (var i=0;i<KB.length;i++){ if (KB[i].id === id) return KB[i]; } return null; }
372
373    function norm(s){ return (' ' + String(s).toLowerCase() + ' ').replace(/[^a-z0-9\u00e0-\u00ff]+/g,' '); }
374    function answer(q){
375      var nq = norm(q), best = null, bestScore = 0;
376      for (var i=0;i<KB.length;i++){
377        var sc = 0;
378        for (var j=0;j<KB[i].k.length;j++){
379          var kw = norm(KB[i].k[j]).trim();
380          if (!kw) continue;
381          if (nq.indexOf(' ' + kw + ' ') >= 0){
382            /* un mot-clé spécifique (multi-mots ou long) pèse plus qu'un mot court */
383            sc += (kw.indexOf(' ') >= 0 ? 3 : (kw.length >= 6 ? 2 : 1));
384          }
385        }
386        if (sc > bestScore){ bestScore = sc; best = KB[i]; }
387      }
388      /* seuil : au moins un signal réel, sinon on ne devine pas */
389      if (!best || bestScore < 2) return null;
390      return best;
391    }
392
393    /* ─── Empilement en bas à droite ───
394       Trois éléments visent le même coin : la bulle d'aide, le panneau #osaiCta,
395       et la barre de cookies — qui vit dans le HTML de chaque page, hors de ce
396       script. Au premier passage d'un visiteur, la bulle recouvrait le bouton
397       « Accept all », dans la même couleur qui plus est.
398       On mesure donc ce qui occupe le bas de l'écran et on remonte la bulle
399       au-dessus. Aucune connaissance du markup de la bannière n'est requise :
400       on repère tout élément fixé en bas, visible et assez large. */
401    function osaiStackBottom(){
402      try{
403        var vh = window.innerHeight || 0, vw = window.innerWidth || 0;
404        var fab = document.getElementById('osaiHelpBtn');
405        var fabW = fab ? fab.offsetWidth || 52 : 52;
406        /* [02/08/2026, Romain] Les deux pastilles vivent desormais dans les deux
407           coins du bas — assistant a GAUCHE, « remonter » a DROITE — et a la
408           MEME hauteur. La zone surveillee couvre donc les deux coins : ce qui
409           gene l'une gene l'autre, puisqu'elles partagent leur ligne. */
410        var zoneL = 12, zoneR = vw - 12;
411        var need = 20;
412
413        /* On balaie deux niveaux : la plupart des calques fixes sont enfants
414           directs de <body>, mais certains sont enveloppés une fois. Aller
415           plus profond coûterait un getComputedStyle sur toute la page. */
416        var nodes = [];
417        var kids = document.body ? document.body.children : [];
418        for (var a = 0; a < kids.length; a++) {
419          nodes.push(kids[a]);
420          var sub = kids[a].children;
421          for (var b = 0; b < sub.length && b < 12; b++) nodes.push(sub[b]);
422        }
423
424        for (var i = 0; i < nodes.length; i++) {
425          var el = nodes[i];
426          if (!el.tagName) continue;
427          if (el.id === 'osaiHelpBtn' || el.id === 'osaiHelpPanel') continue;
428          if (fab && (el === fab || el.contains(fab))) continue;
429          /* [02/08/2026, Romain : « ils se baladent sur l'ecran quand on scroll »]
430             LE BOUTON « REMONTER » N'EST PAS UN OBSTACLE, C'EST UN PAIR.
431             Il partage la meme ligne et la meme variable --osai-fab-bottom que
432             l'assistant. En le laissant dans le balayage, chacun mesurait
433             l'autre et le repoussait : l'assistant monte, donc le bouton monte,
434             donc l'assistant monte encore. La boucle ne s'arretait qu'a la
435             butee « jamais plus haut que la moitie de l'ecran » — d'ou les deux
436             pastilles plantees au milieu du telephone.
437             Sur mobile la barre d'URL qui se replie declenche un resize a
438             chaque defilement, ce qui relançait la boucle : d'ou l'impression
439             qu'elles se baladent. */
440          if (el.matches && el.matches('.back-to-top, #backToTop')) continue;
441          if (el.querySelector && el.querySelector('.back-to-top, #backToTop')) continue;
442          var cs = window.getComputedStyle(el);
443          if (cs.position !== 'fixed' || cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') continue;
444          var r = el.getBoundingClientRect();
445          if (!r.height || !r.width) continue;
446
447          /* Deux cas seulement nous concernent :
448               · une BANNIÈRE large occupant le bas de l'écran ;
449               · un élément qui chevauche la COLONNE de la bulle.
450             ⚠ L'erreur précédente : j'exigeais que l'élément touche le bas de
451             l'écran (r.bottom >= vh - 4). Un bouton posé à bottom:20px a son
452             bord 20 px plus haut — il était donc toujours ignoré. On accepte
453             maintenant tout ce qui se trouve dans le tiers inférieur. */
454          var lower = r.bottom > vh * 0.6;
455          if (!lower) continue;
456          var wideBanner = r.width >= vw * 0.5;
457          var overlapsFab = (r.right > zoneL) && (r.left < zoneR);
458          if (!wideBanner && !overlapsFab) continue;
459
460          need = Math.max(need, (vh - r.top) + 14);
461        }
462        /* Garde-fou : jamais plus haut que la moitié de l'écran. Une mesure
463           aberrante ne doit pas envoyer la bulle au milieu de la page. */
464        if (need > vh * 0.5) need = 20;
465        document.documentElement.style.setProperty('--osai-fab-bottom', Math.round(need) + 'px');
466      }catch(e){}
467    }
468
469    /* Les bannières de cookies apparaissent souvent avec un délai : on repasse. */
470    [0, 400, 1200, 3000].forEach(function(d){ setTimeout(osaiStackBottom, d); });
471    /* Le bandeau cookies deplace la ligne commune : « remonter » doit suivre. */
472    [500, 1300, 3100].forEach(function(d){ setTimeout(function(){
473      try { if (typeof placerAssistant === 'function') placerAssistant(); } catch(e){}
474    }, d); });
475    window.addEventListener('resize', osaiStackBottom);
476    if (window.MutationObserver && document.body) {
477      new MutationObserver(osaiStackBottom).observe(document.body, { childList: true });
478    }
479
480    /* ── CSS ── */
481    var css = document.createElement('style');
482    css.textContent =
483      /* [01/08, Romain] L'assistant partageait le coin bas-droit avec le
484         bouton « remonter en haut » : deux pastilles empilees dans le meme
485         angle, l'une masquant l'autre au defilement. Il passe a GAUCHE —
486         le coin est libre, et la separation rend les deux gestes lisibles. */
487      '#osaiHelpBtn{position:fixed;bottom:var(--osai-fab-bottom,20px);left:20px;width:52px;height:52px;border-radius:50%;border:none;cursor:pointer;transition:bottom .25s ease;'
488      + 'background:var(--surface,#141a29);color:var(--accent,#6366f1);border:1px solid var(--border2,rgba(255,255,255,.18));box-shadow:0 4px 18px rgba(0,0,0,.22);z-index:99998;display:flex;align-items:center;justify-content:center;transition:transform .15s}'
489      + '#osaiHelpBtn:hover{transform:scale(1.06);border-color:rgba(99,102,241,.55)}'
490      + '#osaiHelpBtn svg{width:30px;height:30px}'
491      /* Le signal IA, sans ecrire le mot : la diode du logo respire quand on
492         approche. Rien ne bouge tant qu'on ne s'en approche pas — c'est une
493         icone presente sur toutes les pages du site. */
494      + '#osaiHelpBtn .oludbot-led{transform-origin:26.2px 5.8px;transition:opacity .2s}'
495      + '#osaiHelpBtn:hover .oludbot-led{animation:oludbotPulse 1.9s ease-in-out infinite}'
496      + '@keyframes oludbotPulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.55;transform:scale(.72)}}'
497      + '@media (prefers-reduced-motion: reduce){#osaiHelpBtn:hover .oludbot-led{animation:none}}'
498      + '#osaiHelpPanel{position:fixed;bottom:88px;left:20px;width:min(370px,calc(100vw - 40px));height:min(520px,calc(100vh - 130px));'
499      + 'height:min(520px,calc(100dvh - 130px));'
500      + 'background:var(--surface,#fff);border:1px solid var(--border2,rgba(0,0,0,.12));border-radius:18px;box-shadow:0 18px 60px rgba(0,0,0,.28);'
501      + 'z-index:99999;display:none;flex-direction:column;overflow:hidden}'
502      + '#osaiHelpPanel.open{display:flex}'
503
504      /* [01/08, Romain : « assez ringard »] Le bandeau sombre en degrade avec
505         un logo en PNG, c'est le chatbot de 2019. On passe sur la surface du
506         site, une hairline en degrade de marque pour separer, et la mascotte
507         posee dans une pastille teintee. Tout le reste du panneau suit les
508         variables du site : il change avec le theme au lieu d'imposer son
509         noir. */
510      + '.ohp-head{background:var(--surface,#fff);color:var(--txt,#111);padding:13px 14px;flex:none;'
511      + 'display:flex;align-items:center;gap:11px;position:relative}'
512      + '.ohp-head::after{content:"";position:absolute;left:0;right:0;bottom:0;height:2px;'
513      + 'background:linear-gradient(90deg,#3B57FF,#A257FF,#0ea5e9)}'
514      /* [01/08, Romain : « on est oblige de le mettre dans une carte ? »] Non.
515         La pastille teintee etait une habitude, pas une necessite : elle
516         enfermait la mascotte et lui volait 8px de chaque cote. Sans elle,
517         la tete respire et peut grandir a taille egale. */
518      + '.ohp-logo{flex:none;display:flex;align-items:center;justify-content:center}'
519      + '.ohp-logo svg{width:36px;height:36px}'
520      + '.ohp-ttl{flex:1;min-width:0}'
521      + '.ohp-x{flex:none;width:30px;height:30px;border:0;border-radius:9px;background:var(--surface2,#f1f3f8);'
522      + 'color:var(--muted,#667);font-size:19px;line-height:1;cursor:pointer;padding:0;transition:all .15s}'
523      + '.ohp-x:hover{background:var(--border2,#e3e7ef);color:var(--txt,#111)}'
524      + '.ohp-head b{font-size:.95rem;font-weight:700;letter-spacing:-.01em}'
525      + '.ohp-head span{display:block;font-size:.7rem;line-height:1.35;color:var(--muted,#667);margin-top:1px;'
526      + 'white-space:nowrap;overflow:hidden;text-overflow:ellipsis}'
527      /* Le corps est le seul element elastique : il absorbait tout le deficit
528         et tombait a 36px. Un plancher, qui se rabaisse tout seul sur un tres
529         petit ecran pour ne jamais chasser le champ de saisie. */
530      + '.ohp-body{flex:1;overflow-y:auto;padding:14px;display:flex;flex-direction:column;gap:10px;'
531      + 'background:var(--bg,#fff);min-height:min(96px,22vh)}'
532      + '.ohp-body{min-height:min(96px,22dvh)}'
533      + '.ohp-msg{max-width:85%;padding:10px 13px;border-radius:13px;font-size:.84rem;line-height:1.5}'
534      + '.ohp-bot{align-self:flex-start;background:var(--surface2,#f4f5f8);color:var(--txt,#111);'
535      + 'border:1px solid var(--border,rgba(0,0,0,.06));border-bottom-left-radius:5px}'
536      /* la bulle du visiteur porte le degrade DU LOGO, pas un indigo plat */
537      + '.ohp-user{align-self:flex-end;background:linear-gradient(135deg,#3B57FF,#A257FF);color:#fff;'
538      + 'border-bottom-right-radius:5px}'
539      + '.ohp-msg a{color:var(--accent,#6366f1);font-weight:600;text-decoration:none}.ohp-bot a{display:inline-block;margin-top:4px}'
540      + '.ohp-sugg{display:flex;flex-wrap:wrap;gap:6px;margin-top:2px}'
541      + '.ohp-sugg button{background:none;border:1px solid var(--accent,#6366f1);color:var(--accent,#6366f1);border-radius:999px;'
542      + 'padding:6px 11px;font-family:inherit;font-size:.74rem;cursor:pointer;transition:background .15s}'
543      + '.ohp-sugg button:hover{background:var(--accent,#6366f1);color:#fff}'
544      + '.ohp-foot{flex:none;display:flex;gap:8px;padding:11px;border-top:1px solid var(--border,rgba(0,0,0,.08));background:var(--surface,#fff)}'
545      + '.ohp-foot input{flex:1;border:1px solid var(--border2,rgba(0,0,0,.15));border-radius:999px;padding:9px 14px;font-family:inherit;font-size:.83rem;background:var(--bg,#fff);color:var(--txt,#111);outline:none}'
546      + '.ohp-foot input:focus{border-color:var(--accent,#6366f1)}'
547      + '.ohp-foot button{background:var(--accent,#6366f1);border:none;color:#fff;border-radius:50%;width:38px;height:38px;flex:none;cursor:pointer;font-size:1.05rem}'
548      + '.ohp-note{flex:none;padding:0 13px 9px;font-size:.62rem;line-height:1.35;color:var(--muted,#666);background:var(--surface,#fff)}'
549      /* Sous 520px de haut — bureau reduit, telephone en paysage — le panneau
550         prend la fenetre au lieu d'en reserver 130px. Sur la fenetre mesuree
551         a 374px : 192px pour les messages au lieu de 36. */
552      + '@media (max-height:520px){#osaiHelpPanel{bottom:12px;left:12px;right:auto;'
553      + 'width:min(370px,calc(100vw - 24px));height:calc(100vh - 24px);height:calc(100dvh - 24px)}'
554      + '.ohp-head{padding:9px 14px}.ohp-note{padding:0 13px 7px;font-size:.58rem}}'
555      /* Le champ garde son rayon en pastille : la regle globale de index.html
556         (input{border-radius:8px !important}) le rabotait a 8px sur les pages
557         qui la portent — l'assistant n'avait pas la meme tete selon la page.
558         Regle separee, pour ne pas deplacer la specificite du :focus. */
559      + '.ohp-foot input:not([type="range"]){border-radius:999px !important}'
560      /* Un focus qui se voit sur les DEUX fonds : l'indigo translucide seul
561         tombait a 1,3:1 sur le pied sombre. */
562      + '.ohp-foot input:focus{box-shadow:0 0 0 3px rgba(99,102,241,.30)}'
563      + 'body.dark .ohp-foot input:focus{box-shadow:0 0 0 3px rgba(165,180,252,.34)}';
564    document.head.appendChild(css);
565
566    /* ── DOM ── */
567    var btn = document.createElement('button');
568    btn.id = 'osaiHelpBtn'; btn.type = 'button';
569    btn.setAttribute('aria-label', tt('Help','Aide'));
570    /* ─── LA MASCOTTE ────────────────────────────────────────────────────
571       [01/08, Romain] « une tete de robot qui ressemble a notre logo, et une
572       icone qui fait comprendre subtilement que c'est de l'IA ».
573       Elle N'EST PAS inspiree du logo : elle EST le logo. L'anneau du "O"
574       devient le visage, en trait (2,6 sur un viewBox de 32, soit environ 2px
575       a la taille du bouton — la meme graisse que les icones du menu). Deux
576       yeux rectangulaires arrondis, un regard de machine. PAS DE BOUCHE :
577       Romain la voulait sans sourire, et un outil professionnel n'a pas a
578       faire risette. Le point detache du logo, en haut a droite, devient la
579       diode : c'est LUI qui porte le signal IA — il respire lentement, et
580       seulement au survol, jamais en permanence sur toutes les pages.
581       L'encoche ronde qui le separe de l'anneau est celle du logo, au meme
582       endroit, a l'echelle. */
583    btn.innerHTML = '<svg class="oludbot" viewBox="0 0 32 32" width="30" height="30" aria-hidden="true" focusable="false">'
584      + '<defs><linearGradient id="oludbot-bg" x1="0" y1="1" x2="1" y2="0">'
585      + '<stop offset="0" stop-color="#3B57FF"/><stop offset="1" stop-color="#A257FF"/></linearGradient>'
586      + '<mask id="oludbot-bm"><rect width="32" height="32" fill="#fff"/>'
587      + '<circle cx="26.2" cy="5.8" r="4.4" fill="#000"/></mask></defs>'
588      + '<g mask="url(#oludbot-bm)"><circle cx="14.4" cy="17.6" r="11.2" fill="none" stroke="url(#oludbot-bg)" stroke-width="2.5"/></g>'
589      + '<rect x="9.4" y="15.6" width="4.4" height="3.8" rx="1.7" fill="url(#oludbot-bg)"/>'
590      + '<rect x="17.4" y="15.6" width="4.4" height="3.8" rx="1.7" fill="url(#oludbot-bg)"/>'
591      + '<circle class="oludbot-led" cx="26.2" cy="5.8" r="2.6" fill="url(#oludbot-bg)"/></svg>';
592
593    var panel = document.createElement('div');
594    panel.id = 'osaiHelpPanel';
595    panel.innerHTML =
596        /* [30/07] L'en-tete n'avait ni logo ni bouton de fermeture : une fois
597           l'assistant ouvert, on ne pouvait plus le refermer (releve par
598           Romain). Trois colonnes : la marque, le titre, la croix. */
599        '<div class="ohp-head">'
600        + '<span class="ohp-logo">' + '<svg class="oludbot" viewBox="0 0 32 32" width="26" height="26" aria-hidden="true" focusable="false">'
601      + '<defs><linearGradient id="oludbot-hg" x1="0" y1="1" x2="1" y2="0">'
602      + '<stop offset="0" stop-color="#3B57FF"/><stop offset="1" stop-color="#A257FF"/></linearGradient>'
603      + '<mask id="oludbot-hm"><rect width="32" height="32" fill="#fff"/>'
604      + '<circle cx="26.2" cy="5.8" r="4.4" fill="#000"/></mask></defs>'
605      + '<g mask="url(#oludbot-hm)"><circle cx="14.4" cy="17.6" r="11.2" fill="none" stroke="url(#oludbot-hg)" stroke-width="2.5"/></g>'
606      + '<rect x="9.4" y="15.6" width="4.4" height="3.8" rx="1.7" fill="url(#oludbot-hg)"/>'
607      + '<rect x="17.4" y="15.6" width="4.4" height="3.8" rx="1.7" fill="url(#oludbot-hg)"/>'
608      + '<circle class="oludbot-led" cx="26.2" cy="5.8" r="2.6" fill="url(#oludbot-hg)"/></svg>
608' + '</span>'
609        + '<div class="ohp-ttl"><b>' + tt('Olud assistant','Assistant Olud') + '</b>'
610        + '<span>' + tt('\u2728 AI \u2014 ask me anything about olud.ai',
611                        '\u2728 IA \u2014 posez-moi vos questions sur olud.ai') + '</span></div>'
612        + '<button type="button" class="ohp-x" id="ohpClose" aria-label="' + tt('Close','Fermer') + '">&#215;</button>'
613        + '</div>'
614      + '<div class="ohp-body" id="ohpBody"></div>'
615      + '<div class="ohp-foot"><input type="text" id="ohpInput" maxlength="500" placeholder="' + tt('Type your question…','Écrivez votre question…') + '" autocomplete="off">'
616      + '<button type="button" id="ohpSend" aria-label="Send">&#10148;</button></div>'
617      + '<div class="ohp-note">' + tt('Questions are logged anonymously to improve the answers.','Vos questions sont enregistrées de façon anonyme pour améliorer les réponses.') + '</div>';
618
619    document.body.appendChild(btn);
620    document.body.appendChild(panel);
621
622    var body = panel.querySelector('#ohpBody');
623    var input = panel.querySelector('#ohpInput');
624
625    function esc2(s){ return String(s).replace(/[&<>"]/g, function(c){ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]; }); }
626
627    /* ─── RENDU D'UNE REPONSE DU MODELE ───────────────────────────────────
628       [01/08, Romain] « l'IA pourrait proposer les liens des pages plutot que
629       des markdown ». Le modele ecrit naturellement [ComfyUI](/tool/comfyui.html)
630       et **en gras** ; on n'en rendait que les chemins NUS, donc les crochets
631       et les etoiles s'affichaient tels quels. C'etait illisible.
632
633       La regle de securite ne bouge pas d'un pouce : le texte est ECHAPPE
634       d'abord, donc rien de ce que le modele ecrit ne peut devenir du HTML.
635       On ne re-fabrique ensuite que trois choses, et seulement a partir de
636       motifs verifies : un lien INTERNE (le chemin doit commencer par une
637       barre suivie d'une lettre ou d'un chiffre - ni « javascript: », ni
638       « // » vers un autre domaine), du gras, et les retours a la ligne. */
639    function rendreReponse(txt){
640      var s = echapper(String(txt == null ? '' : txt));
641      /* [libelle](/chemin) -> vrai lien */
642      s = s.replace(/\[([^\]\n]{1,90})\]\((\/[a-z0-9][a-z0-9._\-\/]*)\)/gi,
643        function(m, lib, ch){ return '<a href="' + ch + '">' + lib + '</a>'; });
644      /* chemin nu, hors d'un lien deja fabrique */
645      s = s.replace(/(^|[\s(])(\/[a-z][a-z0-9\-\/]*(?:\.html)?)(?![^<]*<\/a>)/g,
646        function(m, av, ch){ return av + '<a href="' + ch + '">' + ch + '</a>'; });
647      s = s.replace(/\*\*([^*\n]{1,120})\*\*/g, '<strong>$1</strong>');
648      /* une puce en debut de ligne devient une vraie puce */
649      s = s.replace(/(^|\n)[-\u2022]\s+/g, '$1\u2022\u00a0');
650      return s.replace(/\n/g, '<br>');
651    }
652    function addBot(html){ var d = document.createElement('div'); d.className = 'ohp-msg ohp-bot'; d.innerHTML = html; body.appendChild(d); body.scrollTop = body.scrollHeight; }
653    function addUser(txt){ var d = document.createElement('div'); d.className = 'ohp-msg ohp-user'; d.textContent = txt; body.appendChild(d); body.scrollTop = body.scrollHeight; }
654
655    function botAnswer(entry){
656      var html = esc2(entry.a[L] || entry.a.en);
657      if (entry.links && entry.links.length){
658        html += '<br>' + entry.links.map(function(x){ return '<a href="' + esc2(x.u) + '">' + esc2(x.l) + ' &rarr;</a>'; }).join(' &nbsp; ');
659      }
660      addBot(html);
661    }
662    function fallback(){
663      addBot(tt('I did not catch that. Try one of these, or email <a href="mailto:[email protected]">[email protected]</a>.',
664                'Je n\u2019ai pas bien compris. Essayez un de ces sujets, ou écrivez à <a href="mailto:[email protected]">[email protected]</a>.'));
665      showSuggest();
666    }
667    function showSuggest(){
668      var wrap = document.createElement('div'); wrap.className = 'ohp-sugg';
669      SUGGEST.forEach(function(s){
670        var b = document.createElement('button'); b.type = 'button'; b.textContent = s.t;
671        b.onclick = function(){
672          addUser(s.t);
673          var e = kbById(s.id);
674          report(s.t, e);
675          if (e) botAnswer(e); else fallback();   /* référence directe : ne peut pas échouer */
676        };
677        wrap.appendChild(b);
678      });
679      body.appendChild(wrap); body.scrollTop = body.scrollHeight;
680    }
681    function report(q, entry){
682      var topic = entry ? (entry.id || (entry.k && entry.k[0]) || 'topic') : 'none';
683      /* Google Analytics (événement personnalisé) */
684      try { if (window.osaiTrack) window.osaiTrack('assistant_question', { question: String(q).slice(0,90), matched: topic, lang: L }); } catch (e0) {}
685      /* Journal serveur : c'est lui qui donne la liste des questions ratées */
686      try {
687        fetch('/api/assistant-log.php', {
688          method: 'POST', headers: { 'Content-Type': 'application/json' }, keepalive: true,
689          body: JSON.stringify({ q: String(q).slice(0,200), matched: topic, lang: L, path: location.pathname })
690        }).catch(function(){});
691      } catch (e1) {}
692    }
693    /* [01/08/2026] L'assistant repondait par regles figees : il ne
694       comprenait que les formulations prevues, et « fallback » pour tout le
695       reste. Les reponses connues restent PRIORITAIRES (instantanees,
696       gratuites, exactes) ; l'IA ne prend le relais que sur ce qu'elles ne
697       couvrent pas. La cle vit sur le serveur, jamais ici. */
698    function echapper(t){ return String(t).replace(/[&<>]/g, function(c){
699      return {'&':'&amp;','<':'&lt;','>':'&gt;'}[c]; }); }
700
701    function ask(q){
702      addUser(q);
703      /* [01/08] Les reponses toutes faites passaient AVANT l'IA, et elles se
704         declenchent sur un simple mot-cle : « which model runs on 16GB »
705         contient « model », l'assistant repondait donc par le paragraphe
706         generique sur le classement. Desormais, des qu'on lui parle en
707         phrase — quatre mots ou plus, ou un point d'interrogation — la
708         question part directement au modele. Les regles gardent ce pour quoi
709         elles sont bonnes et instantanees : « submit », « badge », « claim »,
710         « rss », tapes en un ou deux mots. */
711      var mots = String(q).trim().split(/\s+/).filter(Boolean).length;
712      var phrase = (mots >= 4) || /[?\uFF1F]/.test(q);
713      var e = answer(q);
714      report(q, e);
715      /* Un ou deux mots : la reponse ecrite d'avance est bonne et instantanee.
716         Une phrase : le modele repond mieux — et la reponse ecrite d'avance
717         devient le FILET si l'appel echoue, au lieu d'un « je n'ai pas
718         compris ». On ne perd rien, on gagne les vraies questions. */
719      if (e && !phrase) { botAnswer(e); return; }
720      var attente = document.createElement('div');
721      attente.className = 'ohp-msg ohp-bot';
722      attente.textContent = tt('Thinking\u2026', 'Je cherche\u2026');
723      body.appendChild(attente); body.scrollTop = body.scrollHeight;
724      fetch('/api/ask.php', { method:'POST', headers:{'Content-Type':'application/json'},
725                              body: JSON.stringify({ q: q }) })
726        .then(function(r){ return r.json().then(function(j){ return { ok:r.ok, j:j }; }); })
727        .then(function(res){
728          attente.remove();
729          if (res.ok && res.j && res.j.answer) {
730            /* le texte est echappe : une reponse de modele n'injecte pas de
731               HTML dans la page. Seuls nos propres liens internes sont
732               rendus cliquables, et uniquement des chemins du site. */
733            addBot(rendreReponse(res.j.answer));
734          } else if (res.j && res.j.error) {
735            addBot(echapper(res.j.error));
736          } else if (e) { botAnswer(e); }
737          else { fallback(); }
738        })
739        .catch(function(){ attente.remove(); if (e) { botAnswer(e); } else { fallback(); } });
740    }
741
742    var greeted = false;
743    function greet(){
744      if (greeted) return; greeted = true;
745      addBot(tt('Hi! I\u2019m the olud.ai assistant. How can I help?','Bonjour ! Je suis l\u2019assistant olud.ai. Comment puis-je vous aider ?'));
746      showSuggest();
747    }
748
749    /* ── LE COIN OPPOSE A « REMONTER EN HAUT » ────────────────────────────
750       [01/08, Romain] « son bouton est a droite sur ordinateur comme le
751       bouton remonter en haut, pourquoi ne pas le mettre a gauche ? »
752       Une regle figee ne suffit pas : « remonter en haut » n'est pas au meme
753       endroit partout — a droite sur la plupart des pages, mais a GAUCHE sous
754       900px sur l'accueil et sur compare-tools (pour liberer le bouton de
755       menu). Une largeur ecrite en dur aurait donc recree l'empilement
756       ailleurs. On regarde ou il est REELLEMENT, et l'assistant prend l'autre
757       coin. Recalcule au redimensionnement, puisque la position change avec
758       la largeur. */
759    function placerAssistant(){
760      /* [02/08/2026, Romain] « sur mobile les deux boutons sont a gauche ; mets
761         remonter a droite, laisse l'assistant a gauche, a la meme hauteur. »
762
763         La regle « coin oppose » qui vivait ici lisait la position de
764         « remonter » et prenait l'autre coin. Elle etait juste sur le principe
765         et fausse en pratique : sous 900px, l'accueil et compare-tools posent
766         « remonter » A GAUCHE, l'assistant filait donc a droite — l'inverse de
767         ce qu'on veut, et differemment d'une page a l'autre.
768
769         On fixe donc les deux places au lieu de les deduire. L'assistant est le
770         geste qu'on propose, il occupe le coin naturel du pouce ; « remonter »
771         est un utilitaire, il va en face. Et les deux partagent la meme ligne :
772         c'est --osai-fab-bottom, qui sait deja s'ecarter du bandeau cookies. */
773      [btn, panel].forEach(function(el){
774        el.style.setProperty('left', '20px', 'important');
775        el.style.setProperty('right', 'auto', 'important');
776      });
777      var haut = document.querySelector('.back-to-top, #backToTop');
778      if (haut) {
779        haut.style.setProperty('right', '20px', 'important');
780        haut.style.setProperty('left', 'auto', 'important');
781        /* Meme ligne que l'assistant, au centrage pres : les deux pastilles
782           n'ont pas la meme taille (44 contre 52), on aligne leurs CENTRES. */
783        var hb = haut.offsetHeight || 44, fb = btn.offsetHeight || 52;
784        var base = parseFloat(getComputedStyle(document.documentElement)
785                     .getPropertyValue('--osai-fab-bottom')) || 20;
786        haut.style.setProperty('bottom', Math.round(base + (fb - hb) / 2) + 'px', 'important');
787      }
788    }
789    placerAssistant();
790    var minuteur = null;
791    window.addEventListener('resize', function(){
792      clearTimeout(minuteur); minuteur = setTimeout(placerAssistant, 150);
793    });
794
795    btn.addEventListener('click', function(){
796      panel.classList.toggle('open');
797      if (panel.classList.contains('open')){ greet(); setTimeout(function(){ input.focus(); }, 80); }
798    });
799    /* [01/08] Ouvrir l'assistant depuis ailleurs — la palette de recherche
800       propose « demander a l'assistant » quand la recherche texte ne suffit
801       pas. C'est le pont honnete entre les deux : la recherche ne devine
802       rien, l'assistant si. */
803    /* ─── REPONDRE SUR PLACE, SANS OUVRIR LE PANNEAU ──────────────────────
804       [01/08, Romain] « quand on tape dans la barre de recherche, pourquoi ca
805       nous renvoie vers Olud assistant ? y'aurait pas moyen d'avoir une bulle
806       de reponse ? ». Si. Chercher et demander sont deux gestes differents :
807       le premier ne doit pas faire changer de meuble. La reponse s'affiche
808       donc DANS la liste de resultats, et le panneau ne s'ouvre que si le
809       visiteur veut poursuivre la conversation.
810       Une seule implementation pour les deux barres du site : le meme appel,
811       le meme rendu, les memes garde-fous. */
812    window.osaiAskInto = function(question, hote){
813      if (!hote) return;
814      var q = String(question || '').trim();
815      if (!q) return;
816      var t2 = function(x){ return (typeof window.osaiT === 'function') ? window.osaiT(x) : x; };
817      hote.innerHTML = '<div class="osai-inline osai-inline-wait">' + esc2(t2('Thinking\u2026')) + '</div>';
818
819      var repli = answer(q);                 /* filet : nos reponses ecrites d'avance */
820      report(q, repli);
821
822      var poser = function(html, suite){
823        hote.innerHTML = '<div class="osai-inline">' + html
824          + (suite === false ? '' :
825             '<button type="button" class="osai-inline-more">' + esc2(t2('Continue in the assistant')) + '</button>')
826          + '</div>';
827        var b = hote.querySelector('.osai-inline-more');
828        if (b) b.addEventListener('click', function(){ window.osaiAsk(q); });
829      };
830
831      fetch('/api/ask.php', { method:'POST', headers:{'Content-Type':'application/json'},
832                              body: JSON.stringify({ q: q }) })
833        .then(function(r){ return r.json().then(function(j){ return { ok:r.ok, j:j }; }); })
834        .then(function(res){
835          if (res.ok && res.j && res.j.answer)      poser(rendreReponse(res.j.answer));
836          else if (repli)                            poser(rendreReponse(repli.a[L] || repli.a.en));
837          else if (res.j && res.j.error)             poser(esc2(res.j.error), false);
838          else                                       poser(esc2(t2('I could not answer that one.')), false);
839        })
840        .catch(function(){
841          if (repli) poser(rendreReponse(repli.a[L] || repli.a.en));
842          else       poser(esc2(t2('I could not answer that one.')), false);
843        });
844    };
845
846    window.osaiAsk = function(question){
847      panel.classList.add('open');
848      greet();
849      if (question) { setTimeout(function(){ ask(question); }, 120); }
850      else { setTimeout(function(){ input.focus(); }, 80); }
851    };
852      /* [30/07] Trois facons de refermer, parce qu'une seule ne suffit jamais :
853         la croix, la touche Echap, et le bouton flottant qui bascule deja.
854         Sans elles, l'assistant ouvert ne se refermait plus — le panneau
855         recouvre en partie le bouton flottant (releve par Romain). */
856      var xBtn = panel.querySelector('#ohpClose');
857      if (xBtn) xBtn.addEventListener('click', function(e){
858        e.stopPropagation(); panel.classList.remove('open'); btn.focus();
859      });
860      document.addEventListener('keydown', function(e){
861        if (e.key === 'Escape' && panel.classList.contains('open')){
862          panel.classList.remove('open'); btn.focus();
863        }
864      });
865    function send(){ var v = input.value.trim(); if (!v) return; input.value = ''; ask(v); }
866    panel.querySelector('#ohpSend').addEventListener('click', send);
867    input.addEventListener('keydown', function(e){ if (e.key === 'Enter') send(); });
868  })();
869  } catch(e){}
870
871window.__OSAI_NAV = 'V166-WEBMCP'; console.log('%c[OSAI-NAV] version qui tourne : V166-WEBMCP — lang=' + NAV_LANG, 'color:#6366f1;font-weight:bold'); } catch(e){}
872  var NAV_I18N = {
873    fr: {
874      "Online":"En ligne","I can help you find AI tools, compare models and explore projects — or just search.":"Je peux vous aider à trouver un outil, comparer des modèles, explorer des projets — ou simplement chercher.","Ask anything.":"Demandez ce que vous voulez.",
875      "Updates":"Mise à jour",
876      "Hourly":"Chaque heure",
877      "Continue in the assistant":"Continuer dans l’assistant","I could not answer that one.":"Je n’ai pas su répondre à celle-là.",
878      "Search, or ask anything…":"Cherchez, ou posez votre question…","Search tools, projects, models":"Rechercher outils, projets, modèles",
879      "Ask the assistant":"Demander à l’assistant","Answered with this site's live data":"Réponse à partir des données du site","No text match":"Aucune correspondance de texte",
880      "Verified \u2014 you are now listed as the maintainer of this project.":"Vérifié — vous êtes désormais listé comme mainteneur de ce projet.","Already verified \u2014 you are listed as the maintainer.":"Déjà vérifié — vous êtes listé comme mainteneur.","To claim a project, your account must be signed in with GitHub.":"Pour réclamer un projet, votre compte doit être connecté via GitHub.","This repository belongs to another GitHub account.":"Ce dépôt appartient à un autre compte GitHub.","You must be a public member of the organization that owns this repository.":"Vous devez être membre public de l’organisation propriétaire du dépôt.","You must appear among the contributors of this repository.":"Vous devez figurer parmi les contributeurs du dépôt.","Claim failed \u2014 contact [email protected] if you believe this is wrong.":"Échec du claim — écrivez à [email protected] si vous pensez qu’il y a erreur.",
881      "Badge copied \u2014 click to copy again":"Badge copié — cliquer pour recopier",
882      "Health badge":"Badge santé","Copy the Markdown badge for your README":"Copiez le badge Markdown pour votre README","Markdown copied!":"Markdown copié !",
883      "My Dashboard":"Mon Dashboard","Compose your own \u2014 members":"Composez le vôtre — membres",
884      "Save this dashboard":"Enregistrez ce dashboard","Get notified when a new project appears":"Soyez alerté à chaque nouveau projet","Follow these projects":"Suivez ces projets","Save discoveries to your portfolio":"Ajoutez vos découvertes à votre portfolio","Track health evolution over time":"Suivez l'évolution de la santé dans le temps","Follow your favorite models":"Suivez vos modèles favoris","Watch these trends from your account":"Surveillez ces tendances depuis votre compte","Build your own AI toolbox":"Construisez votre boîte à outils IA","Get price-drop alerts on the models you use":"Recevez les baisses de prix de vos modèles","Follow this industry’s projects":"Suivez les projets de ce secteur","Save projects":"Sauvegardez des projets","Build AI portfolios":"Créez des portfolios IA","Follow emerging tools":"Suivez les outils émergents","Get daily alerts":"Recevez des alertes quotidiennes","Create your free account":"Créez votre compte gratuit",
885      "Plans":"Tarifs","Free to Business plans":"Du plan Free au Business",
886      "Industries":"Industries","AI by sector: health, finance\u2026":"IA par secteur : santé, finance…",
887      "Maintained by":"Maintenu par","Are you the maintainer?":"Vous êtes le mainteneur ?","Claim this project":"Réclamez ce projet",
888      "see all":"tout voir",
889      "accelerating":"en accélération","detected":"détecté","health":"santé","/ 7 days":"/ 7 jours","projects":"projets","more emerging projects":"autres projets émergents","// no project matches these filters":"// aucun projet ne correspond à ces filtres","The discovery scan has not run yet — come back after the next morning build.":"Le scan de découverte n'a pas encore tourné — revenez après le prochain build matinal.","Could not load the discovery data — please refresh.":"Impossible de charger les données de découverte — rechargez la page.",
890      "Emerging":"Émergents","Detected by our signals":"Détectés par nos signaux","Developer API":"API développeur","Keys, quotas, fused records":"Clés, quotas, fiches fusionnées",
891      "Click to rename":"Cliquez pour renommer",
892      "What is it for?":"Ça sert à quoi ?","Not qualified yet":"Pas encore qualifiée","Saved":"Enregistré","Chat assistant":"Assistant de chat","Ask my documents (RAG)":"Interroger mes documents (RAG)","Coding assistant":"Assistant de code","Agents & automation":"Agents et automatisation","Generate images":"Générer des images","Generate video":"Générer des vidéos","Speech & voice":"Parole et voix","Music & audio":"Musique et audio","AI search engine":"Moteur de recherche IA","OCR & documents":"OCR et documents","Fine-tune models":"Affiner des modèles","Browse & automate the web":"Naviguer et automatiser le web","Connect my tools":"Connecter mes outils","Structured JSON output":"Sortie JSON structurée","One API for all models":"Une seule API pour tous les modèles","Scrape sites into data":"Extraire des sites en données","100% local & private":"100 % local et privé","Easiest setup":"Installation la plus simple","Modest hardware":"Matériel modeste","Serve an API":"Exposer une API",
893      "members use it":"membres l'utilisent","member uses it":"membre l'utilise",
894      "use it":"l'utilisent","Use this stack":"Utiliser cette stack","Copied to your account":"Copiée dans votre compte","Already in your account":"Déjà dans votre compte","used by":"utilisée par","Builder":"Builder",
895      "Members who use it":"Des membres l'utilisent","Community stacks":"Stacks de la communauté",
896      "Models you follow":"Les modèles que vous suivez","Models":"Modèles","open":"ouvert","closed":"fermé","per 1M out":"par 1M sortie","Price change over 30 days":"Variation sur 30 jours","No model followed yet — search one above.":"Aucun modèle suivi — cherchez-en un ci-dessus.","Models I follow (with prices)":"Les modèles que je suis (avec leurs prix)",
897      "members saved this":"membres l'ont enregistré","member saved this":"membre l'a enregistré","public stacks":"stacks publiques","public stack":"stack publique","collections":"collections","collection":"collection","Featured in":"Présent dans","by":"par","projects":"projets","project":"projet",
898      "Public — anyone with the link":"Public — accessible avec le lien",
899      "Add":"Ajouter","Add selected":"Ajouter la sélection","Select all":"Tout sélectionner","from watchlist":"depuis la watchlist","Remove":"Retirer","Delete collection":"Supprimer la collection","Delete this collection?":"Supprimer cette collection ?","Give it a title first.":"Donnez-lui d'abord un titre.","Everything from your watchlist is already here.":"Tout ce qui est dans votre watchlist y est déjà.","No projects yet — add one below or pick from your watchlist.":"Aucun projet — ajoutez-en un ci-dessous ou piochez dans votre watchlist.","View public page":"Voir la page publique","Links":"Liens","Projects I follow (with health)":"Les projets que je suis (avec leur santé)","Save this stack (free account)":"Enregistrer cette stack (compte gratuit)","Save this stack":"Enregistrer cette stack","Save to my account":"Enregistrer dans mon compte","Cancel":"Annuler","Bio":"Bio",
900      "Public profile":"Profil public","Alerts":"Alertes","Homepage":"Page d'accueil","Following":"Abonnements","Favorites":"Favoris","Stacks":"Stacks","Collections":"Collections","Watchlist":"Watchlist","Your data":"Vos données","Delete account":"Supprimer le compte","Public — listed in the members directory":"Public — visible dans l'annuaire des membres","Private — only you can see it":"Privé — vous seul le voyez",
901      "Check your inbox — one click to confirm.":"Vérifiez votre boîte — un clic pour confirmer.","You're already on the list.":"Vous êtes déjà inscrit.",
902      "Members":"Membres","People building with open-source AI. Open a profile to see their stacks, the projects they follow and their links — and follow them to keep up.":"Des gens qui construisent avec l'IA open source. Ouvrez un profil pour voir leurs stacks, les projets qu'ils suivent et leurs liens — et suivez-les pour rester au courant.",
903      "Follow":"Suivre","Following":"Suivi","People you follow":"Membres que vous suivez","I want to":"Je veux","Constraints":"Contraintes","Open →":"Ouvrir →","Stack":"Stack",
904      "Your stacks":"Vos stacks","Open in Stack Builder":"Rouvrir dans le Stack Builder","No stack saved yet.":"Aucune stack enregistrée.","Build one in the Stack Builder →":"Construisez-en une dans le Stack Builder →",
905      "Your collections":"Vos collections","Your public profile":"Votre profil public","Your data":"Vos données","Alerts you receive":"Vos alertes","Why create an account?":"Pourquoi créer un compte ?","Show on my page":"Afficher sur ma page","Save profile":"Enregistrer le profil","Public profile":"Profil public",
906      "Search":"Rechercher",
907      "Member":"Membre","Featured project":"Projet mis en avant","My stack — projects I follow":"Ma stack — les projets que je suis","Favorites":"Favoris","Public collections":"Collections publiques","No public collection yet.":"Aucune collection publique pour l'instant.","An olud.ai member":"Un membre olud.ai","Create your own profile":"Créez votre profil","Profile not found":"Profil introuvable","This profile doesn't exist, or its owner keeps it private.":"Ce profil n'existe pas, ou son propriétaire le garde privé.","Collection":"Collection","Member collection":"Collection d'un membre","This collection is empty for now.":"Cette c
907ollection est vide pour l'instant.","Curated by":"Sélection de","Create your own collection":"Créez votre collection",
908      "My public page":"Ma page publique",
909      "Your homepage":"Votre page d'accueil","Live terminal":"Terminal live","Trending on GitHub":"Tendances GitHub","What's new":"Nouveautés","Latest releases":"Dernières versions","News feed":"Fil d'actualité","AI tools":"Outils IA","More tools":"Plus d'outils","Open-source alternatives":"Alternatives open source","Model comparisons":"Comparatifs de modèles","Model comparator":"Comparateur de modèles","Leaderboard":"Classement","Categories":"Catégories","Prompts":"Prompts","Why olud.ai":"Pourquoi olud.ai",
910      "Private — preview":"Privé — aperçu","Save to move your page to":"Enregistrez pour déplacer votre page vers","Copied!":"Copié !","Public":"Public",
911      "Keep track of what matters":"Gardez un œil sur ce qui compte","Follow projects and get an email when their health drops, a new release ships or the license changes. Free, no password.":"Suivez des projets et recevez un email quand leur santé décline, qu'une version sort ou que la licence change. Gratuit, sans mot de passe.","30 seconds":"30 secondes",
912      "Add to collection":"Ajouter à une collection","Collection":"Collection","In collection":"Dans une collection","New collection…":"Nouvelle collection…","Create":"Créer","No collection yet — create your first one below.":"Aucune collection — créez la première ci-dessous.","Saving…":"Enregistrement…","Profile saved.":"Profil enregistré.",
913      "Your account":"Votre compte","Account":"Compte",
914      "5 favorites max without an account":"5 favoris maximum sans compte","Create a free account to save unlimited favorites, sync them across devices and get alerts.":"Créez un compte gratuit pour enregistrer autant de favoris que vous voulez, les retrouver sur tous vos appareils et recevoir des alertes.","Create a free account":"Créer un compte gratuit","Your favorites":"Vos favoris","No favorites yet — tap the ♡ on any project or tool.":"Aucun favori — cliquez sur le ♡ d'un projet ou d'un outil.",
915      "Enter your email first.":"Entrez d'abord votre email.","Sending…":"Envoi…","Network error — try again.":"Erreur réseau — réessaie.","You are signed in.":"Tu es connecté.","Delete your account and all data? This cannot be undone.":"Supprimer votre compte et toutes vos données ? Irréversible.","Health alerts":"Alertes santé","Release alerts":"Alertes releases","Star spikes":"Pics d'étoiles","Off":"Off","Signed in as":"Connecté en tant que","Sign out":"Se déconnecter","Your watchlist":"Votre watchlist","Delete account":"Supprimer le compte","Delete my account":"Supprimer mon compte","Sign in / create account":"Connexion / créer un compte","Email me a link":"Envoyer un lien","If the address is valid, a sign-in link is on its way. Check your inbox (and spam).":"Si l'adresse est valide, un lien de connexion arrive. Vérifiez votre boîte (et les spams).",
916      "Follow":"Suivre","Following":"Suivi","Sign in to follow projects":"Connecte-toi pour suivre des projets",
917      "new AI projects today":"nouveaux projets IA aujourd'hui",
918      "Language":"Langue",
919      "Model profiles":"Fiches modèles","Stay in the loop":"Restez informé","Trends":"Tendances","Languages":"Langages","Countries":"Pays","Contact":"Contact","DNA":"ADN","DNA families":"Familles ADN","Advertise":"Annoncer","Open data &amp; API":"Données ouvertes & API","Documentation":"Documentation","Step-by-step setup":"Mise en place pas à pas","Connect":"Connecter","MCP, API, webhooks, n8n":"MCP, API, webhooks, n8n","Site &amp; Data":"Site & données","My favorites":"Mes favoris","Weekly report":"Rapport hebdo","Tool profiles":"Fiches outils","Security advisories":"Avis de sécurité","Pulse ranking":"Classement Pulse","Privacy":"Confidentialité","Terms":"Conditions",
920      "Search":"Rechercher",
921      "All":"Tous",
922      "Updated":"Mis à jour",
923      "New Space":"Nouvel espace",
924      "New model":"Nouveau modèle",
925      "New project":"Nouveau projet",
926      "No favorites yet.":"Aucun favori pour l'instant.",
927      "No tools found.":"Aucun outil trouvé.",
928      "No tools match your filters.":"Aucun outil ne correspond à vos filtres.",
929      "Trending tools are temporarily unavailable.":"Les outils tendance sont temporairement indisponibles.",
930      "Click the heart on any tool to save it here.":"Cliquez sur le cœur d'un outil pour l'enregistrer ici.",
931      "Close":"Fermer",
932      "Copied!":"Copié !",
933      "AI Tools":"Outils IA","Directory":"Annuaire","HF Directory":"Annuaire HF","Spaces":"Espaces","Top Projects":"Meilleurs projets","Trending":"Tendances",
934      "Model profiles":"Fiches modèles","Advertise":"Annoncer","Open data &amp; API":"Données ouvertes & API","Documentation":"Documentation","Step-by-step setup":"Mise en place pas à pas","Connect":"Connecter","MCP, API, webhooks, n8n":"MCP, API, webhooks, n8n","Site &amp; Data":"Site & données","Privacy":"Confidentialité","Terms":"Conditions","Tools":"Outils","Models":"Modèles","Discover":"Découvrir","Explore":"Explorer",
935      "Tools & Agents":"Outils & Agents","Alternatives":"Alternatives",
936      "AI Agents":"Agents IA","Open-source frameworks":"Frameworks open source","New":"Nouveau",
937      "Tool Guides":"Guides d'outils","76 in-depth guides":"76 guides détaillés",
938      "Tools Directory":"Annuaire d'outils","225+ tools":"225+ outils","Compare Tools":"Comparer les outils","Categories":"Catégories",
939      "AI Pricing":"Prix des IA","Live costs + calculator":"Coûts en direct + calculateur",
940      "Free vs Paid":"Gratuit vs Payant","Open vs frontier":"Open vs propriétaire","Leaderboard":"Classement",
941      "Compare Models":"Comparer les modèles","Paid Models":"Modèles payants",
942      "All Alternatives":"Toutes les alternatives","75+ tools mapped":"75+ outils répertoriés",
943      "Devin alternatives":"Alternatives à Devin","Cursor alternatives":"Alternatives à Cursor","Notion alternatives":"Alternatives à Notion","Photoshop alternatives":"Alternatives à Photoshop",
944      "Projects":"Projets","News":"Actualités","Latest":"Nouveautés","About":"À propos","Methodology":"Méthodologie","My favorites":"Mes favoris","Dashboards":"Tableaux de bord",
945      "Tools catalog":"Catalogue d'outils","Catalog":"Catalogue","Thinking…":"Je cherche…","Olud assistant":"Assistant Olud","The AI hub.":"Le hub de l'IA.","Open and commercial AI, side by side.":"IA ouverte et commerciale, côte à côte.","Submit a tool":"Proposer un outil","Submit a tool or project":"Proposer un outil ou un projet","Add to the directory":"Ajouter au hub","Add it to the hub":"L'ajouter au hub","Get a badge":"Obtenir un badge","For your README":"Pour votre README",
946      "Featured:":"À la une :","Explore agents":"Explorer les agents",
947      "Stay in the loop":"Restez informé","Subscribe":"S'abonner",
948      "The best of open-source AI in your inbox — new models, trending projects and releases. No spam, unsubscribe anytime.":"Le meilleur de l'IA open source dans votre boîte mail — nouveaux modèles, projets tendance et sorties. Pas de spam, désabonnement à tout moment.",
949      "The world's most complete":"L'annuaire le plus complet","open-source AI directory.":"d'IA open source au monde.","Built for the global community.":"C
949onçu pour la communauté mondiale.",
950      "More":"Plus","Home":"Accueil","Models · Free vs Paid":"Modèles · Gratuit vs Payant","Alternatives to top tools":"Alternatives aux outils populaires","AI Spaces":"Espaces IA","News Feed":"Fil d'actualités",
951      "Independent · Open data":"Indépendant · Données ouvertes"
952    },
953    it: {
954      "Online":"Online","I can help you find AI tools, compare models and explore projects — or just search.":"Posso aiutarti a trovare strumenti, confrontare modelli, esplorare progetti — o semplicemente cercare.","Ask anything.":"Chiedi quello che vuoi.",
955      "Updates":"Aggiornamento",
956      "Hourly":"Ogni ora",
957      "Continue in the assistant":"Continua nell’assistente","I could not answer that one.":"A questa non ho saputo rispondere.",
958      "Olud assistant":"Assistente Olud","✨ AI — ask me anything about olud.ai":"✨ IA — chiedimi tutto su olud.ai","Type your question…":"Scrivi la tua domanda…","Questions are logged anonymously to improve the answers.":"Le domande sono registrate in forma anonima per migliorare le risposte.","Hi! I’m the olud.ai assistant. How can I help?":"Ciao! Sono l’assistente di olud.ai. Come posso aiutarti?","Thinking…":"Sto cercando…","Help":"Aiuto","Close":"Chiudi","What is olud.ai?":"Che cos’è olud.ai?","How do I create an account?":"Come creo un account?","What are the plans?":"Quali sono i piani?","How do I follow a project?":"Come seguo un progetto?",
959      "Search, or ask anything…":"Cerca, o fai una domanda…","Search tools, projects, models":"Cerca strumenti, progetti, modelli",
960      "Ask the assistant":"Chiedi all’assistente","Answered with this site's live data":"Risposta dai dati aggiornati del sito","No text match":"Nessuna corrispondenza testuale",
961      "Verified \u2014 you are now listed as the maintainer of this project.":"Verificato — ora risulti maintainer di questo progetto.","Already verified \u2014 you are listed as the maintainer.":"Già verificato — risulti maintainer.","To claim a project, your account must be signed in with GitHub.":"Per rivendicare un progetto, il tuo account deve accedere con GitHub.","This repository belongs to another GitHub account.":"Questo repository appartiene a un altro account GitHub.","You must be a public member of the organization that owns this repository.":"Devi essere membro pubblico dell’organizzazione proprietaria.","You must appear among the contributors of this repository.":"Devi comparire tra i contributor del repository.","Claim failed \u2014 contact [email protected] if you believe this is wrong.":"Rivendicazione non riuscita — scrivi a [email protected].",
962      "Badge copied \u2014 click to copy again":"Badge copiato — clic per copiarlo di nuovo",
963      "Health badge":"Badge salute","Copy the Markdown badge for your README":"Copia il badge Markdown per il tuo README","Markdown copied!":"Markdown copiato!",
964      "My Dashboard":"La mia Dashboard","Compose your own \u2014 members":"Componi la tua — membri",
965      "Save this dashboard":"Salva questa dashboard","Get notified when a new project appears":"Ricevi avvisi sui nuovi progetti","Follow these projects":"Segui questi progetti","Save discoveries to your portfolio":"Salva le scoperte nel tuo portfolio","Track health evolution over time":"Segui l'evoluzione dell'health score","Follow your favorite models":"Segui i tuoi modelli preferiti","Watch these trends from your account":"Osserva queste tendenze dal tuo account","Build your own AI toolbox":"Costruisci il tuo toolbox IA","Get price-drop alerts on the models you use":"Avvisi sui ribassi dei tuoi modelli","Follow this industry’s projects":"Segui i progetti di questo settore","Save projects":"Salva progetti","Build AI portfolios":"Crea portfolio IA","Follow emerging tools":"Segui gli strumenti emergenti","Get daily alerts":"Ricevi avvisi quotidiani","Create your free account":"Crea il tuo account gratuito",
966      "Plans":"Prezzi","Free to Business plans":"Dal piano Free al Business",
967      "Industries":"Settori","AI by sector: health, finance\u2026":"IA per settore: salute, finanza…",
968      "Maintained by":"Mantenuto da","Are you the maintainer?":"Sei il maintainer?","Claim this project":"Rivendica questo progetto",
969      "see all":"vedi tutti",
970      "accelerating":"in accelerazione","detected":"rilevato","health":"salute","/ 7 days":"/ 7 giorni","projects":"progetti","more emerging projects":"altri progetti emergenti","// no project matches these filters":"// nessun progetto corrisponde a questi filtri","The discovery scan has not run yet — come back after the next morning build.":"La scansione di scoperta non è ancora partita — torna dopo il prossimo build mattutino.","Could not load the discovery data — please refresh.":"Impossibile caricare i dati di scoperta — ricarica la pagina.",
971      "Emerging":"Emergenti","Detected by our signals":"Rilevati dai nostri segnali","Developer API":"API per sviluppatori","Keys, quotas, fused records":"Chiavi, quote, schede unificate",
972      "Click to rename":"Clicca per rinominare",
973      "What is it for?":"A cosa serve?","Not qualified yet":"Non ancora qualificato","Saved":"Salvato","Chat assistant":"Assistente chat","Coding assistant":"Assistente di codice","Generate images":"Generare immagini","100% local & private":"100% locale e privato","Easiest setup":"Installazione più semplice","Modest hardware":"Hardware modesto","Serve an API":"Esporre una API",
974      "members use it":"membri lo usano","member uses it":"membro lo usa",
975      "use it":"lo usano","Use this stack":"Usa questo stack","Copied to your account":"Copiato nel tuo account","Already in your account":"Già nel tuo account","used by":"usato da",
976      "Members who use it":"Membri che lo usano","Community stacks":"Stack della community",
977      "Models you follow":"I modelli che segui","Models":"Modelli","open":"aperto","closed":"chiuso","per 1M out":"per 1M output","Price change over 30 days":"Variazione su 30 giorni","No model followed yet — search one above.":"Nessun modello seguito — cercane uno sopra.","Models I follow (with prices)":"I modelli che seguo (con i prezzi)",
978      "members saved this":"membri l'hanno salvato","member saved this":"membro l'ha salvato","public stacks":"stack pubblici","public stack":"stack pubblico","collections":"raccolte","collection":"raccolta","Featured in":"Presente in","by":"di","projects":"progetti","project":"progetto",
979      "Public — anyone with the link":"Pubblico — accessibile con il link",
980      "Add":"Aggiungi","Add selected":"Aggiungi selezionati","Select all":"Seleziona tutto","from watchlist":"dalla watchlist","Remove":"Rimuovi","Delete collection":"Elimina raccolta","Delete this collection?":"Eliminare questa raccolta?","Give it a title first.":"Dai prima un titolo.","Everything from your watchlist is already here.":"Tutto ciò che è nella tua watchlist è già qui.","No projects yet — add one below or pick from your watchlist.":"Nessun progetto — aggiungine uno o scegli dalla watchlist.","View public page":"Vedi la pagina pubblica","Links":"Link","Projects I follow (with health)":"I progetti che seguo (con la salute)","Save this stack (free account)":"Salva questo stack (account gratuito)","Save this stack":"Salva questo stack","Save to my account":"Salva nel mio account","Cancel":"Annulla","Bio":"Bio",
981      "Public profile":"Profilo pubblico","Alerts":"Avvisi","Homepage":"Home","Following":"Seguiti","Stacks":"Stack","Watchlist":"Watchlist","Your data":"I tuoi dati","Delete account":"Elimina account",
982      "Check your inbox — one click to confirm.":"Controlla la posta — un clic per confermare.",
983      "Members":"Membri",
984      "Follow":"Segui","Following":"Seguito","People you follow":"Membri che segui","I want to":"Voglio","Constraints":"Vincoli","Open →":"Apri →",
985      "Your stacks":"I tuoi stack","Open in Stack Builder":"Apri nello Stack Builder","No stack saved yet.":"Nessuno stack salvato.","Build one in the Stack Builder →":"Creane uno nello Stack Builder →",
986      "Your collections":"Le tue raccolte","Your public profile":"Il tuo profilo pubblico","Your data":"I tuoi dati","Alerts you receive":"I tuoi avvisi","Why create an account?":"Perché creare un account?","Show on my page":"Mostra sulla mia pagina","Save profile":"Salva il profilo","Public profile":"Profilo pubblico",
987      "Search":"Cerca",
988      "Member":"Membro","Featured project":"Progetto in evidenza","My stack — projects I follow":"Il mio stack — i progetti che seguo","Favorites":"Preferiti","Public collections":"Raccolte pubbliche","No public collection yet.":"Nessuna raccolta pubblica.","An olud.ai member":"Un membro olud.ai","Create your own profile":"Crea il tuo profilo","Profile not found":"Profilo non trovato","This profile doesn't exist, or its owner keeps it private.":"Questo profilo non esiste, o il proprietario lo tiene privato.","Member collection":"Raccolta di un membro","This collection is empty for now.":"Questa raccolta è vuota.","Curated by":"A cura di","Create your own collection":"Crea la tua raccolta",
989      "My public page":"La mia pagina pubblica",
990      "Your homepage":"La tua home","Live terminal":"Terminale live","Trending on GitHub":"Tendenze GitHub","What's new":"Novità","Latest releases":"Ultime versioni","News feed":"Notizie","AI tools":"Strumenti IA","More tools":"Altri strumenti","Open-source alternatives":"Alternative open source","Model comparisons":"Confronti tra modelli","Model comparator":"Comparatore di modelli","Leaderboard":"Classifica","Categories":"Categorie","Prompts":"Prompt","Why olud.ai":"Perché olud.ai",
991      "Private — preview":"Privato — anteprima","Save to move your page to":"Salva per spostare la tua pagina su","Copied!":"Copiato!","Public":"Pubblico",
992      "Keep track of what matters":"Tieni d'occhio ciò che conta","Follow projects and get an email when their health drops, a new release ships or the license changes. Free, no password.":"Segui i progetti e ricevi un'email quando la loro salute cala, esce una nuova versione o cambia la licenza. Gratis, senza password.","30 seconds":"30 secondi",
993      "Add to collection":"Aggiungi a una raccolta","Collection":"Raccolta","In collection":"Nella raccolta","New collection…":"Nuova raccolta…","Create":"Crea","No collection yet — create your first one below.":"Nessuna raccolta — creane una qui sotto.","Saving…":"Salvataggio…","Profile saved.":"Profilo salvato.",
994      "Your account":"Il tuo account","Account":"Account",
995      "5 favorites max without an account":"Massimo 5 preferiti senza account","Create a free account to save unlimited favorites, sync them across devices and get alerts.":"Crea un account gratuito per salvare preferiti illimitati, sincronizzarli su tutti i dispositivi e ricevere avvisi.","Create a free account":"Crea un account gratuito","Your favorites":"I tuoi preferiti","No favorites yet — tap the ♡ on any project or tool.":"Nessun preferito — tocca il ♡ su un progetto o strumento.",
996      "Enter your email first.":"Inserisci prima la tua email.","Sending…":"Invio…","Network error — try again.":"Errore di rete — riprova.","You are signed in.":"Sei connesso.","Delete your account and all data? This cannot be undone.":"Eliminare il tuo account e tutti i dati? Irreversibile.","Health alerts":"Avvisi salute","Release alerts":"Avvisi release","Star spikes":"Picchi di stelle","Off":"Off","Signed in as":"Connesso come","Sign out":"Esci","Your watchlist":"La tua watchlist","Delete account":"Elimina account","Delete my account":"Elimina il mio account","Sign in / create account":"Accedi / crea account","Email me a link":"Inviami un link","If the address is valid, a sign-in link is on its way. Check your inbox (and spam).":"Se l'indirizzo è valido, un link di accesso è in arrivo. Controlla la posta (e lo spam).",
997      "Follow":"Segui","Following":"Seguito","Sign in to follow projects":"Accedi per seguire i progetti",
998      "new AI projects today":"nuovi progetti IA oggi",
999      "Language":"Lingua",
1000      "My favorites":"I miei preferiti","Weekly report":"Report settimanale","Tool profiles":"Schede strumenti","Security advisories":"Avvisi di sicurezza","Pulse ranking":"Classifica Pulse",
1001      "Search":"Cerca",
1002      "All":"Tutti",
1003      "Updated":"Aggiornato",
1004      "New Space":"Nuovo spazio",
1005      "New model":"Nuovo modello",
1006      "New project":"Nuovo progetto",
1007      "No favorites yet.":"Nessun preferito.",
1008      "No tools found.":"Nessuno strumento trovato.",
1009      "No tools match your filters.":"Nessuno strumento corrisponde ai filtri.",
1010      "Trending tools are temporarily unavailable.":"Gli strumenti di tendenza non sono al momento disponibili.",
1011      "Click the heart on any tool to save it here.":"Clicca sul cuore di uno strumento per salvarlo qui.",
1012      "Close":"Chiudi",
1013      "Copied!":"Copiato!",
1014      "AI Tools":"Strumenti IA","Directory":"Directory","HF Directory":"Directory HF","Spaces":"Spazi","Top Projects":"Progetti migliori","Trending":"Di tendenza",
1015      "Model profiles":"Schede modelli","Stay in the loop":"Resta aggiornato","Trends":"Tendenze","Languages":"Linguaggi","Countries":"Paesi","Contact":"Contatto","DNA":"DNA","DNA families":"Famiglie DNA","Advertise":"Pubblicità","Open data &amp; API":"Dati aperti e API","Documentation":"Documentazione","Step-by-step setup":"Configurazione passo a passo","Connect":"Collegare","MCP, API, webhooks, n8n":"MCP, API, webhook, n8n","Site &amp; Data":"Sito e dati","Privacy":"Privacy","Terms":"Termini","Tools":"Strumenti","Models":"Modelli","Discover":"Scopri","Explore":"Esplora",
1016      "Tools & Agents":"Strumenti & Agenti","Alternatives":"Alternative",
1017      "AI Agents":"Agenti IA","Open-source frameworks":"Framework open source","New":"Novità",
1018      "Tool Guides":"Guide agli strumenti","76 in-depth guides":"76 guide approfondite",
1019      "Tools Directory":"Directory strumenti","225+ tools":"225+ strumenti","Compare Tools":"Confronta strumenti","Categories":"Categorie",
1020      "AI Pricing":"Prezzi IA","Live costs + calculator":"Costi live + calcolatore",
1021      "Free vs Paid":"Gratis vs A pagamento","Open vs frontier":"Open vs frontier","Leaderboard":"Classifica",
1022      "Compare Models":"Confronta modelli","Paid Models":"Modelli a pagamento",
1023      "All Alternatives":"Tutte le alternative","75+ tools mapped":"75+ strumenti mappati",
1024      "Devin alternatives":"Alternative a Devin","Cursor alternatives":"Alternative a Cursor","Notion alternatives":"Alternative a Notion","Photoshop alternatives":"Alternative a Photoshop",
1025      "Projects":"Progetti","News":"Notizie","Latest":"Ultimi","About":"Chi siamo","Methodology":"Metodologia",
1026      "Tools catalog":"Catalogo strumenti","Catalog":"Catalogo","The AI hub.":"L'hub dell'IA.","Open and commercial AI, side by side.":"IA aperta e commerciale, fianco a fianco.","Submit a tool":"Proponi uno strumento","Submit a tool or project":"Proponi uno strumento o un progetto","Add to the directory":"Aggiungi all'hub","Add it to the hub":"Aggiungilo all'hub","Get a badge":"Ottieni un badge","For your README":"Per il tuo README",
1027      "Featured:":"In evidenza:","Explore agents":"Esplora gli agenti",
1028      "Stay in the loop":"Resta aggiornato","Subscribe":"Iscriviti",
1029      "The best of open-source AI in your inbox — new models, trending projects and releases. No spam, unsubscribe anytime.":"Il meglio dell'IA open source nella tua casella — nuovi modelli, progetti di tendenza e release. Niente spam, disiscrizione in qualsiasi momento.",
1030      "The world's most complete":"La directory più completa","open-source AI directory.":"di IA open source al mondo.","Built for the global community.":"C
1030ostruita per la comunità globale.",
1031      "More":"Altro","Home":"Home","Models · Free vs Paid":"Modelli · Gratis vs A pagamento","Alternatives to top tools":"Alternative agli strumenti popolari","AI Spaces":"Spaces IA","News Feed":"Feed notizie",
1032      "Independent · Open data":"Indipendente · Dati aperti"
1033    },
1034    es: {
1035      "Online":"En línea","I can help you find AI tools, compare models and explore projects — or just search.":"Puedo ayudarte a encontrar herramientas, comparar modelos y explorar proyectos — o simplemente buscar.","Ask anything.":"Pregunta lo que quieras.",
1036      "Updates":"Actualización",
1037      "Hourly":"Cada hora",
1038      "Continue in the assistant":"Continuar en el asistente","I could not answer that one.":"A esa no he sabido responder.",
1039      "Olud assistant":"Asistente Olud","✨ AI — ask me anything about olud.ai":"✨ IA — pregúntame lo que quieras sobre olud.ai","Type your question…":"Escribe tu pregunta…","Questions are logged anonymously to improve the answers.":"Las preguntas se registran de forma anónima para mejorar las respuestas.","Hi! I’m the olud.ai assistant. How can I help?":"¡Hola! Soy el asistente de olud.ai. ¿En qué puedo ayudarte?","Thinking…":"Buscando…","Help":"Ayuda","Close":"Cerrar","What is olud.ai?":"¿Qué es olud.ai?","How do I create an account?":"¿Cómo creo una cuenta?","What are the plans?":"¿Cuáles son los planes?","How do I follow a project?":"¿Cómo sigo un proyecto?",
1040      "Search, or ask anything…":"Busca, o haz una pregunta…","Search tools, projects, models":"Buscar herramientas, proyectos, modelos",
1041      "Ask the assistant":"Pregunta al asistente","Answered with this site's live data":"Respuesta con los datos en vivo del sitio","No text match":"Sin coincidencias de texto",
1042      "Verified \u2014 you are now listed as the maintainer of this project.":"Verificado — ahora figuras como maintainer de este proyecto.","Already verified \u2014 you are listed as the maintainer.":"Ya verificado — figuras como maintainer.","To claim a project, your account must be signed in with GitHub.":"Para reclamar un proyecto, tu cuenta debe iniciar sesión con GitHub.","This repository belongs to another GitHub account.":"Este repositorio pertenece a otra cuenta de GitHub.","You must be a public member of the organization that owns this repository.":"Debes ser miembro público de la organización propietaria.","You must appear among the contributors of this repository.":"Debes figurar entre los contribuidores del repositorio.","Claim failed \u2014 contact [email protected] if you believe this is wrong.":"Reclamo fallido — escribe a [email protected].",
1043      "Badge copied \u2014 click to copy again":"Insignia copiada — clic para copiar de nuevo",
1044      "Health badge":"Insignia de salud","Copy the Markdown badge for your README":"Copia la insignia Markdown para tu README","Markdown copied!":"¡Markdown copiado!",
1045      "My Dashboard":"Mi Dashboard","Compose your own \u2014 members":"Compón el tuyo — miembros",
1046      "Save this dashboard":"Guarda este dashboard","Get notified when a new project appears":"Recibe avisos de nuevos proyectos","Follow these projects":"Sigue estos proyectos","Save discoveries to your portfolio":"Guarda descubrimientos en tu portfolio","Track health evolution over time":"Sigue la evolución del health score","Follow your favorite models":"Sigue tus modelos favoritos","Watch these trends from your account":"Vigila estas tendencias desde tu cuenta","Build your own AI toolbox":"Crea tu caja de herramientas IA","Get price-drop alerts on the models you use":"Alertas de bajadas de precio de tus modelos","Follow this industry’s projects":"Sigue los proyectos de este sector","Save projects":"Guarda proyectos","Build AI portfolios":"Crea portfolios de IA","Follow emerging tools":"Sigue herramientas emergentes","Get daily alerts":"Recibe alertas diarias","Create your free account":"Crea tu cuenta gratis",
1047      "Plans":"Precios","Free to Business plans":"Del plan Free al Business",
1048      "Industries":"Industrias","AI by sector: health, finance\u2026":"IA por sector: salud, finanzas…",
1049      "Maintained by":"Mantenido por","Are you the maintainer?":"¿Eres el maintainer?","Claim this project":"Reclama este proyecto",
1050      "see all":"ver todos",
1051      "accelerating":"acelerando","detected":"detectado","health":"salud","/ 7 days":"/ 7 días","projects":"proyectos","more emerging projects":"proyectos emergentes más","// no project matches these filters":"// ningún proyecto coincide con estos filtros","The discovery scan has not run yet — come back after the next morning build.":"El escaneo de descubrimiento aún no se ha ejecutado — vuelve tras el próximo build matinal.","Could not load the discovery data — please refresh.":"No se pudieron cargar los datos de descubrimiento — recarga la página.",
1052      "Emerging":"Emergentes","Detected by our signals":"Detectados por nuestras señales","Developer API":"API para desarrolladores","Keys, quotas, fused records":"Claves, cuotas, fichas fusionadas",
1053      "Click to rename":"Clic para renombrar",
1054      "What is it for?":"¿Para qué sirve?","Not qualified yet":"Sin calificar","Saved":"Guardado","Chat assistant":"Asistente de chat","Coding assistant":"Asistente de código","Generate images":"Generar imágenes","100% local & private":"100 % local y privado","Easiest setup":"Instalación más simple","Modest hardware":"Hardware modesto","Serve an API":"Servir una API",
1055      "members use it":"miembros lo usan","member uses it":"miembro lo usa",
1056      "use it":"lo usan","Use this stack":"Usar este stack","Copied to your account":"Copiado en tu cuenta","Already in your account":"Ya está en tu cuenta","used by":"usado por",
1057      "Members who use it":"Miembros que lo usan","Community stacks":"Stacks de la comunidad",
1058      "Models you follow":"Los modelos que sigues","Models":"Modelos","open":"abierto","closed":"cerrado","per 1M out":"por 1M s
1058alida","Price change over 30 days":"Variación en 30 días","No model followed yet — search one above.":"Ningún modelo seguido — busca uno arriba.","Models I follow (with prices)":"Los modelos que sigo (con precios)",
1059      "members saved this":"miembros lo guardaron","member saved this":"miembro lo guardó","public stacks":"stacks públicos","public stack":"stack público","collections":"colecciones","collection":"colección","Featured in":"Presente en","by":"por","projects":"proyectos","project":"proyecto",
1060      "Public — anyone with the link":"Público — accesible con el enlace",
1061      "Add":"Añadir","Add selected":"Añadir selección","Select all":"Seleccionar todo","from watchlist":"desde la watchlist","Remove":"Quitar","Delete collection":"Eliminar colección","Delete this collection?":"¿Eliminar esta colección?","Give it a title first.":"Ponle un título primero.","Everything from your watchlist is already here.":"Todo lo de tu watchlist ya está aquí.","No projects yet — add one below or pick from your watchlist.":"Ningún proyecto — añade uno abajo o elige de tu watchlist.","View public page":"Ver la página pública","Links":"Enlaces","Projects I follow (with health)":"Los proyectos que sigo (con su salud)","Save this stack (free account)":"Guardar este stack (cuenta gratuita)","Save this stack":"Guardar este stack","Save to my account":"Guardar en mi cuenta","Cancel":"Cancelar","Bio":"Bio",
1062      "Public profile":"Perfil público","Alerts":"Alertas","Homepage":"Inicio","Following":"Siguiendo","Stacks":"Stacks","Your data":"Tus datos","Delete account":"Eliminar cuenta",
1063      "Check your inbox — one click to confirm.":"Revisa tu correo — un clic para confirmar.",
1064      "Members":"Miembros",
1065      "Follow":"Seguir","Following":"Siguiendo","People you follow":"Miembros que sigues","I want to":"Quiero","Constraints":"Restricciones","Open →":"Abrir →",
1066      "Your stacks":"Tus stacks","Open in Stack Builder":"Abrir en el Stack Builder","No stack saved yet.":"Ningún stack guardado.","Build one in the Stack Builder →":"Crea uno en el Stack Builder →",
1067      "Your collections":"Tus colecciones","Your public profile":"Tu perfil público","Your data":"Tus datos","Alerts you receive":"Tus alertas","Why create an account?":"¿Por qué crear una cuenta?","Show on my page":"Mostrar en mi página","Save profile":"Guardar perfil","Public profile":"Perfil público",
1068      "Search":"Buscar",
1069      "Member":"Miembro","Featured project":"Proyecto destacado","My stack — projects I follow":"Mi stack — los proyectos que sigo","Favorites":"Favoritos","Public collections":"Colecciones públicas","No public collection yet.":"Aún no hay colecciones públicas.","An olud.ai member":"Un miembro de olud.ai","Create your own profile":"Crea tu perfil","Profile not found":"Perfil no encontrado","This profile doesn't exist, or its owner keeps it private.":"Este perfil no existe, o su propietario lo mantiene privado.","Member collection":"Colección de un miembro","This collection is empty for now.":"Esta colección está vacía.","Curated by":"Seleccionado por","Create your own collection":"Crea tu colección",
1070      "My public page":"Mi página pública",
1071      "Your homepage":"Tu página de inicio","Live terminal":"Terminal en vivo","Trending on GitHub":"Tendencias en GitHub","What's new":"Novedades","Latest releases":"Últimas versiones","News feed":"Noticias","AI tools":"Herramientas IA","More tools":"Más herramientas","Open-source alternatives":"Alternativas de código abierto","Model comparisons":"Comparativas de modelos","Model comparator":"Comparador de modelos","Leaderboard":"Clasificación","Categories":"Categorías","Prompts":"Prompts","Why olud.ai":"Por qué olud.ai",
1072      "Private — preview":"Privado — vista previa","Save to move your page to":"Guarda para mover tu página a","Copied!":"¡Copiado!","Public":"Público",
1073      "Keep track of what matters":"Sigue lo que importa","Follow projects and get an email when their health drops, a new release ships or the license changes. Free, no password.":"Sigue proyectos y recibe un email cuando su salud baje, salga una nueva ver
1073sión o cambie la licencia. Gratis, sin contraseña.","30 seconds":"30 segundos",
1074      "Add to collection":"Añadir a una colección","Collection":"Colección","In collection":"En la colección","New collection…":"Nueva colección…","Create":"Crear","No collection yet — create your first one below.":"Ninguna colección — crea la primera abajo.","Saving…":"Guardando…","Profile saved.":"Perfil guardado.",
1075      "Your account":"Tu cuenta","Account":"Cuenta",
1076      "5 favorites max without an account":"Máximo 5 favoritos sin cuenta","Create a free account to save unlimited favorites, sync them across devices and get alerts.":"Crea una cuenta gratuita para guardar favoritos ilimitados, sincronizarlos entre dispositivos y recibir alertas.","Create a free account":"Crear una cuenta gratuita","Your favorites":"Tus favoritos","No favorites yet — tap the ♡ on any project or tool.":"Aún no hay favoritos — pulsa el ♡ en cualquier proyecto o herramienta.",
1077      "Enter your email first.":"Introduce primero tu email.","Sending…":"Enviando…","Network error — try again.":"Error de red — inténtalo de nuevo.","You are signed in.":"Has iniciado sesión.","Delete your account and all data? This cannot be undone.":"¿Eliminar tu cuenta y todos tus datos? Irreversible.","Health alerts":"Alertas de salud","Release alerts":"Alertas de releases","Star spikes":"Picos de estrellas","Off":"Off","Signed in as":"Conectado como","Sign out":"Cerrar sesión","Your watchlist":"Tu watchlist","Delete account":"Eliminar cuenta","Delete my account":"Eliminar mi cuenta","Sign in / create account":"Iniciar sesión / crear cuenta","Email me a link":"Envíame un enlace","If the address is valid, a sign-in link is on its way. Check your inbox (and spam).":"Si la dirección es válida, un enlace de acceso está en camino. Revisa tu bandeja (y el spam).",
1078      "Follow":"Seguir","Following":"Siguiendo","Sign in to follow projects":"Inicia sesión para seguir proyectos",
1079      "new AI projects today":"nuevos proyectos de IA hoy",
1080      "Language":"Idioma",
1081      "My favorites":"Mis favoritos","Weekly report":"Informe semanal","Tool profiles":"Fichas de herramientas","Security advisories":"Avisos de seguridad","Pulse ranking":"Ranking Pulse",
1082      "Search":"Buscar",
1083      "All":"Todos",
1084      "Updated":"Actualizado",
1085      "New Space":"Nuevo espacio",
1086      "New model":"Nuevo modelo",
1087      "New project":"Nuevo proyecto",
1088      "No favorites yet.":"Aún no hay favoritos.",
1089      "No tools found.":"No se encontraron herramientas.",
1090      "No tools match your filters.":"Ninguna herramienta coincide con tus filtros.",
1091      "Trending tools are temporarily unavailable.":"Las herramientas en tendencia no están disponibles temporalmente.",
1092      "Click the heart on any tool to save it here.":"Haz clic en el corazón de una herramienta para guardarla aquí.",
1093      "Close":"Cerrar",
1094      "Copied!":"¡Copiado!",
1095      "AI Tools":"Herramientas IA","Directory":"Directorio","HF Directory":"Directorio HF","Spaces":"Espacios","Top Projects":"Mejores proyectos","Trending":"Tendencias",
1096      "Model profiles":"Fichas de modelos","Stay in the loop":"Mantente al día","Trends":"Tendencias","Contact":"Contacto","DNA":"ADN","Languages":"Lenguajes","Countries":"Paises","DNA families":"Familias de ADN","Advertise":"Anunciarse","Open data &amp; API":"Datos abiertos y API","Documentation":"Documentación","Step-by-step setup":"Configuración paso a paso","Connect":"Conectar","MCP, API, webhooks, n8n":"MCP, API, webhooks, n8n","Site &amp; Data":"Sitio y datos","Privacy":"Privacidad","Terms":"Términos","Tools":"Herramientas","Models":"Modelos","Discover":"Descubrir","Explore":"Explorar",
1097      "Tools & Agents":"Herramientas y Agentes","Alternatives":"Alternativas",
1098      "AI Agents":"Agentes de IA","Open-source frameworks":"Frameworks open source","New":"Nuevo",
1099      "Tool Guides":"Guías de herramientas","76 in-depth guides":"76 guías detalladas",
1100      "Tools Directory":"Directorio de herramientas","225+ tools":"225+ herramientas","Compare Tools":"Comparar herramientas","Categories":"Categorías",
1101      "AI Pricing":"Precios de IA","Live costs + calculator":"Costes en vivo + calculadora",
1102      "Free vs Paid":"Gratis vs De pago","Open vs frontier":"Open vs frontera","Leaderboard":"Clasificación",
1103      "Compare Models":"Comparar modelos","Paid Models":"Modelos de pago",
1104      "All Alternatives":"Todas las alternativas","75+ tools mapped":"75+ herramientas mapeadas",
1105      "Devin alternatives":"Alternativas a Devin","Cursor alternatives":"Alternativas a Cursor","Notion alternatives":"Alternativas a Notion","Photoshop alternatives":"Alternativas a Photoshop",
1106      "Projects":"Proyectos","News":"Noticias","Latest":"Recientes","About":"Acerca de","Methodology":"Metodología","My favorites":"Mis favoritos","Dashboards":"Paneles",
1107      "Tools catalog":"Catálogo de herramientas","Catalog":"Catálogo","The AI hub.":"El hub de la IA.","Open and commercial AI, side by side.":"IA abierta y comercial, lado a lado.","Submit a tool":"Enviar una herramienta","Submit a tool or project":"Enviar una herramienta o un proyecto","Add to the directory":"Añadir al hub","Add it to the hub":"Añádelo al hub","Get a badge":"Consigue una insignia","For your README":"Para tu README",
1108      "Featured:":"Destacado:","Explore agents":"Explorar agentes",
1109      "Stay in the loop":"Mantente al día","Subscribe":"Suscribirse",
1110      "The best of open-source AI in your inbox — new models, trending projects and releases. No spam, unsubscribe anytime.":"Lo mejor de la IA open source en tu bandeja — nuevos modelos, proyectos en tendencia y lanzamientos. Sin spam, cancela cuando quieras.",
1111      "The world's most complete":"El directorio más completo","open-source AI directory.":"de IA open source del mundo.","Built for the global community.":"Hecho para la comunidad global.",
1112      "More":"Más","Home":"Inicio","Models · Free vs Paid":"Modelos · Gratis vs De pago","Alternatives to top tools":"Alternativas a herramientas populares","AI Spaces":"Espacios IA","News Feed":"Noticias",
1113      "Independent · Open data":"Independiente · Datos abiertos"
1114    },
1115    de: {
1116      "Online":"Online","I can help you find AI tools, compare models and explore projects — or just search.":"Ich helfe Ihnen, Tools zu finden, Modelle zu vergleichen und Projekte zu entdecken — oder einfach zu suchen.","Ask anything.":"Fragen Sie, was Sie wollen.",
1117      "Updates":"Aktualisierung",
1118      "Hourly":"Stündlich",
1119      "Continue in the assistant":"Im Assistenten fortfahren","I could not answer that one.":"Diese Frage konnte ich nicht beantworten.",
1120      "Olud assistant":"Olud-Assistent","✨ AI — ask me anything about olud.ai":"✨ KI — fragen Sie mich alles zu olud.ai","Type your question…":"Ihre Frage eingeben…","Questions are logged anonymously to improve the answers.":"Fragen werden anonym gespeichert, um die Antworten zu verbessern.","Hi! I’m the olud.ai assistant. How can I help?":"Hallo! Ich bin der olud.ai-Assistent. Wie kann ich helfen?","Thinking…":"Einen Moment…","Help":"Hilfe","Close":"Schließen","What is olud.ai?":"Was ist olud.ai?","How do I create an account?":"Wie erstelle ich ein Konto?","What are the plans?":"Welche Tarife gibt es?","How do I follow a project?":"Wie folge ich einem Projekt?",
1121      "Search, or ask anything…":"Suchen oder eine Frage stellen…","Search tools, projects, models":"Tools, Projekte, Modelle suchen",
1122      "Ask the assistant":"Den Assistenten fragen","Answered with this site's live data":"Antwort aus den Live-Daten dieser Seite","No text match":"Keine Textübereinstimmung",
1123      "Verified \u2014 you are now listed as the maintainer of this project.":"Verifiziert — Sie sind jetzt als Maintainer gelistet.","Already verified \u2014 you are listed as the maintainer.":"Bereits verifiziert — Sie sind als Maintainer gelistet.","To claim a project, your account must be signed in with GitHub.":"Zum Beanspruchen muss Ihr Konto mit GitHub angemeldet sein.","This repository belongs to another GitHub account.":"Dieses Repository gehört einem anderen GitHub-Konto.","You must be a public member of the organization that owns this repository.":"Sie müssen öffentliches Mitglied der Organisation sein.","You must appear among the contributors of this repository.":"Sie müssen unter den Contributors des Repositories erscheinen.","Claim failed \u2014 contact [email protected] if you believe this is wrong.":"Claim fehlgeschlagen — schreiben Sie an [email protected].",
1124      "Badge copied \u2014 click to copy again":"Badge kopiert — zum erneuten Kopieren klicken",
1125      "Health badge":"Health-Badge","Copy the Markdown badge for your README":"Markdown-Badge für Ihr README kopieren","Markdown copied!":"Markdown kopiert!",
1126      "My Dashboard":"Mein Dashboard","Compose your own \u2014 members":"Stellen Sie Ihres zusammen — Mitglieder",
1127      "Save this dashboard":"Dashboard speichern","Get notified when a new project appears":"Bei neuen Projekten benachrichtigt werden","Follow these projects":"Diesen Projekten folgen","Save discoveries to your portfolio":"Entdeckungen ins Portfolio speichern","Track health evolution over time":"Health-Verlauf im Zeitverlauf verfolgen","Follow your favorite models":"Ihren Lieblingsmodellen folgen","Watch these trends from your account":"Trends im Konto beobachten","Build your own AI toolbox":"Eigene KI-Toolbox aufbauen","Get price-drop alerts on the models you use":"Preisalarme für Ihre Modelle erhalten","Follow this industry’s projects":"Projekten dieser Branche folgen","Save projects":"Projekte speichern","Build AI portfolios":"KI-Portfolios erstellen","Follow emerging tools":"Neuen Tools folgen","Get daily alerts":"Tägliche Alerts erhalten","Create your free account":"Kostenloses Konto erstellen",
1128      "Plans":"Preise","Free to Business plans":"Vom Free- zum Business-Plan",
1129      "Industries":"Branchen","AI by sector: health, finance\u2026":"KI nach Branche: Gesundheit, Finanzen…",
1130      "Maintained by":"Gepflegt von","Are you the maintainer?":"Sind Sie der Maintainer?","Claim this project":"Projekt beanspruchen",
1131      "see all":"alle ansehen",
1132      "accelerating":"beschleunigt","detected":"erkannt","health":"Gesundheit","/ 7 days":"/ 7 Tage","projects":"Projekte","more emerging projects":"weitere aufstrebende Projekte","// no project matches these filters":"// kein Projekt entspricht diesen Filtern","The discovery scan has not run yet — come back after the next morning build.":"Der Discovery-Scan lief noch nicht — schauen Sie nach dem nächsten Morgen-Build vorbei.","Could not load the discovery data — please refresh.":"Discovery-Daten konnten nicht geladen werden — bitte aktualisieren.",
1133      "Emerging":"Aufstrebend","Detected by our signals":"Von unseren Signalen erkannt","Developer API":"Entwickler-API","Keys, quotas, fused records":"Schlüssel, Kontingente, fusionierte Daten",
1134      "Click to rename":"Zum Umbenennen klicken",
1135      "What is it for?":"Wofür ist das?","Not qualified yet":"Noch nicht qualifiziert","Saved":"Gespeichert","Chat assistant":"Chat-Assistent","Coding assistant":"Coding-Assistent","Generate images":"Bilder erzeugen","100% local & private":"100 % lokal & privat","Easiest setup":"Einfachste Einrichtung","Modest hardware":"Bescheidene Hardware","Serve an API":"Eine API bereitstellen",
1136      "members use it":"Mitglieder nutzen ihn","member uses it":"Mitglied nutzt ihn",
1137      "use it":"nutzen ihn","Use this stack":"Diesen Stack nutzen","Copied to your account":"In dein Konto kopiert","Already in your account":"Bereits in deinem Konto","used by":"genutzt von",
1138      "Members who use it":"Mitglieder, die es nutzen","Community stacks":"Community-Stacks",
1139      "Models you follow":"Modelle, denen du folgst","Models":"Modelle","open":"offen","closed":"geschlossen","per 1M out":"pro 1M Output","Price change over 30 days":"Preisänderung über 30 Tage","No model followed yet — search one above.":"Noch kein Modell — suche oben eines.","Models I follow (with prices)":"Modelle, denen ich folge (mit Preisen)",
1140      "members saved this":"Mitglieder haben es gespeichert","member saved this":"Mitglied hat es gespeichert","public stacks":"öffentliche Stacks","public stack":"öffentlicher Stack","collections":"Sammlungen","collection":"Sammlung","Featured in":"Enthalten in","by":"von","projects":"Projekte","project":"Projekt",
1141      "Public — anyone with the link":"Öffentlich — mit dem Link erreichbar",
1142      "Add":"Hinzufügen","Add selected":"Auswahl hinzufügen","Select all":"Alle au
1142swählen","from watchlist":"aus der Watchlist","Remove":"Entfernen","Delete collection":"Sammlung löschen","Delete this collection?":"Diese Sammlung löschen?","Give it a title first.":"Gib ihr zuerst einen Titel.","Everything from your watchlist is already here.":"Alles aus deiner Watchlist ist bereits hier.","No projects yet — add one below or pick from your watchlist.":"Noch keine Projekte — füge unten eines hinzu oder wähle aus deiner Watchlist.","View public page":"Öffentliche Seite ansehen","Links":"Links","Projects I follow (with health)":"Projekte, denen ich folge (mit Gesundheit)","Save this stack (free account)":"Diesen Stack speichern (kostenloses Konto)","Save this stack":"Diesen Stack speichern","Save to my account":"In meinem Konto speichern","Cancel":"Abbrechen","Bio":"Bio",
1143      "Public profile":"Öffentliches Profil","Alerts":"Alerts","Homepage":"Startseite","Following":"Folge ich","Stacks":"Stacks","Your data":"Deine Daten","Delete account":"Konto löschen",
1144      "Check your inbox — one click to confirm.":"Prüfe dein Postfach — ein Klick zur Bestätigung.",
1145      "Members":"Mitglieder",
1146      "Follow":"Folgen","Following":"Folgt","People you follow":"Mitglieder, denen du folgst","I want to":"Ich möchte","Constraints":"Einschränkungen","Open →":"Öffnen →",
1147      "Your stacks":"Deine Stacks","Open in Stack Builder":"Im Stack Builder öffnen","No stack saved yet.":"Noch kein Stack gespeichert.","Build one in the Stack Builder →":"Erstelle einen im Stack Builder →",
1148      "Your collections":"Deine Sammlungen","Your public profile":"Dein öffentliches Profil","Your data":"Deine Daten","Alerts you receive":"Deine Alerts","Why create an account?":"Warum ein Konto erstellen?","Show on my page":"Auf meiner Seite zeigen","Save profile":"Profil speichern","Public profile":"Öffentliches Profil",
1149      "Search":"Suchen",
1150      "Member":"Mitglied","Featured project":"Hervorgehobenes Projekt","My stack — projects I follow":"Mein Stack — Projekte, denen ich folge","Favorites":"Favoriten","Public collections":"Öffentliche Sammlungen","No public collection yet.":"Noch keine öffentliche Sammlung.","An olud.ai member":"Ein olud.ai-Mitglied","Create your own profile":"Erstelle dein Profil","Profile not found":"Profil nicht gefunden","This profile doesn't exist, or its owner keeps it private.":"Dieses Profil existiert nicht oder ist privat.","Member collection":"Sammlung eines Mitglieds","This collection is empty for now.":"Diese Sammlung ist noch leer.","Curated by":"Kuratiert von","Create your own collection":"Erstelle deine Sammlung",
1151      "My public page":"Meine öffentliche Seite",
1152      "Your homepage":"Deine Startseite","Live terminal":"Live-Terminal","Trending on GitHub":"GitHub-Trends","What's new":"Neuheiten","Latest releases":"Neueste Releases","News feed":"News-Feed","AI tools":"KI-Tools","More tools":"Weitere Tools","Open-source alternatives":"Open-Source-Alternativen","Model comparisons":"Modellvergleiche","Model comparator":"Modell-Vergleicher","Leaderboard":"Rangliste","Categories":"Kategorien","Prompts":"Prompts","Why olud.ai":"Warum olud.ai",
1153      "Private — preview":"Privat — Vorschau","Save to move your page to":"Speichern, um deine Seite zu verschieben nach","Copied!":"Kopiert!","Public":"Öffentlich",
1154      "Keep track of what matters":"Behalte im Blick, was zählt","Follow projects and get an email when their health drops, a new release ships or the license changes. Free, no password.":"Folge Projekten und erhalte eine E-Mail, wenn ihre Gesundheit sinkt, ein Release erscheint oder die Lizenz wechselt. Kostenlos, ohne Passwort.","30 seconds":"30 Sekunden",
1155      "Add to collection":"Zu Sammlung hinzufügen","Collection":"Sammlung","In collection":"In Sammlung","New collection…":"Neue Sammlung…","Create":"Erstellen","No collection yet — create your first one below.":"Noch keine Sammlung — erstelle unten deine erste.","Saving…":"Speichern…","Profile saved.":"Profil gespeichert.",
1156      "Your account":"Dein Konto","Account":"Konto",
1157      "5 favorites max without an account":"Maximal 5 Favoriten ohne Konto","Create a free account to save unlimited favorites, sync them across devices and get alerts.":"Erstelle ein kostenloses Konto, um unbegrenzt Favoriten zu speichern, geräteübergreifend zu synchronisieren und Alerts zu erhalten.","Create a free account":"Kostenloses Konto erstellen","Your favorites":"Deine Favoriten","No favorites yet — tap the ♡ on any project or tool.":"Noch keine Favoriten — tippe auf das ♡ bei einem Projekt oder Tool.",
1158      "Enter your email first.":"Gib zuerst deine E-Mail ein.","Sending…":"Senden…","Network error — try again.":"Netzwerkfehler — versuch es erneut.","You are signed in.":"Du bist angemeldet.","Delete your account and all data? This cannot be undone.":"Konto und alle Daten löschen? Unwiderruflich.","Health alerts":"Gesundheits-Alerts","Release alerts":"Release-Alerts","Star spikes":"Stern-Spitzen","Off":"Aus","Signed in as":"Angemeldet als","Sign out":"Abmelden","Your watchlist":"Deine Watchlist","Delete account":"Konto löschen","Delete my account":"Mein Konto löschen","Sign in / create account":"Anmelden / Konto erstellen","Email me a link":"Link per E-Mail senden","If the address is valid, a sign-in link is on its way. Check your inbox (and spam).":"Wenn die Adresse gültig ist, ist ein Anmeldelink unterwegs. Prüfe dein Postfach (und Spam).",
1159      "Follow":"Folgen","Following":"Gefolgt","Sign in to follow projects":"Melde dich an, um Projekten zu folgen",
1160      "new AI projects today":"neue KI-Projekte heute",
1161      "Language":"Sprache",
1162      "My favorites":"Meine Favoriten","Weekly report":"Wochenbericht","Tool profiles":"Tool-Profile","Security advisories":"Sicherheitshinweise","Pulse ranking":"Pulse-
1162Ranking",
1163      "Search":"Suche",
1164      "All":"Alle",
1165      "Updated":"Aktualisiert",
1166      "New Space":"Neuer Space",
1167      "New model":"Neues Modell",
1168      "New project":"Neues Projekt",
1169      "No favorites yet.":"Noch keine Favoriten.",
1170      "No tools found.":"Keine Tools gefunden.",
1171      "No tools match your filters.":"Keine Tools entsprechen deinen Filtern.",
1172      "Trending tools are temporarily unavailable.":"Trend-Tools sind vorübergehend nicht verfügbar.",
1173      "Click the heart on any tool to save it here.":"Klicke auf das Herz eines Tools, um es hier zu speichern.",
1174      "Close":"Schließen",
1175      "Copied!":"Kopiert!",
1176      "AI Tools":"KI-Tools","Directory":"Verzeichnis","HF Directory":"HF-Verzeichnis","Spaces":"Spaces","Top Projects":"Top-Projekte","Trending":"Im Trend",
1177      "Model profiles":"Modell-Profile","Stay in the loop":"Bleib auf dem Laufenden","Trends":"Trends","Languages":"Sprachen","Countries":"Länder","Contact":"Kontakt","DNA":"DNA","DNA families":"DNA-Familien","Advertise":"Werben","Open data &amp; API":"Offene Daten & API","Documentation":"Dokumentation","Step-by-step setup":"Einrichtung Schritt für Schritt","Connect":"Verbinden","MCP, API, webhooks, n8n":"MCP, API, Webhooks, n8n","Site &amp; Data":"Website & Daten","Privacy":"Datenschutz","Terms":"AGB","Tools":"Tools","Models":"Modelle","Discover":"Entdecken","Explore":"Mehr",
1178      "Tools & Agents":"Tools & Agents","Alternatives":"Alternativen",
1179      "AI Agents":"KI-Agenten","Open-source frameworks":"Open-Source-Frameworks","New":"Neu",
1180      "Tool Guides":"Tool-Guides","76 in-depth guides":"76 ausführliche Guides",
1181      "Tools Directory":"Tool-Verzeichnis","225+ tools":"225+ Tools","Compare Tools":"Tools vergleichen","Categories":"Kategorien",
1182      "AI Pricing":"KI-Preise","Live costs + calculator":"Live-Kosten + Rechner",
1183      "Free vs Paid":"Kostenlos vs Bezahlt","Open vs frontier":"Open vs Frontier","Leaderboard":"Rangliste",
1184      "Compare Models":"Modelle vergleichen","Paid Models":"Bezahlte Modelle",
1185      "All Alternatives":"Alle Alternativen","75+ tools mapped":"75+ Tools erfasst",
1186      "Devin alternatives":"Devin-Alternativen","Cursor alternatives":"Cursor-Alternativen","Notion alternatives":"Notion-Alternativen","Photoshop alternatives":"Photoshop-Alternativen",
1187      "Projects":"Projekte","News":"News","Latest":"Neueste","About":"Über uns","Methodology":"Methodik","My favorites":"Meine Favoriten","Dashboards":"Dashboards",
1188      "Tools catalog":"Tool-Katalog","Catalog":"Katalog","The AI hub.":"Der KI-Hub.","Open and commercial AI, side by side.":"Offene und kommerzielle KI, Seite an Seite.","Submit a tool":"Tool einreichen","Submit a tool or project":"Tool oder Projekt einreichen","Add to the directory":"Zum Hub hinzufügen","Add it to the hub":"Zum Hub hinzufügen","Get a badge":"Badge holen","For your README":"Für deine README",
1189      "Featured:":"Empfohlen:","Explore agents":"Agenten entdecken",
1190      "Stay in the loop":"Bleib auf dem Laufenden","Subscribe":"Abonnieren",
1191      "The best of open-source AI in your inbox — new models, trending projects and releases. No spam, unsubscribe anytime.":"Das Beste aus Open-Source-KI in deinem Postfach — neue Modelle, angesagte Projekte und Releases. Kein Spam, jederzeit abbestellbar.",
1192      "The world's most complete":"Das vollständigste Verzeichnis","open-source AI directory.":"für Open-Source-KI weltweit.","Built for the global community.":"Für die globale Community.",
1193      "More":"Mehr","Home":"Startseite","Models · Free vs Paid":"Modelle · Kostenlos vs Bezahlt","Alternatives to top tools":"Alternativen zu Top-Tools","AI Spaces":"KI-Spaces","News Feed":"News-Feed",
1194      "Independent · Open data":"Unabhängig · Offene Daten"
1195    },
1196    pt: {
1197      "Online":"Online","I can help you find AI tools, compare models and explore projects — or just search.":"Posso ajudá-lo a encontrar ferramentas, comparar modelos e explorar projetos — ou simplesmente pesquisar.","Ask anything.":"Pergunte o que quiser.",
1198      "Updates":"Atualização",
1199      "Hourly":"A cada hora",
1200      "Continue in the assistant":"Continuar no assistente","I could not answer that one.":"A essa não consegui responder.",
1201      "Olud assistant":"Assistente Olud","✨ AI — ask me anything about olud.ai":"✨ IA — pergunte-me tudo sobre olud.ai","Type your question…":"Escreva a sua pergunta…","Questions are logged anonymously to improve the answers.":"As perguntas são registadas de forma anónima para melhorar as respostas.","Hi! I’m the olud.ai assistant. How can I help?":"Olá! Sou o assistente do olud.ai. Como posso ajudar?","Thinking…":"A procurar…","Help":"Ajuda","Close":"Fechar","What is olud.ai?":"O que é o olud.ai?","How do I create an account?":"Como crio uma conta?","What are the plans?":"Quais são os planos?","How do I follow a project?":"C
1201omo sigo um projeto?",
1202      "Search, or ask anything…":"Pesquise, ou faça uma pergunta…","Search tools, projects, models":"Pesquisar ferramentas, projetos, modelos",
1203      "Ask the assistant":"Pergunte ao assistente","Answered with this site's live data":"Resposta com os dados ao vivo do site","No text match":"Sem correspondência de texto",
1204      "Verified \u2014 you are now listed as the maintainer of this project.":"Verificado — agora consta como maintainer deste projeto.","Already verified \u2014 you are listed as the maintainer.":"Já verificado — consta como maintainer.","To claim a project, your account must be signed in with GitHub.":"Para reivindicar um projeto, a sua conta deve iniciar sessão com GitHub.","This repository belongs to another GitHub account.":"Este repositório pertence a outra conta GitHub.","You must be a public member of the organization that owns this repository.":"Deve ser membro público da organização proprietária.","You must appear among the contributors of this repository.":"Deve constar entre os contribuidores do repositório.","Claim failed \u2014 contact [email protected] if you believe this is wrong.":"Reivindicação falhou — escreva para [email protected].",
1205      "Badge copied \u2014 click to copy again":"Selo copiado — clique para copiar novamente",
1206      "Health badge":"Selo de saúde","Copy the Markdown badge for your README":"Copie o selo Markdown para o seu README","Markdown copied!":"Markdown copiado!",
1207      "My Dashboard":"Meu Dashboard","Compose your own \u2014 members":"Componha o seu — membros",
1208      "Save this dashboard":"Guarde este dashboard","Get notified when a new project appears":"Seja avisado de novos projetos","Follow these projects":"Siga estes projetos","Save discoveries to your portfolio":"Guarde descobertas no seu portfólio","Track health evolution over time":"Acompanhe a evolução do health score","Follow your favorite models":"Siga os seus modelos favoritos","Watch these trends from your account":"Acompanhe estas tendências na sua conta","Build your own AI toolbox":"Monte a sua toolbox de IA","Get price-drop alerts on the models you use":"Alertas de descidas de preço dos seus modelos","Follow this industry’s projects":"Siga os projetos deste setor","Save projects":"Guarde projetos","Build AI portfolios":"Crie portfólios de IA","Follow emerging tools":"Siga ferramentas emergentes","Get daily alerts":"Receba alertas diários","Create your free account":"Crie a sua conta gratuita",
1209      "Plans":"Preços","Free to Business plans":"Do plano Free ao Business",
1210      "Industries":"Indústrias","AI by sector: health, finance\u2026":"IA por setor: saúde, finanças…",
1211      "Maintained by":"Mantido por","Are you the maintainer?":"É o maintainer?","Claim this project":"Reivindique este projeto",
1212      "see all":"ver todos",
1213      "accelerating":"em aceleração","detected":"detetado","health":"saúde","/ 7 days":"/ 7 dias","projects":"projetos","more emerging projects":"mais projetos emergentes","// no project matches these filters":"// nenhum projeto corresponde a estes filtros","The discovery scan has not run yet — come back after the next morning build.":"O scan de descoberta ainda não correu — volte após o próximo build matinal.","Could not load the discovery data — please refresh.":"Não foi possível carregar os dados de descoberta — recarregue a página.",
1214      "Emerging":"Emergentes","Detected by our signals":"Detetados pelos nossos sinais","Developer API":"API para programadores","Keys, quotas, fused records":"Chaves, quotas, fichas fundidas",
1215      "Click to rename":"Clique para renomear",
1216      "What is it for?":"Para que serve?","Not qualified yet":"Ainda não qualificada","Saved":"Salvo","Chat assistant":"Assistente de chat","Coding assistant":"Assistente de código","Generate images":"Gerar imagens","100% local & private":"100% local e privado","Easiest setup":"Instalação mais simples","Modest hardware":"Hardware modesto","Serve an API":"Servir uma API",
1217      "members use it":"membros a usam","member uses it":"membro a usa",
1218      "use it":"a usam","Use this stack":"Usar esta stack","Copied to your account":"Copiad
1218a na sua conta","Already in your account":"Já está na sua conta","used by":"usada por",
1219      "Members who use it":"Membros que usam","Community stacks":"Stacks da comunidade",
1220      "Models you follow":"Modelos que você segue","Models":"Modelos","open":"aberto","closed":"fechado","per 1M out":"por 1M saída","Price change over 30 days":"Variação em 30 dias","No model followed yet — search one above.":"Nenhum modelo seguido — busque um acima.","Models I follow (with prices)":"Modelos que sigo (com preços)",
1221      "members saved this":"membros salvaram","member saved this":"membro salvou","public stacks":"stacks públicas","public stack":"stack pública","collections":"coleções","collection":"coleção","Featured in":"Presente em","by":"por","projects":"projetos","project":"projeto",
1222      "Public — anyone with the link":"Público — acessível com o link",
1223      "Add":"Adicionar","Add selected":"Adicionar selecionados","Select all":"Selecionar tudo","from watchlist":"da watchlist","Remove":"Remover","Delete collection":"Excluir coleção","Delete this collection?":"Excluir esta coleção?","Give it a title first.":"Dê um título primeiro.","Everything from your watchlist is already here.":"Tudo da sua watchlist já está aqui.","No projects yet — add one below or pick from your watchlist.":"Nenhum projeto — adicione um abaixo ou escolha da sua watchlist.","View public page":"Ver a página pública","Links":"Links","Projects I follow (with health)":"Projetos que sigo (com saúde)","Save this stack (free account)":"Salvar esta stack (conta gratuita)","Save this stack":"Salvar esta stack","Save to my account":"Salvar na minha conta","Cancel":"Cancelar","Bio":"Bio",
1224      "Public profile":"Perfil público","Alerts":"Alertas","Homepage":"Início","Following":"Seguindo","Stacks":"Stacks","Your data":"Seus dados","Delete account":"Excluir conta",
1225      "Check your inbox — one click to confirm.":"Verifique sua caixa — um clique para confirmar.",
1226      "Members":"Membros",
1227      "Follow":"Seguir","Following":"Seguindo","People you follow":"Membros que você segue","I want to":"Eu quero","Constraints":"Restrições","Open →":"Abrir →",
1228      "Your stacks":"Suas stacks","Open in Stack Builder":"Abrir no Stack Builder","No stack saved yet.":"Nenhuma stack salva.","Build one in the Stack Builder →":"Crie uma no Stack Builder →",
1229      "Your collections":"Suas coleções","Your public profile":"Seu perfil público","Your data":"Seus dados","Alerts you receive":"Seus alertas","Why create an account?":"Por que criar uma conta?","Show on my page":"Mostrar na minha página","Save profile":"Salvar perfil","Public profile":"Perfil público",
1230      "Search":"Buscar",
1231      "Member":"Membro","Featured project":"Projeto em destaque","My stack — projects I follow":"Minha stack — projetos que sigo","Favorites":"Favoritos","Public collections":"Coleções públicas","No public collection yet.":"Nenhuma coleção pública ainda.","An olud.ai member":"Um membro olud.ai","Create your own profile":"Crie seu perfil","Profile not found":"Perfil não encontrado","This profile doesn't exist, or its owner keeps it private.":"Este perfil não existe, ou seu dono o mantém privado.","Member collection":"Coleção de um membro","This collection is empty for now.":"Esta coleção está vazia.","Curated by":"Curadoria de","Create your own collection":"Crie sua coleção",
1232      "My public page":"Minha página pública",
1233      "Your homepage":"Sua página inicial","Live terminal":"Terminal ao vivo","Trending on GitHub":"Tendências no GitHub","What's new":"Novidades","Latest releases":"Últimos lançamentos","News feed":"Notícias","AI tools":"Ferramentas de IA","More tools":"Mais ferramentas","Open-source alternatives":"Alternativas open source","Model comparisons":"Comparações de modelos","Model comparator":"Comparador de modelos","Leaderboard":"Ranking","Categories":"Categorias","Prompts":"Prompts","Why olud.ai":"Por que olud.ai",
1234      "Private — preview":"Privado — prévia","Save to move your page to":"Salve para mover sua página para","Copied!":"Copiado!","Public":"Público",
1235      "Keep track of what matters":"Fique de olho no que importa","Follow projects and get an email when their health drops, a new release ships or the license changes. Free, no password.":"Siga projetos e receba um email quando a saúde cair, sair uma nova versão ou a licença mudar. Grátis, sem senha.","30 seconds":"30 segundos",
1236      "Add to collection":"Adicionar a uma coleção","Collection":"Coleção","In collection":"Na coleção","New collection…":"Nova coleção…","Create":"Criar","No collection yet — create your first one below.":"Nenhuma coleção — crie a primeira abaixo.","Saving…":"Salvando…","Profile saved.":"Perfil salvo.",
1237      "Your account":"Sua conta","Account":"Conta",
1238      "5 favorites max without an account":"Máximo de 5 favoritos sem conta","Create a free account to save unlimited favorites, sync them across devices and get alerts.":"Crie uma conta gratuita para salvar favoritos ilimitados, sincronizá-los entre dispositivos e receber alertas.","Create a free account":"Criar conta gratuita","Your favorites":"Seus favoritos","No favorites yet — tap the ♡ on any project or tool.":"Nenhum favorito ainda — toque no ♡ de qualquer projeto ou ferramenta.",
1239      "Enter your email first.":"Digite primeiro seu email.","Sending…":"Enviando…","Network error — try again.":"Erro de rede — tente novamente.","You are signed in.":"Você está conectado.","Delete your account and all data? This cannot be undone.":"Excluir sua conta e todos os dados? Irreversível.","Health alerts":"Alertas de saúde","Release alerts":"Alertas de releases","Star spikes":"Picos de estrelas","Off":"Off","Signed in as":"Conectado como","Sign out":"Sair","Your watchlist":"Sua watchlist","Delete account":"Excluir conta","Delete my account":"Excluir minha conta","Sign in / create account":"Entrar / criar conta","Email me a link":"Env
1239iar um link","If the address is valid, a sign-in link is on its way. Check your inbox (and spam).":"Se o endereço for válido, um link de acesso está a caminho. Verifique sua caixa (e o spam).",
1240      "Follow":"Seguir","Following":"Seguindo","Sign in to follow projects":"Entre para seguir projetos",
1241      "new AI projects today":"novos projetos de IA hoje",
1242      "Language":"Idioma",
1243      "My favorites":"Meus favoritos","Pulse ranking":"Ranking Pulse","Security advisories":"Avisos de segurança",
1244      "Search":"Buscar",
1245      "All":"Todos",
1246      "Updated":"Atualizado",
1247      "New Space":"Novo espaço",
1248      "New model":"Novo modelo",
1249      "New project":"Novo projeto",
1250      "No favorites yet.":"Nenhum favorito ainda.",
1251      "No tools found.":"Nenhuma ferramenta encontrada.",
1252      "No tools match your filters.":"Nenhuma ferramenta corresponde aos filtros.",
1253      "Trending tools are temporarily unavailable.":"As ferramentas em alta estão temporariamente indisponíveis.",
1254      "Click the heart on any tool to save it here.":"Clique no coração de uma ferramenta para salvá-la aqui.",
1255      "Close":"Fechar",
1256      "Copied!":"Copiado!",
1257      "AI Tools":"Ferramentas IA","Directory":"Diretório","HF Directory":"Diretório HF","Spaces":"Espaços","Top Projects":"Melhores projetos","Trending":"Em alta",
1258      "Model profiles":"Fichas de modelos","Stay in the loop":"Fique por dentro","Trends":"Tendências","Contact":"Contacto","DNA":"ADN","Languages":"Linguagens","Countries":"Paises","DNA families":"Familias de ADN","Advertise":"Anunciar","Open data &amp; API":"Dados abertos e API","Documentation":"Documentação","Step-by-step setup":"Configuração passo a passo","Connect":"Ligar","MCP, API, webhooks, n8n":"MCP, API, webhooks, n8n","Site &amp; Data":"Site e dados","Privacy":"Privacidade","Terms":"Termos","Tools":"Ferramentas","Models":"Modelos","Discover":"Descobrir","Explore":"Explorar",
1259      "Tools & Agents":"Ferramentas & Agentes","Alternatives":"Alternativas",
1260      "AI Agents":"Agentes de IA","Open-source frameworks":"Frameworks open source","New":"Novo",
1261      "Tool Guides":"Guias de ferramentas","76 in-depth guides":"76 guias detalhados",
1262      "Tools Directory":"Diretório de ferramentas","225+ tools":"225+ ferramentas","Compare Tools":"Comparar ferramentas","Categories":"Categorias",
1263      "AI Pricing":"Preços de IA","Live costs + calculator":"Custos ao vivo + calculadora",
1264      "Free vs Paid":"Grátis vs Pago","Open vs frontier":"Open vs frontier","Leaderboard":"Classificação",
1265      "Compare Models":"Comparar modelos","Paid Models":"Modelos pagos",
1266      "All Alternatives":"Todas as alternativas","75+ tools mapped":"75+ ferramentas mapeadas",
1267      "Devin alternatives":"Alternativas ao Devin","Cursor alternatives":"Alternativas ao Cursor","Notion alternatives":"Alternativas ao Notion","Photoshop alternatives":"Alternativas ao Photoshop",
1268      "Projects":"Projetos","News":"Notícias","Latest":"Recentes","About":"Sobre","Methodology":"Metodologia","Dashboards":"Painéis",
1269      "Tools catalog":"Catálogo de ferramentas","Catalog":"Catálogo","The AI hub.":"O hub da IA.","Open and commercial AI, side by side.":"IA aberta e comercial, lado a lado.","Submit a tool":"Enviar uma ferramenta","Submit a tool or project":"Enviar uma ferramenta ou um projeto","Add to the directory":"Adicionar ao hub","Add it to the hub":"Adicionar ao hub","Get a badge":"Obter um selo","For your README":"Para o seu README",
1270      "Featured:":"Destaque:","Explore agents":"Explorar agentes",
1271      "Stay in the loop":"Fique por dentro","Subscribe":"Inscrever-se",
1272      "The best of open-source AI in your inbox — new models, trending projects and releases. No spam, unsubscribe anytime.":"O melhor da IA open source na sua caixa de entrada — novos modelos, projetos em alta e lançamentos. Sem spam, cancele quando quiser.",
1273      "The world's most complete":"O diretório mais completo","open-source AI directory.":"de IA open source do mundo.","Built for the global community.":"Feito para a comunidade global.",
1274      "More":"Mais","Home":"Início","Models · Free vs Paid":"Modelos · Grátis vs Pago","Alternatives to top tools":"Alternativas a ferramentas populares","AI Spaces":"Espaços IA","News Feed":"Feed de notícias",
1275      "Independent · Open data":"Independente · Dados abertos"
1276    }
1277  };
1278  /* ═══ V22 — i18n du badge santé + page /health.html (fusionné dans NAV_I18N) ═══ */
1279  (function(){
1280    var H = {
1281      fr: { "Alive or abandoned?":"Vivant ou abandonné ?","Project health":"Santé du projet","Project Health":"Santé des projets",
1282        "Thriving":"Florissant","Healthy":"Sain","Maintained":"Maintenu","Slowing down":"En ralentissement","At risk":"À risque","Archived":"Archivé","Disabled":"Désactivé",
1283        "Activity":"Activité","Momentum":"Élan","Community":"Communauté","Maintenance":"Maintenance",
1284        "Checked":"Vérifié le","See all project health":"Voir la santé de tous les projets",
1285        "Actively developed, with a healthy community behind it.":"Développement actif, porté par une communauté solide.",
1286        "Regularly maintained. Safe to build on.":"Maintenu régulièrement. Fiable pour construire dessus.",
1287        "Still maintained, but development has slowed.":"Toujours maintenu, mais le développement a ralenti.",
1288        "Activity is dropping. Check before you build on it.":"L'activité baisse. Vérifiez avant de construire dessus.",
1289        "Little to no recent activity. It may be abandoned.":"Peu ou pas d'activité récente. Il est peut-être abandonné.",
1290        "The maintainers have archived this repository. It is read-only and will not be updated.":"Les mainteneurs ont archivé ce dépôt. Il est en lecture seule et ne sera plus mis à jour.",
1291        "This repository has been disabled on GitHub.":"Ce dépôt a été désactivé sur GitHub.",
1292        "No commits in the last 4 weeks.":"Aucun commit depuis 4 semaines." },
1293      it: { "Alive or abandoned?":"Vivo o abbandonato?","Project health":"Salute del progetto","Project Health":"Salute dei progetti",
1294        "Thriving":"Fiorente","Healthy":"Sano","Maintained":"Mantenuto","Slowing down":"In rallentamento","At risk":"A rischio","Archived":"Archiviato","Disabled":"Disabilitato",
1295        "Activity":"Attività","Momentum":"Slancio","Community":"Comunità","Maintenance":"Manutenzione",
1296        "Checked":"Verificato il","See all project health":"Vedi la salute di tutti i progetti",
1297        "Actively developed, with a healthy community behind it.":"Sviluppo attivo, sostenuto da una comunità solida.",
1298        "Regularly maintained. Safe to build on.":"Mantenuto regolarmente. Affidabile come base.",
1299        "Still maintained, but development has slowed.":"Ancora mantenuto, ma lo sviluppo è rallentato.",
1300        "Activity is dropping. Check before you build on it.":"L'attività sta calando. Verifica prima di usarlo come base.",
1301        "Little to no recent activity. It may be abandoned.":"Attività recente scarsa o nulla. Potrebbe essere abbandonato.",
1302        "The maintainers have archived this repository. It is read-only and will not be updated.":"I manutentori hanno archiviato questo repository. È in sola lettura e non sarà più aggiornato.",
1303        "This repository has been disabled on GitHub.":"Questo repository è stato disabilitato su GitHub.",
1304        "No commits in the last 4 weeks.":"Nessun commit nelle ultime 4 settimane." },
1305      es: { "Alive or abandoned?":"¿Vivo o abandonado?","Project health":"Salud del proyecto","Project Health":"Salud de los proyectos",
1306        "Thriving":"Próspero","Healthy":"Saludable","Maintained":"Mantenido","Slowing down":"En desaceleración","At risk":"En riesgo","Archived":"Archivado","Disabled":"Deshabilitado",
1307        "Activity":"Actividad","Momentum":"Impulso","Community":"Comunidad","Maintenance":"Mantenimiento",
1308        "Checked":"Verificado el","See all project health":"Ver la salud de todos los proyectos",
1309        "Actively developed, with a healthy community behind it.":"Desarrollo activo, respaldado por una comunidad sólida.",
1310        "Regularly maintained. Safe to build on.":"Mantenido con regularid
1310ad. Fiable para construir sobre él.",
1311        "Still maintained, but development has slowed.":"Todavía mantenido, pero el desarrollo se ha ralentizado.",
1312        "Activity is dropping. Check before you build on it.":"La actividad está bajando. Compruébalo antes de construir sobre él.",
1313        "Little to no recent activity. It may be abandoned.":"Poca o ninguna actividad reciente. Puede estar abandonado.",
1314        "The maintainers have archived this repository. It is read-only and will not be updated.":"Los mantenedores han archivado este repositorio. Es de solo lectura y no se actualizará.",
1315        "This repository has been disabled on GitHub.":"Este repositorio ha sido deshabilitado en GitHub.",
1316        "No commits in the last 4 weeks.":"Ningún commit en las últimas 4 semanas." },
1317      de: { "Alive or abandoned?":"Lebendig oder aufgegeben?","Project health":"Projektgesundheit","Project Health":"Projektgesundheit",
1318        "Thriving":"Blühend","Healthy":"Gesund","Maintained":"Gepflegt","Slowing down":"Verlangsamt sich","At risk":"Gefährdet","Archived":"Archiviert","Disabled":"Deaktiviert",
1319        "Activity":"Aktivität","Momentum":"Dynamik","Community":"Community","Maintenance":"Wartung",
1320        "Checked":"Geprüft am","See all project health":"Gesundheit aller Projekte ansehen",
1321        "Actively developed, with a healthy community behind it.":"Aktiv entwickelt, getragen von einer starken Community.",
1322        "Regularly maintained. Safe to build on.":"Regelmäßig gepflegt. Eine sichere Basis.",
1323        "Still maintained, but development has slowed.":"Wird noch gepflegt, aber die Entwicklung hat sich verlangsamt.",
1324        "Activity is dropping. Check before you build on it.":"Die Aktivität nimmt ab. Vor dem Einsatz prüfen.",
1325        "Little to no recent activity. It may be abandoned.":"Kaum oder keine aktuelle Aktivität. Möglicherweise aufgegeben.",
1326        "The maintainers have archived this repository. It is read-only and will not be updated.":"Die Maintainer haben dieses Repository archiviert. Es ist schreibgeschützt und wird nicht mehr aktualisiert.",
1327        "This repository has been disabled on GitHub.":"Dieses Repository wurde auf GitHub deaktiviert.",
1328        "No commits in the last 4 weeks.":"Keine Commits in den letzten 4 Wochen." },
1329      pt: { "Alive or abandoned?":"Vivo ou abandonado?","Project health":"Saúde do projeto","Project Health":"Saúde dos projetos",
1330        "Thriving":"Próspero","Healthy":"Saudável","Maintained":"Mantido","Slowing down":"Em desaceleração","At risk":"Em risco","Archived":"Arquivado","Disabled":"Desativado",
1331        "Activity":"Atividade","Momentum":"Impulso","Community":"Comunidade","Maintenance":"Manutenção",
1332        "Checked":"Verificado em","See all project health":"Ver a saúde de todos os projetos",
1333        "Actively developed, with a healthy community behind it.":"Desenvolvimento ativo, apoiado por uma comunidade sólida.",
1334        "Regularly maintained. Safe to build on.":"Mantido regularmente. Confiável para construir em cima.",
1335        "Still maintained, but development has slowed.":"Ainda mantido, mas o desenvolvimento desacelerou.",
1336        "Activity is dropping. Check before you build on it.":"A atividade está caindo. Verifique antes de construir em cima.",
1337        "Little to no recent activity. It may be abandoned.":"Pouca ou nenhuma atividade recente. Pode estar abandonado.",
1338        "The maintainers have archived this repository. It is read-only and will not be updated.":"Os mantenedores arquivaram este repositório. Está em modo somente leitura e não será mais atualizado.",
1339        "This repository has been disabled on GitHub.":"Este repositório foi desativado no GitHub.",
1340        "No commits in the last 4 weeks.":"Nenhum commit nas últimas 4 semanas." }
1341    };
1342    for (var hl in H){ if (!NAV_I18N[hl]) NAV_I18N[hl] = {}; for (var hk in H[hl]) NAV_I18N[hl][hk] = H[hl][hk]; }
1343  })();
1344  /* ═══ V23 — i18n du Terminal (dashboard.html, contenu rendu en JS) ═══ */
1345  (function(){
1346    var T = {
1347      fr: { "projects today":"projets aujourd'hui","today":"aujourd'hui","this week":"cette semaine","new Spaces":"nouveaux Spaces",
1348        "cheaper output than the top paid flagship":"moins cher en sortie que le meilleur flagship payant",
1349        "healthy":"sains","slowing":"en ralentissement","at risk":"à risque","archived":"archivés","ARCHIVED":"ARCHIVÉ",
1350        "Paper of the day":"Paper du jour",
1351        "No velocity data yet.":"Pas encore de données de vélocité.",
1352        "No repricing detected recently.":"Aucun changement de prix récent.",
1353        "Model data unavailable.":"Données des modèles indisponibles.",
1354        "Health data unavailable.":"Données de santé indisponibles.",
1355        "Rankings unavailable.":"Classements indisponibles.",
1356        "Nothing yet today.":"Rien pour l'instant aujourd'hui.",
1357        "Releases are being collected.":"Les releases sont en cours de collecte." },
1358      it: { "projects today":"progetti oggi","today":"oggi","this week":"questa settimana","new Spaces":"nuovi Spaces",
1359        "cheaper output than the top paid flagship":"più economico in output del miglior flagship a pagamento",
1360        "healthy":"sani","slowing":"in rallentamento","at risk":"a rischio","archived":"archiviati","ARCHIVED":"ARCHIVIATO",
1361        "Paper of the day":"Paper del giorno",
1362        "No velocity data yet.":"Ancora nessun dato di velocità.",
1363        "No repricing detected recently.":"Nessuna variazione di prezzo recente.",
1364        "Model data unavailable.":"Dati dei modelli non disponibili.",
1365        "Health data unavailable.":"Dati sulla salute non disponibili.",
1366        "Rankings unavailable.":"Classifiche non disponibili.",
1367        "Nothing yet today.":"Ancora niente oggi.",
1368        "Releases are being collected.":"Le release sono in fase di raccolta." },
1369      es: { "projects today":"proyectos hoy","today":"hoy","this week":"esta semana","new Spaces":"nuevos Spaces",
1370        "cheaper output than the top paid flagship":"más barato en salida que el mejor flagship de pago",
1371        "healthy":"saludables","slowing":"en desaceleración","at risk":"en riesgo","archived":"archivados","ARCHIVED":"ARCHIVADO",
1372        "Paper of the day":"Paper del día",
1373        "No velocity data yet.":"Aún no hay datos de velocidad.",
1374        "No repricing detected recently.":"Sin cambios de precio recientes.",
1375        "Model data unavailable.":"Datos de modelos no disponibles.",
1376        "Health data unavailable.":"Datos de salud no disponibles.",
1377        "Rankings unavailable.":"Rankings no disponibles.",
1378        "Nothing yet today.":"Nada todavía hoy.",
1379        "Releases are being collected.":"Las releases se están recopilando." },
1380      de: { "projects today":"Projekte heute","today":"heute","this week":"diese Woche","new Spaces":"neue Spaces",
1381        "cheaper output than the top paid flagship":"günstigerer Output als das beste Bezahlmodell",
1382        "healthy":"gesund","slowing":"verlangsamt","at risk":"gefährdet","archived":"archiviert","ARCHIVED":"ARCHIVIERT",
1383        "Paper of the day":"Paper des Tages",
1384        "No velocity data yet.":"Noch keine Velocity-Daten.",
1385        "No repricing detected recently.":"Keine aktuellen Preisänderungen.",
1386        "Model data unavailable.":"Modelldaten nicht verfügbar.",
1387        "Health data unavailable.":"Gesundheitsdaten nicht verfügbar.",
1388        "Rankings unavailable.":"Rankings nicht verfügbar.",
1389        "Nothing yet today.":"Heute noch nichts.",
1390        "Releases are being collected.":"Releases werden gesammelt." },
1391      pt: { "projects today":"projetos hoje","today":"hoje","this week":"esta semana","new Spaces":"novos Spaces",
1392        "cheaper output than the top paid flagship":"mais barato na saída que o melhor flagship pago",
1393        "healthy":"saudáveis","slowing":"em desaceleração","at risk":"em risco","archived":"arquivados","ARCHIVED":"ARQUIVADO",
1394        "Paper of the day":"Paper do dia",
1395        "No velocity data yet.":"Ainda sem dados de velocidade.",
1396        "No repricing detected recently.":"Nenhuma mudança de preço recente.",
1397        "Model data unavailable.":"Dados dos modelos indisponíveis.",
1398        "Health data unavailable.":"Dados de saúde indisponíveis.",
1399        "Rankings unavailable.":"Rankings indisponíveis.",
1400        "Nothing yet today.":"Ainda nada hoje.",
1401        "Releases are being collected.":"As releases estão sendo coletadas." }
1402    };
1403    for (var tl in T){ if (!NAV_I18N[tl]) NAV_I18N[tl] = {}; for (var tk in T[tl]) NAV_I18N[tl][tk] = T[tl][tk]; }
1404  })();
1405  /* ═══ V25 — i18n des vues agrandies du Terminal (titres + explications) ═══ */
1406  (function(){
1407    var E = {
1408      fr: { "more":"de plus","Recent changes":"Changements récents","All tracked prices":"Tous les prix suivis","New HF Spaces":"Nouveaux Spaces HF",
1409        "Market movers":"Les plus fortes hausses","Price action":"Mouvements de prix","Open vs paid":"Open vs payant","Health monitor":"Moniteur de santé","Media ELO leaders":"Leaders ELO médias","Fresh today":"Nouveautés du jour","Releases":"Releases",
1410        "Stars gained in the last 24 hours. A sudden spike usually means a launch, a viral post or a major release worth checking out.":"Étoiles gagnées ces dernières 24 heures. Un pic soudain signale généralement un lancement, un post viral ou une release majeure qui mérite le coup d'œil.",
1411        "Every time a provider changes a price on OpenRouter, it lands here. Green means it got cheaper. Open-weight models are in a constant price war; frontier prices rarely move. Open-model prices are the cheapest provider rate via OpenRouter; official maker rates may be higher.":"Chaque changement de prix d'un fournisseur sur OpenRouter atterrit ici. Vert = moins cher. Les modèles open se livrent une guerre des prix permanente ; les prix frontier bougent rarement. Les prix des modèles open sont ceux du fournisseur le moins cher via OpenRouter ; les tarifs officiels des éditeurs peuvent être plus élevés.",
1412        "The top-ranked open-weight models side by side with the top paid ones. Prices are per 1M tokens, input / output.":"Les meilleurs modèles open face aux meilleurs payants. Prix pour 1M de tokens, entrée / sortie.",
1413        "Our daily GitHub health check: recent activity, commit momentum, community size and issue upkeep. Archived or at-risk projects are flagged here first.":"Notre bilan de santé GitHub quotidien : activité récente, élan des commits, taille de la communauté et gestion des issues. Les projets archivés ou à risque apparaissent ici en premier.",
1414        "Blind human preference: people compare two outputs without knowing which model produced them, and vote. The only honest way to rank media models.":"Préférence humaine en aveugle : des personnes comparent deux résultats sans savoir quel modèle les a produits, et votent. La seule façon honnête de classer les modèles médias.",
1415        "Brand-new repositories created on GitHub, and the research paper the community is upvoting right now on Hugging Face.":"Les tout nouveaux dépôts créés sur GitHub, et le papier de recherche que la communauté plébiscite en ce moment sur Hugging Face.",
1416        "The latest versions shipped by the open-source tools we track, with their release level.":"Les dernières versions publiées par les outils open source que nous suivons, avec leur niveau de release." },
1417      it: { "more":"altri","Recent changes":"Variazioni recenti","All tracked prices":"Tutti i prezzi monitorati","New HF Spaces":"Nuovi Spaces HF",
1418        "Market movers":"I maggiori rialzi","Price action":"Movimenti di prezzo","Open vs paid":"Open vs a pagamento","Health monitor":"Monitor di salute","Media ELO leaders":"Leader ELO media","Fresh today":"Novità di oggi","Releases":"Release",
1419        "Stars gained in the last 24 hours. A sudden spike usually means a launch, a viral post or a major release worth checking out.":"Stelle guadagnate nelle ultime 24 ore. Un picco improvviso indica di solito un lancio, un post virale o una release importante da tenere d'occhio.",
1420        "Every time a provider changes a price on OpenRouter, it lands here. Green means it got cheaper. Open-weight models are in a constant price war; frontier prices rarely move. Open-model prices are the cheapest provider rate via OpenRouter; official maker rates may be higher.":"Ogni variazione di prezzo su OpenRouter finisce qui. Verde = più economico. I modelli open sono in una guerra dei prezzi continua; i prezzi frontier si muovono raramente. I prezzi dei modelli open sono la tariffa del provider più economico via OpenRouter; le tariffe ufficiali dei produttori possono essere più alte.",
1421        "The top-ranked open-weight models side by side with the top paid ones. Prices are per 1M tokens, input / output.":"I migliori modelli open affiancati ai migliori a pagamento. Prezzi per 1M di token, input / output.",
1422        "Our daily GitHub health check: recent activity, commit momentum, community size and issue upkeep. Archived or at-risk projects are flagged here first.":"Il nostro check di salute GitHub quotidiano: attività recente, slancio dei commit, dimensione della comunità e gestione delle issue. I progetti archiviati o a rischio compaiono qui per primi.",
1423        "Blind human preference: people compare two outputs without knowing which model produced them, and vote. The only honest way to rank media models.":"Preferenza umana alla cieca: le persone confrontano due risultati senza sapere quale modello li ha prodotti, e votano. L'unico modo onesto di classificare i modelli media.",
1424        "Brand-new repositories created on GitHub, and the research paper the community is upvoting right now on Hugging Face.":"I repository appena creati su GitHub e il paper di ricerca che la comunità sta votando in questo momento su Hugging Face.",
1425        "The latest versions shipped by the open-source tools we track, with their release level.":"Le ultime versioni pubblicate dagli strumenti open source che seguiamo, con il loro livello di release." },
1426      es: { "more":"más","Recent changes":"Cambios recientes","All tracked prices":"Todos los precios monitorizados","New HF Spaces":"Nuevos Spaces HF",
1427        "Market movers":"Mayores subidas","Price action":"Movimientos de precios","Open vs paid":"Open vs de pago","Health monitor":"Monitor de salud","Media ELO leaders":"Líderes ELO de medios","Fresh today":"Novedades de hoy","Releases":"Releases",
1428        "Stars gained in the last 24 hours. A sudden spike usually means a launch, a viral post or a major release worth checking out.":"Estrellas ganadas en las últimas 24 horas. Un pico repentino suele indicar un lanzamiento, un post viral o una release importante que conviene mirar.",
1429        "Every time a provider changes a price on OpenRouter, it lands here. Green means it got cheaper. Open-weight models are in a constant price war; frontier prices rarely move. Open-model prices are the cheapest provider rate via OpenRouter;
1429 official maker rates may be higher.":"Cada cambio de precio de un proveedor en OpenRouter aparece aquí. Verde = más barato. Los modelos open viven una guerra de precios constante; los precios frontier apenas se mueven. Los precios de los modelos open son la tarifa del proveedor más barato vía OpenRouter; las tarifas oficiales de los creadores pueden ser más altas.",
1430        "The top-ranked open-weight models side by side with the top paid ones. Prices are per 1M tokens, input / output.":"Los mejores modelos open junto a los mejores de pago. Precios por 1M de tokens, entrada / salida.",
1431        "Our daily GitHub health check: recent activity, commit momentum, community size and issue upkeep. Archived or at-risk projects are flagged here first.":"Nuestro chequeo diario de salud en GitHub: actividad reciente, ritmo de commits, tamaño de la comunidad y gestión de issues. Los proyectos archivados o en riesgo aparecen aquí primero.",
1432        "Blind human preference: people compare two outputs without knowing which model produced them, and vote. The only honest way to rank media models.":"Preferencia humana a ciegas: las personas comparan dos resultados sin saber qué modelo los produjo, y votan. La única forma honesta de clasificar modelos de medios.",
1433        "Brand-new repositories created on GitHub, and the research paper the community is upvoting right now on Hugging Face.":"Los repositorios recién creados en GitHub y el paper de investigación que la comunidad está votando ahora mismo en Hugging Face.",
1434        "The latest versions shipped by the open-source tools we track, with their release level.":"Las últimas versiones publicadas por las herramientas open source que seguimos, con su nivel de release." },
1435      de: { "more":"mehr","Recent changes":"Aktuelle Änderungen","All tracked prices":"Alle beobachteten Preise","New HF Spaces":"Neue HF Spaces",
1436        "Market movers":"Größte Anstiege","Price action":"Preisbewegungen","Open vs paid":"Open vs. Bezahlt","Health monitor":"Gesundheitsmonitor","Media ELO leaders":"Media-ELO-Spitzenreiter","Fresh today":"Neu heute","Releases":"Releases",
1437        "Stars gained in the last 24 hours. A sudden spike usually means a launch, a viral post or a major release worth checking out.":"In den letzten 24 Stunden gewonnene Sterne. Ein plötzlicher Anstieg bedeutet meist einen Launch, einen viralen Post oder ein wichtiges Release, das einen Blick wert ist.",
1438        "Every time a provider changes a price on OpenRouter, it lands here. Green means it got cheaper. Open-weight models are in a constant price war; frontier prices rarely move. Open-model prices are the cheapest provider rate via OpenRouter; official maker rates may be higher.":"Jede Preisänderung eines Anbieters auf OpenRouter landet hier. Grün = günstiger. Open-Modelle liefern sich einen ständigen Preiskampf; Frontier-Preise bewegen sich selten. Die Preise der Open-Modelle entsprechen dem günstigsten Anbieter über OpenRouter; die offiziellen Herstellerpreise können höher liegen.",
1439        "The top-ranked open-weight models side by side with the top paid ones. Prices are per 1M tokens, input / output.":"Die besten Open-Weight-Modelle neben den besten Bezahlmodellen. Preise pro 1M Tokens, Input / Output.",
1440        "Our daily GitHub health check: recent activity, commit momentum, community size and issue upkeep. Archived or at-risk projects are flagged here first.":"Unser täglicher GitHub-Gesundheitscheck: aktuelle Aktivität, Commit-Dynamik, Community-Größe und Issue-Pflege. Archivierte oder gefährdete Projekte erscheinen hier zuerst.",
1441        "Blind human preference: people compare two outputs without knowing which model produced them, and vote. The only honest way to rank media models.":"Blinde menschliche Präferenz: Menschen vergleichen zwei Ergebnisse, ohne zu wissen, welches Modell sie erzeugt hat, und stimmen ab. Die einzig ehrliche Art, Media-Modelle zu bewerten.",
1442        "Brand-new repositories created on GitHub, and the research paper the community is upvoting right now on Hugging Face.":"Brandneue Repositories auf GitHub und das Forschungspaper, das die Community gerade auf Hugging Face hochvotet.",
1443        "The latest versions shipped by the open-source tools we track, with their release level.":"Die neuesten Versionen der Open-Source-Tools, die wir verfolgen, mit ihrem Release-Level." },
1444      pt: { "more":"mais","Recent changes":"Mudanças recentes","All tracked prices":"Todos os preços monitorados","New HF Spaces":"Novos Spaces HF",
1445        "Market movers":"Maiores altas","Price action":"Movimentos de preços","Open vs paid":"Open vs pago","Health monitor":"Monitor de saúde","Media ELO leaders":"Líderes ELO de mídia","Fresh today":"Novidades de hoje","Releases":"Releases",
1446        "Stars gained in the last 24 hours. A sudden spike usually means a launch, a viral post or a major release worth checking out.":"Estrelas ganhas nas últimas 24 horas. Um pico repentino geralmente indica um lançamento, um post viral ou uma release importante que vale conferir.",
1447        "Every time a provider changes a price on OpenRouter, it lands here. Green means it got cheaper. Open-weight models are in a constant price war; frontier prices rarely move. Open-model prices are the cheapest provider rate via OpenRouter; official maker rates may be higher.":"Cada mudança de preço de um provedor no OpenRouter ap
1447arece aqui. Verde = mais barato. Os modelos open vivem uma guerra de preços constante; os preços frontier raramente mudam. Os preços dos modelos open são a tarifa do provedor mais barato via OpenRouter; as tarifas oficiais dos criadores podem ser mais altas.",
1448        "The top-ranked open-weight models side by side with the top paid ones. Prices are per 1M tokens, input / output.":"Os melhores modelos open lado a lado com os melhores pagos. Preços por 1M de tokens, entrada / saída.",
1449        "Our daily GitHub health check: recent activity, commit momentum, community size and issue upkeep. Archived or at-risk projects are flagged here first.":"Nosso check-up diário de saúde no GitHub: atividade recente, ritmo de commits, tamanho da comunidade e gestão de issues. Projetos arquivados ou em risco aparecem aqui primeiro.",
1450        "Blind human preference: people compare two outputs without knowing which model produced them, and vote. The only honest way to rank media models.":"Preferência humana às cegas: as pessoas comparam dois resultados sem saber qual modelo os produziu, e votam. A única forma honesta de classificar modelos de mídia.",
1451        "Brand-new repositories created on GitHub, and the research paper the community is upvoting right now on Hugging Face.":"Os repositórios recém-criados no GitHub e o paper de pesquisa que a comunidade está votando agora no Hugging Face.",
1452        "The latest versions shipped by the open-source tools we track, with their release level.":"As versões mais recentes lançadas pelas ferramentas open source que acompanhamos, com seu nível de release." }
1453    };
1454    for (var el in E){ if (!NAV_I18N[el]) NAV_I18N[el] = {}; for (var ek in E[el]) NAV_I18N[el][ek] = E[el][ek]; }
1455  })();
1456  /* ═══ V26 — i18n de la barre recherche/tri des vues agrandies ═══ */
1457  (function(){
1458    var Q = {
1459      fr: { "No match.":"Aucun résultat.","Stars":"Étoiles","Price":"Prix","Score":"Score","ELO":"ELO","Date":"Date" },
1460      it: { "No match.":"Nessun risultato.","Stars":"Stelle","Price":"Prezzo","Score":"Punteggio","ELO":"ELO","Date":"Data" },
1461      es: { "No match.":"Sin resultados.","Stars":"Estrellas","Price":"Precio","Score":"Puntuación","ELO":"ELO","Date":"Fecha" },
1462      de: { "No match.":"Kein Treffer.","Stars":"Sterne","Price":"Preis","Score":"Score","ELO":"ELO","Date":"Datum" },
1463      pt: { "No match.":"Nenhum resultado.","Stars":"Estrelas","Price":"Preço","Score":"Pontuação","ELO":"ELO","Date":"Data" }
1464    };
1465    for (var ql in Q){ if (!NAV_I18N[ql]) NAV_I18N[ql] = {}; for (var qk in Q[ql]) NAV_I18N[ql][qk] = Q[ql][qk]; }
1466  })();
1467  // Maillage : pages réellement traduites (mêmes que le générateur). Les autres restent EN → pas de 404.
1468  var NAV_LMAP = {
1469    '/':'/', '/index.html':'/',
1470    '/tools.html':'/tools/', '/tools/':'/tools/',
1471    '/blog.html':'/blog/', '/blog/':'/blog/',
1472    '/prompts.html':'/prompts.html',
1473    '/spaces.html':'/spaces/', '/spaces/':'/spaces/',
1474    '/compare.html':'/compare/', '/compare/':'/compare/',
1475    '/category.html':'/category.html',
1476    '/compare-tools.html':'/compare-tools.html',
1477    '/news.html':'/news.html',
1478    '/prompts.html':'/prompts.html',
1479    '/latest.html':'/latest.html',
1480    '/releases.html':'/releases.html',
1481    '/emerging.html':'/emerging.html',
1482    '/plans.html':'/plans.html',
1483    '/my-dashboard.html':'/my-dashboard.html',
1484    '/vertical/':'/vertical/',
1485    '/vertical/healthcare.html':'/vertical/healthcare.html',
1486    '/vertical/finance.html':'/vertical/finance.html',
1487    '/vertical/legal.html':'/vertical/legal.html',
1488    '/vertical/security.html':'/vertical/security.html',
1489    '/vertical/robotics.html':'/vertical/robotics.html',
1490    '/vertical/education.html':'/vertical/education.html',
1491    '/vertical/science.html':'/vertical/science.html',
1492    '/dashboard.html':'/dashboard.html',
1493    '/pricing.html':'/pricing.html',
1494    '/paid-models.html':'/paid-models.html',
1495    '/submit.html':'/submit.html',
1496    '/badge.html':'/badge.html',
1497    '/about.html':'/about.html',
1498    '/methodology.html':'/methodology.html',
1499    '/favorites.html':'/favorites.html',
1500    '/stack-builder.html':'/stack-builder.html',
1501    '/api.html':'/api.html',
1502    '/connect.html':'/connect.html',
1503    '/docs.html':'/docs.html',
1504    '/trends.html':'/trends.html',
1505    '/advertise.html':'/advertise.html',
1506    '/privacy-policy/':'/privacy-policy/',
1507    '/terms/':'/terms/',
1508    '/agents/':'/agents/',
1509    '/leaderboard.html':'/leaderboard.html',
1510    '/health.html':'/health.html',
1511    '/dna.html':'/dna.html',
1512    '/languages.html':'/languages.html',
1513    '/countries.html':'/countries.html',
1514    '/contact.html':'/contact.html',
1515    '/projects.html':'/projects/', '/projects/':'/projects/',
1516    '/models-vs/':'/models-vs/'
1517  };
1518  var NAV_LPREF = ['/alternatives/', '/tool/', '/vs/', '/project/', '/reports/', '/models-vs/'];
1519
1520  // Localise UN href interne vers /xx/ (sinon le renvoie inchangé).
1521  function localizeHref(href){
1522    if (NAV_LANG === 'en' || !href || href.charAt(0) !== '/') return href;   // externes / # / javascript: ignorés
1523    var hash = '';
1524    var hi = href.indexOf('#'); if (hi >= 0){ hash = href.slice(hi); href = href.slice(0, hi); }
1525    if (/^\/(fr|it|es|de|pt)(\/|$)/.test(href)) return href + hash;           // déjà préfixé
1526    if (NAV_LMAP.hasOwnProperty(href)) return LP + NAV_LMAP[href] + hash;
1527    if (href.indexOf('/blog/') === 0) return LP + href + hash;
1527  // articles de blog traduits (y compris /blog/alternatives/)
1528    for (var i = 0; i < NAV_LPREF.length; i++){ if (href.indexOf(NAV_LPREF[i]) === 0) return LP + href + hash; }
1529    return href + hash;                                                       // non mappé -> reste EN (pas de 404)
1530  }
1531
1532  // Localise TOUS les liens internes d'un sous-arbre (menu + footer + contenu + cartes construites en JS).
1533  function localizeAllLinks(root){
1534    if (NAV_LANG === 'en') return;
1535    var links = (root || document).querySelectorAll('a[href]');
1536    for (var k = 0; k < links.length; k++){
1537      var a = links[k], raw = a.getAttribute('href'), out = localizeHref(raw);
1538      if (out !== raw) a.setAttribute('href', out);
1539    }
1540  }
1541
1542  // ═══ Localisation des liens AUTONOME (ne dépend PAS de l'injection du menu) ═══
1543  // Corrige tous les liens internes vers /xx/ sur les pages traduites : contenu statique
1544  // ET cartes construites en JS (en vedette, more tools, recherche…). Trois filets :
1545  //   1) au clic (avant navigation) — increvable, capte même les rendus tardifs
1546  //   2) passe initiale au chargement du DOM
1547  //   3) MutationObserver — capte les liens ajoutés après coup
1548  if (NAV_LANG !== 'en'){
1549    document.addEventListener('click', function(e){
1550      if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
1551      /* V106 — Un bouton d'action posé DANS une carte-lien (cœur, partage) n'est pas
1552         une navigation. Sans ce garde-fou, cet intercepteur (en phase CAPTURE, donc
1553         prioritaire sur le handler du bouton) forçait location.assign() et le clic
1554         sur le cœur envoyait l'utilisateur sur la fiche. */
1555      if (e.target && e.target.closest && e.target.closest('button, .fav-btn, .osai-share, [data-share]')) return;
1556      var a = e.target && e.target.closest ? e.target.closest('a[href]') : null;
1557      if (!a) return;
1558      var tgt = a.getAttribute('target');
1559      if (tgt && tgt !== '_self') return;                       // nouvel onglet / autre cible : laisser faire
1560      var raw = a.getAttribute('href'), loc = localizeHref(raw);
1561      if (loc && loc !== raw){
1562        a.setAttribute('href', loc);                            // pour l'affichage au survol
1563        e.preventDefault();
1564        if (e.stopImmediatePropagation) e.stopImmediatePropagation();  // couper tout handler qui irait en EN (ex. fiche-links.js)
1565        window.location.assign(loc);                            // on navigue nous-mêmes vers /xx/
1566      }
1567    }, true);
1568    var _fixOne = function(n){ if (n && n.nodeType === 1 && n.tagName === 'A' && n.getAttribute('href')){ var o = localizeHref(n.getAttribute('href')); if (o !== n.getAttribute('href')) n.setAttribute('href', o); } };
1569    var _runLinkLoc = function(){
1570      localizeAllLinks(document);
1571      localizeTextTree(document.body);
1572      if (window.MutationObserver){
1573        try {
1574          new MutationObserver(function(muts){
1575            for (var i = 0; i < muts.length; i++){
1576              var m = muts[i];
1577              if (m.type === 'attributes'){ _fixOne(m.target); continue; }   // href modifié après coup (ex. fiche-links.js)
1578              var ad = m.addedNodes;
1579              for (var j = 0; j < ad.length; j++){
1580                var n = ad[j];
1581                if (!n) continue;
1582                if (n.nodeType === 3){ localizeTextTree(n); continue; }
1583                if (n.nodeType !== 1) continue;
1584                _fixOne(n);
1585                if (n.querySelectorAll) localizeAllLinks(n);
1586                localizeTextTree(n);
1587              }
1588            }
1589          }).observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['href'] });
1590        } catch(e){}
1591      }
1592    };
1593    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', _runLinkLoc); else _runLinkLoc();
1594  }
1595
1596
1597  // Remplit le sélecteur de langues (desktop #langMenu + mobile #osaiMMLang)
1598  // à partir des hreflang de la page (URL exactes). Repli : accueil de chaque langue.
1599  function buildLangSelector(){
1600    var NAMES = { en:'English', fr:'Français', de:'Deutsch', es:'Español', it:'Italiano', pt:'Português' };
1601    var SHORT = { en:'EN', fr:'FR', de:'DE', es:'ES', it:'IT', pt:'PT' };
1602    var order = ['en','fr','de','es','it','pt'];
1603    var urls = {};
1604    var links = document.querySelectorAll('link[rel="alternate"][hreflang]');
1605    for (var i = 0; i < links.length; i++){
1606      var lg = links[i].getAttribute('hreflang');
1607      if (lg && lg !== 'x-default') urls[lg] = links[i].getAttribute('href');
1608    }
1609    if (Object.keys(urls).length < 2){
1610      /* Page sans hreflang (page compte pas encore régénérée, page PHP…) :
1611         on reconstruit les URLs à partir du CHEMIN COURANT au lieu de renvoyer
1612         tout le monde à l'accueil — c'était la cause du « retour à l'accueil ». */
1613      var bare = location.pathname.replace(/^\/(fr|it|es|de|pt)(?=\/|$)/, '') || '/';
1614      urls = {};
1615      for (var k = 0; k < order.length; k++){
1616        var l = order[k];
1617        urls[l] = (l === 'en' ? '' : '/' + l) + bare + location.search;
1618      }
1619    }
1620    var cur = (NAV_LANG && urls[NAV_LANG]) ? NAV_LANG : 'en';
1621    var lbl = document.getElementById('langCurrentLbl');
1622    if (lbl) lbl.textContent = SHORT[cur] || cur.toUpperCase();
1623    function items(){
1624      var html = '';
1625      for (var j = 0; j < order.length; j++){
1626        var lg = order[j]; if (!urls[lg]) continue;
1627        var act = (lg === cur);
1628        var st = act ? ' style="color:var(--accent);font-weight:600;"' : '';
1629        html += '<a href="' + urls[lg] + '" hreflang="' + lg + '"' + st + '>' + NAMES[lg] + (act ? ' &#10003;' : '') + '</a>';
1630      }
1631      return html;
1632    }
1633    var dd = document.getElementById('langMenu'); if (dd) dd.innerHTML = items();
1634    var mm = document.getElementById('osaiMMLang'); if (mm) mm.innerHTML = items();
1635    /* mémoriser le choix explicite de langue (piloté par la persistance V132) */
1636    [dd, mm].forEach(function(box){
1637      if (!box) return;
1638      box.querySelectorAll('a[hreflang]').forEach(function(a){
1639        a.addEventListener('click', function(){
1640          try {
1641            var lg = a.getAttribute('hreflang');
1642            localStorage.setItem('osai_lang', lg);
1643            if (lg === 'en') sessionStorage.setItem('osai_lang_stay', '1');
1644            else sessionStorage.removeItem('osai_lang_stay');
1645          } catch(e){}
1646        });
1647      });
1648    });
1649  }
1650
1651  /* [01/08, releve en production] Sur la page d'accueil francaise, la plus
1652     grande barre du site affichait encore « Search tools, projects, models… ».
1653     Le traducteur ne parcourt que les noeuds TEXTE : un placeholder est un
1654     ATTRIBUT, il lui echappe par construction. On les traduit donc a la main,
1655     une fois au demarrage, puis apres un delai pour les champs poses plus tard
1656     (la palette est construite a la volee). */
1657  function localiserAttributs(){
1658    if (NAV_LANG === 'en') return;
1659    var champs = document.querySelectorAll('input[placeholder], textarea[placeholder], [aria-label]');
1660    for (var i = 0; i < champs.length; i++) {
1661      var e = champs[i];
1662      ['placeholder', 'aria-label', 'title'].forEach(function(att){
1663        var v = e.getAttribute(att);
1664        if (!v || e.getAttribute('data-i18n-' + att)) return;
1665        var tr = window.osaiT ? window.osaiT(v) : v;
1666        if (tr && tr !== v) { e.setAttribute(att, tr); e.setAttribute('data-i18n-' + att, '1'); }
1667      });
1668    }
1669  }
1670
1671  function localizeNav(){
1672    if (NAV_LANG !== 'en') { try { localStorage.setItem('osai_lang', NAV_LANG); } catch(e){} }
1673    if (NAV_LANG === 'en') return;                 // pages anglaises : aucune modif
1674    localizeAllLinks(document);                    // ← localise les liens internes (menu + contenu) vers /xx/
1675    var dict = NAV_I18N[NAV_LANG];
1676    if (!dict) return;
1677    var ARROWS = /[\s\u25be\u25b8\u25b6\u25bc\u2192\u2190\u00bb\u203a\u2261\u2715\u00d7]+$/;
1678    localizeTextTree(document.body);
1679    localiserAttributs();
1680    setTimeout(localiserAttributs, 1200);          /* champs construits a la volee */
1681    document.addEventListener('focusin', function(){ localiserAttributs(); });
1682    document.documentElement.setAttribute('lang', NAV_LANG);
1683  }
1684
1685  // Traduction d'une chaîne unique — pour les ATTRIBUTS (placeholder, title, aria-label),
1686  // que le MutationObserver ne peut pas traiter. Repli : la chaîne d'origine.
1687  try {
1688    window.osaiT = function(s){
1689      var d = NAV_I18N[NAV_LANG];
1690      return (d && Object.prototype.hasOwnProperty.call(d, s)) ? d[s] : s;
1691    };
1692  } catch(e){}
1693
1694  // Traduit les nœuds texte d'un sous-arbre (correspondance EXACTE avec le dictionnaire → sûr pour le contenu).
1695  // Gère un préfixe emoji/symbole et un suffixe flèche. Accepte un nœud texte ou un élément.
1696  function localizeTextTree(root){
1697    if (NAV_LANG === 'en' || !root) return;
1698    var dict = NAV_I18N[NAV_LANG];
1699    if (!dict || typeof document.createTreeWalker !== 'function') return;
1700    var ARROWS = /[\s\u25be\u25b8\u25b6\u25bc\u2192\u2190\u00bb\u203a\u2261\u2715\u00d7]+$/;
1701    (function walkAll(rt){
1702      if (!rt) return;
1703      var walker = document.createTreeWalker(rt, NodeFilter.SHOW_TEXT, null, false);
1704      var nodes = [], n;
1705      while ((n = walker.nextNode())) nodes.push(n);
1706      nodes.forEach(function(node){
1707        var raw = node.nodeValue;
1708        if (!raw) return;
1709        var trimmed = raw.trim();
1710        if (!trimmed) return;
1711        var suffix = '', core = trimmed, m = trimmed.match(ARROWS);
1712        if (m) { suffix = m[0].trim(); core = trimmed.slice(0, trimmed.length - m[0].length).trim(); }
1713        var prefix = '', pm = core.match(/^([^\p{L}\p{N}]+?)\s*([\p{L}\p{N}][\s\S]*)$/u);
1714        if (pm) { prefix = pm[1]; core = pm[2]; }
1715        if (Object.prototype.hasOwnProperty.call(dict, core)) {
1716          var lead = (raw.match(/^\s*/) || [''])[0];
1717          var tail = (raw.match(/\s*$/) || [''])[0];
1718          node.nodeValue = lead + (prefix ? prefix + ' ' : '') + dict[core] + (suffix ? ' ' + suffix : '') + tail;
1719        }
1720      });
1721    })(root.nodeType === 3 ? root.parentNode : root);
1722  }
1723
1724  // ═══ CSS des boutons (cmdk + favoris) injecté pour toutes les pages ═══
1725  var navCSS = `:root{ --logo-green:var(--accent); }
1726body.dark{ --logo-green:var(--accent) !important; }
1727@media(min-width:901px){ #site-nav .nav-links{ gap:.8rem !important; } #site-nav .nav-links>a, #site-nav .nav-dropdown-btn{ font-size:.67rem !important; letter-spacing:.04em !important; } #site-nav .lang-dd .nav-dropdown-btn{ padding:0 !important; } }
1728:root{--logo-green:#6366f1;} body.dark{--logo-green:#818cf8;} #site-nav .theme-toggle{ display:inline-block !important; visibility:visible !important; opacity:1 !important; }
1729.theme-toggle{ background:var(--surface2); border:1px solid var(--border2); border-radius:100px; width:56px; height:30px; cursor:pointer; position:relative; transition:background .3s,border-color .3s; flex-shrink:0; overflow:hidden; }
1730.theme-toggle::before{ content:'🌙'; position:absolute; right:5px; top:50%; transform:translateY(-50%); font-size:13px; line-height:1; transition:opacity .25s; opacity:1; }
1731.theme-toggle::after{ content:'☀️'; position:absolute; left:5px; top:50%; transform:translateY(-50%); font-size:13px; line-height:1; transition:opacity .25s; opacity:0; }
1732body.dark .theme-toggle::before{ opacity:0; }
1733body.dark .theme-toggle::after{ opacity:1; }
1734.theme-toggle-knob{ position:absolute; left:3px; top:3px; width:24px; height:24px; background:var(--accent); border-radius:50%; transition:transform .3s cubic-bezier(.4,0,.2,1),background .3s; z-index:1; }
1735body.dark .theme-toggle-knob{ transform:translateX(26px); }
1736.theme-toggle-nav{ display:none; background:none; border:none; cursor:pointer; padding:8px; color:var(--txt); align-items:center; justify-content:center; }
1737.theme-toggle-nav svg{ width:22px; height:22px; }
1738.theme-toggle-nav .tt-moon{ display:none; }
1739.theme-toggle-nav .tt-sun{ display:block; color:var(--accent); }
1740body.dark .theme-toggle-nav .tt-sun{ display:none; }
1741body.dark .theme-toggle-nav .tt-moon{ display:block; color:var(--accent); }
1742@media(max-width:900px){ .theme-toggle-nav{ display:inline-flex; margin-left:auto; margin-right:4px; } }
1743.cmdk-trigger{ display:inline-flex; align-items:center; gap:7px; background:var(--surface2); border:2px solid var(--border2); border-radius:9px; padding:6px 10px; cursor:pointer; color:var(--muted); font-family:inherit; transition:border-color .2s, color .2s; }
1744.cmdk-trigger:hover{ border-color:var(--accent); color:var(--txt2); }
1745.cmdk-trigger svg{ width:15px; height:15px; }
1746.cmdk-trigger span{ font-size:.74rem; }
1747.cmdk-trigger kbd{ font-family:'DM Mono',monospace; font-size:.66rem; background:var(--surface); border:1px solid var(--border2); border-radius:5px; padding:1px 5px; color:var(--muted); }
1748body.dark .cmdk-trigger{ background:#1d2533 !important; border-color:rgba(255,255,255,.14) !important; }
1749body:not(.dark) .fav-nav-btn{ background:#f5f5f7 !important; border:2px solid rgba(0,0,0,.10) !important; color:#86868b !important; }
1750body:not(.dark) .cmdk-trigger{ background:#f5f5f7 !important; border:2px solid rgba(0,0,0,.10) !important; color:#86868b !important; }
1751body:not(.dark) .cmdk-trigger span{ display:inline !important; }
1752body:not(.dark) .cmdk-trigger kbd{ display:inline !important; }
1753
1754
1755.fav-nav-btn{ position:relative; background:var(--surface2); border:2px solid var(--border2); border-radius:9px; width:38px; height:32px; cursor:pointer; color:var(--muted); display:inline-flex; align-items:center; justify-content:center; transition:border-color .2s,color .2s; }
1756.fav-nav-btn:hover{ border-color:#e0245e;
1756 color:#e0245e; }
1757.fav-nav-btn svg{ width:16px; height:16px; }
1758.acct-nav-btn{ position:relative; background:var(--surface2); border:2px solid var(--border2); border-radius:9px; width:38px; height:32px; cursor:pointer; color:var(--muted); display:inline-flex; align-items:center; justify-content:center; transition:border-color .2s,color .2s; text-decoration:none; }
1759.acct-nav-btn:hover{ border-color:var(--accent); color:var(--accent); }
1760.acct-nav-btn svg{ width:16px; height:16px; }
1761.acct-nav-btn.is-in{ color:var(--accent); border-color:var(--accent); }
1762.acct-nav-btn::after{ content:''; position:absolute; top:-3px; right:-3px; width:8px; height:8px; border-radius:50%; background:#ef4444; border:2px solid var(--bg); }
1763.acct-nav-btn.is-in::after{ background:#10b981; }
1764.acct-menu{ position:absolute; top:calc(100% + 10px); right:0; min-width:212px; background:var(--surface); border:1px solid var(--border2); border-radius:12px; box-shadow:0 14px 40px rgba(0,0,0,.22); overflow:hidden; z-index:400; display:none; }
1765.acct-menu.open{ display:block; }
1766.acct-menu .who{ padding:11px 14px; border-bottom:1px solid var(--border2); font-size:.74rem; color:var(--muted); font-family:'DM Mono',monospace; word-break:break-all; }
1767.acct-menu a,.acct-menu button{ display:flex; align-items:center; gap:9px; width:100%; padding:10px 14px; background:none; border:0; cursor:pointer; font-family:'Poppins',sans-serif; color:var(--txt2); text-decoration:none; text-align:left;
1768  /* le menu vit dans .nav-links, qui impose uppercase + letter-spacing aux <a> : on annule */
1769  text-transform:none !important; letter-spacing:0 !important; font-size:.85rem !important; font-weight:500 !important; }
1770.acct-menu a:hover,.acct-menu button:hover{ background:var(--card-hover); color:var(--accent); }
1771.acct-menu svg{ width:15px; height:15px; flex:none; }
1772.acct-menu .out{ border-top:1px solid var(--border2); color:#dc2626; }
1773.acct-menu .out:hover{ color:#dc2626; background:rgba(220,38,38,.08); }
1774.acct-wrap{ position:relative; display:inline-flex; }
1775body:not(.dark) .acct-nav-btn{ background:#f5f5f7 !important; border:2px solid rgba(0,0,0,.10) !important; }
1776body.dark .acct-nav-btn{ background:#1d2533; border-color:rgba(255,255,255,.14); }
1777body.dark .fav-nav-btn{ background:#1d2533; border-color:rgba(255,255,255,.14); }
1778.fav-count-badge{ position:absolute; top:-6px; right:-6px; background:#e0245e; color:#fff; font-size:.6rem; font-weight:600; min-width:16px; height:16px; border-radius:8px; display:none; align-items:center; justify-content:center; padding:0 4px; }
1779.fav-count-badge.show{ display:flex; }
1780
1781
1782/* Compenser la nav fixed : le contenu démarre sous la barre */
1783body{ padding-top:64px; }
1784nav{ position:fixed; top:0; left:0; right:0; }
1785@media(max-width:768px){ body{ padding-top:64px; } }
1786.nav-dropdown-menu a{ white-space:nowrap; display:flex; align-items:center; gap:9px; }
1787.nav-dropdown-menu a svg{ width:15px; height:15px; flex-shrink:0; opacity:.6; }
1788.nav-dropdown-menu{ width:max-content; min-width:210px; max-width:min(360px,92vw); }
1789/* Éléments masqués hors mobile */
1790.nav-close{ display:none !important; }
1791.nav-backdrop{ display:none !important; }
1792/* Sur mobile, le menu inline est masqué : remplacé par l'overlay autonome #osaiMM ci-dessous. */
1793/* ═══ MOBILE : compte, favoris et recherche restent dans la barre du haut.
1794   .nav-links redevient visible, mais seuls ces 3 boutons y apparaissent —
1795   liens, méga-menus et sélecteur de langue restent dans le menu burger. */
1796/* ═══ MOBILE : dans la barre du haut, SEULE l'icône compte reste (à côté du
1797   burger). Favoris et recherche montent en tête du menu ouvert. ═══ */
1798@media(max-width:900px){
1799  #site-nav nav{ padding:0 .9rem; gap:6px; }
1800  .nav-links{ display:flex !important; align-items:center; gap:6px; margin-left:auto; }
1801  .nav-links > *:not(.acct-wrap){ display:none !important; }
1802  #site-nav .nav-links .theme-toggle{ display:none !important; }   /* doublon forcé par un !important */
1803  .theme-toggle-nav{ margin-left:0 !important; margin-right:0 !important; }
1804  #site-nav nav > a{ flex:none; min-width:0; }
1805}
1806@media(max-width:400px){
1807  #site-nav .logo small{ display:none; }
1808  #site-nav .logo{ font-size:1rem; }
1809  #site-nav nav{ padding:0 .7rem; }
1810}
1811/* ═══ MENU MOBILE AUTONOME : overlay enfant direct de <body>, z-index maximal.
1812   Insensible au CSS des pages ET aux contextes d'empilement (nav fixed, etc.). ═══ */
1813#osaiMM{ position:fixed; inset:0; z-index:2147483600; display:none; }
1814#osaiMM.osaiMM-open{ display:block; }
1815#osaiMM .osaiMM-bd{ position:absolute; inset:0; background:rgba(0,0,0,.5); opacity:0; transition:opacity .25s ease; }
1816#osaiMM.osaiMM-open .osaiMM-bd{ opacity:1; }
1817#osaiMM .osaiMM-panel{ position:absolute; top:0; right:0; height:100%; width:86%; max-width:340px; background:var(--surface,#ffffff); box-shadow:-6px 0 34px rgba(0,0,0,.3); transform:translateX(100%); transition:transform .28s ease; padding:4.2rem 1.3rem 2rem; display:flex; flex-direction:column; gap:0; overflow-y:auto; -webkit-overflow-scrolling:touch; box-sizing:border-box; }
1818#osaiMM.osaiMM-open .osaiMM-panel{ transform:translateX(0); }
1819#osaiMM .osaiMM-close{ position:absolute; top:.5rem; right:1rem; width:44px; height:44px; font-size:2rem; line-height:1; background:none; border:none;
1819 color:var(--txt,#1d1d1f); cursor:pointer; }
1820#osaiMM a, #osaiMM .osaiMM-act{ display:flex; align-items:center; gap:.6rem; width:100%; text-align:left; padding:.92rem .3rem; color:var(--txt2,#424245); text-decoration:none; font-family:'Poppins',sans-serif; font-size:1rem; font-weight:400; background:none; border:none; border-bottom:1px solid var(--border,rgba(0,0,0,.08)); cursor:pointer; }
1821#osaiMM a:hover, #osaiMM a:active, #osaiMM .osaiMM-act:hover, #osaiMM .osaiMM-act:active{ color:var(--accent); }
1822#osaiMM a svg, #osaiMM .osaiMM-act svg{ width:18px; height:18px; flex-shrink:0; opacity:.6; }
1823#osaiMM .osaiMM-quick{ display:grid; grid-template-columns:1fr 1fr 1fr; gap:8px; margin:0 0 1rem; }
1824#osaiMM .osaiMM-quick .osaiMM-q{ display:flex; flex-direction:column; align-items:center; justify-content:center; gap:6px; padding:.85rem .3rem; border:1px solid var(--border2,rgba(0,0,0,.12)); border-radius:12px; background:var(--surface2,rgba(0,0,0,.03)); color:var(--txt2,#424245); text-decoration:none; font-size:.74rem; font-weight:600; cursor:pointer; font-family:'Poppins',sans-serif; position:relative; }
1825#osaiMM .osaiMM-quick .osaiMM-q:hover,#osaiMM .osaiMM-quick .osaiMM-q:active{ border-color:var(--accent); color:var(--accent); }
1826#osaiMM .osaiMM-quick .osaiMM-q svg{ width:20px; height:20px; opacity:1; }
1827#osaiMM .osaiMM-sec{ font-size:.66rem; text-transform:uppercase; letter-spacing:.1em; color:var(--muted,#86868b); margin:1.1rem .3rem .25rem; font-family:'Poppins',sans-serif; }
1828/* ═══ CSS du footer partagé ═══ */
1829#site-footer footer{ border-top:1px solid var(--border); padding:3rem 0; background:var(--surface2); margin-top:3rem; }
1830body:not(.dark) #site-footer footer{ background:#f7f9fd !important; border-top-color:rgba(0,0,0,0.08) !important; }
1831body.dark #site-footer footer{ background:#1a2540 !important; border-top-color:rgba(255,255,255,0.10) !important; }
1832/* [09/08] Le bloc etait un enfant de la grille du pied de page : il heritait
1833   donc d'UNE COLONNE de 167 px, et tout badge y etait ecrase par le
1834   max-width:100% de la regle image. Il occupe desormais toute la largeur,
1835   sur sa propre ligne, aligne a gauche comme le logo et la mention legale. */
1836#site-footer .footer-badges{grid-column:1/-1;display:flex;align-items:center;gap:14px;flex-wrap:wrap;max-width:1200px;margin:8px 0 20px;padding:0}
1837#site-footer .footer-badges img{max-width:100%;height:auto;opacity:.9;transition:opacity .15s;border-radius:6px}
1838body.dark #site-footer .footer-badges img{background:#fff;padding:2px}
1839#site-footer .footer-badges a:hover img{opacity:1}
1840#site-footer .loop-strip{display:flex;align-items:center;gap:18px;flex-wrap:wrap;max-width:1200px;margin:0 auto 2rem;padding:14px 18px;background:var(--surface);border:1px solid var(--border);border-radius:14px}
1841#site-footer .loop-strip .loop-t{font-family:'DM Mono',monospace;font-size:.62rem;letter-spacing:.12em;text-transform:uppercase;color:var(--muted)}
1842#site-footer .loop-strip a{font-size:.82rem;font-weight:600;color:var(--txt);text-decoration:none}
1843#site-footer .loop-strip a:hover{color:var(--accent)}
1844/* [24/07] Mobile : le label prend sa ligne, les 3 liens se repartissent sur toute
1845   la largeur (fini le vide a droite + disposition plus nette). */
1846@media(max-width:600px){.loop-strip{justify-content:space-between;gap:10px 8px;padding:12px 14px}.loop-strip .loop-t{flex-basis:100%}}
1847/* [09/08/2026, seconde passe] LE VRAI CORRECTIF.
1848   Le pied de page est injecte dans un <div class="wrap"> et heritait donc
1849   du .wrap de la page HOTE. Or ce .wrap n'a pas la meme valeur partout :
1850     · articles de blog (ai-top-turnover…)  : AUCUNE regle .wrap -> pleine
1851       largeur, le footer partait a gauche, logo coupe ;
1852     · pages alternatives (chatgpt.html)    : .wrap = 920px, largeur de
1853       LECTURE d'article -> le footer s'y comprimait, colonnes empilees.
1854   Ma premiere passe ne centrait que .footer-inner : elle reparait le premier
1855   cas et aggravait le second. Un composant injecte partout ne doit dependre
1856   d'AUCUNE classe generique de la page. On fixe donc la largeur du wrap du
1857   footer sur celle de l'accueil (1280 / 32px), mesuree et non devinee.
1858   #site-footer .wrap l'emporte sur tout .wrap de page (id > classe). */
1859#site-footer .wrap{ max-width:1280px; margin-inline:auto; padding-inline:32px; box-sizing:border-box; }
1860/* [09/08/2026] margin-inline:auto AJOUTE. La regle plafonnait la largeur
1861   a 1200px mais comptait sur le .wrap de la page hote pour centrer. Or les
1862   articles de blog ne definissent AUCUNE regle .wrap : le pied de page y
1863   partait donc a gauche, logo et texte coupes hors de l'ecran. Le footer est
1864   injecte partout, il ne doit dependre d'aucune classe de la page d'accueil. */
1865#site-footer .footer-inner{ display:grid; grid-template-columns:1.5fr 1fr 1fr; gap:2.5rem; max-width:1200px; margin-inline:auto; }
1866#site-footer .footer-brand .logo{ font-size:1rem; font-weight:600; }
1867#site-footer .footer-brand .logo span{ color:var(--accent); }
1868#site-footer .footer-brand p{ font-size:.82rem; color:var(--muted); line-height:1.6; }
1869#site-footer .footer-col h4{ font-size:.72rem; text-transform:uppercase; letter-spacing:.08em; color:var(--muted); margin-bottom:1rem; font-weight:600; }
1870#site-footer .footer-col a{ display:block; font-size:.85rem; color:var(--txt2); text-decoration:none; margin-bottom:.6rem; transition:color .2s; }
1871#site-footer .footer-col a:hover{ color:var(--accent); }
1872#site-footer .footer-more{ column-count:2; column-gap:22px; }
1873#site-footer .footer-more h4{ column-span:all; }
1874#site-footer .footer-more a{ break-inside:avoid; }
1875@media(max-width:560px){ #site-footer .footer-more{ column-count:1; } }
1876#site-footer .footer-social{ display:flex; gap:10px; margin-top:14px; }
1877#site-footer .footer-social a{ width:34px; height:34px; display:inline-flex; align-items:center; justify-content:center; border:1px solid var(--border); border-radius:9px; color:var(--muted); margin:0; transition:color .2s,border-color .2s; }
1878#site-footer .footer-social a:hover{ color:var(--accent); border-color:var(--accent); }
1879#site-footer .footer-social svg{ width:16px; height:16px; }
1880#site-footer .footer-copy{ grid-column:1/-1; margin-top:1.5rem; padding-top:1.5rem; border-top:1px solid var(--border); display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:1rem; font-size:.78rem; color:var(--muted); }
1881#site-footer .footer-news{ grid-column:1/-1; display:flex; align-items:center; justify-content:space-between; gap:1.2rem 2rem; flex-wrap:wrap; padding-bottom:2rem; margin-bottom:.3rem; border-bottom:1px solid var(--border); }
1882#site-footer .footer-news-txt h4{ font-size:1rem; font-weight:600; color:var(--txt); margin-bottom:.3rem; }
1883#site-footer .footer-news-txt p{ font-size:.82rem; color:var(--muted); line-height:1.5; max-width:430px; }
1884#site-footer .footer-news-form{ display:flex; gap:.5rem; flex-wrap:wrap; }
1885#site-footer .footer-news-form input{ font-family:'Poppins',sans-serif; font-size:.85rem; padding:.7rem 1rem; border:1px solid var(--border2); border-radius:10px; background:var(--surface); color:var(--txt); min-width:240px; outline:none; transition:border-color .2s; }
1886#site-footer .footer-news-form input:focus{ border-color:var(--accent); }
1887#site-footer .footer-news-form button{ font-family:'Poppins',sans-serif; font-size:.85rem; font-weight:600; padding:.7rem 1.4rem; border:none; border-radius:10px; background:#6366f1; color:#fff; cursor:pointer; transition:opacity .2s; white-space:nowrap; }
1888#site-footer .footer-news-form button:hover{ opacity:.9; }
1889#site-footer .footer-news-form button:disabled{ opacity:.6; cursor:default; }
1890#site-footer .footer-news-msg{ flex-basis:100%; font-size:.8rem; min-height:1px; }
1891body.dark #site-footer .footer-news-form button{ color:#fff; }
1892@media(max-width:768px){ #site-footer .footer-inner{ grid-template-columns:1fr; gap:1.5rem; } #site-footer .footer-copy{ flex-direction:column; align-items:flex-start; } #site-footer .footer-news{ flex-direction:column; align-items:flex-start; } #site-footer .footer-news-form{ width:100%; } #site-footer .footer-news-form input{ min-width:0; flex:1; } }
1893
1894/* ═══ MEGA MENU ═══ */
1895.nav-dropdown-menu.mega{ position:fixed !important; top:64px !important; left:0 !important; right:0 !important; transform:none !important;
1896  width:100% !important; max-width:none !important; min-width:0 !important; box-sizing:border-box !important;
1897  padding:28px max(32px,calc((100vw - 1320px)/2)) 32px !important;
1898  border-radius:0 0 18px 18px !important; border-top:1px solid var(--border) !important;
1899  max-height:calc(100vh - 88px) !important; overflow-y:auto !important; overscroll-behavior:contain;
1900  scrollbar-width:thin; scrollbar-color:var(--accent) transparent; }
1901.nav-dropdown-menu.mega::-webkit-scrollbar{ width:7px; }
1902.nav-dropdown-menu.mega::-webkit-scrollbar-thumb{ background:var(--accent); border-radius:4px; opacity:.5; }
1903.mega-grid{ display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); gap:28px 26px; }
1904@media(max-width:1120px){ .mega-grid{ grid-template-columns:repeat(3,minmax(0,1fr)); } }
1905.mega-col{ display:flex; flex-direction:column; gap:3px; }
1906.mega-h{ font-family:'DM Mono',monospace; font-size:.82rem; font-weight:700; text-transform:uppercase; letter-spacing:.09em; color:var(--accent); padding:0 12px 9px; }
1907.nav-dropdown-menu.mega a{ display:flex !important; align-items:center; gap:12px; padding:11px 12px !important; border-radius:10px;
1907 white-space:normal !important; color:var(--txt2); }
1908.nav-dropdown-menu.mega a:hover{ background:var(--card-hover); }
1909.nav-dropdown-menu.mega a svg{ width:19px !important; height:19px !important; flex-shrink:0; opacity:1 !important; color:var(--accent); }
1910.nav-dropdown-menu.mega a > span{ display:flex; flex-direction:column; min-width:0; flex:1 1 auto; overflow:hidden; }
1911.nav-dropdown-menu.mega a b{ font-size:.86rem; color:var(--txt); font-weight:400; line-height:1.3; }
1912.nav-dropdown-menu.mega a em{ display:none !important; }
1913.osai-em-home{ padding:48px 0 4px; }
1914.osai-em-home .oeh-wrap{ max-width:1220px; margin:0 auto; padding:0 20px; }
1915.oeh-panel{ background:var(--surface,#fff); border:2px solid var(--border,rgba(0,0,0,.08)); border-radius:16px; padding:18px 20px 16px; }
1916.oeh-head{ display:flex; align-items:baseline; gap:12px; flex-wrap:wrap; margin-bottom:13px; }
1917/* [02/08/2026] Devenu un h2 : on annule les marges par defaut du navigateur
1918   et on aligne l'icone. .sec-ic n'est style que par index.html — le nav
1919   doit donc porter lui-meme la taille et la couleur, sinon l'icone serait
1920   nue sur toute page qui reprendrait ce bloc. */
1921.oeh-title{ display:flex; align-items:center; gap:9px; margin:0;
1922  font-family:'Poppins',sans-serif; font-size:1.05rem; font-weight:700; color:var(--txt,#0f1724); }
1923.oeh-title .sec-ic{ display:inline-flex; flex:none; }
1924.oeh-title .sec-ic svg{ width:19px; height:19px; color:var(--accent,#6366f1); }
1925body.dark .oeh-title .sec-ic svg{ color:var(--accent,#818cf8); }
1926.oeh-title > span{ color:var(--accent,#6366f1); }
1927.oeh-sub{ font-family:'DM Mono',monospace; font-size:.66rem; color:var(--muted,#5a6d87); }
1928.oeh-all{ margin-left:auto; font-family:'DM Mono',monospace; font-size:.74rem; font-weight:500; color:var(--accent,#6366f1); text-decoration:none; white-space:nowrap; border:2px solid var(--border2,rgba(0,0,0,.15)); border-radius:8px; padding:6px 12px; transition:border-color .15s; }
1929.oeh-all:hover{ border-color:var(--accent,#6366f1); }
1930.oeh-cta{ text-align:center; margin-top:16px; }
1931.oeh-cta a{ display:inline-flex; align-items:center; gap:8px; padding:12px 30px;
1932  background:#10b981; color:#fff; border-radius:980px; font-size:.82rem;
1933  font-weight:500; text-decoration:none; transition:background .2s,transform .15s; }
1934.oeh-cta a:hover{ background:#059669; transform:translateY(-1px); color:#fff; }
1935@media(max-width:640px){ .oeh-cta a{ width:100%; max-width:340px; justify-content:center; padding:14px 22px; } }
1936.oeh-grid{ display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:12px; }
1937a.oeh-card{ min-width:0; display:flex; align-items:flex-start; gap:12px; background:var(--surface2,#f7f8fa); border:2px solid var(--border,rgba(0,0,0,.08)); border-radius:12px; padding:14px 15px; text-decoration:none; transition:border-color .18s, transform .18s; }
1938a.oeh-card:hover{ border-color:var(--accent,#6366f1); transform:translateY(-2px); }
1939.oeh-av{ width:40px; height:40px; flex:none; border-radius:10px; overflow:hidden; position:relative; display:flex; align-items:center; justify-content:center; font-weight:700; color:#fff; font-size:1rem; font-family:'Poppins',sans-serif; }
1940.oeh-av img{ position:absolute; inset:0; width:100%; height:100%; object-fit:cover; }
1941.oeh-body{ min-width:0; display:flex; flex-direction:column; gap:3px; }
1942.oeh-body b{ color:var(--txt,#0f1724); font-weight:700; font-size:.98rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
1943a.oeh-card:hover .oeh-body b{ color:var(--accent,#6366f1); }
1944.oeh-line{ display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
1945.oeh-mom{ font-family:'DM Mono',monospace; font-size:.84rem; font-weight:500; color:var(--accent,#6366f1); }
1946.oeh-mom i{ font-style:normal; font-size:.6rem; color:var(--muted,#5a6d87); margin-left:2px; }
1947.oeh-fire{ font-family:'DM Mono',monospace; font-size:.62rem; color:#f24f00; background:rgba(242,79,0,.08); border:1px solid rgba(242,79,0,.3); border-radius:4px; padding:1px 7px; }
1948.oeh-desc{ font-size:.74rem; color:var(--muted,#5a6d87); line-height:1.4; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:100%; }
1949.osai-em-home{ overflow-x:clip; }
1950@media(max-width:760px){ .oeh-grid{ grid-template-columns:1fr; } .oeh-sub{ display:none; } .oeh-panel{ padding:15px 14px 13px; } }
1951.mega-badge{ font-size:.54rem; font-weight:700; text-transform:uppercase; letter-spacing:.04em; color:var(--bg); background:var(--accent); border-radius:5px; padding:2px 6px; align-self:center; flex-shrink:0; }
1952/* V108 — le badge ne pousse plus le libellé : absolu haut-droit, et le texte
1953   garde sa place (V16 met overflow:visible sur les libellés → un mot seul,
1954   insécable, débordait SOUS le badge : « Emerging », « Developer API »). */
1955/* V113 — petits écrans en hauteur (portables 13", écrans externes 768p) :
1956   le méga-menu dépassait la fenêtre et coupait le bas de la colonne Discover
1957   sans aucun scroll. Plafond à la hauteur visible + défilement interne. */
1958.nav-dropdown-menu.mega{ max-height:calc(100vh - 84px); overflow-y:auto; overscroll-behavior:contain; scrollbar-width:thin; scrollbar-color:var(--border2,rgba(0,0,0,.2)) transparent; }
1959.nav-dropdown-menu.mega::-webkit-scrollbar{ width:8px; }
1960.nav-dropdown-menu.mega::-webkit-scrollbar-thumb{ background:var(--border2,rgba(0,0,0,.2)); border-radius:8px; }
1961.nav-dropdown-menu.mega a{ position:relative; }
1962.nav-dropdown-menu.mega .mega-badge{ position:static; align-self:center; margin-left:auto; font-size:.52rem !important; line-height:1.4 !important; padding:2px 5px !important; flex-shrink:0; }
1963.nav-dropdown-menu.mega a:has(.mega-badge) span:not(.mega-badge){ padding-right:40px; }
1964.mega-foot{ display:flex; align-items:center; justify-content:space-between; gap:12px; margin-top:14px; padding:12px 14px; background:var(--surface2); border:1px solid var(--border2); border-radius:11px; flex-wrap:wrap; }
1965/* ═══ V16 — Méga-menu lisible & moderne ═══ */
1966.nav-dropdown-menu.mega{ box-shadow:0 24px 70px rgba(0,0,0,.45) !important; backdrop-filter:blur(14px); border:1px solid var(--border2) !important; border-top:1px solid var(--border) !important; }
1967.mega-grid{ gap:22px 30px; }
1968.nav-dropdown-menu.mega a{ padding:8px 10px !important; gap:12px; border-radius:11px; transition:background .15s ease, transform .15s ease; }
1969.nav-dropdown-menu.mega a, .nav-dropdown-menu.mega a span, .nav-dropdown-menu.mega a b{ text-transform:none !important; letter-spacing:0 !important; font-size:.86rem !important; font-weight:400 !important;
1969 white-space:normal !important; overflow:visible !important; text-overflow:clip !important; line-height:1.25 !important; }
1970.nav-dropdown-menu.mega a em{ text-transform:none !important; letter-spacing:0 !important; }
1971.nav-dropdown-menu.mega a svg{ width:32px !important; height:32px !important; padding:7px; background:var(--card-hover); border-radius:9px; box-sizing:border-box; }
1972.nav-dropdown-menu.mega a:hover{ transform:translateX(3px); }
1973.nav-dropdown-menu.mega a:hover b{ color:var(--accent); }
1974.mega-h{ font-size:.8rem; font-weight:700; letter-spacing:.09em; color:var(--accent); opacity:1; padding:0 10px 9px; border-bottom:1px solid var(--border); margin-bottom:5px; }
1975.nav-dropdown-menu.open{ animation:osaiMegaIn .16s ease; }
1976@keyframes osaiMegaIn{ from{ opacity:0; transform:translateY(-8px); } to{ opacity:1; transform:translateY(0); } }
1977/* ═══ V16 — Finitions globales ═══ */
1978/* boutons menthe : texte sombre lisible en dark (au lieu de blanc sur menthe) */
1979body.dark #showMoreBtn, body.dark #projShowMoreBtn, body.dark #loadMoreLink, body.dark #moretoolsShowMore, body.dark #projectsShowMore,
1980body.dark #showMoreBtn:hover, body.dark #projShowMoreBtn:hover, body.dark #loadMoreLink:hover, body.dark #moretoolsShowMore:hover, body.dark #projectsShowMore:hover{ color:#ffffff !important; font-weight:700 !important; }
1981/* champ de recherche de la page Tools : sombre en dark (fini le blanc) */
1982body.dark .tools-search{ background:var(--surface2) !important; color:var(--txt) !important; border:1px solid var(--border2) !important; }
1983body.dark .tools-search::placeholder{ color:var(--muted) !important; }
1984/* ═══ V18 — CTA vert dur (#6366f1 inline) → menthe harmonisée en dark ═══ */
1985body.dark a[style*="background:#6366f1"], body.dark button[style*="background:#6366f1"]{ background:var(--accent) !important; color:#ffffff !important; }
1986/* ═══ V18 — recherche de la section 1000+ tools (home) : sombre en dark ═══ */
1987body.dark #searchInput{ background:var(--surface2) !important; color:var(--txt) !important; border:1px solid var(--border2) !important; }
1988body.dark #searchInput::placeholder{ color:var(--muted) !important; }
1989/* ═══ V21 — Harmonisation : plus AUCUN vert foncé (#6366f1) visible en dark ═══ */
1990:root{ --osai-green:#6366f1; }
1991/* ═══ FILET INDIGO GLOBAL — recolore TOUTES les pages du site (jour+nuit), même celles non refondues ═══ */
1992:root, body:not(.dark){ --accent:#6366f1 !important; --accentbright:#818cf8 !important; --osai-green:#6366f1 !important; --logo-green:#6366f1 !important; --accent-2:#6366f1 !important; --accent-3:#6366f1 !important; --accent-2-soft:#eef0fe !important; --accent-3-soft:#eef0fe !important; --card-hover:rgba(99,102,241,.06) !important; --bg:#ffffff !important; --bg2:#ffffff !important; --surface2:#f7f8fa !important; --nav-bg:rgba(255,255,255,.85) !important; }
1993body.dark{ --accent:#818cf8 !important; --accentbright:#a5b0ff !important; --osai-green:#818cf8 !important; --logo-green:#818cf8 !important; --accent-2:#818cf8 !important; --accent-3:#818cf8 !important; --accent-2-soft:#1c2136 !important; --accent-3-soft:#1c2136 !important; --card-hover:rgba(129,140,248,.08) !important; --bg:#0b0f1a !important; --bg2:#0b0f1a !important; --surface:#141a29 !important; --surface2:#1a2133 !important; --nav-bg:rgba(11,15,26,.85) !important; }
1994/* ═══ [02/08/2026, mockup] FOND GRIS DES FICHES ═══
1995   Le filet indigo ci-dessus force --bg:#ffffff !important sur tout le site :
1996   il ecrasait le gris #f7f8fc pose dans fiche.css. Meme arme, meme priorite,
1997   declare APRES lui donc gagnant : les pages qui portent la classe
1998   osai-fond-gris (posee par le JS selon le chemin) retrouvent le gris du
1999   mockup. Le mode nuit n'est pas concerne. */
2000body.osai-fond-gris:not(.dark){ --bg:#f7f8fc !important; --bg2:#f7f8fc !important; }
2001
2002/* ═══ LOGO + TOGGLE : indigo forcé sur TOUTES les pages (même vieux nav local) ═══ */
2003.logo span, .logo b, .logo em, .brand span, .navbar .logo span{ color:var(--accent) !important; }
2004.theme-toggle svg, .theme-toggle path, .darkmode svg, .toggle-theme svg,
2005[class*="theme"] svg path, [class*="dark-toggle"] svg{ fill:var(--accent) !important; stroke:var(--accent) !important; }
2006/* ═══ BARRE DE NAV : couleur forcée (bat les nav sombres codées en dur des vieilles pages) ═══ */
2007#site-nav nav, nav{ background:var(--nav-bg) !important; border-bottom-color:var(--border) !important; }
2008/* La couleur reste globale : elle ne perturbe aucune mise en page. */
2009#site-nav .nav-links a, nav .nav-links > a{ color:var(--txt2) !important; }
2010/* ⚠ La MISE EN PAGE, elle, est confinée au bureau.
2011   Sous 900 px, .nav-links > *:not(.acct-wrap){display:none !important} doit
2012   masquer les liens et laisser la place au hamburger. Or « #site-nav .nav-links a »
2013   pèse (1,2,1) contre (0,2,0) : mon display:inline-flex gagnait, les six liens
2014   débordaient de l'écran et poussaient le hamburger dehors.
2015   En n'existant pas sous 901 px, la règle ne peut plus interférer.
2016   Et « > a » plutôt que « a » : le sélecteur de descendance atteignait aussi
2017   les liens du sélecteur de langue, qu'il passait à l'horizontale. */
2018@media(min-width:901px){
2019  #site-nav .nav-links > a{ display:inline-flex !important; align-items:center; gap:6px; line-height:1; }
2020  #site-nav .nav-links > a svg{ width:14px; height:14px; flex-shrink:0; color:var(--accent); opacity:.85; }
2021  #site-nav .nav-links > a:hover svg{ opacity:1; }
2022}
2023/* Les icônes ajoutent ~20 px par lien : sous 1200 px la barre déborde. */
2024@media(min-width:901px) and (max-width:1200px){ #site-nav .nav-links > a svg{ display:none !important; } }
2025/* Explore n'etait pas aligne : un <button> ne partage pas la ligne de base d'un <a>.
2026   On lui donne la meme boite flex et la meme hauteur de ligne. */
2027@media(min-width:901px){ #site-nav .nav-links > .nav-dropdown{ display:inline-flex; align-items:center; } }
2028.nav-dropdown-btn{ display:inline-flex !important; align-items:center; gap:5px; line-height:1 !important; height:auto; }
2029#site-nav .nav-links a:hover, nav .nav-links a:hover{ color:var(--accent) !important; }
2030/* ═══ BOUTONS DE CARTE (cœur, partage) : zéro mouvement ═══ */
2031.fav-btn, .fav-btn:hover, .sf-btn, .sf-btn:hover, .home-share-wrap *:hover{ transform:none !important; transition:color .15s, opacity .15s !important; }
2032/* ═══ POLICE UNIFIÉE : Poppins partout, DM Mono réservée au code (sélecteurs simples = compatibles tous navigateurs) ═══ */
2033body, body *{ font-family:'Poppins','Poppins-fb',sans-serif !important; }
2034pre, code, kbd, samp, pre *, code *{ font-family:'DM Mono',ui-monospace,SFMono-Regular,monospace !important; }
2035/* ═══ #projects + proj-card : mouvement totalement figé ═══ */
2036#projectsGrid .project-card, #projectsGrid .project-card:hover, #projectsGrid .project-card *, #projectsGrid .project-card:hover *{ transform:none !important; transition:none !important; animation:none !important; }
2037#projectsGrid .project-card:hover{ background:var(--surface) !important; border-color:var(--border) !important; box-shadow:var(--shadow) !important; filter:none !important; }
2038#whatsnewGrid .wn-card, #whatsnewGrid .wn-card:hover{ transform:none !important; }
2039#whatsnewGrid > *, #projectsGrid > *{ height:100% !important; }
2040/* ═══ CARTES TRENDING + TOP PROJECTS : ZÉRO effet au survol, sur toutes les pages ═══ */
2041.trend-card-wrap, .trend-card-wrap:hover, .trend-card, .trend-card:hover,
2042.project-card, .project-card:hover, .proj-card, .proj-card:hover,
2043body.dark .trend-card-wrap, body.dark .trend-card-wrap:hover,
2044body.dark .trend-card, body.dark .trend-card:hover,
2045body.dark .project-card, body.dark .project-card:hover,
2046body.dark .proj-card, body.dark .proj-card:hover{
2047  transform:none !important;
2048  transition:none !important;
2049  box-shadow:none !important;
2050  border-color:var(--border,rgba(0,0,0,.08)) !important;
2051}
2052.trend-card-wrap:hover, .trend-card:hover, .project-card:hover, .proj-card:hover,
2053body.dark .trend-card-wrap:hover, body.dark .trend-card:hover,
2054body.dark .project-card:hover, body.dark .proj-card:hover{
2055  background:inherit !important;
2056}
2057.trend-card-wrap::before, .trend-card::before, .project-card::before, .proj-card::before{
2058  display:none !important; content:none !important;
2059}
2060.trend-card-wrap:hover .proj-arrow, .project-card:hover .proj-arrow,
2061.trend-card-wrap:hover .card-arrow, .project-card:hover .card-arrow,
2062.trend-card:hover .proj-arrow, .trend-card:hover .card-arrow{ transform:none !important; }
2063body.dark .logo span, body.dark .nav-links a:hover, body.dark .nav-dropdown-btn:hover,
2064body.dark .btn-ghost:hover, body.dark .section-link, body.dark .embed-section-link,
2065body.dark .footer-col a:hover, body.dark footer .footer-col a:hover,
2066body.dark .theme-toggle-nav .tt-moon{ color:#818cf8 !important; }
2067body.dark .btn-ghost:hover{ border-color:#818cf8 !important; }
2068body.dark .filter-btn.active, body.dark .tag-btn.active{ background:#818cf8 !important; color:#ffffff !important; border-color:#818cf8 !important; }
2069body:not(.dark) .back-to-top{ color:#fff !important; }
2070body:not(.dark) .back-to-top svg{ stroke:#fff; }
2071body.dark .back-to-top{ background:#818cf8 !important; }
2072body.dark .back-to-top:hover{ background:#a5b0ff !important; }
2073body.dark .back-to-top svg{ stroke:#ffffff !important; }
2074/* [24/07] Le bouton « remonter » percutait le widget de chat (tous deux en bas a
2075   droite). On le remonte au-dessus du chat, sur toutes les pages. */
2076.back-to-top{ bottom:5.5rem !important; }
2077@media(max-width:600px){ .back-to-top{ bottom:5rem !important; right:1rem !important; } }
2078body.dark .osai-mint, body.dark .osai-mint:hover{ background:#818cf8 !important; color:#ffffff !important; border-color:transparent !important; }
2079body.dark .osai-mint *{ color:#ffffff !important; }
2080body.dark .osai-mint::before, body.dark .osai-mint::after{ color:#ffffff !important; }
2081body.dark .osai-mint:hover{ filter:brightness(1.06); }
2082body.dark .osai-mint svg{ color:#ffffff; }
2083body.dark .osai-mint-txt{ color:#818cf8 !important; }
2084body.dark .theme-toggle-knob{ background:#818cf8; }
2085/* ═══ V17 — Cartes équilibrées (pied collé en bas, plus de gros vides) ═══ */
2086#projectsGrid > *, #trendingGrid > .trend-card, #trendingGrid > .trend-card-wrap{ display:flex; flex-direction:column; }
2087#projectsGrid > * > :last-child, #trendingGrid .trend-card > :last-child{ margin-top:auto; }
2088/* ═══ V17 — Cartes OG GitHub adoucies en dark ═══ */
2089body.dark img[src*="opengraph.githubassets"]{ border-radius:12px; filter:brightness(.93) saturate(.95); }
2090/* ═══ V17 — Micro-interactions unifiées ═══ */
2091.featured-card, .moretool-card, .trend-card{ transition:transform .18s ease, box-shadow .18s ease; }
2092.featured-card:hover, .moretool-card:hover, .trend-card:hover{ transform:translateY(-3px); box-shadow:0 12px 30px rgba(0,0,0,.28); }
2093/* ═══ V17 — Skeletons (shimmer pendant le chargement des sections JS) ═══ */
2094#featuredGrid:empty, #projectsGrid:empty, #trendingGrid:empty, #moretoolsGrid:empty, #newsGrid:empty, #ttGrid:empty, #whatsnewGrid:empty{ min-height:190px; border-radius:16px; background:linear-gradient(100deg, var(--surface) 40%, var(--surface2) 50%, var(--surface) 60%); background-size:200% 100%; animation:osaiShimmer 1.15s linear infinite; }
2095@keyframes osaiShimmer{ to{ background-position:-200% 0; } }
2096/* ═══ V17 — Badge live du hero ═══ */
2097.osai-live-pill{cursor:pointer;text-decoration:none;transition:border-color .2s;} .osai-live-pill:hover{border-color:var(--accent);} .osai-live-pill .olp-go{opacity:.7;} .osai-live-pill{ display:inline-flex; align-items:center; gap:8px; font-family:'DM Mono',monospace; font-size:.72rem; text-transform:uppercase; letter-spacing:.1em; color:var(--accent); background:var(--card-hover); border:2px solid var(--accent); border-radius:22px; padding:6px 14px; margin-bottom:18px; }
2098.osai-live-pill b{ font-weight:700; }
2099.osai-live-pill .olp-dot{ width:8px; height:8px; border-radius:50%; background:var(--accent); animation:osaiPulse 1.8s infinite; }
2100@keyframes osaiPulse{ 0%{ box-shadow:0 0 0 0 rgba(129,140,248,.55); } 70%{ box-shadow:0 0 0 8px rgba(129,140,248,0); } 100%{ box-shadow:0 0 0 0 rgba(129,140,248,0); } }
2101.mega-foot-t{ display:flex; align-items:center; gap:8px; font-size:.77rem; color:var(--txt2); line-height:1.4; }
2102.mega-foot-t svg{ width:15px; height:15px; color:var(--accent); flex-shrink:0; }
2103.mega-foot-t b{ color:var(--txt); }
2104.mega-foot-cta{ font-size:.78rem; font-weight:600; color:var(--accent); text-decoration:none; white-space:nowrap; }
2105.mega-foot-cta:hover{ text-decoration:underline; }
2106@media(max-width:980px){ .nav-dropdown-menu.mega{ width:min(740px,95vw) !important; } .mega-grid{ grid-template-columns:repeat(2,minmax(0,1fr)); gap:20px 22px; } }
2107
2108/* ═══════════════════════════════════════════════════════════════
2109   TOP OPEN-SOURCE AI PROJECTS (#projectsGrid) : AUCUN EFFET.
2110   Cible le wrapper (>*) ET la carte (.project-card) ET leurs enfants.
2111   État :hover rendu strictement identique à l'état normal.
2112   Règle finale du nav script = a le dernier mot sur tout.
2113   ═══════════════════════════════════════════════════════════════ */
2114#projectsGrid, #projectsGrid *,
2115#projectsGrid > *, #projectsGrid > *:hover, #projectsGrid > *:focus, #projectsGrid > *:active,
2116#projectsGrid .project-card, #projectsGrid .project-card:hover, #projectsGrid .project-card:focus, #projectsGrid .project-card:active,
2117#projectsGrid .project-card *, #projectsGrid .project-card:hover *{
2118  transform:none !important;
2119  transition:none !important;
2120  animation:none !important;
2121  box-shadow:none !important;
2122  filter:none !important;
2123  opacity:1 !important;
2124}
2125#projectsGrid > *:hover, #projectsGrid .project-card:hover,
2126#projectsGrid > *:focus, #projectsGrid .project-card:focus,
2127#projectsGrid > *:active, #projectsGrid .project-card:active{
2128  background:var(--surface) !important;
2129  border-color:var(--border,rgba(0,0,0,.08)) !important;
2130}
2131#projectsGrid .project-card::before, #projectsGrid .project-card::after,
2132#projectsGrid > *::before, #projectsGrid > *::after{ display:none !important; content:none !important; }
2133/* Contours plus francs sur les fiches (injecté ici → aucune régénération de page, aucun impact SEO) */
2134.chip{ border-width:2px !important; }
2135.fiche-ogcard,.fact,.rel-card{ border-width:2px !important; }
2136.fiche-act,.fiche-act-wrap .fav-btn{ border-width:2px !important; }
2137
2138/* ═══ Rangée d'actions de la fiche : hiérarchiser au lieu d'aligner ═══
2139   Sept contrôles de trois hauteurs différentes cohabitaient au même niveau
2140   visuel (voir GitHub, ♡, partage, 👍/👎, suivre, collection, badge) : le
2141   visiteur ne savait pas où cliquer. On ne touche NI au HTML des 60 000 fiches
2142   NI au JS d'injection — seulement à l'ordre et au poids visuel, en CSS. Trois
2143   rangs : l'action principale, les actions de membre, puis les utilitaires.
2144   Les marges en ligne des boutons injectés sont neutralisées, sinon elles
2145   s'ajoutent au gap et l'espacement devient irrégulier. */
2146.cta-row{ align-items:center !important; gap:10px !important; row-gap:12px !important; }
2147.cta-row #osaiWatchBtn,.cta-row #osaiColBtn,.cta-row #osaiBadgeBtn{ margin-left:0 !important; }
2148
2149/* rang 1 — l'action principale reste seule en tête */
2150.cta-row .btn-primary{ order:1; }
2151/* rang 2 — ce qu'un membre fait du projet : même style, côte à côte */
2152.cta-row #osaiWatchBtn{ order:2; }
2153.cta-row #osaiColBtn{ order:3; }
2154/* rang 3 — utilitaires allégés : présents, pas concurrents.
2155   PAS de rejet à droite : un margin-left:auto creusait un grand vide entre
2156   « Collection » et « ♡ », et ces boutons paraissaient orphelins plutôt que
2157   groupés. La hiérarchie se lit à la taille et à la couleur, l'ordre de lecture
2158   reste continu. Un simple écart marque la frontière de groupe, et il se
2159   dégrade proprement quand la ligne passe à la suivante (contrairement à un
2160   séparateur, qui se retrouverait en début de ligne). */
2161.cta-row #ficheFav{ order:6; margin-left:12px !important; }
2162.cta-row .fiche-share-wrap{ order:7; }
2163.cta-row #osaiBadgeBtn{ order:8; height:38px !important; padding:0 12px !important;
2164  font-size:.78rem !important; color:var(--muted,#5a6d87) !important; }
2165.cta-row #ficheFav .fav-btn,.cta-row .fiche-share{ width:38px !important; height:38px !important; }
2166.cta-row #ficheFav .fav-btn svg,.cta-row .fiche-share svg{ width:17px !important; height:17px !important; }
2167/* l'avis du public n'est pas une action sur le projet : dernier, et discret */
2168.cta-row #osai-vote{ order:9; }
2169
2170/* ═══ [02/08/2026, mockup de Romain] L'empilement « un seul bord de lecture »
2171   est ABANDONNÉ : le mockup validé met l'avatar en plaque À GAUCHE du bloc
2172   puce + titre + accroche, le ruban de rang à droite. Les règles !important
2173   qui forçaient .hero-main en pleine largeur (et donc l'avatar seul sur sa
2174   ligne) sont retirées ; fiche.css fait foi. Depuis, les tags, les boutons et
2175   la bande de chiffres ne vivent plus DANS .hero-main mais après </header>,
2176   au bord gauche : le zigzag que la règle corrigeait n'existe plus. */
2177
2178@media(max-width:760px){
2179  /* l'action principale sur toute la largeur : c'est elle qu'on vient chercher */
2180  .cta-row #ficheFav{ margin-left:0 !important; }
2181  .cta-row .btn-primary{ flex:1 1 100%; justify-content:center; }
2182
2183  /* avatar encore un peu plus compact sur petit écran (l'alignement, lui, est
2184     déjà réglé plus haut pour toutes les tailles) */
2185  .hero{ gap:14px !important; }
2186  .hero-ic{ width:72px !important; height:72px !important; border-radius:18px !important; }
2187}
2188
2189/* ═══ Menu principal : lisibilite et alignement ═══
2190   Les intitules etaient a .67rem, soit environ 10,7 px : trop petits, et avec
2191   line-height:1 contre une icone de 14 px le texte paraissait mal cale. On
2192   remonte la taille, on aligne la hauteur de ligne et on grossit l'icone d'autant.
2193   Le crenage et l'ecart entre liens sont reduits en compensation, sinon les six
2194   liens plus la recherche, la langue et l'avatar debordent sur un ecran de 901 px
2195   — un incident deja survenu sur ce menu. Deux paliers pour cette raison. */
2196@media(min-width:901px){
2197  #site-nav .nav-links{ gap:.7rem !important; }
2198  #site-nav .nav-links>a, #site-nav .nav-dropdown-btn{
2199    font-size:.78rem !important; letter-spacing:.01em !important; line-height:1.15 !important; }
2200  #site-nav .nav-links > a, #site-nav .nav-dropdown-btn{ align-items:center !important; }
2201  #site-nav .nav-links > a svg, #site-nav .nav-dropdown-btn svg{ width:15px !important; height:15px !important; }
2202}
2203@media(min-width:1200px){
2204  #site-nav .nav-links{ gap:.85rem !important; }
2205  #site-nav .nav-links>a, #site-nav .nav-dropdown-btn{ font-size:.86rem !important; }
2206  #site-nav .nav-links > a svg, #site-nav .nav-dropdown-btn svg{ width:16px !important; height:16px !important; }
2207}
2208
2209/* ═══ La casse des libelles de la barre ═══
2210   « Pricing », « Projects », « AI tools » s'affichaient en CAPITALES alors que le
2211   markup les ecrit normalement. Ce n'est pas notre fait : chaque page porte dans
2212   son CSS inline « .nav-links a{ text-transform:uppercase } » (index.html:198) et
2213   « .nav-dropdown-btn{...uppercase } » (index.html:707). Comme la barre est
2214   injectee DANS .nav-links, elle en herite. Les liens du mega-menu avaient deja
2215   ete deverrouilles un par un ; la barre elle-meme ne l'avait jamais ete.
2216   On annule ici, une fois, pour les quelque 300 pages du site. */
2217#site-nav .nav-links a, #site-nav .nav-links a span, #site-nav .nav-links a b,
2218#site-nav .nav-dropdown-btn, nav .nav-links > a{
2219  text-transform:none !important;
2220}
2221
2222/* ═══ Filet pour les pages nees SANS le CSS de base de la nav ═══
2223   Les regles structurelles du menu vivent dans le CSS de chaque page. Une page
2224   nouvelle qui les oublie affiche les six colonnes du mega-menu ouvertes en
2225   permanence, par-dessus le contenu — bug constate sur report.html le 27/07.
2226   Ces deux regles sont identiques a celles des pages : en double elles s
2226ont
2227   inoffensives, seules elles sauvent la page. */
2228.nav-dropdown-menu:not(.open){ display:none; }
2229.osaiMM:not(.open){ display:none; }
2230
2231/* ═══ CHARTE : liseres 2 px au survol, PARTOUT ═══
2232   Romain : « a chaque fois que tu verras ca n'importe ou tu devras le corriger,
2233   c'est notre charte graphique ». L'audit du site a trouve 100 regles dans 30
2234   fichiers encore a 1 px. Les corriger fichier par fichier, sans pouvoir rendre
2235   les pages en local, aurait multiplie les risques pour un gain nul : navCSS est
2236   injecte sur CHAQUE page, une seule passe suffit.
2237   La bordure de REPOS passe aussi a 2 px — ne changer que le survol decalerait la
2238   mise en page d'un pixel au passage de la souris.
2239   19 des 85 selecteurs portent leur bordure en !important dans le CSS de leur
2240   page : sans !important ici, la regle perdait le duel — constate sur
2241   .project-card, signale par Romain. Et 12 selecteurs avaient echappe a l'audit
2242   parce que leur bordure vit dans une AUTRE regle que celle du survol.
2243   border-width n'a aucun effet sur un element sans bordure : rien ne peut
2244   apparaitre par surprise. */
2245#histChips button,
2246#histOut li a,
2247.ac-links a,
2248.ag-callout .links a,
2249.ag-card,
2250.ag-cmp a,
2251.agc-links a.gh,
2252.agr-chip,
2253.also-card,
2254.alt-card,
2255.arc-card,
2256.bench-btn,
2257.btn-second,
2258.calc-presets button,
2259.card,
2260.cat-item,
2261.cattile,
2262.champion-card,
2263.chip,
2264.chip-toggle,
2265.cmdk-trigger,
2266.col-add button,
2267.compare-clear,
2268.cr-chip,
2269.dl-btn,
2270.dnah-card,
2271.export-btn,
2272.fav-nav-btn,
2273.feat-card,
2274.featured-card,
2275.follow-btn,
2276.fp-card,
2277.hero-quick-tags button,
2278.homesec label,
2279.hrel-card,
2280.hres-btn,
2281.hub-card,
2282.hub-chip,
2283.hub-item,
2284.img-action-btn,
2285.lang-btn,
2286.lb-share-btn,
2287.like-btn,
2288.mchip,
2289.mtab,
2290.mvs-card,
2291.nc,
2292.pc-btn,
2293.pf-link button,
2294.pg-btn,
2295.ph-card,
2296.pl-cat,
2297.pnl-x,
2298.pod,
2299.preset-pill,
2300.proj-cat,
2301.prompt-card,
2302.psug,
2303.qc,
2304.qchip,
2305.qual-toggle,
2306.rel-card,
2307.rel-cat,
2308.rel-more button,
2309.run-card,
2310.save-row button.ghost,
2311.share-btn,
2312.sp-card,
2313.st-card,
2314.t-mh button,
2315.t-paper,
2316.t-sb,
2317.tag,
2318.thub-card,
2319.today-item,
2320.today-stat-link,
2321.tools-cat,
2322.top,
2323.trend-card,
2324.trend-card-wrap,
2325.tt-item,
2326.vs-pill,
2327.wn-card,
2328a.tcard,
2329a.vcard,
2330#histChips button:hover,
2331#histOut li a:hover,
2332.ac-links a:hover,
2333.ag-callout .links a:hover,
2334.ag-card:hover,
2335.ag-cmp a:hover,
2336.agc-links a.gh:hover,
2337.agr-chip:hover,
2338.also-card:hover,
2339.alt-card:hover,
2340.arc-card:hover,
2341.bench-btn:hover,
2342.btn-second:hover,
2343.calc-presets button:hover,
2344.card:hover,
2345.cat-item:hover,
2346.cattile:hover,
2347.champion-card:hover,
2348.chip:hover,
2349.chip-toggle:hover,
2350.cmdk-trigger:hover,
2351.col-add button:hover,
2352.compare-clear:hover,
2353.cr-chip:hover,
2354.dl-btn:hover,
2355.dnah-card:hover,
2356.export-btn:hover,
2357.fav-nav-btn:hover,
2358.feat-card:hover,
2359.featured-card:hover,
2360.follow-btn:hover,
2361.fp-card:hover,
2362.hero-quick-tags button:hover,
2363.homesec label:hover,
2364.hrel-card:hover,
2365.hres-btn:hover,
2366.hub-card:hover,
2367.hub-chip:hover,
2368.hub-item:hover,
2369.img-action-btn:hover,
2370.lang-btn:hover,
2371.lb-share-btn:hover,
2372.like-btn:hover,
2373.mchip:hover,
2374.mtab:hover,
2375.mvs-card:hover,
2376.nc:hover,
2377.pc-btn:hover,
2378.pf-link button:hover,
2379.pg-btn:hover,
2380.ph-card:hover,
2381.pl-cat:hover,
2382.pnl-x:hover,
2383.pod:hover,
2384.preset-pill:hover,
2385.proj-cat:hover,
2386.prompt-card:hover,
2387.psug:hover,
2388.qc:hover,
2389.qchip:hover,
2390.qual-toggle:hover,
2391.rel-card:hover,
2392.rel-cat:hover,
2393.rel-more button:hover,
2394.run-card:hover,
2395.save-row button.ghost:hover,
2396.share-btn:hover,
2397.sp-card:hover,
2398.st-card:hover,
2399.t-mh button:hover,
2400.t-paper:hover,
2401.t-sb:hover,
2402.tag:hover,
2403.thub-card:hover,
2404.today-item:hover,
2405.today-stat-link:hover,
2406.tools-cat:hover,
2407.top:hover,
2408.trend-card:hover,
2409.trend-card-wrap:hover,
2410.tt-item:hover,
2411.vs-pill:hover,
2412.wn-card:hover,
2413a.tcard:hover,
2414a.vcard:hover,
2415#downloadChat,
2416.article-card,
2417.btn-ghost,
2418.ghost,
2419.model-pill,
2420.moretool-card,
2421.project-card,
2422.rel-toggle,
2423.summ-style,
2424.tbar,
2425.tcard,
2426.tpl,
2427#downloadChat:hover,
2428.article-card:hover,
2429.btn-ghost:hover,
2430.ghost:hover,
2431.model-pill:hover,
2432.moretool-card:hover,
2433.project-card:hover,
2434.rel-toggle:hover,
2435.summ-style:hover,
2436.tbar:hover,
2437.tcard:hover,
2438.tpl:hover{ border-width:2px !important; }
2439
2440/* ══ Médias des posts (u.php, members.php, account.html) ═══════════════════
2441   Une seule feuille pour les trois pages : une galerie qui ne saute pas au
2442   chargement, et une vignette vidéo qui ne contacte YouTube/Vimeo qu'au clic.
2443   Liserés 2 px au repos comme au survol : charte maison. ══ */
2444.pmed{ display:grid; gap:7px; margin-top:11px; grid-template-columns:1fr; }
2445.pmed-n2,.pmed-n3,.pmed-n4{ grid-template-columns:1fr 1fr; }
2446.pmed-i{ display:block; margin:0; border:2px solid var(--border2,#e5e7eb); border-radius:13px; overflow:hidden; background:var(--surface2,#f4f4f5); line-height:0; transition:border-color .15s; }
2447.pmed-i:hover{ border-color:var(--accent,#6366f1); }
2448.pmed-i img{ display:block; width:100%; height:auto; max-height:520px; object-fit:cover; }
2449.pmed-n2 .pmed-i img,.pmed-n3 .pmed-i img,.pmed-n4 .pmed-i img{ aspect-ratio:4/3; max-height:none; }
2450.pmed-n3 .pmed-i:first-child{ grid-column:1 / -1; }
2451.pmed-v{ position:relative; display:block; width:100%; aspect-ratio:16/9; margin-top:11px; padding:0; border:2px solid var(--border2,#e5e7eb); border-radius:13px; overflow:hidden; cursor:pointer; background:#0f1115 center/cover no-repeat; transition:border-color .15s; }
2452.pmed-v:hover{ border-color:var(--accent,#6366f1); }
2453.pmed-play{ position:absolute; inset:0; margin:auto; width:56px; height:56px; border-radius:50%; background:rgba(15,17,21,.72); border:2px solid rgba(255,255,255,.9); }
2454.pmed-play::after{ content:''; position:absolute; top:50%; left:52%; transform:translate(-50%,-50%); border-style:solid; border-width:9px 0 9px 15px; border-color:transparent transparent transparent #fff; }
2455.pmed-v:hover .pmed-play{ background:var(--accent,#6366f1); }
2456.pmed-src{ position:absolute; left:9px; bottom:9px; padding:3px 9px; border-radius:999px; background:rgba(15,17,21,.72); color:#fff; font-size:.68rem; font-weight:600; letter-spacing:.03em; }
2457.pmed-f{ width:100%; aspect-ratio:16/9; margin-top:11px; border:2px solid var(--border2,#e5e7eb); border-radius:13px; display:block; }
2458`;
2459
2460  /* ══ Vidéo des posts : façade → lecteur ════════════════════════════════════
2461     La vignette est un <button data-src>. Au clic seulement, on la remplace par
2462     l'iframe : aucune requête vers YouTube/Vimeo tant que le visiteur n'a rien
2463     demandé (page légère, et pas de traceur tiers imposé). L'écouteur est
2464     délégué au document : il marche donc aussi sur les posts ajoutés après
2465     coup, comme dans le compte. ══ */
2466  document.addEventListener('click', function (ev) {
2467    var t = ev.target;
2468    if (!t || !t.closest) return;
2469    var btn = t.closest('.pmed-v');
2470    if (!btn) return;
2471    var src = btn.getAttribute('data-src') || '';
2472    /* Ceinture : on n'insère une iframe que vers les deux lecteurs attendus.
2473       Si un jour un data-src arrivait d'ailleurs, il ne serait pas chargé. */
2474    if (!/^https:\/\/(www\.youtube-nocookie\.com\/embed\/|player\.vimeo\.com\/video\/)/.test(src)) return;
2475    var f = document.createElement('iframe');
2476    f.className = 'pmed-f';
2477    f.src = src;
2478    f.setAttribute('title', btn.getAttribute('aria-label') || 'Video');
2479    f.setAttribute('allow', 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture');
2480    f.setAttribute('referrerpolicy', 'strict-origin-when-cross-origin');
2481    f.setAttribute('allowfullscreen', '');
2482    f.setAttribute('loading', 'lazy');
2483    if (btn.parentNode) btn.parentNode.replaceChild(f, btn);
2484  });
2485
2486  var styleEl = document.createElement('style');
2487  /* .nav-cta : les regles generiques de la barre portent des !important
2488     (couleur de repos --txt2, couleur de survol --accent) : sans selecteur
2489     PLUS SPECIFIQUE (a.nav-cta) + !important, le survol devenait indigo sur
2490     indigo — libelle invisible (releve en revue le 29/07). Entre 901 et
2491     1199px la barre est deja pleine a craquer (voir l'incident du crenage
2492     plus haut) : le bouton s'efface, le mega-menu et le menu mobile gardent
2493     leur entree « Submit a tool or project ». */
2494  styleEl.textContent = navCSS
2495    + '#site-nav .nav-links a.nav-cta{display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;padding:0;border:0;border-radius:50%;flex:0 0 auto;background:linear-gradient(135deg,#3B57FF 0%,#A257FF 100%);color:#fff !important;text-decoration:none;box-shadow:0 2px 10px rgba(90,80,255,.32);transition:transform .16s cubic-bezier(.2,.8,.3,1),box-shadow .16s}'
2496    + '#site-nav .nav-links a.nav-cta:hover, body.dark #site-nav .nav-links a.nav-cta:hover{background:linear-gradient(135deg,#3B57FF 0%,#A257FF 100%);color:#fff !important;transform:translateY(-2px) scale(1.07);box-shadow:0 6px 20px rgba(90,80,255,.48)}'
2497    + '#site-nav .nav-links a.nav-cta:active{transform:translateY(0) scale(.94);box-shadow:0 1px 6px rgba(90,80,255,.34)}'
2498    + '#site-nav .nav-links a.nav-cta:focus-visible{outline:2px solid var(--accent,#6366f1);outline-offset:3px}'
2499    + '@media(prefers-reduced-motion:reduce){#site-nav .nav-links a.nav-cta,#site-nav .nav-links a.nav-cta:hover,#site-nav .nav-links a.nav-cta:active{transition:none;transform:none}}'
2500    /* le svg de la barre a sa PROPRE couleur (regle 1513, color:var(--accent)) :
2501       sans ceci le + restait indigo sur fond indigo au survol (Romain, 29/07) */
2502    + '#site-nav .nav-links a.nav-cta svg{color:inherit !important}'
2503    /* Deux regles generiques de la barre visaient ce + et l'auraient ruine :
2504       « > a svg{width:14px} » l'aurait laisse minuscule dans un rond de 36,
2505       et surtout « @media 901-1200px { > a svg{display:none} } » l'aurait
2506       carrement efface — un bouton rond parfaitement VIDE sur les ecrans
2507       entre 901 et 1200 px. On surcharge donc taille ET affichage. */
2508    + '#site-nav .nav-links > a.nav-cta svg{width:19px !important;height:19px !important;opacity:1 !important;display:block !important}'
2509    /* [02/08] L'ancienne pilule et son libelle saturaient la barre entre 901
2510       et 1199 px, on la masquait donc. Un rond de 36 px tient a toutes les
2511       largeurs : la regle qui l'effacait est retiree, le bouton est
2512       desormais accessible partout. */
2513    /* ═══ CODE COULEUR PAR TYPE DE CONTENU (30/07/2026, demande de Romain) ═══
2514       Une couleur par famille, disponible sur TOUTES les pages puisque ce
2515       fichier est charge partout. Regles :
2516       - l'indigo (--accent) reste la MARQUE : navigation, liens, titres.
2517         Il ne code aucune categorie, sinon tout se confond.
2518       - la couleur ne porte JAMAIS le sens toute seule : les pastilles
2519         .cat-* embarquent un libelle — 8 % des hommes sont daltoniens.
2520       - deux themes : les fonds passent en version sombre via body.dark.
2521       Les pages consomment var(--cat-project) etc., ou directement les
2522       classes .cat.cat-project | cat-tool | cat-model-open | cat-model-paid. */
2523    + ':root{--cat-project:#10b981;--cat-project-bg:#ecfdf5;--cat-project-bd:#a7f3d0;--cat-project-tx:#047857;'
2524    +       '--cat-tool:#f59e0b;--cat-tool-bg:#fffbeb;--cat-tool-bd:#fde68a;--cat-tool-tx:#b45309;'
2525    +       '--cat-model-open:#0ea5e9;--cat-model-open-bg:#f0f9ff;--cat-model-open-bd:#bae6fd;--cat-model-open-tx:#0369a1;'
2526    +       '--cat-model-paid:#a855f7;--cat-model-paid-bg:#faf5ff;--cat-model-paid-bd:#e9d5ff;--cat-model-paid-tx:#7e22ce}'
2527    + 'body.dark{--cat-project-bg:rgba(16,185,129,.13);--cat-project-bd:rgba(16,185,129,.36);--cat-project-tx:#34d399;'
2528    +           '--cat-tool-bg:rgba(245,158,11,.13);--cat-tool-bd:rgba(245,158,11,.36);--cat-tool-tx:#fbbf24;'
2529    +           '--cat-model-open-bg:rgba(14,165,233,.13);--cat-model-open-bd:rgba(14,165,233,.36);--cat-model-open-tx:#38bdf8;'
2530    +           '--cat-model-paid-bg:rgba(168,85,247,.14);--cat-model-paid-bd:rgba(168,85,247,.38);--cat-model-paid-tx:#c084fc}'
2531    + '.cat{display:inline-flex;align-items:center;gap:4px;padding:2px 9px;border-radius:999px;border:1px solid;'
2532    +      'font-size:.68rem;font-weight:600;letter-spacing:.02em;white-space:nowrap;vertical-align:middle}'
2533    + '.cat-project{background:var(--cat-project-bg);border-color:var(--cat-project-bd);color:var(--cat-project-tx)}'
2534    + '.cat-tool{background:var(--cat-tool-bg);border-color:var(--cat-tool-bd);color:var(--cat-tool-tx)}'
2535    + '.cat-model-open{background:var(--cat-model-open-bg);border-color:var(--cat-model-open-bd);color:var(--cat-model-open-tx)}'
2536    + '.cat-model-paid{background:var(--cat-model-paid-bg);border-color:var(--cat-model-paid-bd);color:var(--cat-model-paid-tx)}'
2537    /* [30/07 soir, v2] Les puces rondes deplaisaient a Romain (et se
2538       deformaient selon le contexte). A la place : l'ICONE existante de
2539       chaque entree prend la couleur de sa famille. Les SVG des menus sont
2540       en stroke="currentColor" : une regle color suffit, le !important bat
2541       les styles inline que portent plusieurs entrees mobiles. */
2542    + '#moreMenu a.cat-ic-project svg,#osaiMM a.cat-ic-project svg,#site-nav a.cat-ic-project svg{color:var(--cat-project,#10b981) !important}'
2543    + '#moreMenu a.cat-ic-tool svg,#osaiMM a.cat-ic-tool svg,#site-nav a.cat-ic-tool svg{color:var(--cat-tool,#f59e0b) !important}'
2544    + '#moreMenu a.cat-ic-model-open svg,#osaiMM a.cat-ic-model-open svg,#site-nav a.cat-ic-model-open svg{color:var(--cat-model-open,#0ea5e9) !important}'
2545    + '#moreMenu a.cat-ic-model-paid svg,#osaiMM a.cat-ic-model-paid svg,#site-nav a.cat-ic-model-paid svg{color:var(--cat-model-paid,#a855f7) !important}'
2546    /* [31/07, Romain] Les fils d'Ariane « Home / Tools Directory » au-dessus
2547       des titres disparaissent PARTOUT — 29 pages + 17 generateurs, donc ici
2548       plutot qu'un par un. On garde la HAUTEUR (leur padding haut compense la
2549       barre fixe : sans lui, les titres passeraient sous le menu) et on clippe
2550       le texte. Le BreadcrumbList JSON-LD, invisible, reste : Google continue
2551       d'afficher l'arborescence dans ses resultats. */
2552    /* Un seul gabarit de titre de page pour tout le site (mesure du 31/07 :
2553       2,1rem/800 ici, clamp(2.4-4rem)/600 la). La couleur du dernier mot, elle,
2554       reste celle de la famille. */
2555    /* ═══ [01/08] LE SIGNE « IA » SUR LES CHAMPS DE RECHERCHE ═══════════
2556       Romain : « comment faire savoir que nos barres sont alimentees par
2557       l'IA ? des liseres arc-en-ciel qui bougent ? » L'usage qui s'est
2558       impose ailleurs (Notion, Linear, Arc, GitHub Copilot) : un degrade
2559       anime en liseré et une etincelle, jamais un arc-en-ciel complet — trop
2560       criard, et illisible pour qui distingue mal les couleurs. On reste
2561       donc dans NOS couleurs de familles (indigo, cyan, violet, ambre), le
2562       degrade ne s'anime QU'AU FOCUS (une bordure qui tourne en permanence
2563       fatigue et attire l'oeil loin du contenu), et il se fige pour qui a
2564       demande a reduire les animations. */
2565    /* ─── [01/08, v2] DEUX DEFAUTS CORRIGES, releves par Romain ───────────
2566       « le contour ne se fait pas bien, pas assez large ».
2567
2568       1. LA GEOMETRIE. L'anneau avait un rayon ECRIT EN DUR (20px) et le
2569          champ un autre. Or index.html pose, ligne 550, une regle globale
2570          input:not([type="range"]){border-radius:8px !important} qui bat les
2571          18px de la barre du hero : l'anneau arrondi a 20px entourait donc un
2572          champ arrondi a 8px. Aux quatre coins la bande calculee tombe a
2573          -0,7px : l'anneau y passe SOUS le champ et disparait, et le coin du
2574          champ ressort nu. On ne devine donc plus aucun rayon — marquerIA()
2575          MESURE le rayon reel du champ et le repose ici, augmente de
2576          l'epaisseur du lisere. Anneau et champ restent concentriques quoi
2577          qu'il arrive.
2578
2579       2. LA FORME DU DEGRADE. Un degrade CONIQUE repartit ses couleurs par
2580          ANGLE autour du centre. Sur la barre du hero, qui fait 648x76 (un
2581          rapport de 8,5 pour 1), chaque petit cote ne couvre que 13 degres
2582          sur 360 : les deux extremites n'affichaient qu'UNE couleur plate
2583          pendant que toute la palette s'ecrasait le long des bords haut et
2584          bas. C'est le rose d'un cote, le bleu de l'autre. Un degrade
2585          LINEAIRE qui glisse rend la meme idee et reste egal sur toute la
2586          longueur, quelle que soit la forme de la boite.
2587
2588       Le lisere ne s'anime toujours QU'AU FOCUS : une bordure qui tourne en
2589       permanence fatigue et attire l'oeil loin du contenu. */
2590    + '.osai-ai{position:relative;background:var(--surface,#fff);border-radius:12px;'
2591    + '--osai-ai-w:2px;--osai-ai-tl:14px;--osai-ai-tr:14px;--osai-ai-br:14px;--osai-ai-bl:14px}'
2592    + '.osai-ai::before{content:"";position:absolute;pointer-events:none;z-index:-1;'
2593    + 'inset:calc(0px - var(--osai-ai-w,2px));'
2594    + 'border-top-left-radius:var(--osai-ai-tl);border-top-right-radius:var(--osai-ai-tr);'
2595    + 'border-bottom-right-radius:var(--osai-ai-br);border-bottom-left-radius:var(--osai-ai-bl);'
2596    /* Palette SYMETRIQUE : l'indigo de la marque aux deux extremites, l'ambre
2597       en simple accent au centre. Avec un degrade decale (220% de large), la
2598       barre du hero n'en montrait que la moitie et virait a l'orange sur tout
2599       son cote droit — la couleur du site avait disparu. Au repos, le degrade
2600       tient donc exactement dans la boite. Il ne s'etire et ne glisse qu'au
2601       focus, et l'animation s'arrete pour qui a demande moins de mouvement. */
2602    + 'background:linear-gradient(100deg,#6366f1,#0ea5e9,#a855f7,#f59e0b,#a855f7,#0ea5e9,#6366f1);'
2603    + 'background-size:100% 100%;
2603opacity:.7;transition:opacity .25s}'
2604    + '.osai-ai:focus-within::before{opacity:1;background-size:200% 100%;'
2605    + 'animation:osaiAiSlide 4.5s linear infinite}'
2606    + '@keyframes osaiAiSlide{from{background-position:0 0}to{background-position:100% 0}}'
2607    + '@media (prefers-reduced-motion: reduce){.osai-ai:focus-within::before{animation:none}}'
2608    /* la pastille qui NOMME la chose : un degrade seul n'explique rien */
2609    + '.osai-ai-tag{display:inline-flex;align-items:center;gap:4px;font-family:\'DM Mono\',ui-monospace,monospace;'
2610    + 'font-size:.6rem;font-weight:600;letter-spacing:.1em;text-transform:uppercase;'
2611    + 'padding:2px 8px;border-radius:999px;border:1px solid var(--border2);color:var(--muted);'
2612    + 'white-space:nowrap;cursor:help}'
2613    + '.osai-ai-tag b{color:var(--accent,#6366f1);font-weight:700}'
2614    + '.cmdk-ia .cmdk-ia-ic{display:flex;align-items:center;justify-content:center;width:34px;height:34px;'
2615    + 'border-radius:9px;background:linear-gradient(135deg,#6366f1,#a855f7);font-size:1rem;flex:none}'
2616    + '.cmdk-ia .cmdk-item-name{font-weight:600}'
2617    /* la barre du hero : meme signature, mais son rayon a elle (18px) et sa
2618       pastille posee DANS le champ, a gauche du bouton d'effacement */
2619    + '.hero-search-box.osai-ai{border-radius:18px;background:transparent;--osai-ai-w:3px}'
2620    /* Le champ reprend le rayon de SA boite. Cette regle bat la regle globale
2621       de index.html par la SPECIFICITE (0,3,1 contre 0,1,1), pas par l'ordre
2622       de chargement : c'est ce qui rend le rayon previsible. On ne touche NI
2623       a la bordure NI a l'ombre du champ : elles sont indigo translucide,
2624       donc de la meme famille que le lisere, et l'oeil ne lit qu'un seul
2625       bord. Les neutraliser creait une couture grise entre l'arc-en-ciel et
2626       le champ, et effacait l'indicateur de focus au clavier. */
2627    + '.hero-search-box.osai-ai>input:not([type="range"]){border-radius:inherit !important}'
2628    /* la pastille se pose SUR le bord haut, comme une legende : dans le champ
2629       elle passerait sous le texte tape (la zone de saisie va jusqu'a 56px du
2630       bord droit, la pastille en fait 50) */
2631    + '.hero-search-box.osai-ai>.osai-ai-tag{position:absolute;top:-9px;right:20px;z-index:3;'
2632    + 'background:var(--bg,#f0f4fa);border-color:rgba(99,102,241,.45);padding:1px 8px}'
2633    + '@media (max-width:640px){.hero-search-box.osai-ai>.osai-ai-tag{right:12px}}'
2634    + '.hsr-ia .hsr-ia-ic{display:flex;align-items:center;justify-content:center;width:34px;height:34px;'
2635    + 'border-radius:9px;background:linear-gradient(135deg,#6366f1,#a855f7);font-size:1rem;flex:none}'
2636    + '.hsr-ia .hsr-name{font-weight:600}'
2637    /* ─── la carte assistant de l'accueil ─── */
2638    /* Le fond opaque ne peut PAS vivre sur la carte elle-meme : index.html pose
2639       « #hero-search > * { position:relative; z-index:1 } », la carte est donc
2640       un contexte d'empilement, et le lisere en z-index:-1 se peint AU-DESSUS
2641       de son propre fond — il remplissait toute la carte. Le fond descend donc
2642       sur une couche intermediaire (::after, z-index 0) et le contenu remonte
2643       au-dessus (z-index 1) : le degrade ne se voit plus que sur les 2px qui
2644       depassent. Meme piege que le 01/08 sur la barre de recherche. */
2645    /* [01/08, Romain : « sur mobile la barre est trop collee au lisere »]
2646       EFFONDREMENT DE MARGES. La carte n'a ni bordure ni remplissage propres
2647       — ils vivent sur la couche ::after — donc la marge basse de son dernier
2648       enfant SORT de la carte au lieu de l'espacer : le champ se retrouvait
2649       colle au bord. « flow-root » ouvre un contexte de formatage : les
2650       marges des enfants restent dedans. On ne peut PAS utiliser
2651       overflow:hidden pour ca, il couperait le lisere qui deborde de 2px. */
2652    + '.hero-ai{position:relative;display:flow-root;max-width:640px;margin:0 auto;border-radius:18px;'
2653    + 'background:transparent;box-shadow:0 14px 44px rgba(15,23,42,.09);overflow:visible;text-align:left}'
2654    + '.hero-ai::after{content:"";position:absolute;inset:0;border-radius:inherit;z-index:0;'
2655    + 'background:var(--surface,#fff);border:1px solid var(--border2,rgba(0,0,0,.1))}'
2656    + '.hero-ai > *{position:relative;z-index:1}'
2657    + 'body.dark .hero-ai{box-shadow:0 14px 44px rgba(0,0,0,.4)}'
2658    + '.hero-ai-head{display:flex;align-items:center;gap:10px;padding:13px 16px 0}'
2659    + '.hero-ai-av{flex:none;display:flex}'
2660    + '.hero-ai-id{min-width:0}'
2661    + '.hero-ai-id b{display:block;font-size:.92rem;font-weight:700;letter-spacing:-.01em;color:var(--txt,#111)}'
2662    + '.hero-ai-on{display:inline-flex;align-items:center;gap:5px;font-size:.68rem;color:var(--muted,#5a6d87)}'
2663    + '.hero-ai-on::before{content:"";width:6px;height:6px;border-radius:50%;background:#10b981;flex:none}'
2664    + '.hero-ai-greet{display:flex;gap:10px;margin:11px 16px 0;padding:12px 14px;border-radius:12px;'
2665    + 'background:var(--surface2,#f5f7fb);font-size:.84rem;line-height:1.55;color:var(--txt,#111)}'
2666    + '.hero-ai-greet b{display:block;margin-top:3px;color:var(--accent,#6366f1);font-weight:600}'
2667    + '.hero-ai-spark{flex:none;display:flex;align-items:center;justify-content:center;width:30px;height:30px;'
2668    + 'border-radius:9px;background:linear-gradient(135deg,#6366f1,#a855f7);font-size:.9rem}'
2669    /* la barre garde son code, elle perd juste son cadre : c'est la carte qui le porte */
2670    + '.hero-ai .hero-search-box{margin:12px 12px 12px;max-width:none}'
2671    + '.hero-ai .hero-search-box input{border-color:transparent !important;box-shadow:none !important;'
2672    + 'background:var(--bg,#f0f4fa) !important;padding-right:96px !important;font-size:1rem !important}'
2673    /* Champ vide = pas de croix d'effacement : reserver ses 38px tronquait
2674       l'invite sur telephone (« Search, or ask anythin… »). */
2675    + '.hero-ai .hero-search-box input:placeholder-shown{padding-right:58px !important}'
2676    + 'body.dark .hero-ai .hero-search-box input{background:rgba(255,255,255,.05) !important}'
2677    + '.hero-ai .hero-search-box input:focus{border-color:var(--accent,#6366f1) !important}'
2678    + '.hero-ai-send{position:absolute;right:14px;top:50%;transform:translateY(-50%);width:36px;height:36px;'
2679    + 'border:none;border-radius:10px;background:linear-gradient(135deg,#6366f1,#a855f7);color:#fff;'
2680    + 'display:flex;align-items:center;justify-content:center;cursor:pointer;transition:filter .15s;z-index:3}'
2681    + '.hero-ai-send:hover{filter:brightness(1.12)}'
2682    + '.hero-ai #heroSearchClear{right:58px}'
2683    /* « relative » et non « static » : la liste doit rester POSITIONNEE pour
2684       que son z-index compte, sinon elle passe sous la couche de fond de la
2685       carte et la reponse s'affiche dans le vide (vu a l'essai : la bulle
2686       existait, mesuree 598x139, et ne se peignait pas). */
2687    + '.hero-ai .hero-search-results{position:relative;top:auto;left:auto;right:auto;'
2688    + 'margin:0 12px 12px;box-shadow:none;'
2689    + 'border:1px solid var(--border,rgba(0,0,0,.07))}'
2690    /* le lisere anime cercle la CARTE, plus la barre */
2691    + '.hero-ai.osai-ai{--osai-ai-w:2px}'
2692    + '.hero-search-box.osai-ai::before{display:none}'
2693    + '@media(max-width:640px){.hero-ai-greet{font-size:.8rem}.hero-ai .hero-search-box input{font-size:.95rem !important}}'
2694    /* la bulle de reponse, dans la liste de resultats */
2695    + '.osai-inline{padding:14px 16px;font-size:.86rem;line-height:1.62;color:var(--txt,#111);'
2696    + 'border-left:2px solid var(--accent,#6366f1);background:var(--surface2,#f7f8fc);'
2697    + 'border-radius:0 10px 10px 0;margin:8px}'
2698    + '.osai-inline a{color:var(--accent,#6366f1);font-weight:600;text-decoration:none}'
2699    + '.osai-inline a:hover{text-decoration:underline}'
2700    + '.osai-inline-wait{color:var(--muted,#5a6d87);font-style:italic}'
2701    + '.osai-inline-more{display:block;width:-moz-fit-content;width:fit-content;'
2702    + 'margin-top:13px;padding:6px 12px;border-radius:999px;'
2703    + 'border:1px solid var(--border2,rgba(0,0,0,.15));background:none;color:var(--muted,#5a6d87);'
2704    + 'font-family:inherit;font-size:.74rem;cursor:pointer;transition:all .15s}'
2705    + '.osai-inline-more:hover{border-color:var(--accent,#6366f1);color:var(--accent,#6366f1)}'
2706    /* [01/08] Le pied de page passe de deux colonnes fourre-tout a quatre
2707       colonnes thematiques. La grille s'adapte d'elle-meme : auto-fit avec un
2708       minimum de 150px, donc quatre colonnes sur un ecran large, deux sur une
2709       tablette, une sur un telephone — sans point de rupture ecrit a la main. */
2710    + '.footer-inner{display:grid !important;grid-template-columns:minmax(210px,1.4fr) repeat(auto-fit,minmax(150px,1fr)) !important;'
2711    + 'gap:30px 26px !important;align-items:start !important}'
2712    + '.footer-col h4{font-size:.72rem !important;text-transform:uppercase !important;letter-spacing:.09em !important;'
2713    + 'font-family:\'DM Mono\',ui-monospace,monospace !important;color:var(--muted) !important;margin-bottom:11px !important}'
2714    + '.footer-col a{display:block !important;padding:3px 0 !important;font-size:.82rem !important;'
2715    + 'color:var(--txt) !important;opacity:.82;text-decoration:none !important;transition:opacity .12s,color .12s}'
2716    + '.footer-col a:hover{opacity:1;color:var(--accent,#6366f1) !important}'
2717    + '.loop-strip a .fic{vertical-align:-2px;margin-right:5px}'
2718    /* .footer-more etait sur deux colonnes internes : c'etait necessaire quand
2719       elle portait 36 liens, ca coupe les libelles en deux maintenant qu'elle
2720       en a 14. La grille exterieure fait deja le travail. */
2721    + '.footer-more{column-count:1 !important}'
2722    + '@media(max-width:760px){.footer-inner{grid-template-columns:1fr 1fr !important}}'
2723    + '@media(max-width:460px){.footer-inner{grid-template-columns:1fr !important}}'
2724    /* ═══ [01/08, Romain] LES BARRES DE RECHERCHE ETAIENT DISPARATES ═══════
2725       Releve page par page : leaderboard un rectangle de 280px arrondi a 10,
2726       AI Tools un rectangle plein de 8 avec un lisere ORANGE de 2px, DNA un
2727       rectangle a 1px, Projects une PASTILLE completement arrondie. Quatre
2728       pages, quatre formes. On aligne tout sur celle d'AI Tools, que Romain
2729       cite en exemple : rectangle a 10px, lisere 2px, dans la couleur de la
2730       famille de la page (--osai-fam, posee par le JS a partir du chemin,
2731       la meme table que le titre et les points du menu).
2732       Le :not([type=range]) n'est pas la pour filtrer mais pour la
2733       SPECIFICITE : (0,3,1) bat le input{border-radius:8px !important}
2734       global de cinq pages sans dependre de l'ordre des feuilles. */
2735    + 'input.osai-sb:not([type="range"]){border:2px solid var(--osai-fam,#6366f1) !important;'
2736    + 'border-radius:10px !important;background:var(--surface,#fff) !important;'
2737    + 'padding:11px var(--osai-sb-pr,14px) 11px var(--osai-sb-pl,14px) !important;'
2738    + 'font-family:inherit !important;font-size:.88rem !important;'
2739    + 'color:var(--txt,#111) !important;
2739outline:none !important;box-shadow:none !important;'
2740    + 'transition:box-shadow .15s,border-color .15s !important}'
2741    + 'input.osai-sb:not([type="range"])::placeholder{color:var(--muted,#5a6d87) !important;opacity:1}'
2742    + 'input.osai-sb:not([type="range"]):hover{border-color:var(--osai-fam,#6366f1) !important;'
2743    + 'box-shadow:0 0 0 3px color-mix(in srgb, var(--osai-fam,#6366f1) 14%, transparent) !important}'
2744    + 'input.osai-sb:not([type="range"]):focus{border-color:var(--osai-fam,#6366f1) !important;'
2745    + 'box-shadow:0 0 0 3px color-mix(in srgb, var(--osai-fam,#6366f1) 26%, transparent) !important}'
2746    /* une loupe posee en fond, pour les champs qui n'en ont pas : le decalage
2747       du texte ne s'applique QUE dans ce cas (classe osai-sb-ic) */
2748    + 'input.osai-sb.osai-sb-ic:not([type="range"]){padding-left:38px !important;'
2749    + 'background-repeat:no-repeat !important;background-position:13px 50% !important;'
2750    + 'background-size:16px 16px !important}'
2751    + '.ohp-ttl>.osai-ai-tag{margin-top:4px;border-color:rgba(255,255,255,.35);color:#c7d2fe}'
2752    + '.ohp-ttl>.osai-ai-tag b{color:#fff}'
2753    /* [31/07, Romain] « le texte sous le titre ne prend pas toute la largeur,
2754       il retourne a la ligne ». Chaque page bridait son intro a 760px ou 64ch
2755       — un usage typographique classique, mais Romain veut la largeur du
2756       contenu, comme les autres elements. Une seule regle, pour toutes. */
2757    + '.lead,.page-header p,.hero p,.mcp-lede,.dna-sub,.rel-hero .lead,.sp-hero p{max-width:none !important}'
2758    + '.osai-h1{font-size:clamp(1.85rem,3.2vw,2.25rem) !important;font-weight:800 !important;'
2759    + 'letter-spacing:-.02em !important;line-height:1.15 !important;margin-bottom:12px !important}'
2760    + '.crumbs,.breadcrumb{height:0 !important;overflow:hidden !important;'
2761    + 'padding-bottom:0 !important;margin-bottom:0 !important;visibility:hidden !important}'
2762    /* [02/08/2026, audit] Le bouton de menu mesurait 36x28 : au-dessus du
2763       minimum WCAG (24x24) mais sous les recommandations d'Apple (44) et de
2764       Google (48), sur LE controle le plus utilise du site en mobile. On
2765       agrandit la ZONE TACTILE seulement : les trois barres gardent 24x2, la
2766       barre garde 64px de haut, le centrage ne bouge pas (mesure du 02/08). */
2767    + '.hamburger{min-width:44px;min-height:44px;align-items:center;justify-content:center}';
2768  document.head.appendChild(styleEl);
2769
2770  /* ═══ Puces de categorie dans LES DEUX menus (30/07/2026) ═══════════════
2771     Le site a deux menus — #moreMenu (bureau) et #osaiMM (mobile) — et la
2772     regle de la maison, apprise a nos depens, est qu'on oublie toujours
2773     l'un des deux. Parade : UNE fonction qui decore tout lien de menu
2774     qu'elle trouve, appelee en differe (setTimeout 0) une fois que le
2775     passage d'initialisation a construit les deux. Impossible d'en
2776     oublier un : il n'y a qu'un seul endroit a maintenir.
2777     La famille se deduit du PREMIER SEGMENT du chemin — a.pathname resout
2778     les hrefs relatifs tout seul — apres retrait du prefixe de langue.
2779     Un segment inconnu ne recoit RIEN : pas de puce plutot qu'une puce
2780     fausse. */
2781  function osaiCatDots() {
2782    var FAM = {
2783      project: ['projects','project','trending','emerging','releases','top','hf','categories','health','pulse','dna','stacks','countries','spaces','graph'],
2784      tool: ['tools','tool','compare-tools','vs','alternatives','agents','guides','submit','mcp','skills'],
2785      'model-open': ['compare','leaderboard','models-vs','model','models','media','local-ai','makers','maker'],
2786      'model-paid': ['pricing','paid-models']
2787    };
2788    var SEG = {};
2789    for (var f in FAM) for (var i = 0; i < FAM[f].length; i++) SEG[FAM[f][i]] = f;
2790    window.__osaiSEG = SEG;   /* la meme table sert au H1 des pages */
2791    /* le H1 de la page est colore ICI, apres construction de la table : appele
2792       plus tot, il tombait sur l'indigo par defaut (mesure sur /local-ai.html) */
2793    /* la couleur de la famille, disponible pour toute la page */
2794    var segCour = (location.pathname.replace(/^\/(fr|de|es|it|pt)(?=\/)/, '').split('/')[1] || '').replace(/\.html$/, '');
2795    var famCour = SEG[segCour] || null;
2796    document.documentElement.style.setProperty('--osai-fam',
2797      famCour ? getComputedStyle(document.documentElement).getPropertyValue('--cat-' + famCour).trim() || '#6366f1' : '#6366f1');
2798    if (typeof accentPageTitle === 'function') accentPageTitle();
2799    if (typeof carteAssistant === 'function') carteAssistant();   /* avant le marquage */
2800    if (typeof marquerIA === 'function') marquerIA();
2801    if (typeof uniformiserRecherches === 'function') {
2802      uniformiserRecherches();
2803      setTimeout(uniformiserRecherches, 1200);   /* champs construits a la volee */
2804    }
2805    /* Le filet : au chargement, puis a chaque fois que la page ajoute des
2806       cartes (les listes se rendent en JavaScript, souvent apres coup). */
2807    if (typeof osaiRelierSorties === 'function') {
2808      osaiRelierSorties();
2809      setTimeout(osaiRelierSorties, 1500);
2810      setTimeout(osaiRelierSorties, 4000);
2811      if (window.MutationObserver) {
2812        var relMinuteur = null;
2813        new MutationObserver(function(){
2814          clearTimeout(relMinuteur);
2815          relMinuteur = setTimeout(function(){ osaiRelierSorties(); }, 350);
2816        }).observe(document.body, { childList: true, subtree: true });
2817      }
2818    }
2819    /* [30/07 soir, v3] La barre du haut aussi (demande de Romain) : Projects,
2820         AI Tools, Models (/leaderboard), DNA prennent leur famille. Le bouton
2821         Submit (.nav-cta) est exclu : c'est un CTA de marque, pas une entree
2822         de catalogue. Latest et Plans n'appartiennent a aucune famille de
2823         contenu — Latest melange les quatre, Plans est l'offre du site — et
2824         gardent donc l'indigo de marque : le doute ne colore pas. */
2825      var links = document.querySelectorAll('#moreMenu a, #osaiMM a, #site-nav .nav-links > a:not(.nav-cta)');
2826    for (var j = 0; j < links.length; j++) {
2827      var a = links[j];
2828      var path = (a.pathname || '').replace(/^\/(fr|de|es|it|pt)(?=\/)/, '');
2829      var seg = (path.split('/')[1] || '').replace(/\.html$/, '');
2830      var fam = SEG[seg];
2831      /* on teinte l'icone existante ; une entree sans icone ou sans
2832           famille reconnue ne change pas — le doute ne colore pas */
2833        if (fam && a.querySelector('svg')) a.classList.add('cat-ic-' + fam);
2834    }
2835  }
2836  /* [30/07, nuit] Constat de Romain : teintes visibles sur l'accueil,
2837     indigo partout ailleurs — alors que le serveur sert le meme fichier a
2838     toutes les pages (verifie variante par variante). Quelque chose, sur
2839     certaines pages, reconstruit ou retraduit les menus APRES notre passage
2840     et emporte les classes. Plutot que courir apres le coupable exact :
2841     la pose des teintes est IDEMPOTENTE (classList.add), donc on la
2842     rappelle a chaque moment charniere. La derniere passe a 1,6 s couvre
2843     toute reinjection tardive. */
2844  setTimeout(osaiCatDots, 0);
2845  setTimeout(osaiCatDots, 400);
2846  setTimeout(osaiCatDots, 1600);
2847  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', osaiCatDots);
2848  window.addEventListener('load', osaiCatDots);
2849
2850  /* ═══ Maillage fiches <->
2850 hubs (31/07/2026, nuit) ═══════════════════════
2851     Sur la fiche d'un projet qui est un serveur MCP ou un pack de skills,
2852     un encart le dit et renvoie au hub. Injecte ICI et non dans le
2853     generateur des fiches : les 60 000 fiches (6 langues) le recoivent
2854     d'un coup, sans regeneration ni retraduction. Les listes de slugs sont
2855     ecrites par les generateurs de hubs a chaque rafraichissement —
2856     mcp-slugs.json et skills-slugs.json, ~5 Ko chacun, charges UNIQUEMENT
2857     sur les pages /project/. Un 404 (listes pas encore generees) est
2858     silencieux : pas d'encart plutot qu'une erreur. */
2859  (function () {
2860    var m = location.pathname.match(/^\/(?:(?:fr|de|es|it|pt)\/)?project\/([a-z0-9-]+)\.html$/);
2861    if (!m) return;
2862    var slug = m[1];
2863    var faits = 0;
2864    function encart(txt, url, fam) {
2865      var h1 = document.querySelector('h1');
2866      if (!h1 || document.getElementById('hubx-' + fam)) return;
2867      var d = document.createElement('a');
2868      d.id = 'hubx-' + fam;
2869      d.href = url;
2870      d.className = 'hubx';
2871      d.innerHTML = '<b>' + txt + '</b><span>See it in the hub \u2192</span>';
2872      h1.parentNode.insertBefore(d, h1.nextSibling);
2873    }
2874    function charge(fmap, farr, txt, url, fam) {
2875      /* nouveau format : {slug: categorie} -> la banniere DIT quelle
2876         categorie et pointe sur l'ancre de sa section ; repli sur
2877         l'ancien tableau de slugs tant que la carte n'est pas generee */
2878      fetch(fmap).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; }).then(function (m) {
2879        if (m && !Array.isArray(m) && typeof m === 'object') {
2880          if (m[slug]) {
2881            var cat = String(m[slug]);
2882            var ancre = cat.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
2883            encart(txt + ' \u2014 ' + cat, url + '#' + ancre, fam);
2884          }
2885          return;
2886        }
2887        fetch(farr).then(function (r) { return r.ok ? r.json() : []; }).then(function (l) {
2888          if (l && l.indexOf && l.indexOf(slug) !== -1) encart(txt, url, fam);
2889        }).catch(function () {});
2890      });
2891    }
2892    var st = document.createElement('style');
2893    st.textContent = '.hubx{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:14px 0;'
2894      + 'padding:10px 16px;border:1px solid var(--cat-tool,#f59e0b);border-left:4px solid var(--cat-tool,#f59e0b);'
2895      + 'border-radius:10px;background:rgba(245,158,11,.07);text-decoration:none;color:inherit;font-size:.88rem}'
2896      + '.hubx b{font-weight:600}.hubx span{color:var(--cat-tool,#f59e0b);font-weight:600;white-space:nowrap}'
2897      + '.hubx:hover{background:rgba(245,158,11,.14)}';
2898    document.head.appendChild(st);
2899    charge('/mcp-map.json', '/mcp-slugs.json', '\u26A1 This project is an MCP server', '/mcp/', 'mcp');
2900    charge('/skills-map.json', '/skills-slugs.json', '\uD83D\uDCDA This project ships AI skills', '/skills/', 'skills');
2901  })();
2902
2903  // Détecter la page d'accueil (multilingue). LP est déjà défini plus haut.
2904  var path = window.location.pathname;
2905  var _pnl = (NAV_LANG === 'en') ? path : (path.replace('/' + NAV_LANG, '') || '/');
2906  var isHome = (_pnl === '/' || _pnl === '/index.html' || _pnl === '');
2907  // Préfixe des ancres home : "" si déjà sur l'accueil (même langue), sinon "/xx/"
2908  var H = isHome ? '' : (LP + '/');
2909
2910  // ═══ NAV ═══
2911  function ic(p){ return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">'+p+'</svg>'; }
2912  var navHTML = ''
2913  + '<nav>'
2914  /* [30/07] Plus de mot-symbole : l'anneau seul, plus grand. Le lien n'ayant
2915     plus de texte, il lui faut un nom accessible explicite. */
2916  + '  <a href="/" aria-label="olud.ai" title="olud.ai" style="text-decoration:none;display:flex;align-items:center">'
2917  + '    <svg width="40" height="40" viewBox="0 0 100 100" aria-hidden="true">' + '<defs> <linearGradient id="oludGradH" x1="0" y1="1" x2="1" y2="0"> <stop offset="0" stop-color="#2540E8"/><stop offset="1" stop-color="#8B3FEF"/> </linearGradient> <mask id="oludNotchH" maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100"> <rect width="100" height="100" fill="#fff"/> <circle cx="86.72" cy="19.91" r="15.91" fill="#000"/> </mask> </defs>
2917 <circle cx="50" cy="50" r="39.61" fill="none" stroke="url(#oludGradH)" stroke-width="20.78" mask="url(#oludNotchH)"/> <circle cx="86.72" cy="19.91" r="10.71" fill="url(#oludGradH)"/>' + '</svg>'
2918  /* [17/08, Romain] Le mot-symbole REVIENT a cote de l'anneau : « olud.ai »
2919     comme sur le mockup de la page promote, puis agrandi et « .ai » fonce a
2920     sa demande. Le style inline bat le .logo span des pages (accent clair) ;
2921     la variable --logo-ai garde le mode nuit lisible (redefinie ci-dessous). */
2922  + '    <span class="logo" style="margin-left:10px;font-size:1.24rem;font-weight:700;letter-spacing:.01em">olud<span style="color:var(--logo-ai,#4338ca)">.ai</span></span>'
2923  + '  </a>'
2924  /* [19/08] Le blindage de la barre (regles !important des pages) battait le
2925     style inline du bouton Promote : texte gris, icone invisible. La charte
2926     donne la parade : #site-nav + !important, injecte APRES tout le reste. */
2927  + '  <style>body.dark{--logo-ai:#8b93f8}'
2928  + '#site-nav .nav-soutien{background:linear-gradient(100deg,#5558e8,#8b3fef) !important;color:#fff !important;border:none !important;border-radius:999px !important;padding:6px 14px !important;box-shadow:0 3px 10px rgba(85,88,232,.4) !important}'
2929  + '#site-nav .nav-soutien span{color:#fff !important}'
2930  + '#site-nav .nav-soutien svg{color:#fff !important;stroke:#fff !important}'
2931  + '#site-nav .nav-soutien:hover{filter:brightness(1.08);color:#fff !important}'
2932  + '</style>'
2933  + '  <div class="nav-links">'
2934  + '    <button class="nav-close" id="navClose" aria-label="Close menu">&times;</button>'
2935  /* [02/08/2026, Romain] « dans la barre du haut on devrait mettre Releases a la
2936     place de Plans ». Releases est du contenu vivant, mis a jour chaque heure —
2937     c'est une raison de revenir. Plans est une page de conversion, consultee une
2938     fois : elle reste dans le menu Explore et dans le pied de page. Meme icone
2939     que l'entree Releases du mega-menu, pour qu'on reconnaisse la meme chose. */
2940  + '    <a href="/releases.html">'+ic('<path d="M12 2v6"/><path d="m16 6-4 4-4-4"/><rect width="18" height="10" x="3" y="12" rx="2"/>')+'<span>Releases</span></a>'
2941  + '    <a href="/projects.html">'+ic('<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>')+'<span>Projects</span></a>'
2942  + '    <a href="/tools.html">'+ic('<path d="M14.7 6.3a4 4 0 0 1-5.4 5.4L4 17l3 3 5.3-5.3a4 4 0 0 1 5.4-5.4z"/>')+'<span>AI Tools</span></a>'
2943  + '    <a href="/leaderboard.html">'+ic('<line x1="6" y1="20" x2="6" y2="14"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="18" y1="20" x2="18" y2="10"/>')+'<span>Models</span></a>'
2944  + '    <a href="/latest.html">'+ic('<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>')+'<span>Latest</span></a>'
2945  + '    <a href="/dna.html">'+ic('<path d="M6 3c0 4 12 5 12 9s-12 5-12 9"/><path d="M18 3c0 4-12 5-12 9s12 5 12 9"/><path d="M8 8h8M8 16h8"/>')+'<span>DNA</span></a>'
2946  /* [17/08, Romain] La pilule Support devient Promote : l'offre payante est
2947     en vente, c'est elle qui merite la place en vitrine. Support reste
2948     accessible par le mega-menu Explore. */
2949  + '    <a href="/promote.html" class="nav-soutien" style="background:linear-gradient(100deg,#5558e8,#8b3fef);border-radius:999px;padding:6px 14px;color:#fff;box-shadow:0 3px 10px rgba(85,88,232,.4)">'+ic('<path d="M11 5 6 9H3v6h3l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/>')+'<span>Promote</span></a>'
2950  + '    <div class="nav-dropdown">'
2951  + '      <button class="nav-dropdown-btn" id="moreMenuBtn">Explore &#9662;</button>'
2952  + '      <div class="nav-dropdown-menu mega" id="moreMenu">'
2953  + '        <div class="mega-grid">'
2954  + '          <div class="mega-col"><div class="mega-h">Tools &amp; Agents</div>'
2955  /* [29/07] Pulse ranking et Security advisories ajoutes ICI aussi. Le site a
2956     DEUX menus : le hamburger mobile (osaiMM) et ce panneau bureau « Explore »
2957     (#moreMenu). Trois heures ont ete perdues a corriger le premier pendant que
2958     Romain regardait le second — sa capture montrait « Community » et « Tools
2959     Directory », absents d'osaiMM, et cet indice n'a pas ete lu. */
2960  + '            <a href="/pulse.html">'+ic('<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>')+'<span><b>Pulse ranking</b></span></a>'
2961  + '            <a href="/security.html">'+ic('<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>')+'<span><b>Security advisories</b></span></a>'
2962  + '            <a href="/agents/">'+ic('<path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/>')+'<span><b>AI Agents</b><em>Open-source frameworks</em></span></a>'
2963  + '            <a href="/tool/">'+ic('<path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/>')+'<span><b>Tool Guides</b><em>76 in-depth guides</em></span></a>'
2964  + '            <a href="/tool/">'+ic('<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/>')+'<span>Tool profiles</span></a>'
2965  + '            <a href="/stack-builder.html">'+ic('<rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/><rect width="7" height="7" x="14" y="14" rx="1"/>')+'<span>Stack Builder</span></a>'
2966  /* [24/07] Entree « Community » (/members) REACTIVEE : on ouvre la decouverte
2967     des membres (Phase A du social). Sans elle, personne ne trouvait personne. */
2968  + '            <a href="/tools.html">'+ic('<path d="M14.7 6.3a4 4 0 0 1-5.4 5.4L4 17l3 3 5.3-5.3a4 4 0 0 1 5.4-5.4z"/>')+'<span><b>Tools catalog</b><em>225+ tools</em></span></a>'
2969  + '            <a href="/skills/">'+ic('<path d="M12 2 2 7l10 5 10-5-10-5z"/><path d="m2 17 10 5 10-5"/><path d="m2 12 10 5 10-5"/>')+'<span><b>AI Skills</b><em>199 analyzed</em></span></a>'
2970  + '            <a href="/mcp/">'+ic('<path d="M12 22v-5"/><path d="M9 8V2"/><path d="M15 8V2"/><path d="M6 8h12l-1 7a5 5 0 0 1-10 0z"/>')+'<span><b>MCP Servers</b><em>237 analyzed</em></span></a>'
2971  + '            <a href="/compare-tools.html">'+ic('<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><path d="M11 18H8a2 2 0 0 1-2-2V9"/>')+'<span><b>Compare Tools</b></span></a>'
2972  + '            <a href="/category.html">'+ic('<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>')+'<span><b>Categories</b></span></a>'
2973  + '          </div>'
2974  + '          <div class="mega-col"><div class="mega-h">Models</div>'
2975  + '            <a href="/local-ai.html">'+ic('<rect width=\"16\" height=\"16\" x=\"4\" y=\"4\" rx=\"2\"/><rect width=\"6\" height=\"6\" x=\"9\" y=\"9\"/><path d=\"M15 2v2M15 20v2M2 15h2M2 9h2M20 15h2M20 9h2M9 2v2M9 20v2\"/>')+'<span><b>Local AI</b><em>What can your machine run?</em></span></a>'
2976  + '            <a href="/makers/">'+ic('<path d="M3 21h18"/><path d="M5 21V7l7-4 7 4v14"/><path d="M9 21v-6h6v6"/>')+'<span><b>Model makers</b></span></a>'
2977  + '            <a href="/pricing.html">'+ic('<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>')+'<span><b>AI Pricing</b><em>Live costs + calculator</em></span></a>'
2978  + '            <a href="/models-vs/">'+ic('<circle cx="12" cy="12" r="10"/><path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"/><path d="M12 18V6"/>')+'<span><b>Free vs Paid</b><em>Open vs frontier</em></span></a>'
2979  + '            <a href="/leaderboard.html">'+ic('<line x1="6" y1="20" x2="6" y2="14"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="18" y1="20" x2="18" y2="10"/>')+'<span><b>Leaderboard</b></span></a>'
2980  + '            <a href="/media-leaderboard.html">'+ic('<line x1="6" y1="20" x2="6" y2="14"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="18" y1="20" x2="18" y2="10"/>')+'<span><b>Media models</b></span></a>'
2981  + '            <a href="/compare.html">'+ic('<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><path d="M11 18H8a2 2 0 0 1-2-2V9"/>')+'<span><b>Compare Models</b></span></a>'
2982  + '            <a href="/vs/">'+ic('<path d="m9 7-5 5 5 5"/><path d="m15 7 5 5-5 5"/>')+'<span><b>Versus &middot; X vs Y</b></span></a>'
2983  + '            <a href="/model/">'+ic('<rect width="18" height="14" x="3" y="5" rx="2"/><path d="M7 15h4M7 11h10"/>')+'<span><b>Model profiles</b></span></a>'
2984  + '            <a href="/paid-models.html">'+ic('<path d="M7.5 7.5h.01"/><path d="M19 9.3V5a2 2 0 0 0-2-2h-4.3a2 2 0 0 0-1.4.6l-7.7 7.7a2 2 0 0 0 0 2.8l4.3 4.3a2 2 0 0 0 2.8 0l7.7-7.7a2 2 0 0 0 .6-1.4Z"/>')+'<span><b>Paid Models</b></span></a>'
2985  + '          </div>'
2986  + '          <div class="mega-col"><div class="mega-h">Alternatives</div>'
2987  + '            <a href="/alternatives/">'+ic('<path d="m2 9 3-3 3 3"/><path d="M13 18H7a2 2 0 0 1-2-2V6"/><path d="m22 15-3 3-3-3"/><path d="M11 6h6a2 2 0 0 1 2 2v10"/>')+'<span><b>All Alternatives</b><em>129 tools mapped</em></span></a>'
2988  + '            <a href="/alternatives/jira.html">'+ic('<path d="M3 3h18v18H3z"/><path d="M8 12h8M12 8v8"/>')+'<span><b>Jira alternatives</b></span></a>'
2989  + '            <a href="/alternatives/chatgpt.html">'+ic('<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>')+'<span><b>ChatGPT alternatives</b></span></a>'
2990  + '            <a href="/alternatives/midjourney.html">'+ic('<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>')+'<span><b>Midjourney alternatives</b></span></a>'
2991  + '            <a href="/alternatives/figma.html">'+ic('<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/>')+'<span><b>Figma alternatives</b></span></a>'
2992  + '            <a href="/alternatives/slack.html">'+ic('<line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/><line x1="10" y1="3" x2="8" y2="21"/><line x1="16" y1="3" x2="14" y2="21"/>')+'<span><b>Slack alternatives</b></span></a>'
2993  + '            <a href="/alternatives/zoom.html">'+ic('<polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>')+'<span><b>Zoom alternatives</b></span></a>'
2994  + '            <a href="/blog/alternatives/github-copilot.html">'+ic('<path d="M16 18 22 12 16 6"/><path d="M8 6 2 12l6 6"/>')+'<span><b>Copilot alternatives</b></span></a>'
2995  + '            <a href="/alternatives/google-analytics.html">'+ic('<path d="M3 3v18h18"/><path d="M7 15l4-4 3 3 6-6"/>')+'<span><b>Analytics alternatives</b></span></a>'
2996  + '            <a href="/alternatives/cursor.html">'+ic('<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>')+'<span><b>Cursor alternatives</b></span></a>'
2997  + '            <a href="/alternatives/notion.html">'+ic('<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>')+'<span><b>Notion alternatives</b></span></a>'
2998  + '            <a href="/alternatives/adobe-photoshop.html">'+ic('<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/>')+'<span><b>Photoshop alternatives</b></span></a>'
2999  + '          </div>'
3000  + '          <div class="mega-col"><div class="mega-h">Discover</div>'
3001  + '            <a href="/projects.html">'+ic('<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>')+'<span><b>Projects</b></span></a>'
3002  + '            <a href="/spaces.html">'+ic('<polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/>')+'<span><b>Spaces</b></span></a>'
3003  + '            <a href="/vertical/">'+ic('<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>')+'<span><b>Industries</b><em>AI by sector: health, finance…</em></span></a>'
3004  + '            <a href="/dna.html">'+ic('<path d="M6 3c0 4 12 5 12 9s-12 5-12 9"/><path d="M18 3c0 4-12 5-12 9s12 5 12 9"/><path d="M8 8h8M8 16h8"/>')+'<span><b>DNA families</b><em>How projects share DNA</em></span></a>'
3005  + '            <a href="/languages.html">'+ic('<path d="m5 8 6 6"/><path d="m4 14 6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/><path d="m22 22-5-10-5 10"/><path d="M14 18h6"/>')+'<span><b>Languages</b><em>Python, Rust, Go\u2026</em></span></a>'
3006  + '            <a href="/countries.html">'+ic('<circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>')+'<span><b>Countries</b><em>Where projects come from</em></span></a>'
3007  + '          </div>'
3008  + '          <div class="mega-col"><div class="mega-h">What&#39;s new</div>'
3009  + '            <a href="/latest.html">'+ic('<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>')+'<span><b>Latest</b></span></a>'
3010  + '            <a href="/emerging.html">'+ic('<path d="M12 2v8M8 6l4-4 4 4"/><circle cx="12" cy="16" r="5"/>')+'<span><b>Emerging</b><em>Detected by our signals</em></span></a>'
3011  + '            <a href="/releases.html">'+ic('<path d="M12 2v6"/><path d="m16 6-4 4-4-4"/><rect width="18" height="10" x="3" y="12" rx="2"/>')+'<span><b>Releases</b><em>New versions daily</em></span></a>'
3012  + '            <a href="/news.html">'+ic('<path d="M4 11a9 9 0 0 1 9 9"/><path d="M4 4a16 16 0 0 1 16 16"/><circle cx="5" cy="19" r="1"/>')+'<span><b>News</b></span></a>'
3013  + '            <a href="/blog.html">'+ic('<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>')+'<span><b>Blog</b></span></a>'
3014  + '            <a href="/health.html">'+ic('<path d="M19.5 13.6 12 21l-7.5-7.4A5.2 5.2 0 1 1 12 6.3a5.2 5.2 0 1 1 7.5 7.3z"/>')+'<span><b>Project Health</b><em>Alive or abandoned?</em></span></a>'
3015  + '            <a href="/dashboard.html">'+ic('<rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/>')+'<span><b>Dashboards</b></span></a>'
3016  + '          </div>'
3017  + '        <div class="mega-col"><div class="mega-h">Site &amp; Data</div>'
3018  + '            <a href="/about.html">'+ic('<circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/>')+'<span><b>About</b><em>The project &amp; the maker</em></span></a>'
3019  + '            <a href="/methodology.html">'+ic('<path d="M14.7 6.3a4 4 0 0 1-5.4 5.4L4 17l3 3 5.3-5.3a4 4 0 0 1 5.4-5.4l-2.3 2.3-1-1z"/>')+'<span><b>Methodology</b><em>How we score &amp; rank</em></span></a>'
3020  + '            <a href="/contact.html">'+ic('<path d="M4 4h16v12H5.17L4 17.17V4z"/><path d="M8 9h8"/>
3020<path d="M8 12h5"/>')+'<span><b>Contact</b><em>Corrections, questions, press</em></span></a>'
3021  /* [04/08 pivot] Open Data API, Developer API, Documentation, Connect, Advertise : SUPPRIMES du mega-menu (l'attribut hidden etait ecrase par le display du CSS). */
3022  + '            <a href="/support.html">'+ic('<path d="M19.5 13.6 12 21l-7.5-7.4A5.2 5.2 0 1 1 12 6.3a5.2 5.2 0 1 1 7.5 7.3z"/>')+'<span><b>Support us</b><em>Keep olud.ai independent</em></span></a>'
3023  + '            <a href="/favorites.html">'+ic('<path d="M12 17.8 5.8 21 7 14.1 2 9.3l7-1L12 2l3 6.3 7 1-5 4.8 1.2 6.9z"/>')+'<span><b>Favorites</b><em>Your saved tools</em></span></a>'
3024  + '            <a href="/my-dashboard.html">'+ic('<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M9 3v18"/><path d="M15 9h6"/><path d="M15 15h6"/>')+'<span><b>My Dashboard</b><em>Compose your own — members</em></span></a>'
3025  + '            <a href="/submit.html">'+ic('<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/>')+'<span><b>Submit a tool or project</b><em>Add it to the hub</em></span></a>'
3026  + '            <a href="/badge.html">'+ic('<circle cx="12" cy="8" r="6"/><path d="M15.477 12.89 17 22l-5-3-5 3 1.523-9.11"/>')+'<span><b>Get a badge</b><em>For your README</em></span></a>'
3027  + '          </div>'
3028  + '        </div>'
3029  + '        <div class="mega-foot"><span class="mega-foot-t">'+ic('<path d="m12 3 1.9 5.8a2 2 0 0 0 1.3 1.3L21 12l-5.8 1.9a2 2 0 0 0-1.3 1.3L12 21l-1.9-5.8a2 2 0 0 0-1.3-1.3L3 12l5.8-1.9a2 2 0 0 0 1.3-1.3z"/>')+' <b>Featured:</b> Open-source AI agents \u2014 CrewAI, OpenHands, Browser Use</span><a href="/agents/" class="mega-foot-cta">Explore agents &#8594;</a></div>'
3030  + '      </div>'
3031  + '    </div>'
3032  /* [29/07] Bouton « Submit a tool or project » dans la barre bureau — demande de Romain
3033     apres la reparation du formulaire : le parcours de soumission merite une
3034     entree visible, pas seulement une ligne dans le mega-menu. La cle « Submit
3035     a tool » existe deja dans les 5 dictionnaires ; NAV_LMAP prefixe le lien. */
3036  + '    <a href="/submit.html" class="nav-cta" aria-label="Submit a tool or project" title="Submit a tool or project">'+'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round">'+'<path d="M12 6v12"/><path d="M6 12h12"/></svg></a>'
3037  + '    <button class="fav-nav-btn" id="favNavBtn" aria-label="My favorites" onclick="if(window.osaiMMClose)window.osaiMMClose();setTimeout(function(){if(window.openFavorites)openFavorites();},60)" title="My favorites">'
3038  + '      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>'
3039  + '      <span class="fav-count-badge" id="favCountBadge">0</span>'
3040  + '    </button>'
3041  + '    <span class="acct-wrap">'
3042  + '      <a class="acct-nav-btn" id="acctNavBtn" href="/account.html" aria-label="Account" title="Sign in">'
3043  + '        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>'
3044  + '      </a>'
3045  + '      <div class="acct-menu" id="acctMenu"></div>'
3046  + '    </span>'
3047  + '    <button class="cmdk-trigger" id="cmdkTrigger" aria-label="Search" onclick="if(window.osaiMMClose)window.osaiMMClose();setTimeout(function(){if(window.openCmdK)openCmdK();},60)">'
3048  + '      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>'
3049  + '      <span>Search</span><kbd>&#8984;K</kbd>'
3050  + '    </button>'
3051  + '    <div class="nav-dropdown lang-dd">'
3052  + '      <button class="nav-dropdown-btn" id="langMenuBtn" aria-label="Language / Langue"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg> <span id="langCurrentLbl">EN</span> &#9662;</button>'
3053  + '      <div class="nav-dropdown-menu" id="langMenu"></div>'
3054  + '    </div>'
3055  + '    <button class="theme-toggle" id="themeToggle" aria-label="Toggle light/dark mode"><div class="theme-toggle-knob"></div></button>'
3056  + '  </div>'
3057  + '  <button class="theme-toggle-nav" id="themeToggleNav" onclick="if(window.applyTheme)applyTheme(!document.body.classList.contains(\'dark\'))" aria-label="Toggle theme">'
3058  + '    <svg class="tt-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/></svg>'
3059  + '    <svg class="tt-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/></svg>'
3060  + '  </button>'
3061  + '  <button class="hamburger" id="hamburger" aria-label="Menu"><span></span><span></span><span></span></button>'
3062  + '</nav>';
3063
3064  // ═══ FOOTER ═══
3065  /* Une icone au trait de 2px, la meme graisse que celles du menu du site. */
3066  function FIC(d){
3067    return '<svg class="fic" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor"'
3068         + ' stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + d + '</svg>';
3069  }
3070  var footerHTML = ''
3071  + '<footer><div class="wrap">'
3072  /* [01/08, Romain] « le pied de page utilise des emojis que nous n'utilisons
3073     plus ». Il y en avait vingt-trois, en entites HTML. Ici trois icones au
3074     trait de 2px, comme celles du menu ; ailleurs, plus rien. */
3075  + '  <div class="loop-strip"><span class="loop-t">Stay in the loop</span>'
3076  + '    <a href="/reports/">' + FIC('<path d="M4 4h16v12H8l-4 4z"/><path d="M8 9h8M8 12.5h5"/>') + 'Weekly report</a>'
3077  + '    <a href="/feed.php">' + FIC('<path d="M5 19a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"/><path d="M4 11a9 9 0 0 1 9 9M4 5a15 15 0 0 1 15 15"/>') + 'RSS</a>'
3078  + '    <a href="/favorites.html">' + FIC('<path d="M12 20s-7-4.5-7-9.2A3.9 3.9 0 0 1 12 8a3.9 3.9 0 0 1 7 2.8C19 15.5 12 20 12 20z"/>') + 'My favorites</a>'
3079  + '  </div>'
3080  + '  <div class="footer-inner">'
3081  + '    <div class="footer-news">'
3082  + '      <div class="footer-news-txt"><h4>Stay in the loop</h4><p>The best of open-source AI in your inbox &#8212; new models, trending projects and releases. No spam, unsubscribe anytime.</p></div>'
3083  + '      <form class="footer-news-form" id="newsletterForm">'
3084  + '        <input type="email" id="newsletterEmail" required placeholder="[email protected]" aria-label="Email address">'
3085  + '        <button type="submit">Subscribe</button>'
3086  + '      </form>'
3087  + '      <div class="footer-news-msg" id="newsletterMsg"></div>'
3088  + '    </div>'
3089  + '    <div class="footer-brand">'
3090  + '      <div style="display:flex;align-items:center;gap:8px;margin-bottom:.5rem">'
3091  + '    <svg width="44" height="44" viewBox="0 0 100 100" aria-hidden="true">' + '<defs> <linearGradient id="oludGradF" x1="0" y1="1" x2="1" y2="0"> <stop offset="0" stop-color="#2540E8"/><stop offset="1" stop-color="#8B3FEF"/> </linearGradient> <mask id="oludNotchF" maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100"> <rect width="100" height="100" fill="#fff"/> <circle cx="86.72" cy="19.91" r="15.91" fill="#000"/> </mask> </defs> <circle cx="50" cy="50" r="39.61" fill="none" stroke="url(#oludGradF)" stroke-width="20.78" mask="url(#oludNotchF)"/> <circle cx="86.72" cy="19.91" r="10.71" fill="url(#oludGradF)"/>' + '</svg>'
3092  /* [30/07] Pied de page : le mot-symbole etait trop gras et trop petit a cote
3093     d'un anneau de 44 px. Graisse 600 au lieu de l'heritage 800, corps 1.35rem,
3094     et le « .ai » suit a 65 % plutot que d'etre ecrase a .6rem. */
3095  + '        <div class="logo" style="text-transform:none;letter-spacing:-.02em;font-size:1.35rem;font-weight:600">olud<small style="font-family:\'DM Mono\';font-size:.65em;color:var(--muted);font-weight:400">.ai</small></div>'
3096  + '      </div>'
3097  + '      <p style="margin-top:8px">The AI hub.<br>Open and commercial AI, side by side.<br>Built for the global community.</p>'
3098  + '      <div class="footer-social"><a href="https://x.com/tecOpensourceAI" target="_blank" rel="noopener" aria-label="Follow us on X" title="Follow @tecOpensourceAI on X"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231 5.45-6.231zm-1.161 17.52h1.833L7.084 4.126H5.117l11.966 15.644z"/></svg></a><a href="https://bsky.app/profile/oludai.bsky.social" target="_blank" rel="noopener" aria-label="Follow us on Bluesky" title="Follow us on Bluesky"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 
30980 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z"/></svg></a><a href="https://mastodon.social/@opensourceaitech" target="_blank" rel="me noopener" aria-label="Follow us on Mastodon" title="Follow us on Mastodon"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/></svg></a><a href="https://www.linkedin.com/company/136084906" target="_blank" rel="noopener" aria-label="Follow us on LinkedIn" title="Follow us on LinkedIn"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 1 1 0-4.125 2.062 2.062 0 0 1 0 4.125zM7.119 20.452H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.225 0z"/></svg></a></div>'
3099  + '    </div>'
3100  /* [01/08] Colonnes refaites. Avant : « Explore » melangeait tout avec un
3101     emoji par ligne, et « More » alignait 36 liens dont sept DOUBLONS
3102     (Prompts, Tool profiles, Pulse, Stack Builder, Weekly report, RSS...).
3103     Les pages ouvertes depuis — MCP, Skills, Local AI, constructeurs — n'y
3104     figuraient meme pas, alors que ce sont celles qu'on veut mailler. */
3105  + '    <div class="footer-col">'
3106  + '      <h4>Explore</h4>'
3107  + '      <a href="/latest.html">Latest</a>'
3108  + '      <a href="/emerging.html">Emerging</a>'
3109  + '      <a href="/releases.html">Releases</a>'
3110  + '      <a href="' + H + '#trending">Trending</a>'
3111  + '      <a href="' + H + '#projects">Top projects</a>'
3112  + '      <a href="/tools.html">AI tools</a>'
3113  + '      <a href="/spaces.html">Hugging Face spaces</a>'
3114  + '      <a href="/category.html">Categories</a>'
3115  + '      <a href="/dna.html">DNA families</a>'
3116  + '      <a href="/languages.html">Languages</a>'
3117  + '      <a href="/trends.html">Trends</a>'
3118  + '      <a href="/news.html">News feed</a>'
3119  + '    </div>'
3120  + '    <div class="footer-col">'
3121  + '      <h4>Compare &amp; rank</h4>'
3122  + '      <a href="/leaderboard.html">LLM leaderboard</a>'
3123  + '      <a href="/media-leaderboard.html">Media leaderboard</a>'
3124  + '      <a href="/compare.html">Compare models</a>'
3125  + '      <a href="/compare-tools.html">Compare tools</a>'
3126  + '      <a href="/vs/">Versus &middot; X vs Y</a>'
3127  + '      <a href="/models-vs/">Free vs paid</a>'
3128  + '      <a href="/pricing.html">AI pricing</a>'
3129  + '      <a href="/paid-models.html">Paid models</a>'
3130  + '      <a href="/makers/">Model makers</a>'
3131  + '      <a href="/pulse.html">Pulse ranking</a>'
3132  + '      <a href="/health.html">Project health</a>'
3133  + '    </div>'
3134  + '    <div class="footer-col">'
3135  + '      <h4>Build</h4>'
3136  + '      <a href="/local-ai.html">Run AI locally</a>'
3137  + '      <a href="/mcp/">MCP servers</a>'
3138  + '      <a href="/skills/">AI skills</a>'
3139  + '      <a href="/agents/">AI agents</a>'
3140  + '      <a href="/alternatives/">Open-source alternatives</a>'
3141  + '      <a href="/stack-builder.html">Stack builder</a>'
3142  + '      <a href="/members">Community stacks</a>'
3143  + '      <a href="/prompts.html">Prompts</a>'
3144  + '      <a href="/model/">Model profiles</a>'
3145  + '      <a href="/tool/">Tool profiles</a>'
3146  + '    </div>'
3147  + '    <div class="footer-col footer-more">'
3148  + '      <h4>olud.ai</h4>'
3149  + '      <a href="/about.html">About</a>'
3150  + '      <a href="/methodology.html">Methodology</a>'
3151  + '      <a href="/blog.html">Blog</a>'
3152  + '      <a href="/reports/">Weekly report</a>'
3153  + '      <a href="/dashboard.html">Dashboard</a>'
3154  + '      <a href="/submit.html">Submit a tool or project</a>'
3155  + '      <a href="/claim.html">Claim your project</a>'
3156  + '      <a href="/badge.html">Get a badge</a>'
3157  + '      <a href="/favorites.html">My favorites</a>'
3158  + '      <a href="/contact.html">Contact</a>'
3159  + '      <a href="/privacy-policy/">Privacy</a>'
3160  + '      <a href="/terms/">Terms</a>'
3161  + '    </div>'
3162  + '    <div class="footer-badges">'
3163  /* Badge de vérification There\'s An AI For That. Leur code impose une image
3164     de 600 px affichée à 300 : on précise width ET height pour que le
3165     navigateur réserve la place et ne fasse pas sauter le pied de page au
3166     chargement. loading="lazy" parce qu\'il est tout en bas. */
3167  /* [30/07] Badge Product Hunt retire a la demande de Romain : le lancement
3168     visait « opensourceai-tech », un nom que le site ne porte plus. Le lien
3169     et l'image venaient de leur domaine, rien d'autre a nettoyer. */
3170  + '    </div>'
3171  + '    <div class="footer-copy">'
3172  + '      <span>&copy; 2026 olud.ai — Powered by the open global AI community.</span>'
3173  + '      <span style="color:var(--muted);font-family:\'DM Mono\',monospace;font-size:.62rem">Independent &middot; Open data</span>'
3174  + '    </div>'
3175  + '  </div>'
3176  + '</div></footer>';
3177
3178  // ═══ Injection ═══
3179  // Charger le système de favoris partagé (si pas déjà présent)
3180  if(!window.favBtnHtml && !document.querySelector('script[src*="favorites.js"]')){
3181    var fs = document.createElement('script');
3182    fs.src = '/favorites.js?v=6';
3183    document.head.appendChild(fs);
3184  }
3185
3186  // Surcouche favoris : plafond 5 sans compte, synchro serveur pour les membres.
3187  // Additive — n'altère pas favorites.js, se contente de l'envelopper.
3188  if(!document.querySelector('script[src*="favorites-plus.js"]')){
3189    var fp = document.createElement('script');
3190    fp.src = '/favorites-plus.js?v=1';
3191    document.head.appendChild(fp);
3192  }
3193
3194  function inject(){
3195    try{ if(window.console) console.log('olud.ai nav: menu mobile autonome v6 actif'); }catch(e){}
3196    var navSlot = document.getElementById('site-nav');
3197    if(navSlot) navSlot.innerHTML = navHTML;
3198    var footSlot = document.getElementById('site-footer');
3199    if(footSlot) footSlot.innerHTML = footerHTML;
3200
3201    // Réactiver les comportements du menu après injection
3202    initNavBehaviors();
3203    initNewsletter();
3204    localizeNav();   // traduit le nav/footer/mobile si page /fr/ /it/ /es/ /de/ /pt/ (no-op en anglais)
3205    sortMegaColumns();   // colonnes du mega-menu par ordre alphabetique
3206    accentSectionTitles();   // dernier mot des titres de section en indigo
3207
3208
3209    buildLangSelector();  // remplit le sélecteur de langues (desktop + mobile) depuis les hreflang
3210  }
3211
3212
3213  /* ═══ Méga-menu : chaque colonne triée par ordre alphabétique ═══
3214     Les entrées s'étaient empilées dans l'ordre où on les avait ajoutées au fil
3215     des mois — six colonnes, une trentaine de liens, et plus moyen de retrouver
3216     quoi que ce soit. On trie APRÈS localizeNav(), donc sur le libellé réellement
3217     affiché : l'ordre est juste dans chacune des six langues, et pas seulement en
3218     anglais. Trier dans le DOM plutôt que dans le HTML source a un second
3219     avantage : une entrée ajoutée demain se range toute seule. */
3220  function sortMegaColumns(){
3221    var cols = [].slice.call(document.querySelectorAll('.nav-dropdown-menu.mega .mega-col'));
3222    if(!cols.length) return;
3223    var coll = null;
3224    try { coll = new Intl.Collator(document.documentElement.getAttribute('lang') || 'en',
3225                                   {sensitivity:'base', numeric:true}); } catch(e){}
3226    function label(a){
3227      var b = a.querySelector('b');
3228      return ((b ? b.textContent : a.textContent) || '').replace(/\s+/g,' ').trim();
3229    }
3230    cols.forEach(function(col){
3231      var links = [].slice.call(col.children).filter(function(n){ return n.tagName === 'A'; });
3232      if(links.length < 2) return;
3233      var sorted = links.slice().sort(function(x, y){
3234        var lx = label(x), ly = label(y);
3235        if(coll) return coll.compare(lx, ly);
3236        lx = lx.toLowerCase(); ly = ly.toLowerCase();
3237        return lx < ly ? -1 : lx > ly ? 1 : 0;
3238      });
3239      /* deja trie : on ne touche pas au DOM pour rien */
3240      var identique = true;
3241      for(var i=0;i<links.length;i++){ if(sorted[i] !== links[i]){ identique = false; break; } }
3242      if(identique) return;
3243      sorted.forEach(function(a){ col.appendChild(a); });
3244    });
3245  }
3246
3247
3248  /* ═══ Titres de section : le dernier mot en indigo ═══
3249     La convention existait deja sur cinq titres (« Open-Source <em>Alternatives</em> »)
3250     mais pas sur les vingt autres. Romain veut la generaliser.
3251
3252     POURQUOI EN JAVASCRIPT ET NON DANS LE HTML : ecrire
3253     « Trending on <em>GitHub</em> » dans la source couperait le titre en DEUX
3254     noeuds texte, et le traducteur apparie des noeuds ENTIERS. C'est exactement
3255     la faute qui a produit « L'IA open-source hub. » et « $9 0,99 $/mois ».
3256     Ici le titre est traduit d'un bloc par localizeNav(), PUIS habille : le
3257     dernier mot est mis en valeur dans la langue affichee, quelle qu'elle soit.
3258
3259     Un titre d'un seul mot est laisse tel quel — le passer entierement en indigo
3260     n'accentue plus rien. Un titre qui porte deja un <em> n'est pas touche. */
3261  /* [31/07] Le H1 des pages porte le code couleur sur son DERNIER MOT, comme
3262     les titres de section. Pourquoi ici et pas dans chaque page : decouper le
3263     titre en deux <span> traduits separement casse l'ordre des mots — mesure
3264     en production, « Outils IA open-source » devenait « IA open-source
3265     Outils », « IA locale » devenait « Local IA ». La phrase reste donc
3266     ENTIERE pour le traducteur, et la couleur est posee apres, dans les six
3267     langues. La famille vient du chemin (meme table que les icones du menu).
3268     Les pages qui gerent leur titre elles-memes (un <span> deja present) ne
3269     sont pas touchees. */
3270  /* ═══ [01/08] Marquer les champs alimentes par l'IA ═══════════════════
3271     Le degrade seul ne dit rien a personne : on ajoute une pastille qui
3272     NOMME la chose, avec une infobulle qui explique quoi. On ne marque que
3273     ce qui est REELLEMENT augmente par l'IA — la palette (qui bascule sur
3274     l'assistant) et le champ de l'assistant lui-meme. Marquer une recherche
3275     texte ordinaire serait un mensonge visuel. */
3276  /* Lit le rayon REELLEMENT calcule du champ et le repose sur la boite,
3277     augmente de l'epaisseur du lisere : l'anneau epouse le champ au lieu de
3278     parier sur une valeur. On lit les quatre proprietes longues et non le
3279     raccourci : getComputedStyle().borderRadius rend tantot « 8px », tantot
3280     « 8px 8px 8px 8px », tantot une forme elliptique avec une barre oblique.
3281     On n'ecrit jamais un nombre mais un calc() qui reprend la variable
3282     d'epaisseur : changer l'epaisseur dans la feuille suffit. */
3283  var osaiAiPaires = [];
3284  function osaiAiRayon(champ, boite){
3285    if (!champ || !boite || !window.getComputedStyle) return;
3286    var cs = getComputedStyle(champ);
3287    var coins = ['TopLeft', 'TopRight', 'BottomRight', 'BottomLeft'];
3288    var noms  = ['--osai-ai-tl', '--osai-ai-tr', '--osai-ai-br', '--osai-ai-bl'];
3289    for (var i = 0; i < 4; i++) {
3290      var v = cs['border' + coins[i] + 'Radius'] || cs.borderRadius || '0px';
3291      v = String(v).split('/')[0].trim().split(/\s+/)[0] || '0px';
3292      boite.style.setProperty(noms[i], 'calc(' + v + ' + var(--osai-ai-w, 2px))');
3293    }
3294  }
3295  function osaiAiRemesure(){
3296    for (var i = 0; i < osaiAiPaires.length; i++) {
3297      osaiAiRayon(osaiAiPaires[i][0], osaiAiPaires[i][1]);
3298    }
3299  }
3300  /* le rayon d'un champ peut changer avec la largeur de l'ecran (media
3301     queries) : on remesure, sinon l'anneau garde l'ancienne forme. */
3302  (function(){
3303    var m = null;
3304    window.addEventListener('resize', function(){
3305      clearTimeout(m); m = setTimeout(osaiAiRemesure, 160);
3306    });
3307    if (document.fonts && document.fonts.ready) document.fonts.ready.then(osaiAiRemesure);
3308  })();
3309
3310  function marquerIA(){
3311    /* [01/08, correction] J'avais marque la palette « AI » : elle ne fait
3312       QUE de la correspondance de texte. C'etait precisement le mensonge
3313       visuel que je disais vouloir eviter. Seul le champ de l'assistant,
3314       qui interroge vraiment le modele, porte la marque. La palette, elle,
3315       propose desormais une PORTE vers l'assistant (voir renderCmdk). */
3316    var cibles = [
3317      /* [01/08, correction] L'assistant ne porte plus de pastille du tout.
3318         Dans la barre de saisie elle volait 47px sur 368 au champ ; deplacee
3319         dans l'en-tete, elle y ajoutait une TROISIEME ligne : 107px d'en-tete
3320         sur un panneau de 244px, il ne restait que 36px pour les messages
3321         (mesure en production, fenetre 791x374). Et l'anneau, pose sur le
3322         PARENT du champ, entourait toute la barre du bas, bouton d'envoi
3323         compris : il designait la mauvaise chose. L'IA est dite avec des MOTS
3324         dans le sous-titre — cout en hauteur : zero. */
3325      /* la grande barre de l'accueil : elle propose « demander a l'assistant »
3326         des que la saisie ressemble a une question (voir index.html) */
3327      { sel: '#heroSearchInput', mot: 'AI' }
3328    ];
3329    cibles.forEach(function(c){
3330      var el = document.querySelector(c.sel);
3331      if (!el || el.getAttribute('data-ia')) return;
3332      el.setAttribute('data-ia', '1');
3333      var boite = el.parentElement;
3334      /* sur l'accueil, l'objet « alimente par IA » est la CARTE entiere, pas
3335         le seul champ : le lisere doit en faire le tour. */
3336      var carte = el.closest ? el.closest('.hero-ai') : null;
3337      if (carte) boite = carte;
3338      if (boite && !boite.classList.contains('osai-ai')) boite.classList.add('osai-ai');
3339      /* [01/08, Romain : « les bordures dans les coins ca va pas du tout »]
3340         On mesurait le rayon du CHAMP. C'etait juste tant que l'anneau
3341         epousait le champ ; depuis que la carte de l'accueil existe, l'anneau
3342         fait le tour de la CARTE (18px) avec des coins calcules sur le champ
3343         (8px) — les quatre coins s'evasaient. On mesure donc toujours
3344         l'element que l'anneau entoure reellement, c'est-a-dire celui qui
3345         porte la classe. */
3346      osaiAiRayon(boite, boite);
3347      osaiAiPaires.push([boite, boite]);
3348      /* Dans la carte de l'accueil, la pastille ne sert plus a rien : l'en-tete
3349         dit deja « Olud assistant », avec la mascotte et le point en ligne. */
3350      if (carte) return;
3351      var t = (typeof window.osaiT === 'function') ? window.osaiT : function(x){ return x; };
3352      var tag = document.createElement('span');
3353      tag.className = 'osai-ai-tag';
3354      tag.innerHTML = '\u2728 <b>' + c.mot + '</b>';
3355      tag.title = t('Ask in your own words — the assistant answers from this site\'s live data, and points you to the right page.');
3356      var hote = c.hote ? document.querySelector(c.hote) : boite;
3357      if (hote) hote.appendChild(tag);
3358    });
3359  }
3360
3361  /* ─── Repere les barres de recherche et leur pose la meme forme ───────
3362     On ne touche PAS a n'importe quel champ texte : seulement a ceux qui
3363     servent visiblement a chercher ou filtrer (identifiant, classe ou texte
3364     d'invite). Les trois champs qui ont leur propre identite graphique sont
3365     exclus nommement : la grande barre de l'accueil (son anneau IA), la
3366     palette (elle vit dans une modale) et le champ de l'assistant (pastille).
3367     La couleur vient de la famille de la page, comme le titre. */
3368  function uniformiserRecherches(){
3369    var EXCLUS = { heroSearchInput: 1, cmdkInput: 1, ohpInput: 1 };
3370    var MOTS = /(search|recherch|cherch|filtr|filter|buscar|suche|ricerca|pesquis|query)/i;
3371    var champs = document.querySelectorAll('input[type="text"],input[type="search"],input:not([type])');
3372    for (var i = 0; i < champs.length; i++) {
3373      var e = champs[i];
3374      if (EXCLUS[e.id] || e.classList.contains('osai-sb')) continue;
3375      var indice = (e.id || '') + ' ' + (e.className || '') + ' ' + (e.getAttribute('placeholder') || '')
3376                 + ' ' + (e.getAttribute('aria-label') || '');
3377      if (!MOTS.test(indice)) continue;
3378      if (e.closest('form[action]')) continue;        /* un vrai formulaire garde son style */
3379      /* Plusieurs champs ont une LOUPE posee en absolu par-dessus, et se
3380         menagent la place avec un padding gauche. Forcer le meme retrait
3381         partout collait le texte sous l'icone (vu sur leaderboard). On releve
3382         donc le retrait D'ORIGINE avant de changer quoi que ce soit, et on le
3383         garde s'il servait a quelque chose. Idem a droite pour les champs qui
3384         portent une croix d'effacement. */
3385      var av = getComputedStyle(e);
3386      var pl = parseFloat(av.paddingLeft) || 0, pr = parseFloat(av.paddingRight) || 0;
3387      if (pl > 20) e.style.setProperty('--osai-sb-pl', Math.round(pl) + 'px');
3388      if (pr > 20) e.style.setProperty('--osai-sb-pr', Math.round(pr) + 'px');
3389      e.classList.add('osai-sb');
3390    }
3391  }
3392
3393  var MASCOTTE_32 = '<svg class="oludbot" viewBox="0 0 32 32" width="30" height="30" aria-hidden="true">'
3394    + '<defs><linearGradient id="oludbot-cg" x1="0" y1="1" x2="1" y2="0">'
3395    + '<stop offset="0" stop-color="#3B57FF"/><stop offset="1" stop-color="#A257FF"/></linearGradient>'
3396    + '<mask id="oludbot-cm"><rect width="32" height="32" fill="#fff"/>'
3397    + '<circle cx="26.2" cy="5.8" r="4.4" fill="#000"/></mask></defs>'
3398    + '<g mask="url(#oludbot-cm)">
3398<circle cx="14.4" cy="17.6" r="11.2" fill="none" stroke="url(#oludbot-cg)" stroke-width="2.5"/></g>'
3399    + '<rect x="9.4" y="15.6" width="4.4" height="3.8" rx="1.7" fill="url(#oludbot-cg)"/>'
3400    + '<rect x="17.4" y="15.6" width="4.4" height="3.8" rx="1.7" fill="url(#oludbot-cg)"/>'
3401    + '<circle cx="26.2" cy="5.8" r="2.6" fill="url(#oludbot-cg)"/></svg>';
3402
3403  /* La meme tete, mais posee sur la pastille degradee de la bulle d'accueil :
3404     elle y est tracee en BLANC, sinon l'indigo sur l'indigo ne se lit pas. */
3405  var MASCOTTE_B = '<svg viewBox="0 0 32 32" width="19" height="19" aria-hidden="true">'
3406    + '<defs><mask id="oludbot-bm"><rect width="32" height="32" fill="#fff"/>'
3407    + '<circle cx="26.2" cy="5.8" r="4.4" fill="#000"/></mask></defs>'
3408    + '<g mask="url(#oludbot-bm)"><circle cx="14.4" cy="17.6" r="11.2" fill="none" stroke="#fff" stroke-width="2.7"/></g>'
3409    + '<rect x="9.4" y="15.6" width="4.4" height="3.8" rx="1.7" fill="#fff"/>'
3410    + '<rect x="17.4" y="15.6" width="4.4" height="3.8" rx="1.7" fill="#fff"/>'
3411    + '<circle cx="26.2" cy="5.8" r="2.6" fill="#fff"/></svg>';
3412
3413  /* ═══ [01/08, maquettes de Romain] LA CARTE ASSISTANT DE L'ACCUEIL ═══════
3414     La barre du hero devient une carte qui se presente : la mascotte, le nom,
3415     un point « en ligne », une bulle d'accueil qui dit ce qu'on peut demander,
3416     et un bouton d'envoi. On ne REMPLACE rien : on ENVELOPPE la barre et sa
3417     liste de resultats, qui gardent leurs identifiants et tout leur code.
3418     La recherche texte continue donc de fonctionner exactement comme avant ;
3419     la carte ajoute le cadre, l'accueil et l'envoi.
3420     Le lisere anime passe de la barre a la CARTE : c'est elle, desormais,
3421     l'objet « alimente par IA ». */
3422  function carteAssistant(){
3423    var champ = document.getElementById('heroSearchInput');
3424    if (!champ) return null;                       /* pas l'accueil */
3425    var boite = champ.closest('.hero-search-box');
3426    var liste = document.getElementById('heroSearchResults');
3427    if (!boite || !liste || document.querySelector('.hero-ai')) return null;
3428
3429    var t = function(x){ return (typeof window.osaiT === 'function') ? window.osaiT(x) : x; };
3430    var carte = document.createElement('div');
3431    carte.className = 'hero-ai';
3432    carte.innerHTML =
3433        '<div class="hero-ai-head">'
3434      + '<span class="hero-ai-av">' + MASCOTTE_32 + '</span>'
3435      + '<div class="hero-ai-id"><b>' + t('Olud assistant') + '</b>'
3436      + '<span class="hero-ai-on">' + t('Online') + '</span></div>'
3437      + '</div>'
3438      /* [01/08, Romain] « a la place de l'icone avec les etoiles il faut mettre
3439         notre icone olud assistant, ca parait logique ». Oui : c'est lui qui
3440         parle dans cette bulle, l'etincelle etait un generique. */
3441      + '<div class="hero-ai-greet" id="heroAiGreet"><span class="hero-ai-spark">' + MASCOTTE_B + '</span>'
3442      + '<div>' + t('I can help you find AI tools, compare models and explore projects \u2014 or just search.')
3443      + '<b>' + t('Ask anything.') + '</b></div></div>';
3444
3445    boite.parentNode.insertBefore(carte, boite);
3446    carte.appendChild(boite);
3447    carte.appendChild(liste);
3448
3449    /* le bouton d'envoi de la maquette */
3450    var envoi = document.createElement('button');
3451    envoi.type = 'button';
3452    envoi.className = 'hero-ai-send';
3453    envoi.setAttribute('aria-label', t('Ask the assistant'));
3454    envoi.innerHTML = '<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor"'
3455      + ' stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20 21 12 4 4l3 8z"/></svg>';
3456    boite.appendChild(envoi);
3457
3458    var demander = function(){
3459      var q = (champ.value || '').trim();
3460      if (!q) { champ.focus(); return; }
3461      liste.classList.add('open');
3462      if (window.osaiAskInto) window.osaiAskInto(q, liste);
3463      else if (window.osaiAsk) window.osaiAsk(q);
3464    };
3465    envoi.addEventListener('click', demander);
3466    champ.addEventListener('keydown', function(e){
3467      /* Entree n'avait aucune action sur cette barre : les resultats
3468         s'affichent deja a la frappe. Elle sert donc a DEMANDER, ce que le
3469         champ propose maintenant en toutes lettres. */
3470      if (e.key === 'Enter') { e.preventDefault(); demander(); }
3471    });
3472
3473    /* [01/08] Sur telephone, le bouton flottant de l'assistant tombait
3474       EXACTEMENT sur le bouton d'envoi de la carte : le geste principal de la
3475       page devenait intapable. Et tant que la carte est a l'ecran, le bouton
3476       fait doublon — c'est le meme assistant. Il s'efface donc pendant que la
3477       carte est visible et revient des qu'on l'a depassee. */
3478    /* Une simple mesure au defilement, et non un IntersectionObserver : je
3479       n'ai pas pu verifier ce dernier (il ne se declenche pas dans le
3480       navigateur d'essai, meme cree a la main), et un correctif que je ne
3481       peux pas voir fonctionner n'en est pas un. La mesure est calee sur
3482       requestAnimationFrame : une seule lecture par image, au pire. */
3483    var enCours = false;
3484    function placerFlottant(){
3485      enCours = false;
3486      var flottant = document.getElementById('osaiHelpBtn');
3487      if (!flottant) return;
3488      if (!flottant.style.transition) flottant.style.transition = 'opacity .22s, transform .22s';
3489      var r = carte.getBoundingClientRect();
3490      var vue = r.bottom > 90 && r.top < (window.innerHeight || 0) - 60;
3491      flottant.style.opacity       = vue ? '0' : '';
3492      flottant.style.pointerEvents = vue ? 'none' : '';
3493      flottant.style.transform     = vue ? 'scale(.85)' : '';
3494    }
3495    function planifier(){
3496      if (enCours) return;
3497      enCours = true;
3498      setTimeout(placerFlottant, 16);   /* et non requestAnimationFrame : il ne
3499                                           se declenche pas dans tous les
3500                                           contextes, et je veux pouvoir le
3501                                           verifier */
3502    }
3503    window.addEventListener('scroll', planifier, { passive: true });
3504    window.addEventListener('resize', planifier);
3505    setTimeout(placerFlottant, 60);
3506    setTimeout(placerFlottant, 900);   /* le bouton arrive apres la carte */
3507
3508    var accueil = carte.querySelector('#heroAiGreet');
3509    var majAccueil = function(){ accueil.style.display = (champ.value || '').trim() ? 'none' : ''; };
3510    champ.addEventListener('input', majAccueil);
3511    majAccueil();
3512    return carte;
3513  }
3514
3515  /* ═══════════════════════════════════════════════════════════════════════
3516     LE FILET : PLUS AUCUN LIEN NE SORT DU SITE QUAND LA FICHE EXISTE
3517
3518     [01/08, Romain] « il faut ABSOLUMENT empecher les utilisateurs de sortir
3519     du site ». Je corrigeais moteur par moteur depuis le 31/07 — la palette,
3520     la barre du hero, la palette de l'accueil, le bandeau du jour — et il en
3521     reapparaissait un a chaque fois. Mesure qui a arrete les rustines : sur
3522     la SEULE page leaderboard, 326 liens partaient chez openrouter.ai. Ce
3523     n'etait pas quelques sections, c'etait le site.
3524
3525     Un seul filet, pose une fois, sur toutes les pages : on relit les liens
3526     sortants et on les remplace par NOTRE fiche quand elle existe. Ce qui n'a
3527     pas de fiche chez nous n'est pas touche — on ne fabrique pas de lien mort.
3528
3529     Cout : model-slugs.json fait 6 Ko et ne se charge que sur une page qui
3530     porte des liens de modeles ; project-slugs.json fait 757 Ko et ne se
3531     charge QUE si la page porte des liens GitHub et qu'aucune table n'est
3532     deja en memoire (l'accueil la charge deja pour son propre compte). */
3533  var OSAI_SLUGS_M = null, OSAI_SLUGS_P = null, osaiEnVol = {};
3534  function osaiPrefixe(){
3535    var m = location.pathname.match(/^\/(fr|it|es|de|pt)(\/|$)/);
3536    return m ? '/' + m[1] : '';
3537  }
3538  function osaiSlugifie(x){
3539    return String(x || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
3540  }
3541  function osaiCharge(nom, cle){
3542    if (osaiEnVol[cle]) return osaiEnVol[cle];
3543    osaiEnVol[cle] = fetch(nom).then(function(r){ return r.ok ? r.json() : null; }).catch(function(){ return null; });
3544    return osaiEnVol[cle];
3545  }
3546  function osaiPose(a, url){
3547    a.setAttribute('href', url);
3548    a.removeAttribute('target');
3549    a.removeAttribute('rel');
3550    a.setAttribute('data-osai-interne', '1');
3551  }
3552  function osaiRelierSorties(racine){
3553    var R = racine || document;
3554    if (!R.querySelectorAll) return;
3555    var pfx = osaiPrefixe();
3556
3557    /* ── les modeles ── */
3558    var mods = [].slice.call(R.querySelectorAll('a[href*="openrouter.ai/"]:not([data-osai-interne])'));
3559    if (mods.length) {
3560      (OSAI_SLUGS_M ? Promise.resolve(OSAI_SLUGS_M) : osaiCharge('/model-slugs.json', 'm')).then(function(t){
3561        if (!t) return;
3562        OSAI_SLUGS_M = t;
3563        mods.forEach(function(a){
3564          var h = a.getAttribute('href') || '';
3565          var q = h.split('openrouter.ai/')[1];
3566          if (!q) return;
3567          var brut = q.split(/[?#]/)[0].replace(/\/+$/, '').split('/').pop();
3568          var s1 = brut.toLowerCase(), s2 = osaiSlugifie(brut);
3569          var s = t[s1] ? s1 : (t[s2] ? s2 : null);
3570          if (s) osaiPose(a, pfx + '/model/' + s + '.html');
3571        });
3572      });
3573    }
3574
3575    /* ── les projets ── */
3576    var gits = [].slice.call(R.querySelectorAll('a[href*="github.com/"]:not([data-osai-interne])'))
3577      .filter(function(a){ return /github\.com\/[^\/]+\/[^\/?#]+/.test(a.getAttribute('href') || ''); });
3578    if (gits.length) {
3579      /* [01/08, apres mesure] project-slugs.json pese 757 Ko. Le telecharger
3580         au demarrage pour reecrire trois liens etait un mauvais marche : le
3581         site est deja lourd, et Romain le trouve lent a s'afficher. On ne le
3582         va chercher QUE si la page en a vraiment besoin (plus de trois liens
3583         GitHub), et seulement quand le navigateur n'a plus rien d'urgent a
3584         faire. Les liens ne bougent donc plus au chargement mais un instant
3585         apres, ce qui ne change rien pour le visiteur : il n'a pas encore eu
3586         le temps de cliquer. */
3587      var pret;
3588      if (window.__FICHE_SLUGS && Object.keys(window.__FICHE_SLUGS).length > 50) {
3589        pret = Promise.resolve(window.__FICHE_SLUGS);
3590      } else if (OSAI_SLUGS_P) {
3591        pret = Promise.resolve(OSAI_SLUGS_P);
3592      } else if (gits.length > 3) {
3593        pret = new Promise(function(ok){
3594          var lance = function(){ osaiCharge('/project-slugs.json', 'p').then(ok); };
3595          if (window.requestIdleCallback) requestIdleCallback(lance, { timeout: 4000 });
3596          else setTimeout(lance, 2500);
3597        });
3598      } else {
3599        return;                       /* deux ou trois liens : pas 757 Ko pour ca */
3600      }
3601      pret.then(function(t){
3602        if (!t) return;
3603        OSAI_SLUGS_P = t;
3604        gits.forEach(function(a){
3605          var h = (a.getAttribute('href') || '').split(/[?#]/)[0].replace(/\/+$/, '');
3606          var s = t[h] || t[h + '/'] || t[h.replace(/^http:/, 'https:')];
3607          if (s) osaiPose(a, pfx + '/project/' + s + '.html');
3608        });
3609      });
3610    }
3611  }
3612
3613  /* le fond gris ne vaut que pour les fiches et leurs hubs — pose tot pour
3614     eviter un eclair blanc */
3615  try{
3616    if (/^\/(fr|de|es|it|pt)?\/?(project|tool|language|country|languages\.html|countries\.html)/.test(location.pathname.replace(/^\/(fr|de|es|it|pt)(?=\/)/,'')) ||
3617        /^\/(project|tool|language|country)\//.test(location.pathname.replace(/^\/(fr|de|es|it|pt)(?=\/)/,''))) {
3618      document.body.classList.add('osai-fond-gris');
3619    }
3620  }catch(e){}
3621
3622  function accentPageTitle(){
3623    var seg = (location.pathname.replace(/^\/(fr|de|es|it|pt)(?=\/)/, '').split('/')[1] || '').replace(/\.html$/, '');
3624    var fam = (window.__osaiSEG || {})[seg] || null;
3625    var col = fam ? 'var(--cat-' + fam + ')' : 'var(--accent,#6366f1)';
3626    var h = document.querySelector('.wrap h1, main h1, .mwrap h1, .twrap h1, header h1');
3627    if (!h || h.getAttribute('data-accented')) return;
3628    /* [31/07, Romain] « aucun n'a la meme taille ni la meme graisse » : mesure
3629       faite, les titres allaient de 2,1rem/800 (leaderboard) a clamp(2.4-4rem)
3630       /600 (tools) en passant par 600 sur projects. On pose ici une classe
3631       d'uniformisation — une seule regle pour tout le site, plutot que de
3632       corriger trente feuilles de style. Les FICHES generees (project, model,
3633       tool, alt, blog, maker : 60 000 pages deja coherentes entre elles) et le
3634       hero de l'accueil gardent leur propre echelle. */
3635    var chemin = location.pathname.replace(/^\/(fr|de|es|it|pt)(?=\/)/, '');
3636    /* Les fiches generees ont leur echelle propre, et le TABLEAU DE BORD est
3637       une interface, pas une page de contenu : son titre vit dans une barre
3638       d'indicateurs (LIVE, DATA, SYNC) alignes sur la meme ligne de base.
3639       Lui imposer le gabarit editorial cassait cette rangee (releve par
3640       Romain le 01/08). */
3641    var fiche = /^\/(?:project|model|tool|alt|alt-models|alternatives|blog|maker|u)\//.test(chemin)
3642             || /^\/(?:dashboard|account|my-dashboard)\.html$/.test(chemin);
3643    if (!fiche && !h.classList.contains('hero-title')) {
3644      h.classList.add('osai-h1');
3645      /* Une simple classe ne suffit pas : les pages ont des selecteurs plus
3646         specifiques (« .hero h1 », « .page-header h1 »), parfois en
3647         !important — mesure faite, tools restait en graisse 600. On ecrit
3648         donc le gabarit EN LIGNE avec priorite, ce que rien ne peut battre. */
3649      h.style.setProperty('font-size', 'clamp(1.85rem,3.2vw,2.25rem)', 'important');
3650      h.style.setProperty('font-weight', '800', 'important');
3651      h.style.setProperty('letter-spacing', '-.02em', 'important');
3652      h.style.setProperty('line-height', '1.15', 'important');
3653      h.style.setProperty('font-family', "'Poppins','Poppins-fb',sans-serif", 'important');
3654      /* MEME DISTANCE AVEC LE MENU (demande de Romain, mesure du 31/07 :
3655         98px sur leaderboard et latest, 104 sur releases, 108 sur dna, 110
3656         sur pricing, 174 sur projects/tools/spaces/local-ai). Chaque page a
3657         son propre rembourrage d'en-tete ; plutot que d'en corriger trente,
3658         on mesure la position REELLE du titre et on la ramene a la valeur
3659         voulue. Recalcule apres le chargement des polices, qui deplace tout. */
3660      var caler = function(){
3661        var VOULU = (window.innerWidth < 640) ? 96 : 128;
3662        /* On repart de la position NATURELLE : sans cela, chaque nouvel appel
3663           (polices chargees, minuterie) se cumulait a la marge precedente. */
3664        h.style.removeProperty('margin-top');
3665        var t = h.getBoundingClientRect().top + (window.scrollY || 0);
3666        var mt = parseFloat(getComputedStyle(h).marginTop) || 0;
3667        var d = VOULU - t;
3668        /* [02/08/2026 — AUDIT] GARDE-FOU. Remonter le titre a une hauteur fixe
3669           le faisait passer PAR-DESSUS ce qui le precede : sur les pages qui
3670           ont un sur-titre visible (« maintainers », « geography · updated
3671           daily »...), le titre le recouvrait, illisible. Mesure du 02/08 :
3672           une quinzaine de pages touchees, dont claim, contact, plans,
3673           newsletter, countries, archive, advertise.
3674           On ne remonte donc jamais au-dessus du bas de ce qui est DEJA
3675           au-dessus, 6 px de respiration compris. Les elements en position
3676           fixe (la barre) ou absolue (halos decoratifs) ne comptent pas, et
3677           ce qui est SOUS le titre non plus. */
3678        if (d < 0) {
3679          var plancher = null, tous = document.body.querySelectorAll('*');
3680          for (var q = 0; q < tous.length; q++) {
3681            var e2 = tous[q];
3682            if (e2 === h) break;                 /* ordre du document : on s'arrete au titre */
3683            if (e2.contains(h)) continue;        /* un ancetre n'est pas « au-dessus » */
3684            var c2 = getComputedStyle(e2);
3685            if (c2.position === 'fixed' || c2.position === 'absolute') continue;
3686            if (c2.visibility === 'hidden' || c2.display === 'none') continue;
3687            var r2 = e2.getBoundingClientRect();
3688            if (r2.height <= 0 || r2.width <= 0) continue;
3689            var b2 = r2.bottom + (window.scrollY || 0);
3690            if (b2 > t + 1) continue;            /* pas au-dessus : sans objet */
3691            if (plancher === null || b2 > plancher) plancher = b2;
3692          }
3693          if (plancher !== null) { var mini = plancher + 6 - t; if (d < mini) d = mini; }
3694        }
3695        if (Math.abs(d) > 1) h.style.setProperty('margin-top', (mt + d) + 'px', 'important');
3696      };
3697      caler();
3698      if (document.fonts && document.fonts.ready) document.fonts.ready.then(caler);
3699      setTimeout(caler, 400);
3700    }
3701    /* [31/07, urgent] La page d'accueil a mange l'espace : son titre est
3702       « The AI <em>hub.</em> », le dernier NOEUD TEXTE est « The AI » et la
3703       fonction a colore « AI » puis supprime l'espace final (« AIhub »).
3704       Regle : des qu'un H1 contient un element autre qu'une icone (em, span,
3705       b...), la page gere son titre elle-meme — on ne touche a rien. */
3706    var kids = [].slice.call(h.childNodes), node = null;
3707    for (var e = 0; e < kids.length; e++) {
3708      var el = kids[e];
3709      if (el.nodeType !== 1) continue;
3710      var cl = ' ' + (el.getAttribute('class') || '') + ' ';
3711      var estIcone = el.tagName === 'SVG' || el.tagName === 'svg'
3712                  || cl.indexOf(' h1-ic ') > -1 || cl.indexOf(' sec-ic ') > -1;
3713      if (!estIcone) return;
3714    }
3715    for (var i = kids.length - 1; i >= 0; i--) {
3716      if (kids[i].nodeType === 3 && kids[i].nodeValue && kids[i].nodeValue.trim() !== '') { node = kids[i]; break; }
3717    }
3718    if (!node) return;
3719    /* [02/08, Romain] Sur une FICHE le titre est un nom propre, souvent en un
3720       seul mot : « le dernier mot » = tout le nom, peint aux couleurs de sa
3721       famille — awesome en vert, whisper.cpp en orange, sous le seuil de
3722       contraste AA (2,15:1 et 2,54:1 mesures). La couleur de categorie vit
3723       desormais dans la puce au-dessus du titre ; le nom reste en encre. */
3724    if (fiche) { h.setAttribute('data-accented', '1'); return; }
3725    var brut = node.nodeValue;
3726    var fin  = (brut.match(/\s+$/) || [''])[0];      /* l'espace final est RENDU */
3727    var m = brut.replace(/\s+$/, '').match(/^([\s\S]*?)(\S+)$/);
3728    if (!m) return;
3729    node.nodeValue = m[1];
3730    var sp = document.createElement('span');
3731    sp.className = 'osai-ttx-h1';
3732    sp.style.color = col;
3733    sp.textContent = m[2];
3734    node.parentNode.insertBefore(sp, node.nextSibling);
3735    if (fin) node.parentNode.insertBefore(document.createTextNode(fin), sp.nextSibling);
3736    h.setAttribute('data-accented', '1');
3737  }
3738
3739  function accentSectionTitles(){
3740    var path = location.pathname.replace(/index\.html$/, '');
3741    /* page d'accueil, et la page Connect qui suit la meme charte de titres */
3742    if (!/^\/(?:[a-z]{2}\/)?(?:connect\.html|docs\.html)?$/.test(path)) return;
3743    /* .featured-title n'est pas un titre de niveau mais un div : il porte quand
3744       meme un intitule de section, et Romain veut le meme traitement. */
3745    var sel = 'h2.section-title, h2.embed-section-title, h3.section-title, h3.embed-section-title, .featured-title';
3746    [].slice.call(document.querySelectorAll(sel)).forEach(function(h){
3747      /* [10/08/2026, demande de Romain] LE MOT ACCENTUE ET LE BOUTON SUIVENT L ICONE.
3748         L icone de chaque section portait deja sa couleur de famille - ambre pour les
3749         outils, ciel pour les modeles, emeraude pour les projets - mais le mot mis en
3750         valeur juste a cote restait indigo, et le bouton de la section aussi : trois
3751         couleurs sur une meme ligne.
3752         Ce bloc est AVANT les deux sorties anticipees ci-dessous, et c'est deliberé :
3753         un titre deja accentue dans le HTML (« Featured Tools ») en sortait avant
3754         d'avoir recu sa couleur. La colorisation ne depend pas de l'accentuation.
3755         --sic est pose sur le titre ET sur la section : le bouton .sec-cta vit hors du
3756         titre et n'en heriterait pas. Les sections dont l icone est deja indigo
3757         recoivent la meme couleur qu'elles avaient : rien ne bouge pour elles. */
3758      var pastille = h.querySelector('.sec-ic') || h.querySelector('svg');
3759      /* .featured-title est un div dont la pastille est posee A COTE, pas dedans. */
3760      if (!pastille && h.parentElement) pastille = h.parentElement.querySelector('.sec-ic, svg');
3761      if (pastille) {
3762        var teinte = '';
3763        try { teinte = getComputedStyle(pastille).color || ''; } catch (e) {}
3764        if (teinte && teinte !== 'rgba(0, 0, 0, 0)') {
3765          h.style.setProperty('--sic', teinte);
3766          var sect = h.closest ? h.closest('section') : null;
3767          if (sect) {
3768            sect.style.setProperty('--sic', teinte);
3769            /* Les boutons sont peints ICI, en style direct, et non par une regle CSS :
3770               une feuille non lisible depuis le script (chargee d'une autre origine)
3771               battait une regle pourtant plus specifique ET en !important. Constate a
3772               la mesure : --sic arrivait bien jusqu'au bouton, la regle correspondait,
3773               et le fond restait indigo. Un style direct en !important tranche, et
3774               reste au meme endroit que le reste de la logique. */
3775            [].slice.call(sect.querySelectorAll('.sec-cta')).forEac
3775h(function (b) {
3776              b.style.setProperty('background', teinte, 'important');
3777              if (!b.getAttribute('data-teinte')) {
3778                b.setAttribute('data-teinte', '1');
3779                b.addEventListener('mouseenter', function () {
3780                  b.style.setProperty('box-shadow', 'inset 0 0 0 999px rgba(0,0,0,.14)', 'important');
3781                });
3782                b.addEventListener('mouseleave', function () { b.style.removeProperty('box-shadow'); });
3783              }
3784            });
3785          }
3786          /* Un <em> deja present peut porter sa couleur en style DIRECT (« Alternatives »,
3787             « DNA », « vs Paid Models »...), ce qui bat toute feuille de style. On le
3788             renvoie vers --sic au lieu de le laisser imposer l'indigo. */
3789          /* On peint TOUS les <em> du titre, pas seulement ceux qui portaient deja
3790             une couleur en style direct. « Featured Tools » avait son propre
3791             selecteur .featured-title em, a specificite egale et en !important lui
3792             aussi : a egalite, la derniere regle du document gagne, et ce n'etait
3793             pas la notre. Un style direct met fin au concours. */
3794          [].slice.call(h.querySelectorAll('em')).forEach(function (mot) {
3795            if (!mot.style) return;
3796            mot.style.setProperty('color', teinte, 'important');
3797            mot.style.setProperty('-webkit-text-fill-color', teinte, 'important');
3798          });
3799        }
3800      }
3801
3802      if (h.getAttribute('data-accented')) return;
3803      if (h.querySelector('.osai-ttx')) return;
3804
3805      /* TOUT ce qui suit la pastille d'icone est regroupe dans UN SEUL element.
3806         Ces titres passent en display:flex sur mobile : chaque noeud reste alors
3807         un element flex distinct, avec trois consequences vues en production —
3808         l'espace entre deux noeuds disparait (on lisait « FeaturedTools »), un
3809         espacement de flex s'insere la ou le texte n'en a pas (« 1000  + »), et
3810         les lignes suivantes s'alignent sur le debut du DERNIER noeud au lieu du
3811         debut du titre (« Open-Source AI » cale sous le « + » et non sous le
3812         « 1000 »). Un seul bloc, et le texte se replie proprement sous lui-meme. */
3813      var kids = [].slice.call(h.childNodes), start = 0;
3814      for (var k = 0; k < kids.length; k++){
3815        var c = kids[k];
3816        if (c.nodeType === 1 && (' ' + (c.getAttribute('class') || '') + ' ').indexOf(' sec-ic ') > -1) start = k + 1;
3817      }
3818
3819      var body = kids.slice(start);
3820      if (!body.length) return;
3821
3822      /* dernier noeud texte non vide du bloc : c'est lui qui porte l'accent */
3823      var node = null;
3824      for (var i = body.length - 1; i >= 0; i--) {
3825        var n = body[i];
3826        if (n.nodeType === 3 && n.nodeValue && n.nodeValue.replace(/[\s ]+/g, '') !== '') { node = n; break; }
3827        if (n.nodeType === 1) break;                      /* un element ferme la marche : on n'y touche pas */
3828      }
3829      var m = null, tailP = '';
3830      if (node) {
3831        var txt = node.nodeValue;
3832        /* Une ponctuation finale detachee n'est pas un mot. En francais le « ? »
3833           est precede d'une espace fine insecable : « Quoi de neuf ? » se serait
3834           donc termine par le seul caractere « ? », trop court pour etre accentue,
3835           et le titre n'aurait recu aucun indigo. On la met de cote, on accentue
3836           le vrai dernier mot, et on la remet derriere. */
3837        var mp = txt.match(/^([\s\S]*?)([\s  ]*[?!:;»]+\s*)$/);
3838        if (mp && mp[1].replace(/[\s  ]+/g, '') !== '') { txt = mp[1]; tailP = mp[2]; }
3839        m = txt.match(/^([\s\S]*?[\s ])([^\s ]+)([\s ]*)$/);
3840        if (!m || m[2].length < 2) { m = null; tailP = ''; }   /* un seul mot : pas d'accent */
3841      }
3842      /* un titre qui porte deja son accent dans le HTML garde le sien : on le
3843         regroupe pour la mise en page, sans lui en ajouter un second */
3844      if (h.querySelector('em')) m = null;
3845      /* rien a accentuer ET rien a regrouper : on laisse le titre tranquille */
3846      if (!m && body.length < 2) return;
3847
3848      var wrap = document.createElement('span');
3849      wrap.className = 'osai-ttx';
3850      h.replaceChild(wrap, body[0]);
3851      for (var b = 0; b < body.length; b++) {
3852        var nb = body[b];
3853        if (m && nb === node) {
3854          /* le noeud d'origine est remplace par ses morceaux : il faut le retirer
3855             du titre, sinon le texte apparait deux fois */
3856          if (nb.parentNode === h) h.removeChild(nb);
3857          wrap.appendChild(document.createTextNode(m[1]));
3858          var em = document.createElement('em');
3859          em.textContent = m[2];
3860          wrap.appendChild(em);
3861          if (m[3])  wrap.appendChild(document.createTextNode(m[3]));
3862          if (tailP) wrap.appendChild(document.createTextNode(tailP));
3863        } else {
3864          wrap.appendChild(nb);                           /* appendChild deplace le noeud */
3865        }
3866      }
3867      h.setAttribute('data-accented', '1');
3868    });
3869  }
3870
3871  // ═══ Newsletter (footer) — soumission AJAX vers Web3Forms ═══
3872  function initNewsletter(){
3873    var forms = [].slice.call(document.querySelectorAll('#newsletterForm, form.osai-sub'));
3874    forms.forEach(function(form){ wireSub(form); });
3875  }
3876  function wireSub(form){
3877    if(!form || form.__wired) return;
3878    form.__wired = true;
3879    form.addEventListener('submit', function(e){
3880      e.preventDefault();
3881      var email = form.querySelector('input[type=email]');
3882      var msg = form.parentNode.querySelector('.footer-news-msg, .sub-msg');
3883      var btn = form.querySelector('button');
3884      if(!email || !email.value) return;
3885      var src = form.getAttribute('data-src') || 'footer';
3886      btn.disabled = true; var orig = btn.textContent; btn.textContent = 'Subscribing\u2026';
3887      /* V94 — les inscriptions partaient vers Web3Forms (service tiers) : elles
3888         n'arrivaient JAMAIS en base, d'où 0 abonné dans l'admin, aucun weekly
3889         report possible, et un transfert de données non déclaré. On poste
3890         désormais sur notre propre API, en double opt-in. */
3891      /* la cadence est facultative : un formulaire sans <select name=freq>
3892         reste valide et vaut hebdomadaire */
3893      var fq = form.querySelector('[name=freq]');
3894      var payload = { email: email.value, src: src, freq: (fq && fq.value) || 'weekly' };
3895      fetch('/api/subscribe.php', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) })
3896        .then(function(r){ return r.json(); })
3897        .then(function(j){
3898          if(j && j.ok){
3899            if(msg){
3900              msg.style.color='var(--accent)';
3901              msg.textContent = j.already
3902                ? "You're already on the list."
3903                : osaiT('Check your inbox — one click to confirm.');
3904            }
3905            form.reset();
3906          }
3907          else { if(msg){ msg.style.color='#e0245e'; msg.textContent = (j && j.error) ? j.error : "Hmm, that didn't work \u2014 please try again."; } }
3908        })
3909        .catch(function(){ if(msg){ msg.style.color='#e0245e'; msg.textContent='Network error \u2014 please try again.'; } })
3910        .then(function(){ btn.disabled=false; btn.textContent=orig; });
3911    });
3912  }
3913
3914  function initNavBehaviors(){
3915    // Dropdown "More"
3916    var moreBtn = document.getElementById('moreMenuBtn');
3917    var moreMenu = document.getElementById('moreMenu');
3918    if(moreBtn && moreMenu){
3919      moreBtn.addEventListener('click', function(e){ e.stopPropagation(); moreMenu.classList.toggle('open'); });
3920      document.addEventListener('click', function(){ moreMenu.classList.remove('open'); });
3921    }
3922    var langBtn = document.getElementById('langMenuBtn');
3923    var langMenu = document.getElementById('langMenu');
3924    if(langBtn && langMenu){
3925      langBtn.addEventListener('click', function(e){ e.stopPropagation(); langMenu.classList.toggle('open'); });
3926      document.addEventListener('click', function(){ langMenu.classList.remove('open'); });
3927    }
3928    // Hamburger (menu mobile) : ouverture/fermeture robustes
3929    var ham = document.getElementById('hamburger');
3930    if(ham){
3931      // Menu mobile AUTONOME : un overlay est créé comme enfant direct de <body>,
3932      // donc indépendant du <nav> (contexte d'empilement) et du CSS de chaque page.
3933      var mm = document.getElementById('osaiMM');
3934      if(!mm){
3935        mm = document.createElement('div');
3936        mm.id = 'osaiMM';
3937        mm.innerHTML =
3938            '<div class="osaiMM-bd"></div>'
3939          + '<div class="osaiMM-panel">'
3940          +   '<button type="button" class="osaiMM-close" aria-label="Close menu">&times;</button>'
3941          +   '<div class="osaiMM-quick">'
3942          +     '<a class="osaiMM-q" href="' + LP + '/account.html">' + ic('<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>') + '<span>Account</span></a>'
3943          +     '<button type="button" class="osaiMM-q" onclick="if(window.osaiMMClose)window.osaiMMClose();setTimeout(function(){if(window.openFavorites)openFavorites();},60)">' + ic('<path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/>') + '<span>Favorites</span></button>'
3944          +     '<button type="button" class="osaiMM-q" onclick="if(window.osaiMMClose)window.osaiMMClose();setTimeout(function(){if(window.openCmdK)openCmdK();},60)">' + ic('<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>') + '<span>Search</span></button>'
3945          +   '</div>'
3946          +   '<a href="/promote.html" style="color:var(--accent,#6366f1);font-weight:600;">'+ic('<path d="M11 5 6 9H3v6h3l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/>')+'Promote your project</a>'
3947          +   '<a href="/my-dashboard.html" style="color:var(--accent);font-weight:600;">'+ic('<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M9 3v18"/><path d="M15 9h6"/><path d="M15 15h6"/>')+' My Dashboard</a>'
3948          +   '<a href="/projects.html">Projects</a>'
3949          +   '<a href="/tools.html">Catalog</a>'
3950          +   '<a href="/tools.html">AI Tools</a>'
3951          +   '<a href="/latest.html" style="color:var(--accent);font-weight:600;">'+ic('<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>')+' Latest</a>'
3952          +   '<a href="/emerging.html" style="color:var(--accent);font-weight:600;">'+ic('<path d="M12 2v8M8 6l4-4 4 4"/><circle cx="12" cy="16" r="5"/>')+' Emerging</a>'
3953          +   '<a href="/vertical/">'+ic('<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>')+' Industries</a>'
3954          +   '<a href="/releases.html" style="color:var(--accent);font-weight:600;">'+ic('<path d="M12 2v6"/><path d="m16 6-4 4-4-4"/><rect width="18" height="10" x="3" y="12" rx="2"/>')+' Releases</a>'
3955          +   '<a href="/dashboard.html" style="color:var(--accent);font-weight:600;">'+ic('<rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/>')+' Dashboards</a>'
3956          +   '<div class="osaiMM-sec">Tools & Agents</div>'
3957          +   '<a href="/skills/">'+ic('<path d="M12 2 2 7l10 5 10-5-10-5z"/><path d="m2 17 10 5 10-5"/><path d="m2 12 10 5 10-5"/>')+' AI Skills</a>'
3958          +   '<a href="/mcp/">'+ic('<path d="M12 22v-5"/><path d="M9 8V2"/><path d="M15 8V2"/><path d="M6 8h12l-1 7a5 5 0 0 1-10 0z"/>')+' MCP Servers</a>'
3959          +   '<a href="/local-ai.html">'+ic('<rect width=\"16\" height=\"16\" x=\"4\" y=\"4\" rx=\"2\"/><rect width=\"6\" height=\"6\" x=\"9\" y=\"9\"/><path d=\"M15 2v2M15 20v2M2 15h2M2 9h2M20 15h2M20 9h2M9 2v2M9 20v2\"/>')+' Local AI</a>'
3960          +   '<a href="/makers/">'+ic('<path d="M3 21h18"/><path d="M5 21V7l7-4 7 4v14"/><path d="M9 21v-6h6v6"/>')+' Model makers</a>'
3961          +   '<a href="/pulse.html">'+ic('<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>')+'Pulse ranking</a>'
3962          +   '<a href="/security.html">'+ic('<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>')+'Security advisories</a>'
3963          +   '<a href="/agents/">'+ic('<path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/>')+' AI Agents</a>'
3964          +   '<a href="/tool/">'+ic('<path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/>')+' Tool Guides</a>'
3965          +   '<a href="/compare-tools.html">'+ic('<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><path d="M11 18H8a2 2 0 0 1-2-2V9"/>')+' Compare Tools</a>'
3966          +   '<a href="/category.html">'+ic('<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>')+' Categories</a>'
3967          +   '<a href="/stack-builder.html">'+ic('<rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/>
3967')+'Stack Builder</a>'
3968          +   '<div class="osaiMM-sec">Models</div>'
3969          +   '<a href="/pricing.html" style="color:var(--accent);font-weight:600;">'+ic('<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>')+' AI Pricing</a>'
3970          +   '<a href="/models-vs/" style="color:var(--accent);font-weight:600;">'+ic('<circle cx="12" cy="12" r="10"/><path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"/><path d="M12 18V6"/>')+' Models &middot; Free vs Paid</a>'
3971          +   '<a href="/leaderboard.html">'+ic('<line x1="6" y1="20" x2="6" y2="14"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="18" y1="20" x2="18" y2="10"/>')+' Leaderboard</a>'
3972          +   '<a href="/media-leaderboard.html">'+ic('<line x1="6" y1="20" x2="6" y2="14"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="18" y1="20" x2="18" y2="10"/>')+'Media models</a>'
3973          +   '<a href="/compare.html" style="color:var(--accent);font-weight:600;">'+ic('<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><path d="M11 18H8a2 2 0 0 1-2-2V9"/>')+' Compare Models</a>'
3974          +   '<a href="/vs/" style="color:var(--accent);font-weight:600;">'+ic('<path d="m9 7-5 5 5 5"/><path d="m15 7 5 5-5 5"/>')+' Versus &middot; X vs Y</a>'
3975          +   '<a href="/model/">'+ic('<rect width="18" height="14" x="3" y="5" rx="2"/><path d="M7 15h4M7 11h10"/>')+'Model profiles</a>'
3976          +   '<a href="/paid-models.html">'+ic('<path d="M7.5 7.5h.01"/><path d="M19 9.3V5a2 2 0 0 0-2-2h-4.3a2 2 0 0 0-1.4.6l-7.7 7.7a2 2 0 0 0 0 2.8l4.3 4.3a2 2 0 0 0 2.8 0l7.7-7.7a2 2 0 0 0 .6-1.4Z"/>')+' Paid Models</a>'
3977          +   '<div class="osaiMM-sec">Alternatives</div>'
3978          +   '<a href="/alternatives/">'+ic('<path d="m2 9 3-3 3 3"/><path d="M13 18H7a2 2 0 0 1-2-2V6"/><path d="m22 15-3 3-3-3"/><path d="M11 6h6a2 2 0 0 1 2 2v10"/>')+' Alternatives</a>'
3979          +   '<div class="osaiMM-sec">Discover</div>'
3980          +   '<a href="/spaces.html">'+ic('<polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/>')+' Spaces</a>'
3981          +   '<a href="/dna.html">'+ic('<path d="M6 3c0 4 12 5 12 9s-12 5-12 9"/><path d="M18 3c0 4-12 5-12 9s12 5 12 9"/><path d="M8 8h8M8 16h8"/>')+'DNA families</a>'
3982          +   '<a href="/languages.html">'+ic('<path d="m5 8 6 6"/><path d="m4 14 6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/><path d="m22 22-5-10-5 10"/><path d="M14 18h6"/>')+'Languages</a>'
3983          +   '<a href="/countries.html">'+ic('<circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>')+' Countries</a>'
3984          +   '<div class="osaiMM-sec">What&#39;s new</div>'
3985          +   '<a href="/trends.html">'+ic('<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/>')+'Trends</a>'
3986          +   '<a href="/news.html">'+ic('<path d="M4 11a9 9 0 0 1 9 9"/><path d="M4 4a16 16 0 0 1 16 16"/><circle cx="5" cy="19" r="1"/>')+' News</a>'
3987          +   '<a href="/blog.html">'+ic('<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>')+' Blog</a>'
3988          +   '<a href="/health.html">'+ic('<path d="M19.5 13.6 12 21l-7.5-7.4A5.2 5.2 0 1 1 12 6.3a5.2 5.2 0 1 1 7.5 7.3z"/>')+' Project Health</a>'
3989          +   '<a href="/reports/">'+ic('<path d="M3 3v18h18"/><path d="m19 9-5 5-4-4-3 3"/>')+'Weekly report</a>'
3990          +   '<div class="osaiMM-sec">Site & Data</div>'
3991          +   '<a href="/about.html">'+ic('<circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/>')+'About</a>'
3992          +   '<a href="/methodology.html">'+ic('<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>')+'Methodology</a>'
3993          +   '<a href="/contact.html">'+ic('<path d="M4 4h16v12H5.17L4 17.17V4z"/><path d="M8 9h8"/><path d="M8 12h5"/>')+' Contact</a>'
3994          +   '<a href="/favorites.html">'+ic('<path d="M12 17.8 5.8 21 7 14.1 2 9.3l7-1L12 2l3 6.3 7 1-5 4.8 1.2 6.9z"/>')+'Favorites</a>'
3995          +   '<a href="/submit.html" style="color:var(--accent);font-weight:600;">'+ic('<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/>
3995<line x1="8" y1="12" x2="16" y2="12"/>')+' Submit a tool or project</a>'
3996          +   '<a href="/badge.html">'+ic('<circle cx="12" cy="8" r="6"/><path d="M15.477 12.89 17 22l-5-3-5 3 1.523-9.11"/>')+' Get a badge</a>'
3997          +   '<div class="osaiMM-sec">Language</div>'
3998          +   '<div id="osaiMMLang"></div>'
3999          +   '<div class="osaiMM-sec">Tools</div>'
4000          +   '<button type="button" class="osaiMM-act" onclick="if(window.osaiMMClose)window.osaiMMClose();setTimeout(function(){if(window.openFavorites)openFavorites();},60)">'+ic('<path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/>')+' My favorites</button>'
4001          +   '<a class="osaiMM-act" href="' + ((location.pathname.match(/^\/(fr|it|es|de|pt)\//)||[,''])[1] ? '/' + location.pathname.split('/')[1] : '') + '/account.html">'+ic('<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>')+' Account</a>'
4002          +   '<button type="button" class="osaiMM-act" onclick="if(window.osaiMMClose)window.osaiMMClose();setTimeout(function(){if(window.openCmdK)openCmdK();},60)">'+ic('<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>')+' Search</button>'
4003          + '</div>';
4004        document.body.appendChild(mm);
4005      }
4006      var mmOpen  = function(){ mm.classList.add('osaiMM-open'); ham.classList.add('active'); document.body.style.overflow='hidden'; };
4007      var mmClose = function(){ mm.classList.remove('osaiMM-open'); ham.classList.remove('active'); document.body.style.overflow=''; };
4008      /* #osaiMM porte un z-index de 2 147 483 600 : la palette de recherche et
4009         le panneau Favoris (9999) s'ouvraient DERRIÈRE lui, donc invisibles.
4010         On expose la fermeture pour que ces boutons referment le menu d'abord. */
4011      window.osaiMMClose = mmClose;
4012      ham.addEventListener('click', function(e){ e.preventDefault(); e.stopPropagation(); mm.classList.contains('osaiMM-open') ? mmClose() : mmOpen(); });
4013      mm.querySelector('.osaiMM-bd').addEventListener('click', mmClose);
4014      mm.querySelector('.osaiMM-close').addEventListener('click', mmClose);
4015      // Taper un lien/bouton : on laisse la navigation se faire puis on referme.
4016      mm.querySelectorAll('.osaiMM-panel a, .osaiMM-act').forEach(function(el){ el.addEventListener('click', function(){ setTimeout(mmClose, 80); }); });
4017      document.addEventListener('keydown', function(e){ if(e.key === 'Escape') mmClose(); });
4018      window.addEventListener('resize', function(){ if(window.innerWidth > 900) mmClose(); });
4019    }
4020    // Thème : si la page a déjà applyTheme, le bouton l'utilise via onclick.
4021    // Sinon, fallback minimal :
4022    if(!window.applyTheme){
4023      window.applyTheme = function(dark){
4024        document.body.classList.toggle('dark', dark);
4025        try{ localStorage.setItem('osai_theme', dark?'dark':'light'); }catch(e){}
4026      };
4027    }
4028    var themeToggle = document.getElementById('themeToggle');
4029    if(themeToggle){ themeToggle.addEventListener('click', function(){ window.applyTheme(!document.body.classList.contains('dark')); }); }
4030    // Appliquer le thème : le choix explicite prime ; sinon on suit le réglage
4031    // jour/nuit du système du visiteur (prefers-color-scheme). Doit rester aligné
4032    // avec le snippet inline en tête de page, sinon la nav réécrase l'auto.
4033    try{
4034      var saved = localStorage.getItem('osai_theme');
4035      var dark = saved ? (saved === 'dark')
4036                       : (window.matchMedia && window.matchMedia('(prefers-color-scheme:dark)').matches);
4037      document.body.classList.toggle('dark', !!dark);
4038      // Bascule en direct si le système passe jour↔nuit (tant qu'aucun choix manuel)
4039      if(window.matchMedia && !saved){
4040        var _mq = window.matchMedia('(prefers-color-scheme:dark)');
4041        if(_mq.addEventListener) _mq.addEventListener('change', function(e){
4042          if(!localStorage.getItem('osai_theme')) document.body.classList.toggle('dark', e.matches);
4043        });
4044      }
4045    }catch(e){ document.body.classList.add('dark'); }
4046    // Mettre à jour le badge favoris si la fonction existe
4047    if(window.favUpdateBadge) window.favUpdateBadge();
4048  }
4049
4050  if(document.readyState === 'loading'){ document.addEventListener('DOMContentLoaded', inject); }
4051  else { inject(); }
4052
4053  /* ═══ V17 — Badge live du hero (home uniquement) : "N new AI projects today" ═══ */
4054  function osaiLivePill(){
4055    /* Romain 31/07/2026 : pastille retiree de l accueil — avec le badge
4056       Olud Index a cote du titre, deux pilules autour du hero faisaient
4057       trop charge. Coupee ICI et pas seulement via le slot : le repli
4058       ci-dessous reinjecte la pastille avant le h1 quand le slot manque.
4059       Pour la remettre un jour : supprimer ce return. */
4060    return;
4061    if (!isHome || document.querySelector('.osai-live-pill')) return;
4062    var h1 = document.querySelector('#heroSection .hero-title') || document.querySelector('.hero-title');
4063    if (!h1) return;
4064    fetch('/today-data.json?d=' + new Date().toISOString().slice(0,13)).then(function(r){ return r.ok ? r.json() : null; }).catch(function(){ return null; }).then(function(d){
4065      var c = d && d.counts;
4066      if (!c || !c.projects_today) return;
4067      var pill = document.createElement('a');
4068      pill.className = 'osai-live-pill';
4069      pill.href = '/latest.html';
4070      pill.setAttribute('aria-label', 'See the latest AI projects');
4071      pill.innerHTML = '<span class="olp-dot"></span><b>' + parseInt(c.projects_today, 10) + '</b> <span>new AI projects today</span> <span class="olp-go">&rarr;</span>';
4072      var slot = document.getElementById('osaiPillSlot');
4073      if (slot) { slot.appendChild(pill); }               /* slot réservé → zéro décalage de mise en page */
4074      else { h1.parentNode.insertBefore(pill, h1); }      /* repli : anciennes pages sans slot */
4075      if (typeof localizeTextTree === 'function') localizeTextTree(pill);
4076    });
4077  }
4078  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', osaiLivePill); else osaiLivePill();
4079
4080  /* ═══ V23b — Section EMERGING sous le hero (home uniquement).
4081     Les 3 découvertes du jour en cartes, injectées APRÈS #heroSection :
4082     zéro modification d'index.html, zéro régénération des 6 langues.
4083     Données : /emerging-public.json (build-graph, cron 9h45). */
4084  function osaiEmergingHome(){
4085    if (!isHome || document.querySelector('.osai-em-home')) return;
4086    fetch('/emerging-public.json?d=' + new Date().toISOString().slice(0,10)).then(function(r){ return r.ok ? r.json() : null; }).catch(function(){ return null; }).then(function(d){
4087      var P = d && d.projects;
4088      if (!P || P.length < 3) return;
4089      var hero = document.getElementById('heroSection') || (function(){ var t = document.querySelector('.hero-title'); return t ? t.closest('section') : null; })();
4090      if (!hero || !hero.parentNode) return;
4091
4092      function av(p){
4093        var initial = (p.name || '?').trim().charAt(0).toUpperCase() || '?';
4094        var h = 0, n = p.name || ''; for (var i = 0; i < n.length; i++){ h = n.charCodeAt(i) + ((h << 5) - h); }
4095        var cols = ['#6366f1','#0071e3','#e0245e','#9b59d9','#f24f00','#c98a00','#1a56db','#16a394'];
4096        var img = p.avatar ? '<img src="' + String(p.avatar).replace(/"/g,'&quot;') + '" alt="" loading="lazy" onerror="this.remove();" onload="this.parentNode.style.background=\'transparent\'">' : '';
4097        return '<span class="oeh-av" style="background:' + cols[Math.abs(h) % cols.length] + '">' + initial + img + '</span>';
4098      }
4099      function esc(x){ return String(x == null ? '' : x).replace(/</g, '&lt;'); }
4100      function kf(n){ n = +n || 0; return n >= 1000 ? (Math.round(n / 100) / 10) + 'k' : String(n); }
4101
4102      var sec = document.createElement('section');
4103      sec.className = 'osai-em-home';
4104      sec.innerHTML = '<div class="oeh-wrap"><div class="oeh-panel">'
4105        + '<div class="oeh-head">'
4106        + '<h2 class="oeh-title"><span class="sec-ic" aria-hidden="true">'
4107        +   '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">'
4108        +   '<path d="M12 2v8M8 6l4-4 4 4"/><circle cx="12" cy="16" r="5"/></svg></span>'
4109        +   '<span>Emerging</span></h2>'   /* [02/08/2026, Romain] etait un <span> avec un emoji fusee : les autres sections ont un h2 et une icone au trait. Meme icone que l'entree « Emerging » du menu. */
4110        + '<span class="oeh-sub"><span>Detected by our signals</span> &middot; <span>updated daily</span></span>'
4111        + '</div>'
4112        + '<div class="oeh-grid">'
4113        + P.slice(0, 3).map(function(p){
4114            var href = p.page || p.url;
4115            return '<a class="oeh-card" href="' + String(href).replace(/"/g,'&quot;') + '">'
4116              + av(p)
4117              + '<span class="oeh-body"><b>' + esc(p.name) + '</b>'
4118              + '<span class="oeh-line"><span class="oeh-mom">+' + kf(p.d7 || 0) + '&#9733;<i>/7d</i></span>'
4119              + (p.accel ? '<span class="oeh-fire"><span>accelerating</span></span>' : '')
4120              + '</span>'
4121              + (p.desc ? '<span class="oeh-desc">' + esc(String(p.desc).slice(0, 90)) + '</span>' : '')
4122              + '</span></a>';
4123          }).join('')
4124        + '</div>'
4125        /* [09/08/2026] Le lien vivait en pastille dans l'en-tete, colle au titre.
4126           Il descend sous la grille, en bouton plein, comme toutes les autres
4127           sections de l'accueil depuis le 08/08. */
4128        + '<div class="oeh-cta"><a href="/emerging.html">See all ' + P.length + ' emerging projects &rarr;</a></div>'
4129        + '</div></div>';
4130      hero.parentNode.insertBefore(sec, hero.nextSibling);
4131      /* largeur : calée sur le conteneur réel du hero (la home n'utilise pas
4132         la même max-width que les pages internes) — mesurée, pas devinée. */
4133      try {
4134        var ref = hero.firstElementChild;
4135        var vw = document.documentElement.clientWidth || window.innerWidth;
4136        /* Mobile : le CSS par défaut suffit — et le hero peut contenir des
4137           éléments plus larges que l'écran (ticker) qui fausseraient la mesure. */
4138        if (ref && vw > 760) {
4139          var rw = Math.min(Math.round(ref.getBoundingClientRect().width), vw);
4140          if (rw > 700) {
4141            var rcs = getComputedStyle(ref), wr = sec.querySelector('.oeh-wrap');
4142            wr.style.maxWidth = rw + 'px';
4143            wr.style.paddingLeft = rcs.paddingLeft;
4144            wr.style.paddingRight = rcs.paddingRight;
4145            wr.style.boxSizing = 'border-box';
4146          }
4147        }
4148      } catch(e) {}
4149      if (typeof localizeTextTree === 'function') localizeTextTree(sec);
4150    });
4151  }
4152  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', osaiEmergingHome); else osaiEmergingHome();
4153
4154  /* ═══ V114 — « Maintained by @x ✓ » sur les fiches /project/ réclamées,
4155     et l'invitation à réclamer sur les autres. Données : /claims-public.json
4156     (écrit par api/claim.php à chaque claim). Zéro régénération de fiches. */
4157  function osaiClaim(){
4158    var path = location.pathname.replace(/^\/(fr|it|es|de|pt)(?=\/)/, '');
4159    var m = path.match(/^\/project\/([^\/]+)\.html$/);
4160    if (!m || document.querySelector('.osai-claim')) return;
4161    var slug = decodeURIComponent(m[1]);
4162
4163    function findRepo(){
4164      var links = document.querySelectorAll('a[href*="github.com/"]');
4165      for (var j = 0; j < links.length; j++){
4166        if (links[j].closest && links[j].closest('#site-nav,#site-footer,.osaiMM')) continue;
4167        var mm = (links[j].getAttribute('href') || '').match(/^https?:\/\/(?:www\.)?github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?(?:[\/#?].*)?$/);
4168        if (mm && ['topics','sponsors','features','orgs','search','trending'].indexOf(mm[1].toLowerCase()) < 0)
4169          return (mm[1] + '/' + mm[2]).toLowerCase();
4170      }
4171      return null;
4172    }
4173    function place(el){
4174      var tries = 0;
4175      (function tick(){
4176        var h = document.querySelector('.osai-health');
4177        var anchor = h || document.querySelector('.cta-row') || document.querySelector('main h1, .hero h1, h1');
4178        if (anchor){
4179          if (h && h.insertAdjacentElement) h.insertAdjacentElement('afterend', el);
4180          else if (anchor.insertAdjacentElement) anchor.insertAdjacentElement('afterend', el);
4181          if (typeof localizeTextTree === 'function') localizeTextTree(el);
4182          return;
4183        }
4184        if (++tries < 10) setTimeout(tick, 300);
4185      })();
4186    }
4187    function css(){
4188      if (document.getElementById('osaiClaimCSS')) return;
4189      var st = document.createElement('style'); st.id = 'osaiClaimCSS';
4190      st.textContent =
4191        '.osai-claim{margin:10px 0 6px;font-family:Poppins,system-ui,sans-serif;}' +
4192        '.oc-pill{display:inline-flex;align-items:center;gap:8px;padding:7px 14px;border-radius:999px;border:2px solid #6366f1;color:#6366f1;font-size:.84rem;font-weight:600;text-decoration:none;}' +
4193        'body.dark .oc-pill{border-color:#818cf8;color:#818cf8;}' +
4194        '.oc-pill b{font-family:"DM Mono",monospace;font-weight:500;}' +
4195        '.oc-cta{display:inline-block;margin:4px 0 0;font-family:"DM Mono",monospace;font-size:.7rem;color:var(--muted,#5a6d87);text-decoration:none;}' +
4196        '.oc-cta:hover{color:#6366f1;text-decoration:underline;}';
4197      document.head.appendChild(st);
4198    }
4199
4200    fetch('/claims-public.json?d=' + new Date().toISOString().slice(0,15)).then(function(r){ return r.ok ? r.json() : null; }).catch(function(){ return null; }).then(function(d){
4201      var claims = (d && d.claims) || {};
4202      var repo = findRepo();
4203      var hit = claims[slug] || (repo ? claims[repo] : null);
4204      css();
4205      var box = document.createElement('div');
4206      box.className = 'osai-claim';
4207      /* [31/07] t() etait declare plus bas, DANS la branche « else if » : par
4208         hoisting du var il existait ici mais valait undefined, et l'appeler
4209         faisait planter la fonction sans un mot dans la console — badge
4210         invisible malgre une revendication verifiee et publiee. */
4211      var t = function(x){ return (typeof window.osaiT === 'function') ? window.osaiT(x) : x; };
4212      /* [31/07, 2e passe] Le badge n'apparaissait QUE si le membre avait lie
4213         GitHub. Un mainteneur verifie par le marqueur README (donc sans
4214         OAuth) n'obtenait rien apres avoir pousse un commit dans son depot.
4215         On affiche desormais la meilleure identite disponible, et a defaut
4216         le simple fait — verifie — qu'un mainteneur tient cette page. */
4217      if (hit){
4218        var gh = String(hit.gh || '').replace(/[^A-Za-z0-9-]/g, '');
4219        var hd = String(hit.handle || '').replace(/[^A-Za-z0-9_-]/g, '');
4220        var lg = (NAV_LANG && NAV_LANG !== 'en') ? '/' + NAV_LANG : '';
4221        if (gh) {
4222          box.innerHTML = '<a class="oc-pill" href="https://github.com/' + gh + '" target="_blank" rel="noopener">'
4223            + '&#128737;&#65039; <span>' + t('Maintained by') + '</span> <b>@' + gh + '</b> &#10003;</a>';
4224        } else if (hd) {
4225          box.innerHTML = '<a class="oc-pill" href="' + lg + '/u/' + hd + '">'
4226            + '&#128737;&#65039; <span>' + t('Maintained by') + '</span> <b>@' + hd + '</b> &#10003;</a>';
4227        } else {
4228          box.innerHTML = '<span class="oc-pill">&#128737;&#65039; <span>'
4229            + t('Claimed by its maintainer') + '</span> &#10003;</span>';
4230        }
4231      } else if (repo){
4232        /* le verdict du claim, s'il revient en ?claimed=… */
4233        var flag = (location.search.match(/[?&]claimed=([a-z_]+)/) || [])[1];
4234        if (flag) {
4235          try { history.replaceState(null, '', location.pathname); } catch(e2){}
4236          var okC = (flag === 'ok' || flag === 'already');
4237          var MSG = {
4238            ok: t('Verified — you are now listed as the maintainer of this project.'),
4239            already: t('Already verified — you are listed as the maintainer.'),
4240            link_github_first: t('To claim a project, your account must be signed in with GitHub.'),
4241            not_the_owner: t('This repository belongs to another GitHub account.'),
4242            not_public_org_member: t('You must be a public member of the organization that owns this repository.'),
4243            not_a_contributor: t('You must appear among the contributors of this repository.')
4244          };
4245          var vb = document.createElement('div');
4246          vb.style.cssText = 'margin:10px 0;padding:12px 16px;border-radius:12px;font-size:.85rem;font-weight:600;'
4247            + (okC ? 'background:rgba(13,158,112,.1);border:1px solid #0d9e70;color:#0d9e70;'
4248                   : 'background:rgba(217,119,6,.09);border:1px solid #d97706;color:#b45309;');
4249          vb.innerHTML = (okC ? '\u2713 ' : '\u26A0 ') + (MSG[flag] || t('Claim failed — contact [email protected] if you believe this is wrong.'))
4250            + (flag === 'link_github_first'
4251               ? ' <a href="/api/github-auth.php" style="display:inline-block;margin-left:8px;background:#1c2030;color:#fff;border-radius:8px;padding:5px 12px;font-size:.78rem;text-decoration:none">
4251' + t('Continue with GitHub') + ' \u2192</a>'
4252               : '');
4253          box.appendChild(vb);
4254          if (okC) { try { fetch('/claims-public.json?d=' + new Date().toISOString().slice(0,15)); } catch(e3){} }
4255        }
4256        /* [31/07] Ce lien pointait sur /api/claim.php : le mainteneur qui
4257           cliquait « Are you the maintainer? » recevait {"error":"Not signed
4258           in."} en JSON brut, sur 60 000 fiches. Il mene desormais a la page
4259           de revendication, qui explique et connecte. */
4260        box.innerHTML += '<a class="oc-cta" href="/claim.html?repo=' + encodeURIComponent(repo) + '">'
4261          + '&#128273; <span>' + t('Are you the maintainer?') + '</span> <span>' + t('Claim this project') + '</span> &rarr;</a>';
4262        /* le claim se joue ICI, en POST : l'anonyme part se connecter, le
4263           connecté voit TOUJOURS un verdict — plus jamais de JSON brut ni de
4264           clic silencieux. (Le lien ?go=1 reste en filet si JS meurt.) */
4265        /* [31/07/2026 — AUDIT] Ce bouton interceptait le clic et postait vers
4266           /api/claim.php SANS action : l'API repondait le statut (sans champ
4267           « ok »), le script lisait donc une erreur et affichait « Claim
4268           failed — contact [email protected] » a TOUT mainteneur connecte qui
4269           cliquait. Un refus imaginaire, sur toutes les fiches. Le clic suit
4270           desormais simplement le lien vers /claim.html, qui pre-remplit le
4271           depot, explique la preuve et gere la connexion : une seule voie de
4272           revendication, celle qui marche. */
4273      } else { return; }
4274      place(box);
4275    });
4276  }
4277  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', osaiClaim); else osaiClaim();
4278
4279  /* ═══ V115 — avatar du membre dans la barre (Google ou GitHub).
4280     /api/me.php en cache sessionStorage 10 min : une requête par session,
4281     et les anonymes (401) sont mémorisés aussi pour ne pas marteler l'API. */
4282  function osaiNavAvatar(){
4283    var btn = document.getElementById('acctNavBtn');
4284    if (!btn) return;
4285    function paint(url, plan){
4286      if (url) {
4287        btn.innerHTML = '<img src="' + String(url).replace(/"/g, '&quot;') + '" alt="" ' +
4288          'style="width:26px;height:26px;border-radius:50%;object-fit:cover;display:block" ' +
4289          'onerror="this.remove()">';
4290        btn.setAttribute('title', 'Your account');
4291      }
4292      /* chip de plan VISIBLE, à côté du bouton compte (payants uniquement) */
4293      if (plan && plan !== 'free' && !document.querySelector('.osai-plan-chip')) {
4294        if (!document.getElementById('osaiPlanCSS')) {
4295          var pst = document.createElement('style'); pst.id = 'osaiPlanCSS';
4296          /* Romain 31/07 : l ancienne version (1,5px + halo + etoile \u2726 en
4297             .58rem monospace) faisait gadget. Liseres 2 px = la charte ;
4298             DM Mono comme le reste de la barre ; hover rempli indigo. */
4299          pst.textContent = '.osai-plan-chip{display:inline-flex;align-items:center;margin-left:8px;' +
4300            'background:transparent;color:var(--accent,#6366f1);border:2px solid var(--accent,#6366f1);border-radius:999px;' +
4301            'padding:3px 12px;font-weight:700;font-size:.62rem;letter-spacing:.1em;' +
4302            'font-family:\'DM Mono\',ui-monospace,monospace;line-height:1.7;text-decoration:none;' +
4303            'transition:background .15s,color .15s}' +
4304            '#site-nav a.osai-plan-chip:hover, a.osai-plan-chip:hover{background:var(--accent,#6366f1) !important;color:#fff !important}';
4305          document.head.appendChild(pst);
4306        }
4307        var chip = document.createElement('a');
4308        chip.className = 'osai-plan-chip';
4309        chip.href = '/account.html#secDev';
4310        chip.textContent = String(plan).toUpperCase();
4311        chip.setAttribute('title', 'Your API plan — manage in your account');
4312        /* [04/08 pivot, Romain : « personne n'a d'offre, y'a que moi »]
4313           la pastille de plan (DEV/PRO) n'est plus inseree dans la barre. */
4314        void chip;
4315        btn.setAttribute('title', 'Your account — ' + plan + ' plan');
4316      }
4317    }
4318    /* [30/07] LE CACHE MONTRAIT L'AVATAR DU COMPTE PRECEDENT.
4319       Il etait indexe sur une seule cle, 'osai_me1', valable 10 minutes. Se
4320       deconnecter puis se reconnecter avec un AUTRE compte dans cette fenetre
4321       affichait donc la photo du premier — constate par Romain en passant de
4322       [email protected] a [email protected]. Sur un poste partage,
4323       c'est le visage de quelqu'un d'autre sur son propre compte.
4324       Le cache porte desormais une empreinte de la SESSION : un cookie
4325       different — autre compte, ou deconnexion — ne correspond plus a la
4326       cle, et on redemande a /api/me.php. */
4327    function sessFingerprint(){
4328      var m = document.cookie.match(/(?:^|;\s*)osai_sess=([^;]*)/);
4329      var v = m ? m[1] : '';
4330      var h = 0;
4331      for (var i = 0; i < v.length; i++) h = (h * 31 + v.charCodeAt(i)) >>> 0;
4332      return String(h);
4333    }
4334    var fp = sessFingerprint();
4335    var cached = null;
4336    try { cached = JSON.parse(sessionStorage.getItem('osai_me1') || 'null'); } catch(e) {}
4337    if (cached && cached.fp === fp && (Date.now() - cached.t) < 600000){ paint(cached.avatar, cached.plan); return; }
4338    fetch('/api/me.php').then(function(r){ return r.ok ? r.json() : null; }).catch(function(){ return null; })
4339    .then(function(j){
4340      var avatar = (j && j.avatar) || '';
4341      var plan = (j && j.plan) || '';
4342      var logged = !!(j && j.email);
4343      try { sessionStorage.setItem('osai_me1', JSON.stringify({ t: Date.now(), fp: fp, avatar: avatar, plan: plan, logged: logged })); } catch(e) {}
4344      paint(avatar, plan);
4345    });
4346  }
4347  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', osaiNavAvatar); else osaiNavAvatar();
4348
4349  /* ═══ V121 — L'ENCART COMPTE + FRICTION INTELLIGENTE ═══
4350     Pour les visiteurs anonymes, sur les pages clés : un encart discret dont
4351     le TITRE est l'action contextuelle de la page (la friction intelligente)
4352     et le corps l'argumentaire du compte gratuit. Refermable 7 jours.
4353     Jamais pour les connectés, jamais sur les pages compte/auth. */
4354  function osaiSignupNudge(){
4355    var path = location.pathname.replace(/^\/(fr|it|es|de|pt)(?=\/)/, '') || '/';
4356    var CTX = {
4357      '/dashboards.html': ['\u2B50', 'Save this dashboard'],
4358      '/latest.html':     ['\uD83D\uDD14', 'Get notified when a new project appears'],
4359      '/releases.html':   ['\uD83D\uDCE6', 'Follow these projects'],
4360      '/emerging.html':   ['\uD83D\uDE80', 'Save discoveries to your portfolio'],
4361      '/health.html':     ['\uD83D\uDC9A', 'Track health evolution over time'],
4362      '/leaderboard.html':['\uD83D\uDCCA', 'Follow your favorite models'],
4363      '/trends.html':     ['\uD83D\uDCC8', 'Watch these trends from your account'],
4364      '/category.html':   ['\uD83E\uDDF0', 'Build your own AI toolbox'],
4365      '/pricing.html':    ['\uD83D\uDCB0', 'Get price-drop alerts on the models you use']
4366    };
4367    var ctx = CTX[path];
4368    if (!ctx && /^\/vertical\//.test(path)) ctx = ['\uD83C\uDFED', 'Follow this industry\u2019s projects'];
4369    if (!ctx) return;
4370    try { if (localStorage.getItem('osai_nudge_off') && Date.now() - (+localStorage.getItem('osai_nudge_off')) < 6048e5) return; } catch(e) {}
4371
4372    /* connecté ? (même cache que l'avatar : zéro requête ajoutée) */
4373    var me = null;
4374    try { me = JSON.parse(sessionStorage.getItem('osai_me1') || 'null'); } catch(e) {}
4375    function show(){
4376      if (document.querySelector('.osai-nudge')) return;
4377      var host = document.querySelector('main .wrap, main, .wrap');
4378      var h1 = host ? host.querySelector('h1') : null;
4379      var after = h1 ? (h1.closest('header') || h1) : null;
4380      if (!after) return;
4381      var css = document.createElement('style');
4382      css.textContent = '.osai-nudge{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin:18px 0 6px;padding:13px 16px;border:1.5px dashed var(--accent,#6366f1);border-radius:13px;background:var(--accent-2-soft,rgba(99,102,241,.06));font-family:Poppins,system-ui,sans-serif;position:relative;}' +
4383        '.osai-nudge b{font-size:.9rem;}' +
4384        '.osai-nudge .on-ticks{display:flex;gap:12px;flex-wrap:wrap;font-size:.72rem;color:var(--muted,#5a6d87);}' +
4385        '.osai-nudge .on-ticks span::before{content:"\\2713  ";color:var(--accent,#6366f1);font-weight:700;}' +
4386        '.osai-nudge a.on-cta{margin-left:auto;flex:none;font-weight:600;font-size:.8rem;background:var(--accent,#6366f1);color:#fff;border-radius:9px;padding:9px 16px;text-decoration:none;}' +
4387        '.osai-nudge .on-x{position:absolute;top:6px;right:9px;background:none;border:none;color:var(--muted,#5a6d87);cursor:pointer;font-size:.9rem;line-height:1;padding:2px;}' +
4388        '@media(max-width:700px){.osai-nudge a.on-cta{margin-left:0;}}';
4389      document.head.appendChild(css);
4390      var box = document.createElement('aside');
4391      box.className = 'osai-nudge';
4392      box.innerHTML = '<div><b>' + ctx[0] + ' <span>' + ctx[1] + '</span></b>' +
4393        '<div class="on-ticks"><span>Save projects</span><span>Build AI portfolios</span><span>Follow emerging tools</span><span>Get daily alerts</span></div></div>' +
4394        '<a class="on-cta" href="/account.html"><span>Create your free account</span> &rarr;</a>' +
4395        '<button class="on-x" aria-label="Dismiss" title="Hide for a week">&#10005;</button>';
4396      box.querySelector('.on-x').addEventListener('click', function(){
4397        box.remove();
4398        try { localStorage.setItem('osai_nudge_off', String(Date.now())); } catch(e) {}
4399      });
4400      if (after.insertAdjacentElement) after.insertAdjacentElement('afterend', box);
4401      if (typeof localizeTextTree === 'function') localizeTextTree(box);
4402    }
4403    if (me && (Date.now() - me.t) < 600000) {
4404      if (!me.logged) show();                      /* cache dit anonyme (fraîcheur ≤ 10 min) */
4405      return;
4406    }
4407    fetch('/api/me.php').then(function(r){ return r.ok ? r.json() : null; }).catch(function(){ return null; })
4408    .then(function(j){ if (!j || !j.email) show(); });
4409  }
4410  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', osaiSignupNudge); else osaiSignupNudge();
4411
4412  /* ═══ V122 — L'INVITATION CONTEXTUELLE ═══
4413     [27/08/2026, demande de Romain] Une sollicitation a s'inscrire, revendiquer
4414     ou deposer un outil. L'encart V121 ci-dessus ne couvre que neuf pages de
4415     listes ; il ignore les 10 000 fiches projet, qui sont l'essentiel du trafic
4416     et le SEUL endroit ou « ce projet est le votre ? » veut dire quelque chose.
4417
4418     Trois partis pris :
4419     . JAMAIS un interstitiel plein ecran. Google degrade le classement mobile
4420       des pages qui masquent le contenu a l'arrivee ; une carte en bas d'ecran
4421       n'est pas concernee, et ne coute pas le referencement qu'on cherche.
4422     . JAMAIS a l'ouverture. Elle attend un signe d'interet reel — trente
4423       secondes, la moitie de la page, ou la souris qui part vers la barre
4424       d'adresse. Un visiteur qui rebondit en trois secondes n'est pas un
4425       inscrit potentiel, c'est quelqu'un qu'on derange.
4426     . JAMAIS deux fois. Refermee, elle se tait quatorze jours ; et si l'encart
4427       V121 occupe deja la page, elle ne s'affiche pas du tout. */
4428  function osaiInvite(){
4429    var p = location.pathname.replace(/^\/(fr|it|es|de|pt)(?=\/)/, '') || '/';
4430    /* la ou l'action est deja engagee, insister serait absurde */
4431    /* token.html est exclue depuis le 27/08 : sa raison d'etre est qu'on lise
4432       une adresse de 44 caracteres et deux avertissements sur des fonds
4433       perdus. Une carte qui recouvre le coin pendant cette lecture travaille
4434       exactement contre la page. Vu a l'ecran, elle masquait « The rule has
4435       no exception ». */
4436    if (/^\/(account|claim|submit|login|signin|auth|unsub|legal|privacy|terms|cgu|token)/.test(p)) return;
4437    if (document.querySelector('.osai-nudge, .osai-inv')) return;
4438    try {
4439      var off = +localStorage.getItem('osai_inv_off');
4440      if (off && Date.now() - off < 12096e5) return;              /* 14 jours */
4441      if (sessionStorage.getItem('osai_inv_seen')) return;         /* une fois par visite */
4442    } catch(e) {}
4443
4444    var T = function(x){ return (typeof window.osaiT === 'function' && window.osaiT(x)) || x; };
4445
4446    /* ── quel repo, s'il y en a un ── */
4447    function repoIci(){
4448      var e = document.querySelector('[data-repo]');
4449      var v = e ? (e.getAttribute('data-repo') || '').trim() : '';
4450      return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(v) ? v : '';
4451    }
4452
4453    /* ── que proposer ici ── */
4454    var repo = /^\/(tool|project)\//.test(p) ? repoIci() : '';
4455    var msg;
4456    if (repo) {
4457      msg = { t: 'Is this your project?',
4458              s: 'Claim it to write the description in your own words, see your page’s audie
4458nce, and get alerted when the signals move.',
4459              c: 'Claim ' + repo.split('/')[1],
4460              u: '/claim.html?repo=' + encodeURIComponent(repo).replace(/%2F/gi, '/') };
4461    } else if (/^\/(category|categories|alternatives|compare|latest|emerging|trends|leaderboard)/.test(p)) {
4462      msg = { t: 'You maintain an open-source AI project?',
4463              s: 'Add it to olud.ai. It gets a page that keeps itself up to date — stars, releases, health, in five languages.',
4464              c: 'Submit your project',
4465              u: '/submit.html' };
4466    } else {
4467      msg = { t: 'Create a free account',
4468              s: 'Save projects, build your own AI toolbox, and get a daily digest of what actually moved.',
4469              c: 'Create your account',
4470              u: '/account.html' };
4471    }
4472
4473    function css(){
4474      if (document.getElementById('osaiInvCSS')) return;
4475      var st = document.createElement('style'); st.id = 'osaiInvCSS';
4476      /* Tout passe par les variables du theme : la carte doit vivre en clair
4477         comme en sombre sans qu'aucune couleur ne soit figee ici. */
4478      st.textContent =
4479        '.osai-inv{position:fixed;z-index:9998;right:18px;bottom:18px;width:340px;max-width:calc(100vw - 24px);' +
4480          'background:var(--surface,#fff);border:1px solid var(--border,rgba(15,23,36,.10));border-radius:16px;' +
4481          'padding:16px 18px 15px;font-family:Poppins,system-ui,sans-serif;color:var(--txt,#0f1424);' +
4482          'box-shadow:0 2px 6px rgba(15,23,36,.06),0 14px 40px rgba(15,23,36,.14);' +
4483          'opacity:0;transform:translateY(14px);transition:opacity .28s ease,transform .28s ease;}' +
4484        '.osai-inv.on{opacity:1;transform:none;}' +
4485        '.osai-inv .oi-t{font-weight:600;font-size:.95rem;line-height:1.35;padding-right:20px;}' +
4486        '.osai-inv .oi-s{margin-top:6px;font-size:.79rem;line-height:1.6;color:var(--muted,#5a6d87);}' +
4487        '.osai-inv .oi-go{display:inline-block;margin-top:13px;background:var(--accent,#6366f1);color:#fff;' +
4488          'font-weight:600;font-size:.8rem;border-radius:10px;padding:9px 16px;text-decoration:none;}' +
4489        '.osai-inv .oi-go:hover{filter:brightness(1.08);}' +
4490        '.osai-inv .oi-x{position:absolute;top:8px;right:10px;background:none;border:none;cursor:pointer;' +
4491          'color:var(--muted,#5a6d87);font-size:.95rem;line-height:1;padding:4px;border-radius:6px;}' +
4492        '.osai-inv .oi-x:hover{background:var(--accent-2-soft,rgba(99,102,241,.10));}
4492' +
4493        '.osai-inv .oi-x:focus-visible,.osai-inv .oi-go:focus-visible{outline:2px solid var(--accent,#6366f1);outline-offset:2px;}' +
4494        /* En dessous de 640px la carte devient un bandeau : plus de place pour
4495           le texte, et le pouce tombe naturellement sur le bouton. */
4496        /* 80px et non 10 : #osaiHelpBtn occupe le coin bas-gauche avec un
4497           z-index superieur, et recouvrait le bouton d'action. Verifie a
4498           l'ecran le 27/08 — la carte passe au-dessus de lui, pas dessous. */
4499        '@media(max-width:640px){.osai-inv{right:10px;left:10px;bottom:80px;width:auto;border-radius:14px;}}' +
4500        '@media(prefers-reduced-motion:reduce){.osai-inv{transition:none;}}';
4501      document.head.appendChild(st);
4502    }
4503
4504    /* ── le coin bas est deja occupe ────────────────────────────────────────
4505       Releve a l'ecran le 27/08, accueil en 1280x820 : #backToTop tient le
4506       coin bas-droite (44x44), #osaiHelpBtn le bas-gauche, et #cookieBanner
4507       barre toute la largeur sur 89 px avec un z-index SUPERIEUR au notre.
4508       On mesure au moment d'afficher plutot que de figer une valeur : ces
4509       elements se deplacent selon la page, le defilement et le consentement. */
4510    function decalageBas(){
4511      var d = 18, vw = window.innerWidth, vh = window.innerHeight, etroit = vw <= 640;
4512      var ids = etroit ? ['backToTop', 'osaiHelpBtn'] : ['backToTop'];
4513      for (var i = 0; i < ids.length; i++) {
4514        var e = document.getElementById(ids[i]);
4515        if (!e) continue;
4516        var st = getComputedStyle(e);
4517        if (st.display === 'none' || st.visibility === 'hidden') continue;
4518        /* On ne saute PAS un element transparent : #backToTop est a opacity 0
4519           tant qu'on n'a pas defile, et notre carte s'affiche justement APRES
4520           un defilement. L'ignorer reviendrait a le voir surgir sous elle. */
4521        var r = e.getBoundingClientRect();
4522        if (!r.height) continue;
4523        /* Seulement ce qui est REELLEMENT ancre en bas. Mesure du 27/08 en
4524           375x812 : #backToTop y passe a bottom:324px, en plein milieu de
4525           l'ecran — le compter comme un obstacle du bas propulsait la carte
4526           a 384 px du sol. Au-dela de 160 px, ce n'est plus le coin bas. */
4527        if (vh - r.bottom > 160) continue;
4528        if (!etroit && r.right < vw - 400) continue;      /* hors de notre colonne */
4529        d = Math.max(d, vh - r.top + 16);   /* 16 et non 12 : a 12 les deux bords se touchaient */
4530      }
4531      return d;
4532    }
4533
4534    /* Le consentement passe AVANT nous : c'est une obligation, pas une offre.
4535       Tant que le bandeau est la, on se tait — et on repasse plus tard, parce
4536       qu'un visiteur qui vient d'accepter est justement disponible. */
4537    function consentementEnCours(){
4538      var c = document.getElementById('cookieBanner');
4539      if (!c) return false;
4540      var st = getComputedStyle(c);
4541      return st.display !== 'none' && st.visibility !== 'hidden' && c.getBoundingClientRect().height > 0;
4542    }
4543
4544    var affichee = false, reports = 0;
4545    function montrer(){
4546      if (affichee || document.querySelector('.osai-inv')) return;
4547      if (consentementEnCours()) {
4548        if (++reports > 6) return;                        /* deux minutes, puis on renonce */
4549        setTimeout(montrer, 20000);
4550        return;
4551      }
4552      affichee = true;
4553      try { sessionStorage.setItem('osai_inv_seen', '1'); } catch(e) {}
4554      css();
4555      var b = document.createElement('aside');
4556      b.className = 'osai-inv';
4557      b.setAttribute('role', 'complementary');
4558      b.setAttribute('aria-label', T(msg.t));
4559      b.innerHTML = '<button class="oi-x" type="button" aria-label="' + T('Dismiss') + '">&#10005;</button>' +
4560        '<div class="oi-t"></div><div class="oi-s"></div>' +
4561        '<a class="oi-go" href="' + msg.u + '"></a>';
4562      b.querySelector('.oi-t').textContent = T(msg.t);
4563      b.querySelector('.oi-s').textContent = T(msg.s);
4564      b.querySelector('.oi-go').textContent = T(msg.c) + ' →';
4565      function fermer(){
4566        b.classList.remove('on');
4567        try { localStorage.setItem('osai_inv_off', String(Date.now())); } catch(e) {}
4568        setTimeout(function(){ if (b.parentNode) b.parentNode.removeChild(b); }, 300);
4569        document.removeEventListener('keydown', echap);
4570      }
4571      function echap(e){ if (e.key === 'Escape' || e.keyCode === 27) fermer(); }
4572      b.querySelector('.oi-x').addEventListener('click', fermer);
4573      document.addEventListener('keydown', echap);
4574      b.style.bottom = decalageBas() + 'px';
4575      document.body.appendChild(b);
4576      if (typeof localizeTextTree === 'function') localizeTextTree(b);
4577      requestAnimationFrame(function(){ requestAnimationFrame(function(){ b.classList.add('on'); }); });
4578    }
4579
4580    /* ── ne jamais doubler ce que la page dit deja ──────────────────────────
4581       Releve du 27/08 : une fiche projet porte DEJA trois invitations dans son
4582       flux (« Are you the maintainer? » a 2 532 px, « Claim this page » a
4583       3 146, « Get email alerts » a 3 834), et l'accueil en porte deux. Elles
4584       sont enterrees — l'accueil fait 24 746 px de haut — donc la carte garde
4585       son utilite pour qui ne descend pas. Mais si le visiteur en a croise
4586       une, insister devient du harcelement : on se retire.
4587       Les liens a taille nulle sont ignores : ce sont ceux des menus replies. */
4588    function guetterDoublon(renoncer){
4589      if (!window.IntersectionObserver) return;
4590      var liens = [];
4591      var tous = document.querySelectorAll('a[href*="/claim.html"],a[href*="/account.html"],a[href*="/submit.html"]');
4592      for (var i = 0; i < tous.length; i++) {
4593        var a = tous[i];
4594        if (a.closest('#site-nav,#site-footer,.osaiMM,#moreMenu,.osai-inv')) continue;
4595        var r = a.getBoundingClientRect();
4596        if (!r.width || !r.height) continue;
4597        liens.push(a);
4598      }
4599      if (!liens.length) return;
4600      var io = new IntersectionObserver(function(ents){
4601        for (var k = 0; k < ents.length; k++) {
4602          if (ents[k].isIntersecting) { io.disconnect(); renoncer(); return; }
4603        }
4604      }, { threshold: 0.5 });
4605      for (var j = 0; j < liens.length; j++) io.observe(liens[j]);
4606    }
4607
4608    /* ── les trois signes d'interet ── */
4609    function armer(){
4610      var minuteur = setTimeout(montrer, 30000);
4611      guetterDoublon(function(){
4612        clearTimeout(minuteur);
4613        affichee = true;                 /* verrou : la page a fait le travail */
4614        var d = document.querySelector('.osai-inv');
4615        if (d && d.parentNode) d.parentNode.removeChild(d);
4616      });
4617      function auScroll(){
4618        var h = document.documentElement;
4619        var total = (h.scrollHeight - h.clientHeight);
4620        if (total > 400 && (h.scrollTop || document.body.scrollTop) / total > 0.55) {
4621          clearTimeout(minuteur); montrer();
4622          window.removeEventListener('scroll', auScroll);
4623        }
4624      }
4625      window.addEventListener('scroll', auScroll, { passive: true });
4626      /* Intention de sortie : la souris franchit le haut de la fenetre. Sans
4627         objet au doigt, donc reserve aux pointeurs fins. */
4628      if (window.matchMedia && window.matchMedia('(pointer:fine)').matches) {
4629        document.addEventListener('mouseout', function(e){
4630          if (!e.relatedTarget && e.clientY <= 0) { clearTimeout(minuteur); montrer(); }
4631        });
4632      }
4633    }
4634
4635    /* ── connecte ? on se tait. Meme cache que l'avatar : zero requete ajoutee. ── */
4636    var me = null;
4637    try { me = JSON.parse(sessionStorage.getItem('osai_me1') || 'null'); } catch(e) {}
4638    if (me && (Date.now() - me.t) < 600000) { if (!me.logged) armer(); return; }
4639    fetch('/api/me.php').then(function(r){ return r.ok ? r.json() : null; }).catch(function(){ return null; })
4640    .then(function(j){ if (!j || !j.email) armer(); });
4641  }
4642  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', osaiInvite); else osaiInvite();
4643
4644  /* ═══ V22 — BADGE SANTÉ sur les fiches /tool/ et /project/ ═══
4645     Répond à « ce projet est-il vivant ? » directement sur la fiche, sans
4646     régénérer une seule page : données de /health-data.json (cron quotidien).
4647     Détection du repo : attribut [data-repo] de la fiche, sinon premier lien
4648     GitHub owner/repo du contenu (hors nav/footer). Pas de donnée → pas de badge. */
4649  function osaiHealth(){
4650    var hpath = location.pathname.replace(/^\/(fr|it|es|de|pt)(?=\/)/, '');
4651    if (!/^\/(tool|project)\/[^\/]+\.html$/.test(hpath)) return;
4652    if (/\/index\.html$/.test(hpath) || document.querySelector('.osai-health')) return;
4653
4654    function findRepo(){
4655      var els = document.querySelectorAll('[data-repo]');
4656      for (var i = 0; i < els.length; i++){
4657        var v = (els[i].getAttribute('data-repo') || '').trim().toLowerCase();
4658        if (/^[a-z0-9_.-]+\/[a-z0-9_.-]+$/.test(v)) return v;
4659      }
4660      /* priorité aux boutons d'action de la fiche (View on GitHub), puis n'importe quel lien du contenu */
4661      var scopes = ['.btn-row a[href*="github.com/"], .cta-row a[href*="github.com/"]', 'a[href*="github.com/"]'];
4662      for (var s = 0; s < scopes.length; s++){
4663        var links = document.querySelectorAll(scopes[s]);
4664        for (var j = 0; j < links.length; j++){
4665          if (links[j].closest && links[j].closest('#site-nav,#site-footer,.osaiMM')) continue;   // liens du menu/footer : ignorés
4666          var m = (links[j].getAttribute('href') || '').match(/^https?:\/\/(?:www\.)?github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?(?:[\/#?].*)?$/);
4667          if (m && ['topics','sponsors','features','orgs','search','trending'].indexOf(m[1].toLowerCase()) < 0)
4668            return (m[1] + '/' + m[2]).toLowerCase();
4669        }
4670      }
4671      return null;
4672    }
4673
4674    var HCOLORS = { great:'#22c55e', good:'#4ade80', ok:'#eab308', warn:'#f97316', bad:'#ef4444', dead:'#9ca3af' };
4675
4676    function hCSS(){
4677      if (document.getElementById('osaiHealthCSS')) return;
4678      var s = document.createElement('style'); s.id = 'osaiHealthCSS';
4679      /* La carte du mockup (02/08/2026) : anneau a gauche, composantes a
4680         droite. Tout passe par les variables du theme — aucun fond fige. */
4681      s.textContent =
4682        '.osai-health{margin:18px 0 8px;font-family:Poppins,system-ui,sans-serif;}' +
4683        '.oh-card{display:grid;grid-template-columns:290px 1fr;background:var(--surface,#fff);' +
4684          'border:1px solid var(--border,rgba(0,0,0,.08));border-radius:18px;overflow:hidden;' +
4685          'box-shadow:0 1px 2px rgba(15,23,36,.04),0 6px 20px rgba(15,23,36,.05);}' +
4686        '.oh-left{padding:22px 20px 24px;border-right:1px solid var(--border,rgba(0,0,0,.08));' +
4687          'display:flex;flex-direction:column;align-items:center;}' +
4688        '.oh-t{display:flex;gap:9px;align-items:center;align-self:flex-start;font-weight:700;' +
4689          'font-size:1rem;color:var(--txt,#111);}' +
4690        '.oh-t svg{width:19px;height:19px;color:var(--accent,#6366f1);flex:none;}' +
4691        '.oh-ring{position:relative;width:150px;height:150px;margin-top:16px;}' +
4692        '.oh-ring svg{display:block;transform:rotate(-90deg);}' +
4693        '.oh-tr{fill:none;stroke:var(--surface2,#f2f4f9);stroke-width:11;}' +
4694        '.oh-va{fill:none;stroke-width:11;stroke-linecap:round;}' +
4695        '.oh-mid{position:absolute;top:0;left:0;right:0;bottom:0;display:flex;flex-direction:column;' +
4696          'align-items:center;justify-content:center;line-height:1;}' +
4697        '.oh-mid b{font-size:2.25rem;font-weight:700;letter-spacing:-.03em;color:var(--txt,#111);' +
4698          'font-variant-numeric:tabular-nums;}' +
4699        '.oh-mid i{font-style:normal;font-size:.85rem;color:var(--muted,#5a6d87);margin-top:3px;}' +
4700        '.oh-verdict{margin-top:15px;font-weight:700;font-size:1rem;display:flex;align-items:center;gap:8px;}' +
4701        '.oh-verdict i{width:9px;height:9px;border-radius:50%;background:currentColor;font-style:normal;}' +
4702        '.oh-right{padding:20px 24px 16px;min-width:0;}' +
4703        '.oh-why{margin:0 0 14px;font-size:.92rem;color:var(--txt2,#3a4460);line-height:1.5;}' +
4704        '.oh-row{display:grid;grid-template-columns:132px minmax(0,1fr) 180px 92px;gap:12px;' +
4705          'align-items:center;margin:11px 0;}' +
4706        '.oh-nm{display:flex;gap:9px;align-items:center;font-weight:700;font-size:.88rem;color:var(--txt,#111);}' +
4707        '.oh-nm svg{width:17px;height:17px;color:var(--muted,#5a6d87);flex:none;}' +
4708        '.oh-txt{font-size:.8rem;color:var(--muted,#5a6d87);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}' +
4709        '.oh-bar{display:block;height:7px;border-radius:99px;background:var(--surface2,#eef0f5);overflow:hidden;min-width:44px;}' +
4710        '.oh-fill{display:block;height:100%;border-radius:99px;}' +
4711        '.oh-pillr{justify-self:end;font-size:.76rem;font-weight:600;padding:4px 12px;border-radius:8px;white-space:nowrap;}
4711' +
4712        '.oh-meta{margin-top:12px;padding-top:12px;border-top:1px solid var(--border,rgba(0,0,0,.08));' +
4713          'font-size:.84rem;color:var(--muted,#5a6d87);display:flex;gap:6px 18px;flex-wrap:wrap;align-items:center;}' +
4714        '.oh-meta b{color:var(--txt,#111);}' +
4715        '.oh-meta a{color:var(--accent,#6366f1);text-decoration:none;font-weight:600;}' +
4716        '@media(max-width:900px){' +
4717          '.oh-card{grid-template-columns:1fr;}' +
4718          '.oh-left{border-right:0;border-bottom:1px solid var(--border,rgba(0,0,0,.08));}' +
4719          '.oh-row{grid-template-columns:1fr 92px;row-gap:5px;}' +
4720          '.oh-nm{grid-column:1;}' +
4721          '.oh-pillr{grid-row:1;grid-column:2;}' +
4722          '.oh-bar{grid-column:1/-1;}' +
4723          '.oh-txt{grid-column:1/-1;white-space:normal;}' +
4724        '}';
4725      document.head.appendChild(s);
4726    }
4727
4728    function esc(t){ return String(t).replace(/[&<>"]/g, function(c){ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]; }); }
4729
4730    /* icones au trait, meme grammaire que le mega-menu */
4731    function hIc(d){
4732      return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
4733             'stroke-linecap="round" stroke-linejoin="round">' + d + '</svg>';
4734    }
4735    var H_ICONS = {
4736      shield:      '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>',
4737      Activity:    '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
4738      Momentum:    '<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/>',
4739      Community:   '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
4740      Maintenance: '<path d="M9 12l2 2 4-4"/><circle cx="12" cy="12" r="10"/>'
4741    };
4742
4743    /* l'appreciation d'une composante, deduite de son taux */
4744    function hRate(pct){
4745      if (pct >= 85) return ['Excellent', '#15803d', 'rgba(34,197,94,.13)'];
4746      if (pct >= 60) return ['Good',      '#1d4ed8', 'rgba(59,130,246,.13)'];
4747      if (pct >= 35) return ['Fair',      '#b45309', 'rgba(245,158,11,.14)'];
4748      return              ['Low',        '#b91c1c', 'rgba(239,68,68,.13)'];
4749    }
4750
4751    function render(h){
4752      hCSS();
4753      var col = HCOLORS[h.cls] || HCOLORS.ok;
4754      var wrap = document.createElement('div');
4755      wrap.className = 'osai-health';
4756      var rows = '';
4757      var order = ['Activity', 'Momentum', 'Community', 'Maintenance'];
4758      for (var i = 0; i < order.length; i++){
4759        var p = h.parts && h.parts[order[i]];
4760        if (!p) continue;
4761        var pct = p.max ? Math.round(p.v / p.max * 100) : 0;
4762        var trend = p.trend === 'up' ? ' &#8599;' : (p.trend === 'down' ? ' &#8600;' : '');
4763        var r = hRate(pct);
4764        rows += '<div class="oh-row">'
4765              + '<span class="oh-nm">' + hIc(H_ICONS[order[i]] || '') + order[i] + '</span>'
4766              + '<span class="oh-txt">' + esc(p.txt || '') + trend + '</span>'
4767              + '<span class="oh-bar"><span class="oh-fill" style="width:' + pct + '%;background:' + col + ';"></span></span>'
4768              + '<span class="oh-pillr" style="color:' + r[1] + ';background:' + r[2] + ';">' + r[0] + '</span>'
4769              + '</div>';
4770      }
4771      var score = (h.parts && h.score > 0) ? parseInt(h.score, 10) : 0;
4772      /* anneau : rayon 64, circonference 402,1 */
4773      var dash = Math.max(0, Math.min(100, score)) / 100 * 402.1;
4774      wrap.innerHTML =
4775        '<div class="oh-card">'
4776        + '<div class="oh-left">'
4777        +   '<span class="oh-t">' + hIc(H_ICONS.shield) + '<span>Project health</span></span>'
4778        +   '<span class="oh-ring"><svg width="150" height="150" viewBox="0 0 150 150">'
4779        +     '<circle class="oh-tr" cx="75" cy="75" r="64"></circle>'
4780        +     '<circle class="oh-va" cx="75" cy="75" r="64" stroke="' + col + '" stroke-dasharray="' + dash.toFixed(1) + ' 402.1"></circle>'
4781        +   '</svg><span class="oh-mid"><b>' + score + '</b><i>/100</i></span></span>'
4782        +   '<span class="oh-verdict" style="color:' + col + ';"><span>' + esc(h.label || '') + '</span><i></i></span>'
4783        + '</div>'
4784        + '<div class="oh-right">'
4785        +   '<p class="oh-why">' + esc(String(h.why || '')) + '</p>' + rows
4786        +   '<div class="oh-meta"><span><span>Checked</span> <b>' + esc(h.checked || '') + '</b></span>'
4787        +   '<a href="/health.html"><span>See all project health</span> &rarr;</a></div>'
4788        + '</div>'
4789        + '</div>';
4790      var slot = document.getElementById('osaiHealthSlot');
4791      var badges = document.querySelector('.wrap > .badges') || document.querySelector('.badges');
4792      var cta = document.querySelector('.hero-main .cta-row') || document.querySelector('.cta-row');
4793      var h1 = document.querySelector('main h1, .hero h1, article h1, h1');
4794      if (slot) slot.appendChild(wrap);
4795      /* ORDRE DU MOCKUP : etiquettes -> sante -> boutons. Sur la fiche outil la
4796         rangee de badges precede les boutons, on se pose apres elle ; sur la
4797         fiche projet on se glisse AVANT la rangee de boutons — l'ancien code se
4798         posait apres, et la sante arrivait sous les boutons. */
4799      else if (badges && badges.insertAdjacentElement) badges.insertAdjacentElement('afterend', wrap);
4800      else if (cta && cta.insertAdjacentElement) cta.insertAdjacentElement('beforebegin', wrap);
4801      else if (h1 && h1.parentNode) h1.parentNode.insertBefore(wrap, h1.nextSibling);
4802      else return;
4803      if (typeof localizeTextTree === 'function') localizeTextTree(wrap);
4804      if (typeof localizeAllLinks === 'function') localizeAllLinks(wrap
4804);
4805    }
4806
4807    var repo = findRepo();
4808    if (!repo) return;
4809    var dayKey = new Date().toISOString().slice(0, 10).replace(/-/g, '');   // cache-bust quotidien (données quotidiennes)
4810    fetch('/health-data.json?d=' + dayKey)
4811      .then(function(r){ return r.ok ? r.json() : null; })
4812      .catch(function(){ return null; })
4813      .then(function(d){
4814        var h = d && d.data && d.data[repo];
4815        if (h && h.label) render(h);
4816      });
4817  }
4818  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', osaiHealth); else osaiHealth();
4819
4820  /* ═══ V24 — TICKER TERMINAL sur la home : bandeau défilant (movers, prix, nouveaux
4821     modèles) qui mène au /dashboard.html. Même philosophie que le live pill : zéro
4822     modification d'index.html, dégradation propre si une source manque. ═══ */
4823  function osaiHomeTape(){
4824    if (!isHome || document.getElementById('osaiTape')) return;
4825    /* Ancrage, du plus précis au filet ultime — mêmes repères que le live pill V17 :
4826       1. #heroSection · 2. le conteneur de .hero-title · 3. .hero · 4. le conteneur
4827       du slot pill · 5. juste après le nav injecté (le bandeau apparaît TOUJOURS). */
4828    var ht = document.querySelector('#heroSection .hero-title') || document.querySelector('.hero-title');
4829    /* [02/08/2026, Romain] « la barre Terminal ne devrait-elle pas etre au
4830       niveau de la section Terminal ? » Si : un bandeau qui s'annonce
4831       « olud.ai TERMINAL » et qui flotte a dix sections de la section du meme
4832       nom se lit comme un element decoratif. Colle a elle, il devient sa
4833       ligne vivante — la preuve que ce qu'annonce la section bouge vraiment.
4834       #termHome d'abord, le hero seulement s'il n'existe pas. */
4835    var anchor = document.getElementById('termHome')
4836              || document.getElementById('heroSection')
4837              || (ht && ht.closest && ht.closest('section,header'))
4838              || document.querySelector('.hero')
4839              || (document.getElementById('osaiPillSlot') && document.getElementById('osaiPillSlot').parentNode)
4840              || document.getElementById('site-nav');
4841    if (!anchor) return;
4842    function j(u){ return fetch(u + '?t=' + Date.now()).then(function(r){ return r.ok ? r.json() : null; }).catch(function(){ return null; }); }
4843    function esc(t){ return String(t == null ? '' : t).replace(/[&<>"]/g, function(c){ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]; }); }
4844    function kk(n){ n = +n || 0; return n >= 1000 ? (n/1000).toFixed(n >= 10000 ? 0 : 1) + 'k' : String(n); }
4845    Promise.all([j('/today-data.json'), j('/projects-velocity.json'), j('/price-history.json'), j('/paid-models-data.json')]).then(function(R){
4846      var today = R[0], vel = R[1], prices = R[2], paid = R[3], tks = [];
4847      if (vel){
4848        var mv = [];
4849        for (var u in vel){ var m = String(u).match(/github\.com\/[^\/]+\/([^\/#?]+)/i); if (m) mv.push({ n: m[1], d1: +vel[u].d1 || 0 }); }
4850        mv.sort(function(a, b){ return b.d1 - a.d1; });
4851        mv.slice(0, 6).forEach(function(x){ tks.push('<span class="ot-up">&#9650;</span><b>' + esc(x.n) + '</b> +' + kk(x.d1) + ' &#9733; 24h'); });
4852      }
4853      if (prices && prices.models){
4854        var pc = [];
4855        for (var id in prices.models){
4856          var pm = prices.models[id], h = pm.history || [];
4857          if (h.length >= 2 && (h[h.length-1]['in'] !== h[h.length-2]['in'] || h[h.length-1].out !== h[h.length-2].out))
4858            pc.push({ n: pm.name, a: h[h.length-2]['in'], b2: h[h.length-1]['in'], d: h[h.length-1].d });
4859        }
4860        pc.sort(function(a, b){ return a.d < b.d ? 1 : -1; });
4861        pc.slice(0, 4).forEach(function(x){
4862          var dn = x.b2 < x.a;
4863          tks.push('<span class="' + (dn ? 'ot-up' : 'ot-down') + '">' + (dn ? '&#9660;' : '&#9650;') + '</span><b>' + esc(x.n) + '</b> $' + x.a + ' &rarr; $' + x.b2);
4864        });
4865      }
4866      if (today && today.models){ today.models.slice(0, 3).forEach(function(m){ tks.push('<span class="ot-new">&#9679;</span><b>' + esc(m.name) + '</b> ' + esc(m.provider)); }); }
4867      if (paid && paid.models){
4868        paid.models.filter(function(m){ return m.created && (Date.now()/1000 - m.created) < 14*86400; })
4869          .sort(function(a, b){ return b.created - a.created; }).slice(0, 3)
4870          .forEach(function(m){ tks.push('<span class="ot-new">&#9679;</span><b>' + esc(m.name) + '</b> ' + esc(m.provider) + ' &middot; paid'); });
4871      }
4872      /* zip open/paid dans les NEW : un lancement payant n'attend pas derrière tous les open */
4873      (function(){
4874        var po=[], pp=[];
4875        tks.forEach(function(t){ if(t.indexOf('NEW')>-1 || t.indexOf('ot-new')>-1){ (t.indexOf('paid')>-1?pp:po).push(t); } });
4876        if(pp.length&&po.length){
4877          var z=[], i2=0; while(po.length||pp.length){ if(po.length) z.push(po.shift()); if(pp.length) z.push(pp.shift()); }
4878          var zi=0; tks=tks.map(function(t){ return (t.indexOf('NEW')>-1||t.indexOf('ot-new')>-1)?z[zi++]:t; });
4879        }
4880      })();
4881      /* entrelacement : un NEW toutes les 2 positions → visibles dès les premières secondes */
4882      (function(){
4883        var news = [], rest = [];
4884        tks.forEach(function(t){ (t.indexOf('ot-new') > -1 ? news : rest).push(t); });
4885        if (news.length){
4886          var mixed = [];
4887          while (rest.length || news.length){
4888            if (rest.length) mixed.push(rest.shift());
4889            if (rest.length) mixed.push(rest.shift());
4890            if (news.length) mixed.push(news.shift());
4891          }
4892          tks = mixed;
4893        }
4894      })();
4895      if (tks.length < 4) return;   /* pas assez de matière → pas de bandeau */
4896      if (!document.getElementById('osaiTapeCSS')){
4897        var s = document.createElement('style'); s.id = 'osaiTapeCSS';
4898        s.textContent =
4899          '#osaiTape{margin:14px auto 4px;max-width:1180px;padding:0 22px;}' +
4900          '#osaiTape .ot-box{display:flex;align-items:stretch;border:1px solid rgba(128,128,150,.25);border-radius:10px;overflow:hidden;background:rgba(128,128,160,.05);}' +
4901          'body.dark #osaiTape .ot-box{background:rgba(255,255,255,.03);border-color:rgba(255,255,255,.1);}
4901' +
4902          '#osaiTape .ot-lb{display:flex;align-items:center;gap:7px;padding:8px 14px;font-family:"DM Mono",monospace;font-size:.66rem;font-weight:500;letter-spacing:.1em;white-space:nowrap;color:inherit;text-decoration:none;border-right:1px solid rgba(128,128,150,.25);}' +
4903          '#osaiTape .ot-lb b{color:#6366f1;font-weight:500;}' +
4904          'body.dark #osaiTape .ot-lb b{color:#818cf8;}' +
4905          '#osaiTape .ot-dot{width:7px;height:7px;border-radius:50%;background:#10b981;animation:otp 2s infinite;}' +
4906          '@keyframes otp{0%,100%{opacity:1}50%{opacity:.3}}' +
4907          '#osaiTape .ot-vp{flex:1;overflow:hidden;position:relative;}' +
4908          '#osaiTape .ot-tape{display:flex;width:max-content;animation:otk 55s linear infinite;}' +
4909          '#osaiTape .ot-vp:hover .ot-tape{animation-play-state:paused;}' +
4910          '@keyframes otk{from{transform:translateX(0)}to{transform:translateX(-50%)}}' +
4911          '#osaiTape .ot-i{display:inline-flex;align-items:center;gap:6px;padding:8px 18px;font-family:"DM Mono",monospace;font-size:.7rem;white-space:nowrap;color:inherit;text-decoration:none;opacity:.85;border-right:1px dashed rgba(128,128,150,.25);}' +
4912          '#osaiTape .ot-i:hover{opacity:1;}' +
4913          '#osaiTape .ot-i b{font-weight:500;}' +
4914          '#osaiTape .ot-up{color:#10b981;} body.dark #osaiTape .ot-up{color:#34d399;}' +
4915          '#osaiTape .ot-down{color:#ef4444;} body.dark #osaiTape .ot-down{color:#f87171;}' +
4916          '#osaiTape .ot-new{color:#6366f1;} body.dark #osaiTape .ot-new{color:#818cf8;}' +
4917          '@media(prefers-reduced-motion:reduce){#osaiTape .ot-tape{animation:none;flex-wrap:wrap;width:auto;} #osaiTape .ot-dot{animation:none;}}' +
4918          '@media(max-width:640px){#osaiTape .ot-lb span.ot-t{display:none;}}';
4919        document.head.appendChild(s);
4920      }
4921      var items = tks.map(function(t){ return '<a class="ot-i" href="/dashboard.html">' + t + '</a>'; }).join('');
4922      var wrap = document.createElement('div');
4923      wrap.id = 'osaiTape';
4924      wrap.innerHTML = '<div class="ot-box">'
4925        + '<a class="ot-lb" href="/dashboard.html"><span class="ot-dot"></span><b>olud.ai</b><span class="ot-t">TERMINAL</span></a>'   /* [02/08/2026] etait « OSAI » : l'ancien nom de marque, visible en clair */
4926        + '<div class="ot-vp"><div class="ot-tape">' + items + items + '</div></div></div>';
4927      anchor.insertAdjacentElement('afterend', wrap);
4928      if (typeof localizeAllLinks === 'function') localizeAllLinks(wrap);
4929      if (window.osaiTrack){ wrap.addEventListener('click', function(){ osaiTrack('home_tape_click', {}); }); }
4930    });
4931  }
4932  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', osaiHomeTape); else osaiHomeTape();
4933
4934
4935
4936  /* ═══ V21 — Harmonisation dark : fonds verts → .osai-mint (menthe + texte sombre), textes verts
4937     en dur → .osai-mint-txt, SVG fill/stroke verts ou blancs-sur-menthe corrigés, et re-passe
4938     débouncée (MutationObserver) pour le contenu rendu en JS après le chargement. ═══ */
4939  var OSAI_GREEN = 'rgb(13, 158, 112)', OSAI_MINT = 'rgb(61, 255, 181)';
4940  /* classes d'état : un fond vert porté par ces classes est transitoire → ne jamais figer */
4941  var OSAI_STATE = ['active','selected','checked','current','is-active','on','open'];
4942  function osaiIsStateful(el){ for (var i = 0; i < OSAI_STATE.length; i++){ if (el.classList.contains(OSAI_STATE[i])) return true; } return false; }
4943  function osaiIsWhite(v){ v = String(v||'').toLowerCase().replace(/\s/g,''); return v==='#fff'||v==='#ffffff'||v==='white'||v==='rgb(255,255,255)'; }
4944  function osaiIsGreen(v){ v = String(v||'').toLowerCase().replace(/\s/g,''); return v==='#6366f1'||v==='rgb(13,158,112)'; }
4945  /* flèches/icônes des boutons verts : blanc ou vert (attribut OU feuille de style) → currentColor */
4946  function osaiFixSvg(root){
4947    try {
4948      var nodes = root.querySelectorAll('svg, svg *');
4949      for (var i = 0; i < nodes.length; i++){
4950        var n = nodes[i], f = n.getAttribute('fill'), s = n.getAttribute('stroke');
4951        var cs = getComputedStyle(n);
4952        var fBad = osaiIsWhite(f) || osaiIsGreen(f) || ((f===null||f==='') && (osaiIsWhite(cs.fill)   || osaiIsGreen(cs.fill)));
4953        var sBad = osaiIsWhite(s) || osaiIsGreen(s) || ((s===null||s==='') && (osaiIsWhite(cs.stroke) || osaiIsGreen(cs.stroke)));
4954        if (fBad && String(n.style.fill).toLowerCase()   !== 'currentcolor') n.style.fill   = 'currentColor';
4955        if (sBad && String(n.style.stroke).toLowerCase() !== 'currentcolor') n.style.stroke = 'currentColor';
4956      }
4957    } catch(e){}
4958  }
4959  function osaiMintify(){
4960    try {
4961      /* 1) fonds vert foncé OU menthe → .osai-mint (couvre aussi le blanc-sur-menthe, ex. bouton remonter)
4962         ⚠️ jamais sur un fond TRANSITOIRE (état coché, survol) : la classe est permanente et
4963         figerait le bouton en "coché" (bug des filtres de /compare/). Ces cas sont couverts
4964         par le filet CSS global (--accent menthe en dark). */
4965      var els = document.querySelectorAll('a,button,input[type="submit"],[class*="btn"],[class*="cta"],[class*="badge"],[class*="pill"],[class*="top"],[class*="Top"],[class*="fab"],[class*="scroll"],[class*="Scroll"],[class*="up"],[class*="Up"],[id*="top"],[id*="Top"],[id*="scroll"],[id*="Scroll"]');
4966      for (var i = 0; i < els.length; i++){
4967        var el = els[i];
4968        if (el.classList.contains('osai-mint')){
4969          if (osaiIsStateful(el)) { el.classList.remove('osai-mint'); }
4969   // répare les boutons déjà figés
4970          else { osaiFixSvg(el); }
4971          continue;
4972        }
4973        if (osaiIsStateful(el)) continue;                                  // état coché/ouvert : ne pas figer
4974        try { if (el.matches(':hover')) continue; } catch(e){}             // survol : fond transitoire
4975        var bg = getComputedStyle(el).backgroundColor;
4976        if (bg === OSAI_GREEN || bg === OSAI_MINT){ el.classList.add('osai-mint'); osaiFixSvg(el); }
4977      }
4978      /* 2) textes vert foncé en dur → .osai-mint-txt (menthe en dark) */
4979      var tEls = document.querySelectorAll('a,button,span,b,strong,em,small,p,li,td,th,h1,h2,h3,h4,h5,h6,label,dt,dd');
4980      for (var j = 0; j < tEls.length; j++){
4981        var t = tEls[j];
4982        if (t.classList.contains('osai-mint-txt')) continue;
4983        if (getComputedStyle(t).color === OSAI_GREEN && !t.closest('.osai-mint')) t.classList.add('osai-mint-txt');
4984      }
4985      /* 3) SVG verts en dur hors boutons (icônes, courbes) → suivent le thème via --osai-green */
4986      var sv = document.querySelectorAll('svg[stroke="#6366f1"], svg [stroke="#6366f1"], svg[fill="#6366f1"], svg [fill="#6366f1"]');
4987      for (var k = 0; k < sv.length; k++){
4988        var n = sv[k];
4989        if (n.getAttribute('stroke') === '#6366f1' && n.style.stroke !== 'var(--osai-green)') n.style.stroke = 'var(--osai-green)';
4990        if (n.getAttribute('fill')   === '#6366f1' && n.style.fill   !== 'var(--osai-green)') n.style.fill   = 'var(--osai-green)';
4991      }
4992    } catch(e){}
4993  }
4994  function osaiMintifyRuns(){ osaiMintify(); setTimeout(osaiMintify, 1200); setTimeout(osaiMintify, 3200); }
4995  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', osaiMintifyRuns); else osaiMintifyRuns();
4996  /* contenu rendu en JS après coup (grilles, résultats de recherche…) : re-passe débouncée.
4997     childList uniquement → nos propres ajouts de classes/styles ne re-déclenchent pas l'observer. */
4998  try {
4999    var osaiMintT = null;
5000    new MutationObserver(function(){ clearTimeout(osaiMintT); osaiMintT = setTimeout(osaiMintify, 350); })
5001      .observe(document.body, { childList:true, subtree:true });
5002  } catch(e){}
5003})();
5004/* ═══ V27 — Palette ⌘K GLOBALE : fonctionne sur toutes les pages.
5005   Sur la home (qui a sa propre palette plus riche), ce module s'efface.
5006   Données chargées à la PREMIÈRE ouverture seulement (aucun coût au chargement). ═══ */
5007(function(){
5008  if (document.getElementById('cmdkOverlay')) return;   // la home garde la sienne
5009
5010  /* utilitaires de recherche partagés (les mêmes que la home) */
5011  window.__osaiSearchUtil = window.__osaiSearchUtil || (function(){
5012    function norm(s){ s=String(s||'').toLowerCase(); try{ s=s.normalize('NFD').replace(/[\u0300-\u036f]/g,''); }catch(e){} return s; }
5013    function hay(it){ return it._q || (it._q = norm((it.name||'')+' '+(it.desc||'')+' '+(it.tag||'')+' '+(it.extra||''))); }
5014    function tokens(qn){ var t=qn.split(/\s+/).filter(Boolean); if(t.length>1){ var b=t.filter(function(w){return w.length>2;}); if(b.length) t=b; } return t; }
5015    function match(it,tk){ var h=hay(it); for(var i=0;i<tk.length;i++){ if(h.indexOf(tk[i])<0) return false; } return true; }
5016    return {norm:norm, hay:hay, tokens:tokens, match:match};
5017  })();
5018
5019  /* modèles + alternatives : même promesse défensive que la home (créée ici si absente) */
5020  window.__searchExtrasPromise = window.__searchExtrasPromise || (function(){
5021    var norm = window.__osaiSearchUtil.norm;
5022    function jget(u){ return fetch(u+'?d='+new Date().toISOString().slice(0,10)).then(function(r){ return r.ok?r.json():null; }).catch(function(){ return null; }); }
5023    function asList(d,keys){ if(Array.isArray(d)) return d; if(d){ for(var i=0;i<keys.length;i++){ if(Array.isArray(d[keys[i]])) return d[keys[i]]; } } return []; }
5024    function cap(s){ s=String(s||''); return s?s.charAt(0).toUpperCase()+s.slice(1):s; }
5025    function modelSlug(name){ return String(name||'').toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,''); }
5026    function modelEntries(data,fallback,slugSet){
5027      var out=[];
5028      asList(data,['models','data','items']).forEach(function(m){
5029        if(!m) return;
5030        var id=String(m.id||m.slug||''), name=String(m.name||id); if(!name) return;
5031        var provider=String(m.provider||m.author||m.vendor||m.company||(id.indexOf('/')>0?id.split('/')[0]:''));
5032        var desc=String(m.desc||m.description||''); if(!desc) desc=provider?('AI model by '+cap(provider)):'AI model';
5033        /* route vers la FICHE du modèle si elle existe, sinon page générique */
5034        var sl=modelSlug(name), murl=(slugSet && slugSet[sl]) ? ('/model/'+sl+'.html') : fallback;
5035        out.push({name:name,desc:desc,tag:'Model',url:murl,type:'model',extra:(id+' '+provider)});
5036      });
5037      return out;
5038    }
5039    return Promise.all([jget('/models-data.json'), jget('/paid-models-data.json'), jget('/alternatives-index.json'), jget('/model-slugs.json')])
5040      .then(function(res){
5041        var extras=[], seen={}, slugSet=res[3]||{};
5042        modelEntries(res[0],'/compare/',slugSet).concat(modelEntries(res[1],'/pricing.html',slugSet)).forEach(function(e){
5043          var k=norm(e.name).replace(/^[a-z0-9 .\-]+:\s*/,''); if(!seen[k]){ seen[k]=1; extras.push(e); }
5044        });
5045        asList(res[2],['products','items']).forEach(function(p){
5046          if(p&&p.name&&p.url){ extras.push({name:String(p.name),desc:String(p.desc||p.domain||'Open-source alternatives'),tag:'Alternatives',url:String(p.url),type:'alt',extra:String(p.domain||'')}); }
5047        });
5048        return extras;
5049      });
5050  })();
5051
5052  var CMDK_CSS = ''
5053   +'.cmdk-overlay{position:fixed;inset:0;background:rgba(10,15,25,.5);backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);z-index:100000;display:none;align-items:flex-start;justify-content:center;padding-top:12vh;}'
5054   +'.cmdk-overlay.open{display:flex;}'
5055   +'.cmdk-box{width:90%;max-width:620px;background:var(--surface,#fff);border:1px solid var(--border2,rgba(0,0,0,.15));border-radius:16px;box-shadow:0 24px 60px rgba(0,0,0,.3);overflow:hidden;animation:cmdkIn .15s ease;}'
5056   +'@keyframes cmdkIn{from{opacity:0;transform:translateY(-8px) scale(.98);}to{opacity:1;transform:none;}}'
5057   +'.cmdk-input-row{display:flex;align-items:center;gap:12px;padding:16px 18px;border-bottom:1px solid var(--border,rgba(0,0,0,.08));}'
5058   +'.cmdk-search-icon{width:20px;height:20px;color:var(--muted,#5a6d87);flex-shrink:0;}'
5059   +'#cmdkInput{flex:1;border:none;background:none;font-family:inherit;font-size:1.05rem;color:var(--txt,#0f1724);outline:none;}'
5060   +'#cmdkInput::placeholder{color:var(--muted,#5a6d87);}'
5061   +'.cmdk-esc{font-family:monospace;font-size:.64rem;background:var(--surface2,#f7f9fd);border:1px solid var(--border2,rgba(0,0,0,.15));border-radius:5px;padding:2px 7px;color:var(--muted,#5a6d87);}'
5062   +'.cmdk-results{max-height:50vh;overflow-y:auto;padding:8px;}'
5063   +'.cmdk-group-label{font-size:.68rem;text-transform:uppercase;letter-spacing:.08em;color:var(--muted,#5a6d87);padding:10px 12px 6px;font-weight:600;}'
5064   +'.cmdk-item{display:flex;align-items:center;gap:12px;padding:10px 12px;border-radius:10px;cursor:pointer;text-decoration:none;color:var(--txt,#0f1724);}'
5065   +'.cmdk-item.active{background:#6366f1;color:#fff;}'
5066   +'body.dark .cmdk-item.active{background:#818cf8;color:#ffffff;}'
5067   +'.cmdk-item.active .cmdk-item-desc,.cmdk-item.active .cmdk-item-tag{color:rgba(255,255,255,.85);}'
5068   +'body.dark .cmdk-item.active .cmdk-item-desc,body.dark .cmdk-item.active .cmdk-item-tag{color:rgba(6,40,27,.8);}'
5069   +'.cmdk-item-icon{width:30px;height:30px;flex-shrink:0;border-radius:8px;display:flex;align-items:center;justify-content:center;font-weight:600;color:#fff;font-size:.95rem;overflow:hidden;position:relative;}'
5070   +'.cmdk-item-icon img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;}'
5071   +'.cmdk-item-info{min-width:0;flex:1;}'
5072   +'.cmdk-item-name{font-weight:600;font-size:.88rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}'
5073   +'.cmdk-item-desc{font-size:.76rem;color:var(--muted,#5a6d87);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}'
5074   +'.cmdk-item-tag{font-size:.66rem;color:var(--accent,#6366f1);flex-shrink:0;font-weight:500;}'
5075   +'.cmdk-empty{padding:32px;text-align:center;color:var(--muted,#5a6d87);font-size:.88rem;}'
5076   +'.cmdk-foot{display:flex;gap:18px;padding:10px 18px;border-top:1px solid var(--border,rgba(0,0,0,.08));}'
5077   +'.cmdk-foot span{font-size:.7rem;color:var(--muted,#5a6d87);display:flex;align-items:center;gap:5px;}'
5078   +'.cmdk-foot kbd{font-family:monospace;font-size:.62rem;background:var(--surface2,#f7f9fd);border:1px solid var(--border2,rgba(0,0,0,.15));border-radius:4px;padding:1px 5px;}'
5079   +'body.dark .cmdk-box{background:#161c28 !important;border-color:rgba(255,255,255,.14) !important;}'
5080   +'body.dark .cmdk-input-row,body.dark .cmdk-foot{border-color:rgba(255,255,255,.08) !important;}'
5081   +'@media(max-width:768px){.cmdk-foot{display:none;}}';
5082
5083  var navItems = [
5084    {type:'page', name:'Home',              desc:'Back to the homepage',                 tag:'Page', url:'/'},
5085    {type:'page', name:'Tools catalog',     desc:'Curated open-source AI tools',         tag:'Page', url:'/tools.html'},
5086    {type:'page', name:'Compare Models',    desc:'Side-by-side model comparison',        tag:'Page', url:'/compare.html'},
5087    {type:'page', name:'AI Pricing',        desc:'Live model pricing',                   tag:'Page', url:'/pricing.html'},
5088    {type:'page', name:'Paid Models',       desc:'Commercial model pricing',             tag:'Page', url:'/paid-models.html'},
5089    {type:'page', name:'Leaderboard',       desc:'Top models ranking',                   tag:'Page', url:'/leaderboard.html'},
5090    {type:'page', name:'Latest',            desc:'Newest AI projects',                   tag:'Page', url:'/latest.html'},
5091    {type:'page', name:'Industries',        desc:'AI by sector: healthcare, finance…',   tag:'Page', url:'/vertical/'},
5092    {type:'page', name:'Releases',          desc:'New tool versions daily',              tag:'Page', url:'/releases.html'},
5093    {type:'page', name:'Dashboards',        desc:'Open-source AI dashboard',             tag:'Page', url:'/dashboard.html'},
5094    {type:'page', name:'News',              desc:'Latest AI news',                       tag:'Page', url:'/news.html'},
5095    {type:'page', name:'Prompts',           desc:'Prompt library',                       tag:'Page', url:'/prompts.html'},
5096    {type:'page', name:'AI Agents',         desc:'Open-source AI agents',                tag:'Page', url:'/agents/'},
5097    {type:'page', name:'Projects',          desc:'Trending GitHub AI projects',          tag:'Page', url:'/projects/'},
5098    {type:'page', name:'Alternatives to top tools', desc:'Open-source replacements',     tag:'Page', url:'/alternatives/'},
5099    {type:'page', name:'Blog',              desc:'Articles and guides',                  tag:'Page', url:'/blog/'},
5100    {type:'page', name:'Submit a tool or project',     desc:'Suggest an AI tool or project',       tag:'Page', url:'/submit.html'},
5101    {type:'page', name:'About',             desc:'About this project',                   tag:'Page', url:'/about.html'},
5102    {type:'page', name:'Methodology',       desc:'How our data is collected',            tag:'Page', url:'/methodology.html'}
5103  ];
5104
5105  var built=false, overlay=null, input=null, results=null, activeIdx=0, currentList=[], cmdkIndex=null;
5106
5107  function iconColor(str){
5108    var h=0; for(var i=0;i<str.length;i++){ h=str.charCodeAt(i)+((h<<5)-h); }
5109    var c=['#6366f1','#0071e3','#e0245e','#9b59d9','#f24f00','#c98a00','#1a56db','#16a394'];
5110    return c[Math.abs(h)%c.length];
5111  }
5112  function realIcon(m){
5113    var initial=(m.name||'?').trim().charAt(0).toUpperCase()||'?';
5114    var img=''; var gh=(m.url||'').match(/github\.com\/([^\/]+)/);
5115    if(gh){ img='<img src="https://github.com/'+gh[1]+'.png?size=160" alt="" loading="lazy" onerror="this.remove();">'; }
5116    return '<div class="cmdk-item-icon" style="background:'+iconColor(m.name||'?')+';">'+initial+img+'</div>';
5117  }
5118  function esc(s){ return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
5119  function itemHtml(it){
5120    return '<a class="cmdk-item" href="'+esc(it.url)+'" data-url="'+esc(it.url)+'">'
5121      + realIcon(it)
5122      + '<div class="cmdk-item-info"><div class="cmdk-item-name">'+esc(it.name)+'</div>'
5123      + '<div class="cmdk-item-desc">'+esc(it.desc||'')+'</div></div>'
5124      + '<span class="cmdk-item-tag">'+esc(it.tag||'')+'</span></a>';
5125  }
5126
5127  /* [04/08/2026] LA RECHERCHE VOIT ENFIN TOUT, ET PESE DIX FOIS MOINS.
5128     Avant : projets + modeles + alternatives, au prix de projects-data.json
5129     entier (5 116 Ko a chaque ouverture de la palette). Les 232 outils, les
5130     700 serveurs MCP, les 277 skills et les pages du site etaient
5131     introuvables. Desormais un seul fichier compact, search-index.json
5132     (~600 Ko), fabrique par generate-search-index.php.
5133     Le chemin d'avant reste en secours : si l'index manque — pas encore
5134     genere, erreur reseau — la palette retombe sur l'ancienne methode
5135     plutot que de ne rien trouver. */
5136  var TYPES_IDX = { p:'Project', t:'Tool', m:'Model', c:'MCP server',
5137                    s:'Skill', a:'Alternatives', v:'Compare', g:'Page' };
5138  function loadIndexCompact(){
5139    return fetch('/search-index.json?d=' + new Date().toISOString().slice(0,10))
5140      .then(function(r){ return r.ok ? r.json() : null; })
5141      .catch(function(){ return null; })
5142      .then(function(j){
5143        var L = j && j.e;
5144        if (!L || !L.length) return false;
5145        for (var i = 0; i < L.length; i++) {
5146          var it = L[i];
5147          cmdkIndex.push({ type: it[2], name: it[0], desc: it[3] || '',
5148                           tag: TYPES_IDX[it[2]] || 'Page', url: it[1] });
5149        }
5150        if (input && input.value) renderCmdk(input.value);
5151        return true;
5152      });
5153  }
5154
5155  function loadIndex(){
5156    if(cmdkIndex) return;
5157    cmdkIndex=[];
5158    /* l'index compact d'abord ; l'ancien chemin seulement s'il manque */
5159    loadIndexCompact().then(function(ok){ if (!ok) loadIndexAncien(); });
5160  }
5161
5162  function loadIndexAncien(){
5163    try{
5164      window.__searchExtrasPromise.then(function(extras){ extras.forEach(function(e){ cmdkIndex.push(e); }); if(input&&input.value) renderCmdk(input.value); }).catch(function(){});
5165    }catch(e){}
5166    try{
5167      (window.__projectsPromise || (window.__projectsPromise = fetch('/projects-data.json?d='+new Date().toISOString().slice(0,10)).then(function(r){ if(!r.ok) throw 0; return r.json(); })))
5168      .then(function(data){
5169        var list=(data&&data.projects)?data.projects:[]; if(!list.length) return;
5170        /* [29/07] Le .slice(0,1500) qui etait ici rendait 85 % du catalogue
5171           INTROUVABLE : il fallait plus de 6 426 etoiles pour exister dans la
5172           recherche. SeekStorm (rang 3818) et neuron-ai (rang 3663) etaient
5173           invisibles. Le plafond n'economisait rien : le JSON entier est deja
5174           telecharge, seule la boucle de filtrage grandit — quelques
5175           millisecondes sur 10 000 entrees. Tri conserve : les plus etoiles
5176           restent en tete des resultats. */
5177        list.slice().sort(function(a,b){ return (+b.stars||0)-(+a.stars||0); }).forEach(function(p){
5178          if(p&&p.name&&p.url){ cmdkIndex.push({type:'project',name:p.name,desc:p.desc||'',tag:(p.tags&&p.tags[0])||'Project',url:p.url}); }
5179        });
5180        if(input&&input.value) renderCmdk(input.value);
5181      }).catch(function(){});
5182    }catch(e){}
5183  }
5184
5185  /* Les liens affiches pointent l'URL du catalogue (GitHub). On les reecrit
5186     vers nos fiches une fois la liste rendue : le survol, le clic-milieu et
5187     « ouvrir dans un nouvel onglet » restent chez nous, pas seulement le
5188     clic simple. Verification HEAD memoisee, au plus 20 liens visibles. */
5189  function ramenerLesLiens(){
5190    if (!window.osaiFicheForAsync || !results) return;
5191    [].slice.call(results.querySelectorAll('.cmdk-item[data-url*="github.com/"]')).forEach(function(a){
5192      var u = a.getAttribute('data-url');
5193      window.osaiFicheForAsync(u).then(function(f){
5194        if (f && f !== u) { a.setAttribute('href', f); a.setAttribute('data-url', f); }
5195      });
5196    });
5197  }
5198
5199  function renderCmdk(query){
5200    var q=(query||'').trim().toLowerCase(), html='';
5201    currentList=[];
5202    if(!q){
5203      html+='<div class="cmdk-group-label">Pages</div>';
5204      navItems.forEach(function(it){ currentList.push(it); });
5205      html+=navItems.map(itemHtml).join('');
5206    } else {
5207      var U=window.__osaiSearchUtil, qn=U.norm(q), toks=U.tokens(qn);
5208      var pages=navItems.filter(function(it){ return U.norm(it.name).indexOf(qn)>=0; });
5209      /* [04/08] CLASSEMENT PAR PERTINENCE. Avant, on prenait les 20 premiers
5210         de l'index dans l'ordre d'insertion : les 4 000 projets saturaient la
5211         liste et les outils, modeles ou serveurs MCP n'apparaissaient jamais.
5212         « ollama » remontait quatre projets et pas la fiche outil. On note
5213         desormais chaque resultat — nom exact, debut de nom, debut de mot,
5214         contenu — et on garde les 20 MEILLEURS, pas les 20 premiers. */
5215      var scoreCmdk = function(it){
5216        var nom = U.norm(it.name || '');
5217        var sc = 0;
5218        if (nom === qn) sc = 1000;
5219        else if (nom.indexOf(qn) === 0) sc = 600;
5220        else if ((' ' + nom).indexOf(' ' + qn) >= 0) sc = 400;   /* debut d'un mot */
5221        else if (nom.indexOf(qn) >= 0) sc = 200;
5222        else sc = 40;                                            /* trouve via desc */
5223        /* a pertinence egale, on montre d'abord ce qui est cure : une page du
5224           site, un outil du catalogue ou un modele plutot qu'un depot brut */
5225        var bonus = { g: 30, t: 25, m: 20, a: 15, c: 10, s: 10, p: 0 };
5226        return sc + (bonus[it.type] || 0);
5227      };
5228      var tools = [];
5229      if (cmdkIndex) {
5230        tools = cmdkIndex.filter(function(it){ return U.match(it, toks); });
5231        tools.forEach(function(it){ it._sc = scoreCmdk(it); });
5232        tools.sort(function(a, b){ return b._sc - a._sc; });
5233        tools = tools.slice(0, 20);
5234      }
5235      /* [01/08] LA PORTE VERS L'ASSISTANT. La recherche texte ne comprend pas
5236         « un modele pour transcrire du francais sur 16 Go » : elle cherche ces
5237         mots-la. Des que la saisie ressemble a une question (plusieurs mots,
5238         ou peu de resultats), on propose en TETE de la poser a l'assistant,
5239         qui lui repond avec nos donnees. C'est la seule facon honnete de
5240         relier la recherche a l'IA sans pretendre que la recherche EST une IA. */
5241      var TT = function(x){ return (typeof window.osaiT === 'function') ? window.osaiT(x) : x; };
5242      var motsQ  = q.split(/\s+/).filter(Boolean).length;
5243      var maigre = (tools.length + pages.length) < 3;
5244      var question = /[?\uFF1F]/.test(String(query || ''));
5245      var ligneIA = '';
5246      if (question || motsQ >= 3 || maigre) {
5247        ligneIA = '<div class="cmdk-group-label">' + TT('Ask the assistant') + '</div>'
5248                + '<a class="cmdk-item cmdk-ia" href="#" data-ia-q="' + esc(query) + '">'
5249                + '<span class="cmdk-ia-ic">\u2728</span>'
5250                + '<div class="cmdk-item-info"><div class="cmdk-item-name">' + esc(query) + '</div>'
5251                + '<div class="cmdk-item-desc">' + TT('Answered with this site\'s live data') + '</div></div>'
5252                + '<span class="cmdk-item-tag">AI</span></a>';
5253      }
5254      if(pages.length){ html+='<div class="cmdk-group-label">Pages</div>'; pages.forEach(function(it){ currentList.push(it); }); html+=pages.map(itemHtml).join(''); }
5255      if(tools.length){ html+='<div class="cmdk-group-label">Tools &amp; Projects</div>'; tools.forEach(function(it){ currentList.push(it); }); html+=tools.map(itemHtml).join(''); }
5256      html += ligneIA;                       /* apres les resultats, pas avant */
5257      /* Zero resultat texte = le moment ou l'assistant sert le PLUS.
5258         On garde donc la ligne IA au lieu de tout ecraser. */
5259      if(!pages.length&&!tools.length){
5260        html = '<div class="cmdk-empty">' + TT('No text match') + ' \u2014 "' + esc(query) + '"</div>' + ligneIA;
5261      }
5262    }
5263    results.innerHTML=html; activeIdx=0; highlight();
5264    ramenerLesLiens();
5265  }
5266  function highlight(){
5267    var items=results.querySelectorAll('.cmdk-item');
5268    for(var i=0;i<items.length;i++){ items[i].classList.toggle('active', i===activeIdx); }
5269    var act=items[activeIdx]; if(act&&act.scrollIntoView) act.scrollIntoView({block:'nearest'});
5270  }
5271  function go(el){
5272    if(!el) return;
5273    var iaQ = el.getAttribute('data-ia-q');
5274    if (iaQ) {
5275      if (window.osaiAskInto) { window.osaiAskInto(iaQ, results); }
5276      else { closeGlobalCmdk(); if (window.osaiAsk) window.osaiAsk(iaQ); }
5277      return;
5278    }
5279    var url=el.getAttribute('data-url'); closeGlobalCmdk();
5280    /* [31/07, Romain] « chercher compass skills m'a amene sur GitHub » :
5281       l'index de recherche pousse l'URL du catalogue, qui EST l'adresse
5282       GitHub du depot. On envoyait donc nos visiteurs chez GitHub depuis
5283       notre propre moteur. Le meme resolveur que le partage ramene la fiche
5284       olud.ai quand elle existe (verification HEAD memoisee) ; sinon on
5285       garde l'adresse d'origine plutot qu'un lien mort. */
5286    if (window.osaiFicheForAsync) {
5287      window.osaiFicheForAsync(url).then(function(u){ window.location.href = u || url; });
5288    } else {
5289      window.location.href = url;
5290    }
5291  }
5292  function buildOnce(){
5293    if(built) return; built=true;
5294    var st=document.createElement('style'); st.textContent=CMDK_CSS; document.head.appendChild(st);
5295    var d=document.createElement('div');
5296    d.className='cmdk-overlay'; d.id='cmdkOverlay';
5297    d.innerHTML=''
5298      +'<div class="cmdk-box" role="dialog" aria-modal="true" aria-label="Search">'
5299      +'<div class="cmdk-input-row">'
5300      +'<svg class="cmdk-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>'
5301      +'<input type="text" id="cmdkInput" placeholder="Search tools, projects, models&hellip;" autocomplete="off" spellcheck="false">'
5302      +'<kbd class="cmdk-esc">ESC</kbd>'
5303      +'</div>'
5304      +'<div class="cmdk-results" id="cmdkResults"></div>'
5305      +'<div class="cmdk-foot"><span><kbd>&#8593;</kbd><kbd>&#8595;</kbd> to navigate</span><span><kbd>&#8629;</kbd> to open</span><span><kbd>esc</kbd> to close</span></div>'
5306      +'</div>';
5307    d.addEventListener('click', function(ev){ if(ev.target===d) closeGlobalCmdk(); });
5308    document.body.appendChild(d);
5309    overlay=d; input=d.querySelector('#cmdkInput'); results=d.querySelector('#cmdkResults');
5310    input.addEventListener('input', function(){ renderCmdk(input.value); });
5311    input.addEventListener('keydown', function(e){
5312      var items=results.querySelectorAll('.cmdk-item');
5313      if(e.key==='ArrowDown'){ e.preventDefault(); activeIdx=Math.min(activeIdx+1, items.length-1); highlight(); }
5314      else if(e.key==='ArrowUp'){ e.preventDefault(); activeIdx=Math.max(activeIdx-1, 0); highlight(); }
5315      else if(e.key==='Enter'){ e.preventDefault(); go(items[activeIdx]); }
5316    });
5317    results.addEventListener('click', function(ev){
5318      var a=ev.target && ev.target.closest ? ev.target.closest('.cmdk-item') : null;
5319      if(a){ ev.preventDefault(); go(a); }
5320    });
5321  }
5322  function closeGlobalCmdk(){ if(overlay) overlay.classList.remove('open'); }
5323
5324  window.openCmdK = function(){
5325    buildOnce(); loadIndex();
5326    overlay.classList.add('open');
5327    input.value=''; renderCmdk(''); 
5328    try{ input.focus(); }catch(e){}
5329  };
5330  window.closeCmdK = window.closeCmdK || closeGlobalCmdk;
5331
5332  document.addEventListener('keydown', function(e){
5333    if((e.metaKey||e.ctrlKey) && (e.key==='k'||e.key==='K')){
5334      e.preventDefault();
5335      if(overlay && overlay.classList.contains('open')) closeGlobalCmdk(); else window.openCmdK();
5336    } else if(e.key==='Escape' && overlay && overlay.classList.contains('open')){ closeGlobalCmdk(); }
5337  });
5338})();
5339
5340
5341/* ═══════════════════════════════════════════════════════════════════════════
5342   PARTAGE UNIVERSEL — un seul système pour tout le site.
5343
5344   Fini Facebook et LinkedIn (que personne n'utilise pour partager un outil dev).
5345   Les réseaux qui comptent pour ce public : X, Bluesky, Mastodon, WhatsApp,
5346   Reddit — et surtout « Copier le lien », de loin le plus utilisé.
5347
5348   USAGE — n'importe quelle carte du site devient partageable avec ça :
5349     <button class="osai-share" data-url="/releases.html#x" data-text="Ollama v0.31 is out">…</button>
5350   ou en JS :  osaiShare(url, text)
5351
5352   Les éléments ajoutés dynamiquement (cartes rendues en JS) sont pris en charge :
5353   on écoute au niveau du document, pas sur chaque bouton.
5354   ═══════════════════════════════════════════════════════════════════════════ */
5355(function(){
5356  if (window.__osaiShareReady) return;
5357  window.__osaiShareReady = true;
5358
5359  var NETS = [
5360    {k:'x',        label:'X',            ico:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>',
5361     url:function(u,t){ return 'https://x.com/intent/post?text=' + encodeURIComponent(t) + '&url=' + encodeURIComponent(u); }},
5362    {k:'bluesky',  label:'Bluesky',      ico:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 
53620 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8z"/></svg>',
5363     url:function(u,t){ return 'https://bsky.app/intent/compose?text=' + encodeURIComponent(t + ' ' + u); }},
5364    {k:'mastodon', label:'Mastodon',     ico:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.066-.051 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545z"/></svg>',
5365     url:function(u,t){ return 'https://mastodonshare.com/?text=' + encodeURIComponent(t) + '&url=' + encodeURIComponent(u); }},
5366    {k:'reddit',   label:'Reddit',       ico:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-6.995 4.87-3.865 0-6.994-2.176-6.994-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z"/></svg>',
5367     url:function(u,t){ return 'https://www.reddit.com/submit?url=' + encodeURIComponent(u) + '&title=' + encodeURIComponent(t); }},
5368    {k:'whatsapp', label:'WhatsApp',     ico:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 0 1-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 0 1-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 0 1 2.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0 0 12.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 0 0 5.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893A11.821 11.821 0 0 0 20.465 3.488"/></svg>',
5369     url:function(u,t){ return 'https://wa.me/?text=' + encodeURIComponent(t + ' ' + u); }}
5370  ];
5371
5372  /* CSS injecté une fois */
5373  var st = document.createElement('style');
5374  st.textContent = ''
5375    + '.osai-sheet{position:fixed;inset:0;z-index:2147483640;display:none;align-items:center;justify-content:center;background:rgba(0,0,0,.55);backdrop-filter:blur(3px)}'
5376    + '.osai-sheet.open{display:flex}'
5377    + '.osai-sheet-in{background:var(--surface,#fff);border:1px solid var(--border2,rgba(0,0,0,.1));border-radius:18px;padding:22px;width:min(360px,92vw);box-shadow:0 20px 60px rgba(0,0,0,.35)}'
5378    + '.osai-sheet h4{margin:0 0 4px;font-family:Poppins,sans-serif;font-size:1rem;font-weight:700;color:var(--txt,#111)}'
5379    + '.osai-sheet p{margin:0 0 16px;font-size:.8rem;color:var(--muted,#667);word-break:break-all;line-height:1.4}'
5380    + '.osai-nets{display:grid;grid-template-columns:repeat(auto-fit,minmax(92px,1fr));gap:8px}'
5381    + '.osai-net{display:flex;flex-direction:column;align-items:center;gap:6px;background:var(--surface2,#f6f7f9);border:1px solid var(--border2,rgba(0,0,0,.08));border-radius:12px;padding:12px 8px;cursor:pointer;text-decoration:none;color:var(--txt,#111);font-family:Poppins,sans-serif;font-size:.72rem;font-weight:600;transition:border-color .15s,background .15s}'
5382    + '.osai-net:hover{border-color:var(--accent,#6366f1);color:var(--accent,#6366f1)}'
5383    + '.osai-net svg{width:20px;height:20px}'
5384    + '.osai-copy{grid-column:1/-1;background:var(--accent,#6366f1);border-color:var(--accent,#6366f1);color:#fff;flex-direction:row;gap:8px;padding:12px}'
5385    + '.osai-copy:hover{color:#fff;opacity:.9}'
5386    + '.osai-close{width:100%;margin-top:12px;background:none;border:none;color:var(--muted,#667);font-family:Poppins,sans-serif;font-size:.78rem;cursor:pointer;padding:8px}'
5387    + '.osai-close:hover{color:var(--txt,#111)}'
5388    /* le bouton lui-même, réutilisable partout */
5389    + '.osai-share{display:inline-flex;align-items:center;justify-content:center;gap:5px;background:none;border:none;cursor:pointer;color:var(--muted,#888);padding:4px 6px;border-radius:6px;font-size:.72rem;font-family:inherit;transition:color .15s,background .15s}'
5390    + '.osai-share:hover{color:var(--accent,#6366f1);background:var(--card-hover,rgba(99,102,241,.06))}'
5391    + '.osai-share svg{width:14px;
5391height:14px}';
5392  document.head.appendChild(st);
5393
5394  var sheet = null;
5395  function build(){
5396    sheet = document.createElement('div');
5397    sheet.className = 'osai-sheet';
5398    sheet.innerHTML = '<div class="osai-sheet-in" role="dialog" aria-label="Share">'
5399      + '<h4>Share</h4><p id="osaiShareUrl"></p><div class="osai-nets" id="osaiNets"></div>'
5400      + '<button class="osai-close">Close</button></div>';
5401    document.body.appendChild(sheet);
5402    sheet.addEventListener('click', function(e){ if(e.target === sheet || e.target.classList.contains('osai-close')) close(); });
5403    document.addEventListener('keydown', function(e){ if(e.key === 'Escape') close(); });
5404  }
5405  function close(){ if(sheet) sheet.classList.remove('open'); }
5406
5407  window.osaiShare = function(url, text){
5408    url  = url  || location.href;
5409    text = text || document.title;
5410    if(url.charAt(0) === '/') url = location.origin + url;
5411    /* [04/08] un partage de plus pour cette page — anonyme, comme les vues */
5412    try {
5413      var pu = '/api/pv.php?ev=share&p=' + encodeURIComponent(location.pathname || '/');
5414      if (navigator.sendBeacon) navigator.sendBeacon(pu);
5415    } catch(e){}
5416
5417    /* mobile : on utilise le partage natif du système, bien meilleur */
5418    if(navigator.share && /Android|iPhone|iPad/i.test(navigator.userAgent)){
5419      navigator.share({title: text, text: text, url: url}).catch(function(){});
5420      if(window.osaiTrack) osaiTrack('share', {net:'native'});
5421      return;
5422    }
5423    if(!sheet) build();
5424    sheet.querySelector('#osaiShareUrl').textContent = url;
5425    var box = sheet.querySelector('#osaiNets');
5426    box.innerHTML = NETS.map(function(n){
5427      return '<a class="osai-net" href="' + n.url(url, text) + '" target="_blank" rel="noopener" data-n="' + n.k + '">'
5428           + n.ico + '<span>' + n.label + '</span></a>';
5429    }).join('')
5430    + '<button class="osai-net osai-copy" id="osaiCopy">'
5431    + '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>'
5432    + '<span>Copy link</span></button>';
5433
5434    box.querySelectorAll('.osai-net[data-n]').forEach(function(a){
5435      a.addEventListener('click', function(){ if(window.osaiTrack) osaiTrack('share', {net:a.getAttribute('data-n')}); close(); });
5436    });
5437    var cp = box.querySelector('#osaiCopy');
5438    cp.addEventListener('click', function(){
5439      var done = function(){
5440        var sp = cp.querySelector('span'); sp.textContent = 'Copied!';
5441        setTimeout(function(){ sp.textContent = 'Copy link'; close(); }, 900);
5442      };
5443      if(navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(url).then(done).catch(function(){});
5444      if(window.osaiTrack) osaiTrack('share', {net:'copy'});
5445    });
5446    sheet.classList.add('open');
5447  };
5448
5449  /* ═══ V87 — bouton compte : pastille rouge/verte + mini-menu ═══ */
5450  (function(){
5451    var T = function(s){ return (typeof window.osaiT === 'function') ? window.osaiT(s) : s; };
5452    function lang(){ return (location.pathname.match(/^\/(fr|it|es|de|pt)\//) || [, ''])[1]; }
5453    function lp(u){ var l = lang(); return (l ? '/' + l : '') + u; }
5454    function logged(){ return /(?:^|;\s*)osai_m=1(?:;|$)/.test(document.cookie); }
5455
5456    var IC = {
5457      user: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>',
5458      grid: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>',
5459      card: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M2 10h20"/></svg>',
5460      out:  '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="M16 17l5-5-5-5"/>
5460<path d="M21 12H9"/></svg>'
5461    };
5462
5463    function closeMenu(){ var m = document.getElementById('acctMenu'); if (m) m.classList.remove('open'); }
5464
5465    function buildMenu(me){
5466      var m = document.getElementById('acctMenu');
5467      if (!m) return;
5468      var html = '';
5469      if (me && me.email) html += '<div class="who">' + me.email.replace(/[<>&]/g, '') + '</div>';
5470      html += '<a href="' + lp('/account.html') + '">' + IC.card + '<span>' + T('Your account') + '</span></a>';
5471      html += '<a href="' + lp('/my-dashboard.html') + '">' + IC.grid + '<span>' + T('My Dashboard') + '</span></a>';
5472      if (me && me.handle && Number(me.pf_public)) {
5473        html += '<a href="/u/' + encodeURIComponent(me.handle) + '">' + IC.user + '<span>' + T('My public page') + '</span></a>';
5474      }
5475      html += '<button type="button" class="out" id="acctOut">' + IC.out + '<span>' + T('Sign out') + '</span></button>';
5476      m.innerHTML = html;
5477      var out = document.getElementById('acctOut');
5478      if (out) out.onclick = function(){
5479        fetch('/api/logout.php', { method: 'POST' }).catch(function(){})
5480        .then(function(){
5481          try { document.cookie = 'osai_m=; Max-Age=0; path=/'; } catch(e){}
5482          location.reload();
5483        });
5484      };
5485    }
5486
5487    function paint(){
5488      var b = document.getElementById('acctNavBtn');
5489      if (!b) return;
5490      var inn = logged();
5491      b.setAttribute('href', lp('/account.html'));
5492      b.classList.toggle('is-in', inn);
5493      b.setAttribute('title', T(inn ? 'Your account' : 'Sign in / create account'));
5494      b.setAttribute('aria-label', b.getAttribute('title'));
5495
5496      b.onclick = function(e){
5497        if (!logged()) return;                    /* déconnecté : lien direct vers la page compte */
5498        e.preventDefault();
5499        e.stopPropagation();
5500        var m = document.getElementById('acctMenu');
5501        if (!m) return;
5502        if (m.classList.contains('open')) { closeMenu(); return; }
5503        buildMenu(null);                          /* ouverture immédiate */
5504        m.classList.add('open');
5505        fetch('/api/me.php').then(function(r){ return r.ok ? r.json() : null; }).catch(function(){ return null; })
5506        .then(function(me){ if (me && document.getElementById('acctMenu').classList.contains('open')) buildMenu(me); });
5507      };
5508    }
5509
5510    document.addEventListener('click', function(e){
5511      var m = document.getElementById('acctMenu');
5512      if (m && m.classList.contains('open') && !e.target.closest('.acct-wrap')) closeMenu();
5513    });
5514    document.addEventListener('keydown', function(e){ if (e.key === 'Escape') closeMenu(); });
5515
5516    paint();
5517    document.addEventListener('DOMContentLoaded', paint);
5518    window.addEventListener('load', paint);
5519  })();
5520
5521  /* Écoute globale : marche AUSSI pour les cartes créées dynamiquement en JS */
5522  document.addEventListener('click', function(e){
5523    var b = e.target.closest('.osai-share, [data-share]');
5524    if(!b) return;
5525    e.preventDefault();
5526    e.stopPropagation();
5527    var u0 = b.getAttribute('data-url') || b.getAttribute('data-share') || '';
5528    var t0 = b.getAttribute('data-text') || '';
5529    window.osaiFicheForAsync(u0).then(function(u){ osaiShare(u, t0); });
5530  });
5531
5532  /* ── partage vers NOS fiches : une URL GitHub devient la fiche projet
5533     quand elle existe. Resolution AU CLIC (1 requete HEAD par partage
5534     reel, memoisee) — pas de gros mapping charge sur chaque page.
5535     [31/07] osaiFicheFor n'a jamais ete defini nulle part : le repointage
5536     promis par shareUrl()/refreshShares() ne fonctionnait sur AUCUNE page. */
5537  var _ficheCache = {};
5538  window.osaiFicheForAsync = function(url){
5539    var m = String(url || '').match(/^https?:\/\/(?:www\.)?github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?(?:[\/#?].*)?$/);
5540    if (!m) return Promise.resolve(url);
5541    var slug = (m[1] + '-' + m[2]).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
5542    if (_ficheCache[slug] !== undefined) return Promise.resolve(_ficheCache[slug] || url);
5543    var p = '/project/' + slug + '.html';
5544    return fetch(p, { method: 'HEAD' })
5545      .then(function(r){ _ficheCache[slug] = r.ok ? (location.origin + p) : ''; return _ficheCache[slug] || url; })
5546      .catch(function(){ return url; });
5547  };
5548
5549  /* Icône prête à l'emploi pour construire un bouton */
5550  window.osaiShareIcon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>';
5551})();
5552
5553
5554/* ═══════════════════════════════════════════════════════════════════════════
5555   UNIFICATION DU PARTAGE — on écrase les anciennes fonctions de chaque page.
5556
5557   Le site avait accumulé une dizaine de systèmes de partage différents
5558   (doShare, doShareTool, shareProject, lbShareToggle, tlDoShare, shareOn…),
5559   chacun avec son petit menu maison et ses réseaux (Facebook, LinkedIn…).
5560   Résultat : un comportement différent sur chaque page.
5561
5562   Ce script se charge APRÈS les scripts des pages, donc ces redéfinitions
5563   gagnent. Toutes ces fonctions ouvrent désormais LA MÊME feuille :
5564   X · Bluesky · Mastodon · Reddit · WhatsApp · Copy link.
5565   ═══════════════════════════════════════════════════════════════════════════ */
5566(function(){
5567  function txt(name){
5568    return name ? ('Check out ' + name + ' \u2014 found on olud.ai')
5569                : (document.title || 'olud.ai');
5570  }
5571  function go(url, name){
5572    if(!window.osaiShare) return false;
5573    var u0 = url || location.href;
5574    if (window.osaiFicheForAsync) window.osaiFicheForAsync(u0).then(function(u){ osaiShare(u, txt(name)); });
5575    else osaiShare(u0, txt(name));
5576    return true;
5577  }
5578
5579  function unify(){
5580    if(!window.osaiShare) return;
5581
5582    /* ── Les OUVREURS (ils affichaient un petit menu) → feuille universelle ── */
5583    /* signature (e, name, url) */
5584    ['shareProject','shareSpace','shareTool','shareModel','shareRelease'].forEach(function(fn){
5585      window[fn] = function(e, name, url){
5586        if(e && e.preventDefault) e.preventDefault();
5587        if(e && e.stopPropagation) e.stopPropagation();
5588        go(url, name);
5589      };
5590    });
5591    /* signature (e, name, url, kind) */
5592    window._openShareMenu = function(e, name, url){
5593      if(e && e.preventDefault) e.preventDefault();
5594      if(e && e.stopPropagation) e.stopPropagation();
5595      go(url, name);
5596    };
5597    /* toggles sans url : on partage la page courante */
5598    ['lbShareToggle','tlShareToggle','shareToggle','_shareToggle'].forEach(function(fn){
5599      window[fn] = function(e){
5600        if(e && e.preventDefault) e.preventDefault();
5601        if(e && e.stopPropagation) e.stopPropagation();
5602        go(location.href, null);
5603      };
5604    });
5605
5606    /* ── Les EXÉCUTEURS (ils envoyaient direct sur un réseau) → feuille aussi ──
5607       On ignore le réseau demandé : l'utilisateur choisit dans la feuille. */
5608    /* signature (network, name, url) */
5609    ['doShare','doShareSpace','doShareTool','doShareModel','tlDoShare'].forEach(function(fn){
5610      window[fn] = function(network, name, url){ go(url, name); };
5611    });
5612    /* signature (net) — l'url était stockée ailleurs → page courante */
5613    ['_doGlobalShare','lbDoShare','shareOn'].forEach(function(fn){
5614      window[fn] = function(){ go(location.href, null); };
5615    });
5616  }
5617
5618  unify();                                        /* maintenant */
5619  document.addEventListener('DOMContentLoaded', unify);   /* et après le DOM, au cas où */
5620  window.addEventListener('load', unify);                 /* et après tout, au cas où */
5621
5622  /* ═══ V81 — ADOPTION GÉNÉRIQUE DES MENUS HÉRITÉS ═══
5623     L'absorption par NOM de fonction (ci-dessus) rate toutes les pages dont la
5624     fonction s'appelle autrement (tools.html, fiches /project/ avec
5625     ficheShareToggle…) : elles gardent leur vieux menu « X + copier ».
5626     Ici on intercepte au niveau du DOCUMENT, en phase de CAPTURE : le clic
5627     n'atteint jamais le handler inline de la page, quel que soit son nom.
5628     Marche aussi pour les cartes rendues en JS (aucun observateur requis). */
5629
5630  /* on ne montre plus jamais un menu local : la feuille universelle le remplace */
5631  var hide = document.createElement('style');
5632  hide.textContent = '[class*="share-menu"]{display:none !important}';
5633  document.head.appendChild(hide);
5634
5635  /* extrait les arguments entre apostrophes d'un onclick hérité */
5636  function argsOf(el){
5637    var oc = (el && el.getAttribute) ? (el.getAttribute('onclick') || '') : '';
5638    var out = [], re = /'((?:[^'\\]|\\.)*)'/g, m;
5639    while((m = re.exec(oc))) out.push(m[1].replace(/\\'/g, "'"));
5640    return out;
5641  }
5642  var NETW = /^(twitter|x|bluesky|mastodon|reddit|whatsapp|facebook|linkedin|telegram|copy|email)$/i;
5643  function pick(args){
5644    var url = null, name = null;
5645    for (var i = 0; i < args.length; i++){
5646      var a = args[i];
5647      if (/^(https?:\/\/|\/)/.test(a)) { if (!url) url = a; }
5648      else if (a && !NETW.test(a) && !name) name = a;
5649    }
5650    return { url: url, name: name };
5651  }
5652  /* l'ouvreur ne porte pas toujours l'URL (ex. ficheShareToggle(event)) :
5653     on va la chercher dans les boutons du menu qu'il commande. */
5654  function itemOf(el){
5655    var got = pick(argsOf(el));
5656    if (got.url) return got;
5657    var wrap = (el.closest && (el.closest('[class*="share-wrap"]') || el.parentElement)) || null;
5658    var menu = wrap ? wrap.querySelector('[class*="share-menu"]') : null;
5659    if (menu){
5660      var btns = menu.querySelectorAll('button, a');
5661      for (var i = 0; i < btns.length; i++){
5662        var g = pick(argsOf(btns[i]));
5663        if (g.url) return { url: g.url, name: g.name || got.name };
5664      }
5665    }
5666    return got;   /* rien trouvé → page courante */
5667  }
5668
5669  document.addEventListener('click', function(e){
5670    if (!window.osaiShare || !e.target || !e.target.closest) return;
5671    var t = e.target;
5672    if (t.closest('.osai-sheet')) return;                 /* la feuille elle-même */
5673    if (t.closest('.osai-share, [data-share]')) return;   /* déjà géré proprement */
5674
5675    /* un clic sur un ouvreur hérité, ou dans un menu hérité resté ouvert */
5676    var hit = t.closest('button[class*="share"], a[class*="share"], [class*="share-menu"] button, [class*="share-menu"] a');
5677    if (!hit) return;
5678
5679    e.preventDefault();
5680    e.stopPropagation();                                  /* le handler inline ne verra rien */
5681
5682    var it = itemOf(hit.closest('[class*="share-menu"]') ? hit : hit);
5683    if (!it.url){                                          /* menu ouvert : chercher dans le menu */
5684      var mn = hit.closest('[class*="share-menu"]');
5685      if (mn) it = itemOf(mn.querySelector('button, a') || hit);
5686    }
5687    osaiShare(it.url || location.href, txt(it.name));
5688  }, true);                                                /* ← CAPTURE */
5689})();
5690
5691/* ═══ V75 — bouton ★ Follow sur les fiches /project/ (watchlist, comptes) ═══
5692   Anonyme → renvoi vers /account.html (magic link). Connecté → toggle via
5693   /api/watch.php. Dégradation totale si l'API est absente (site statique pur). */
5694(function(){
5695  var hpath = location.pathname.replace(/^\/(fr|it|es|de|pt)(?=\/)/, '');
5696  if (!/^\/(tool|project)\/[^\/]+\.html$/.test(hpath) || /\/index\.html$/.test(hpath)) return;
5697
5698  function findRepo(){
5699    var links = document.querySelectorAll('a[href*="github.com/"]');
5700    for (var j = 0; j < links.length; j++){
5701      if (links[j].closest && links[j].closest('#site-nav,#site-footer,.osaiMM')) continue;
5702      var m = (links[j].getAttribute('href') || '').match(/^https?:\/\/(?:www\.)?github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?(?:[\/#?].*)?$/);
5703      if (m && ['topics','sponsors','features','orgs','search','trending'].indexOf(m[1].toLowerCase()) < 0)
5704        return (m[1] + '/' + m[2]).toLowerCase();
5705    }
5706    return null;
5707  }
5708  var t = function(s){ return (typeof window.osaiT === 'function') ? window.osaiT(s) : s; };
5709
5710  function boot(){
5711    var repo = findRepo();
5712    if (!repo || document.getElementById('osaiWatchBtn')) return;
5713    var anchor = document.querySelector('.hero-main .cta-row') || document.querySelector('.cta-row')
5714              || document.querySelector('.wrap > .badges') || document.querySelector('.badges')
5715              || document.querySelector('main h1, .hero h1, h1');
5716    if (!anchor) return;
5717
5718    if (!document.getElementById('osaiWatchCSS')){
5719      var s = document.createElement('style'); s.id = 'osaiWatchCSS';
5720      s.textContent = '#osaiWatchBtn{display:inline-flex;align-items:center;gap:8px;height:46px;padding:0 18px;'
5721        + 'border-radius:12px;border:2px solid var(--border2,rgba(0,0,0,.15));background:var(--surface2,transparent);'
5722        + 'color:var(--muted,#5a6d87);font:inherit;font-size:.88rem;font-weight:600;cursor:pointer;'
5723        + 'transition:color .2s,border-color .2s,transform .15s}'
5724        + '#osaiWatchBtn:hover{border-color:var(--accent,#6366f1);color:var(--accent,#6366f1);transform:scale(1.04)}'
5725        + '#osaiWatchBtn.on{border-color:var(--accent,#6366f1);color:var(--accent,#6366f1);background:rgba(99,102,241,.08)}'
5726        + '#osaiWatchBtn .st{font-size:1.05rem;line-height:1}'
5727        + '@media(max-width:640px){#osaiWatchBtn{height:42px;padding:0 14px}}';
5728      document.head.appendChild(s);
5729    }
5730    var btn = document.createElement('button');
5731    btn.id = 'osaiWatchBtn'; btn.type = 'button';
5732    var watching = false, logged = false;
5733    function paint(){
5734      btn.innerHTML = '<span class="st">' + (watching ? '\u2605' : '\u2606') + '</span><span>'
5735                    + (watching ? t('Following') : t('Follow')) + '</span>';
5736      btn.classList.toggle('on', watching);
5737      btn.title = logged ? '' : t('Sign in to follow projects');
5738    }
5739    paint();
5740    if (anchor.classList && anchor.classList.contains('cta-row')) anchor.appendChild(btn);
5741    else anchor.insertAdjacentElement('afterend', btn);
5742
5743    /* le badge README : chaque mainteneur qui l'affiche relaie la fiche.
5744       L'état « copié » persiste (localStorage) — le bouton reste coloré
5745       au retour sur la fiche pour signaler qu'on a déjà pris le badge. */
5746    if (!document.getElementById('osaiBadgeBtn')) {
5747      var bb = document.createElement('button');
5748      bb.id = 'osaiBadgeBtn'; bb.type = 'button';
5749      bb.style.cssText = 'display:inline-flex;align-items:center;gap:7px;height:46px;padding:0 15px;margin-left:8px;'
5750        + 'border-radius:12px;border:2px solid var(--border2,rgba(0,0,0,.15));background:var(--surface2,transparent);'
5751        + 'color:var(--muted,#5a6d87);font:inherit;font-size:.85rem;font-weight:600;cursor:pointer;'
5752        + 'transition:color .2s,border-color .2s,background .2s';
5753      function badgeTaken(){
5754        try { return !!(JSON.parse(localStorage.getItem('osai_badge_md') || '{}')[repo]); } catch(e){ return false; }
5755      }
5756      function rememberBadge(){
5757        try { var m = JSON.parse(localStorage.getItem('osai_badge_md') || '{}'); m[repo] = 1;
5758              localStorage.setItem('osai_badge_md', JSON.stringify(m)); } catch(e){}
5759      }
5760      function paintB(flash){
5761        var on = badgeTaken();
5762        if (flash) {
5763          bb.innerHTML = '<span style="font-size:1rem">&#10003;</span><span>' + t('Markdown copied!') + '</span>';
5764        } else {
5765          bb.innerHTML = '<span style="font-size:1rem">' + (on ? '&#10003;' : '&#128278;') + '</span><span>' + t('Health badge') + '</span>';
5766        }
5767        bb.style.borderColor = on ? 'var(--accent,#6366f1)' : 'var(--border2,rgba(0,0,0,.15))';
5768        bb.style.color = on ? 'var(--accent,#6366f1)' : 'var(--muted,#5a6d87)';
5769        bb.style.background = on ? 'rgba(99,102,241,.08)' : 'var(--surface2,transparent)';
5770        bb.title = on ? t('Badge copied — click to copy again') : t('Copy the Markdown badge for your README');
5771      }
5772      paintB(false);
5773      bb.addEventListener('mouseenter', function(){ bb.style.borderColor = 'var(--accent,#6366f1)'; bb.style.color = 'var(--accent,#6366f1)'; });
5774      bb.addEventListener('mouseleave', function(){ paintB(false); });
5775      bb.addEventListener('click', function(){
5776        var md = '[![AI Health](https://olud.ai/badge.php?repo=' + repo + ')]'
5777               + '(https://olud.ai' + hpath + ')';
5778        var done = function(){
5779          rememberBadge();
5780          paintB(true);
5781          setTimeout(function(){ paintB(false); }, 2200);
5782        };
5783        if (navigator.clipboard && navigator.clipboard.writeText) {
5784          navigator.clipboard.writeText(md).then(done, function(){ window.prompt(t('Copy the Markdown badge for your README'), md); done(); });
5785        } else { window.prompt(t('Copy the Markdown badge for your README'), md); done(); }
5786      });
5787      btn.insertAdjacentElement('afterend', bb);
5788    }
5789    /* [17/08, Romain, 2e passe : « en haut a cote du nom du projet »]
5790       Le bouton quitte la rangee de boutons pour une pilule compacte collee
5791       au H1 — visible des le premier ecran, sans pousser les vrais boutons. */
5792    if (!document.getElementById('osaiPromoBtn')) {
5793      var pb = document.createElement('a');
5794      pb.id = 'osaiPromoBtn'; pb.href = '/promote.html';
5795      pb.style.cssText = 'display:inline-flex;align-items:center;gap:5px;vertical-align:middle;margin-left:14px;'
5796        + 'padding:5px 13px;border-radius:999px;border:2px solid var(--accent,#6366f1);'
5797        + 'color:var(--accent,#6366f1);background:transparent;font-family:Poppins,system-ui,sans-serif;'
5798        + 'font-size:.72rem;font-weight:600;letter-spacing:.04em;text-decoration:none;white-space:nowrap;'
5799        + 'transition:background .2s,color .2s';
5800      pb.innerHTML = '<span style="font-size:.85rem;line-height:1">&#128226;</span><span>' + t('Promote') + '</span>';
5801      pb.addEventListener('mouseenter', function(){ pb.style.background = 'var(--accent,#6366f1)'; pb.style.color = '#fff'; });
5802      pb.addEventListener('mouseleave', function(){ pb.style.background = 'transparent'; pb.style.color = 'var(--accent,#6366f1)'; });
5803      var h1f = document.querySelector('.hero-main h1') || document.querySelector('.wrap h1') || document.querySelector('h1');
5804      if (h1f) h1f.appendChild(pb);
5805      else { var anc2 = document.getElementById('osaiBadgeBtn') || btn; anc2.insertAdjacentElement('afterend', pb); }
5806    }
5807
5808    fetch('/api/me.php').then(function(r){ return r.ok ? r.json() : null; }).catch(function(){ return null; })
5809    .then(function(me){
5810      if (!me) return;
5811      logged = true;
5812      watching = (me.watch || []).some(function(w){ return w.repo === repo; });
5813      paint();
5814    });
5815
5816    btn.addEventListener('click', function(){
5817      if (!logged) {
5818        /* nudge au lieu d'une redirection seche : le visiteur garde sa fiche,
5819           et on lui dit ce que le compte apporte (voir osaiSignupNudge). */
5820        if (window.osaiSignupNudge) { osaiSignupNudge(btn, 'follow'); return; }
5821        var lg = (location.pathname.match(/^\/(fr|it|es|de|pt)\//) || [,''])[1];
5822        location.href = (lg ? '/' + lg : '') + '/account.html';
5823        return;
5824      }
5825      var next = !watching;
5826      fetch('/api/watch.php', { method:'POST', headers:{'Content-Type':'application/json'},
5827        body: JSON.stringify({ repo: repo, action: next ? 'add' : 'remove' }) })
5828      .then(function(r){ return r.ok ? r.json() : null; })
5829      .then(function(res){
5830        if (res && res.ok) { watching = res.watching; paint();
5831          if (window.osaiTrack) osaiTrack('watch_toggle', { repo: repo, on: watching });
5832          /* il vient de demander a etre tenu au courant : proposer le navigateur */
5833          if (watching && window.osaiPushNudge) osaiPushNudge(btn); }
5834      });
5835    });
5836  }
5837  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();
5838})();
5839/* ═══ V86 — PAGE D'ACCUEIL PERSONNALISÉE ═══════════════════════════════════
5840   Le membre choisit ses sections depuis /account.html. On applique d'abord le
5841   miroir localStorage (zéro clignotement, marche même hors ligne), puis on
5842   resynchronise depuis le compte (multi-appareils). Aucun effet pour les
5843   visiteurs anonymes : la home reste complète pour eux et pour Google. */
5844(function(){
5845  var p = location.pathname.replace(/^\/(fr|it|es|de|pt)(?=\/|$)/, '');
5846  if (!(p === '/' || p === '' || p === '/index.html')) return;
5847
5848  function apply(list){
5849    if (!Array.isArray(list)) return;
5850    var all = document.querySelectorAll('section[id]');
5851    /* On ne réaffiche QUE ce que ce module a caché lui-même : certaines
5852       sections (ex-annuaire Spaces #tools) sont volontairement masquées en
5853       inline par la page — les « réinitialiser » les ressusciterait vides. */
5854    for (var i = 0; i < all.length; i++) {
5855      if (all[i].getAttribute('data-osai-hidden') === '1') {
5856        all[i].style.display = '';
5857        all[i].removeAttribute('data-osai-hidden');
5858      }
5859    }
5860    list.forEach(function(id){
5861      if (!/^[A-Za-z][A-Za-z0-9_-]{1,30}$/.test(id)) return;
5862      var el = document.getElementById(id);
5863      if (el && el.tagName === 'SECTION') { el.style.display = 'none'; el.setAttribute('data-osai-hidden', '1'); }
5864    });
5865  }
5866  function fromLocal(){
5867    try { return JSON.parse(localStorage.getItem('osai_home_hide') || '[]'); } catch(e){ return []; }
5868  }
5869
5870  var cached = fromLocal();
5871  if (cached.length) {
5872    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function(){ apply(cached); });
5873    else apply(cached);
5874  }
5875
5876  /* membre connecté : le compte fait foi */
5877  if (/(?:^|;\s*)osai_m=1(?:;|$)/.test(document.cookie)) {
5878    fetch('/api/me.php').then(function(r){ return r.ok ? r.json() : null; }).catch(function(){ return null; })
5879    .then(function(me){
5880      if (!me) return;
5881      var h = me.home_hide || [];
5882      try { localStorage.setItem('osai_home_hide', JSON.stringify(h)); } catch(e){}
5883      apply(h);
5884    });
5885  } else if (cached.length) {
5886    /* déconnecté : on oublie les préférences du compte */
5887    try { localStorage.removeItem('osai_home_hide'); } catch(e){}
5888    apply([]);
5889  }
5890})();
5891
5892/* ═══ V84 — INCITATION DOUCE À L'INSCRIPTION ════════════════════════════════
5893   Principe : on ne masque AUCUN contenu. Un bandeau discret apparaît après
5894   quelques pages visitées, propose un compte, et disparaît si on le ferme
5895   (30 jours de silence). Zéro contenu caché = zéro risque de cloaking, donc
5896   le SEO — qui amène justement ces visiteurs — reste intact. */
5897(function(){
5898  if (/(?:^|;\s*)osai_m=1(?:;|$)/.test(document.cookie)) return;          /* déjà membre */
5899  var p = location.pathname;
5900  if (/account\.html|\/u\.php|\/c\.php|privacy|terms/.test(p)) return;   /* pages hors-sujet */
5901
5902  var SEUIL = 4, SILENCE = 30 * 86400000;
5903  var pv = 0, dis = 0;
5904  try {
5905    dis = parseInt(localStorage.getItem('osai_cta_off') || '0', 10);
5906    if (dis && Date.now() - dis < SILENCE) return;
5907    pv = parseInt(localStorage.getItem('osai_pv') || '0', 10) + 1;
5908    localStorage.setItem('osai_pv', String(pv));
5909    if (sessionStorage.getItem('osai_cta_seen')) return;                   /* 1 fois par session */
5910  } catch(e){ return; }
5911  if (pv < SEUIL) return;
5912
5913  var t = function(s){ return (typeof window.osaiT === 'function') ? window.osaiT(s) : s; };
5914  function acct(){
5915    var lg = (location.pathname.match(/^\/(fr|it|es|de|pt)\//) || [, ''])[1];
5916    return (lg ? '/' + lg : '') + '/account.html';
5917  }
5918
5919  function show(){
5920    if (document.getElementById('osaiCta')) return;
5921    try { sessionStorage.setItem('osai_cta_seen', '1'); } catch(e){}
5922    var st = document.createElement('style');
5923    st.textContent = '#osaiCta{position:fixed;right:20px;bottom:20px;z-index:99998;width:min(340px,calc(100vw - 32px));'
5924      + 'padding:18px 18px 16px;border-radius:16px;background:var(--surface,#141a29);color:var(--txt,#eef1f7);'
5925      + 'border:1px solid var(--border2,rgba(255,255,255,.12));box-shadow:0 18px 50px rgba(0,0,0,.32);'
5926      + 'font-family:inherit;animation:osaiCtaIn .3s ease-out}'
5927      + '@keyframes osaiCtaIn{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}'
5928      + '#osaiCta h4{margin:0 0 6px;font-size:.98rem}'
5929      + '#osaiCta p{margin:0 0 13px;font-size:.83rem;line-height:1.5;color:var(--muted,#8a99ad)}'
5930      + '#osaiCta a.cta{display:inline-block;padding:10px 16px;border-radius:10px;background:#6366f1;color:#fff;'
5931      + 'text-decoration:none;font-weight:600;font-size:.84rem}'
5932      + '#osaiCta button{position:absolute;top:10px;right:12px;background:none;border:0;color:var(--muted,#8a99ad);'
5933      + 'font-size:1.15rem;cursor:pointer;line-height:1;padding:2px 4px}'
5934      + '#osaiCta .free{margin-left:10px;font-size:.76rem;color:var(--muted,#8a99ad)}';
5935    document.head.appendChild(st);
5936
5937    var d = document.createElement('div');
5938    d.id = 'osaiCta';
5939    d.innerHTML = '<button aria-label="Close">&times;</button>'
5940      + '<h4>' + t('Keep track of what matters') + '</h4>'
5941      + '<p>' + t('Follow projects and get an email when their health drops, a new release ships or the license changes. Free, no password.') + '</p>'
5942      + '<a class="cta" href="' + acct() + '">' + t('Create a free account') + '</a>'
5943      + '<span class="free">' + t('30 seconds') + '</span>';
5944    d.querySelector('button').onclick = function(){
5945      try { localStorage.setItem('osai_cta_off', String(Date.now())); } catch(e){}
5946      d.remove();
5947    };
5948    document.body.appendChild(d);
5949  }
5950
5951  /* on attend un signe d'intérêt : 25 s de lecture, ou un défilement conséquent */
5952  var armed = false;
5953  function arm(){ if (!armed) { armed = true; show(); } }
5954  setTimeout(arm, 25000);
5955  window.addEventListener('scroll', function(){
5956    if (!armed && (window.scrollY > document.body.scrollHeight * 0.45)) arm();
5957  }, { passive: true });
5958})();
5959
5960/* ═══ V83 — bouton « + Collection » sur les fiches /project/ et /tool/ ═══════
5961   Le chaînon manquant : les collections étaient créables depuis la page compte,
5962   mais RIEN n'y menait depuis le site. Ce bouton s'installe à côté de ★ Follow.
5963   Anonyme → page compte. Connecté → panneau : cocher/décocher ses collections,
5964   ou en créer une à la volée. Dégradation totale si l'API est absente. */
5965(function(){
5966  var hp = location.pathname.replace(/^\/(fr|it|es|de|pt)(?=\/)/, '');
5967  if (!/^\/(tool|project)\/[^\/]+\.html$/.test(hp) || /\/index\.html$/.test(hp)) return;
5968
5969  var t = function(s){ return (typeof window.osaiT === 'function') ? window.osaiT(s) : s; };
5970  function logged(){ return /(?:^|;\s*)osai_m=1(?:;|$)/.test(document.cookie); }
5971  function acctUrl(){
5972    var lg = (location.pathname.match(/^\/(fr|it|es|de|pt)\//) || [, ''])[1];
5973    return (lg ? '/' + lg : '') + '/account.html';
5974  }
5975  function findRepo(){
5976    var links = document.querySelectorAll('a[href*="github.com/"]');
5977    for (var j = 0; j < links.length; j++){
5978      if (links[j].closest && links[j].closest('#site-nav,#site-footer,.osaiMM')) continue;
5979      var m = (links[j].getAttribute('href') || '').match(/^https?:\/\/(?:www\.)?github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?(?:[\/#?].*)?$/);
5980      if (m && ['topics','sponsors','features','orgs','search','trending'].indexOf(m[1].toLowerCase()) < 0)
5981        return (m[1] + '/' + m[2]).toLowerCase();
5982    }
5983    return null;
5984  }
5985
5986  var repo = null, panel = null, btn = null;
5987
5988  function css(){
5989    if (document.getElementById('osaiColCSS')) return;
5990    var s = document.createElement('style'); s.id = 'osaiColCSS';
5991    s.textContent =
5992      '#osaiColBtn{display:inline-flex;align-items:center;gap:8px;height:46px;padding:0 18px;margin-left:8px;'
5993      + 'border-radius:12px;border:2px solid var(--border2,rgba(0,0,0,.15));background:var(--surface2,transparent);'
5994      + 'color:var(--muted,#5a6d87);font:inherit;font-size:.88rem;font-weight:600;cursor:pointer;'
5995      + 'transition:color .2s,border-color .2s,transform .15s}'
5996      + '#osaiColBtn:hover{border-color:var(--accent,#6366f1);color:var(--accent,#6366f1);transform:scale(1.04)}'
5997      + '#osaiColBtn.on{border-color:var(--accent,#6366f1);color:var(--accent,#6366f1);background:rgba(99,102,241,.08)}'
5998      + '#osaiColPanel{position:absolute;z-index:9999;width:290px;padding:12px;border-radius:14px;'
5999      + 'background:var(--surface,#fff);border:1px solid var(--border2,rgba(0,0,0,.12));'
6000      + 'box-shadow:0 16px 44px rgba(0,0,0,.22);font-size:.86rem;color:var(--txt,#1a2540)}'
6001      + '#osaiColPanel .hd{font-weight:700;margin-bottom:9px;font-size:.82rem}'
6002      + '#osaiColPanel .ls{max-height:190px;overflow:auto;display:flex;flex-direction:column;gap:2px}'
6003      + '#osaiColPanel label{display:flex;align-items:center;gap:9px;padding:6px 4px;border-radius:8px;cursor:pointer}'
6004      + '#osaiColPanel label:hover{background:var(--card-hover,rgba(99,102,241,.06))}'
6005      + '#osaiColPanel input[type=checkbox]{accent-color:var(--accent,#6366f1);cursor:pointer;flex:none;margin:0}'
6006      + '#osaiColPanel .nw{display:flex;gap:6px;margin-top:10px;padding-top:10px;border-top:1px solid var(--border2,rgba(0,0,0,.1))}'
6007      + '#osaiColPanel .nw input[type=text]{flex:1;min-width:0;padding:7px 9px;border-radius:8px;font:inherit;font-size:.82rem;'
6008      + 'border:1px solid var(--border2,rgba(0,0,0,.15));background:var(--bg,#fff);color:var(--txt,#1a2540)}'
6009      + '#osaiColPanel .nw button{padding:7px 11px;border-radius:8px;border:0;background:var(--accent,#6366f1);color:#fff;'
6010      + 'font:inherit;font-size:.8rem;font-weight:600;cursor:pointer}'
6011      + '#osaiColPanel .em{color:var(--muted,#7a8fa8);font-size:.8rem;padding:6px 2px}';
6012    document.head.appendChild(s);
6013  }
6014
6015  function api(body){
6016    return fetch('/api/collections.php', { method:'POST', headers:{'Content-Type':'application/json'},
6017      body: JSON.stringify(body) }).then(function(r){ return r.ok ? r.json() : null; })
6018      .catch(function(){ return null; });
6019  }
6020  function close(){ if (panel){ panel.remove(); panel = null; } }
6021
6022  function open(){
6023    close();
6024    css();
6025    panel = document.createElement('div');
6026    panel.id = 'osaiColPanel';
6027    panel.innerHTML = '<div class="hd">' + t('Add to collection') + '</div><div class="ls em">…</div>';
6028    document.body.appendChild(panel);
6029    var r = btn.getBoundingClientRect();
6030    panel.style.top  = (r.bottom + window.scrollY + 8) + 'px';
6031    panel.style.left = Math.max(10, Math.min(r.left + window.scrollX, window.innerWidth - 302)) + 'px';
6032    panel.addEventListener('click', function(e){ e.stopPropagation(); });
6033
6034    fetch('/api/collections.php').then(function(r2){ return r2.ok ? r2.json() : null; })
6035    .then(function(j){
6036      if (!panel) return;
6037      var cols = (j && j.collections) || [];
6038      var ls = document.createElement('div'); ls.className = 'ls';
6039      if (!cols.length) {
6040        var em = document.createElement('div'); em.className = 'em';
6041        em.textContent = t('No collection yet — create your first one below.');
6042        ls.appendChild(em);
6043      }
6044      cols.forEach(function(c){
6045        var l = document.createElement('label');
6046        var cb = document.createElement('input'); cb.type = 'checkbox';
6047        cb.checked = (c.items || []).indexOf(repo) >= 0;
6048        cb.onchange = function(){
6049          api({ action: cb.checked ? 'add' : 'remove', id: c.id, repo: repo }).then(function(){ mark(); });
6050        };
6051        l.appendChild(cb); l.appendChild(document.createTextNode(c.title));
6052        ls.appendChild(l);
6053      });
6054      var nw = document.createElement('div'); nw.className = 'nw';
6055      var inp = document.createElement('input'); inp.type = 'text'; inp.placeholder = t('New collection…'); inp.maxLength = 80;
6056      var go = document.createElement('button'); go.textContent = t('Create');
6057      function create(){
6058        var title = (inp.value || '').trim();
6059        if (!title) return;
6060        api({ action:'create', title: title }).then(function(j2){
6061          if (!j2 || !j2.ok) return;
6062          api({ action:'add', id: j2.id, repo: repo }).then(function(){ open(); mark(); });
6063        });
6064      }
6065      go.onclick = create;
6066      inp.addEventListener('keydown', function(e){ if (e.key === 'Enter') create(); });
6067      nw.appendChild(inp); nw.appendChild(go);
6068      panel.innerHTML = '<div class="hd">' + t('Add to collection') + '</div>';
6069      panel.appendChild(ls); panel.appendChild(nw);
6070    });
6071  }
6072
6073  /* le bouton s'allume si le projet est déjà dans au moins une collection */
6074  function mark(){
6075    if (!logged()) return;
6076    fetch('/api/collections.php').then(function(r){ return r.ok ? r.json() : null; })
6077    .then(function(j){
6078      if (!j || !btn) return;
6079      var inAny = (j.collections || []).some(function(c){ return (c.items || []).indexOf(repo) >= 0; });
6080      btn.classList.toggle('on', inAny);
6081      btn.querySelector('.lb').textContent = inAny ? t('In collection') : t('Collection');
6082    });
6083  }
6084
6085  function boot(){
6086    repo = findRepo();
6087    if (!repo || document.getElementById('osaiColBtn')) return;
6088    var follow = document.getElementById('osaiWatchBtn');
6089    var anchor = follow || document.querySelector('.hero-main .cta-row') || document.querySelector('.cta-row');
6090    if (!anchor) return;
6091    css();
6092    btn = document.createElement('button');
6093    btn.id = 'osaiColBtn'; btn.type = 'button';
6094    btn.innerHTML = '<span style="font-size:1.05rem;line-height:1">+</span><span class="lb">' + t('Collection') + '</span>';
6095    if (follow) follow.insertAdjacentElement('afterend', btn);
6096    else anchor.appendChild(btn);
6097
6098    btn.addEventListener('click', function(e){
6099      e.stopPropagation();
6100      if (!logged()) { if (window.osaiSignupNudge) { osaiSignupNudge(btn, 'collection'); } else { location.href = acctUrl(); } return; }
6101      if (panel) { close(); return; }
6102      open();
6103    });
6104    /* fermeture sur clic extérieur — motif robuste : on teste la cible plutôt
6105       que de compter sur la propagation (un repaint peut décaler le bouton) */
6106    document.addEventListener('click', function(e){
6107      if (!panel) return;
6108      if (panel.contains(e.target) || btn.contains(e.target) || e.target === btn) return;
6109      close();
6110    });
6111    document.addEventListener('keydown', function(e){ if (e.key === 'Escape') close(); });
6112    mark();
6113  }
6114
6115  /* le bouton ★ Follow s'installe au chargement : on se greffe juste après */
6116  var n = 0;
6117  (function wait(){
6118    if (document.getElementById('osaiWatchBtn') || n > 25) { boot(); return; }
6119    n++; setTimeout(wait, 200);
6120  })();
6121})();
6122
6123/* ═══ V95 — CŒURS ET PARTAGE SUR LES PAGES /alternatives/ ═══════════════════
6124   Ces pages n'avaient ni favori ni partage. Plutôt que de régénérer 6 langues
6125   × N produits, on les équipe DEPUIS LE NAV : le cœur est injecté dans chaque
6126   .alt-card (avec l'URL du dépôt, pour rester cohérent avec les favoris posés
6127   ailleurs sur le site), et un bouton de partage rejoint le hero. */
6128(function(){
6129  var hp = location.pathname.replace(/^\/(fr|it|es|de|pt)(?=\/)/, '');
6130  if (!/^\/alternatives\/[^\/]+\.html$/.test(hp)) return;
6131
6132  function txt(el){ return el ? String(el.textContent || '').trim() : ''; }
6133
6134  /* Étiquette du favori = le produit remplacé. On la tire du SLUG plutôt que du
6135     titre : le titre est traduit dans 6 langues, et un regex sur les tirets
6136     mordait dans « Open-source ». Le slug, lui, est stable. */
6137  function pageTag(){
6138    var m = hp.match(/^\/alternatives\/([^\/]+)\.html$/);
6139    if (!m) return 'Alternative';
6140    return m[1].split('-').map(function(w){
6141      return w ? w.charAt(0).toUpperCase() + w.slice(1) : '';
6142    }).join(' ').trim().slice(0, 32);
6143  }
6144
6145  /* URL du favori : le dépôt GitHub d'abord (cohérent avec les autres pages),
6146     sinon le site officiel, sinon la fiche interne. */
6147  function cardUrl(card){
6148    var as = card.querySelectorAll('.ac-links a');
6149    var gh = null, site = null, fiche = null;
6150    for (var i = 0; i < as.length; i++){
6151      var href = as[i].getAttribute('href') || '';
6152      if (/^https?:\/\/(www\.)?github\.com\//i.test(href)) { if (!gh) gh = href; }
6153      else if (/^https?:\/\//i.test(href)) { if (!site) site = href; }
6154      else if (href.charAt(0) === '/') { if (!fiche) fiche = href; }
6155    }
6156    return gh || site || fiche || null;
6157  }
6158
6159  function boot(){
6160    if (typeof window.favBtnHtml !== 'function') return false;
6161    var tag = pageTag();
6162
6163    /* ── un cœur par carte ── */
6164    var cards = document.querySelectorAll('.alt-card');
6165    for (var i = 0; i < cards.length; i++){
6166      var card = cards[i];
6167      if (card.querySelector('.fav-btn')) continue;
6168      var name = txt(card.querySelector('.ac-head b')).replace(/\s*›\s*$/, '');
6169      var desc = txt(card.querySelector('.ac-desc'));
6170      var url  = cardUrl(card);
6171      if (!name || !url) continue;
6172
6173      var head = card.querySelector('.ac-head');
6174      if (!head) continue;
6175      var slot = document.createElement('span');
6176      slot.className = 'alt-fav';
6177      slot.innerHTML = window.favBtnHtml(name, desc, tag, url);
6178      head.appendChild(slot);
6179    }
6180
6181    /* ── partage de la page, dans le hero ── */
6182    var hero = document.querySelector('.alt-hero');
6183    if (hero && !document.getElementById('altShare')) {
6184      var b = document.createElement('button');
6185      b.id = 'altShare';
6186      b.className = 'osai-share alt-share';
6187      b.setAttribute('data-url', location.pathname);
6188      b.setAttribute('data-text', txt(document.querySelector('.alt-hero h1')) || document.title);
6189      b.setAttribute('aria-label', 'Share');
6190      b.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg><span>Share</span>';
6191      hero.appendChild(b);
6192    }
6193
6194    if (!document.getElementById('altFavCSS')) {
6195      var s = document.createElement('style');
6196      s.id = 'altFavCSS';
6197      s.textContent =
6198        '.alt-card .ac-head{display:flex;align-items:center;gap:9px;flex-wrap:wrap}'
6199        + '.alt-card .alt-fav{margin-left:auto;flex:none;display:inline-flex}'
6200        + '.alt-share{margin-left:auto;display:inline-flex;align-items:center;gap:7px;flex:none;'
6201        + 'padding:9px 15px;border-radius:10px;border:1px solid var(--border2);background:var(--surface);'
6202        + 'color:var(--muted);font:inherit;font-size:.82rem;font-weight:600;cursor:pointer}'
6203        + '.alt-share:hover{border-color:var(--accent);color:var(--accent)}'
6204        + '.alt-share svg{width:15px;height:15px}'
6205        + '@media(max-width:620px){.alt-share{margin:12px auto 0}}';
6206      document.head.appendChild(s);
6207    }
6208    if (typeof window.favUpdateBadge === 'function') window.favUpdateBadge();
6209    return true;
6210  }
6211
6212  /* favorites.js est injecté par le nav : on attend son API */
6213  var n = 0;
6214  (function wait(){
6215    if (boot() || ++n > 40) return;
6216    setTimeout(wait, 200);
6217  })();
6218})();
6219
6220/* ═══ V99 — PREUVE SOCIALE SUR LES FICHES ═══════════════════════════════════
6221   Tes 16 000 fiches reçoivent le trafic SEO et sont des culs-de-sac : personne
6222   n'y apprend que la communauté existe. Ce bloc les transforme en portes
6223   d'entrée vers les stacks et collections — et donc vers les comptes.
6224
6225   Règle absolue : si les compteurs sont à zéro, on n'affiche RIEN. Un bloc
6226   « 0 membre » fait plus de mal que pas de bloc du tout. */
6227(function(){
6228  var hp = location.pathname.replace(/^\/(fr|it|es|de|pt)(?=\/)/, '');
6229  if (!/^\/(tool|project)\/[^\/]+\.html$/.test(hp) || /\/index\.html$/.test(hp)) return;
6230
6231  var T = function(s){ return (typeof window.osaiT === 'function') ? window.osaiT(s) : s; };
6232  function lp(u){
6233    var l = (location.pathname.match(/^\/(fr|it|es|de|pt)\//) || [, ''])[1];
6234    return (l ? '/' + l : '') + u;
6235  }
6236  function findRepo(){
6237    var links = document.querySelectorAll('a[href*="github.com/"]');
6238    for (var j = 0; j < links.length; j++){
6239      if (links[j].closest && links[j].closest('#site-nav,#site-footer,.osaiMM')) continue;
6240      var m = (links[j].getAttribute('href') || '').match(/^https?:\/\/(?:www\.)?github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?(?:[\/#?].*)?$/);
6241      if (m && ['topics','sponsors','features','orgs','search','trending'].indexOf(m[1].toLowerCase()) < 0)
6242        return (m[1] + '/' + m[2]).toLowerCase();
6243    }
6244    return null;
6245  }
6246
6247  function css(){
6248    if (document.getElementById('osaiSocialCSS')) return;
6249    var s = document.createElement('style');
6250    s.id = 'osaiSocialCSS';
6251    s.textContent =
6252      '#osaiSocial{margin:22px 0;padding:16px 18px;border:1px solid var(--border,rgba(0,0,0,.1));'
6253      + 'border-radius:16px;background:var(--surface,#fff)}'
6254      + '#osaiSocial .os-stats{display:flex;gap:20px;flex-wrap:wrap;align-items:center}'
6255      + '#osaiSocial .os-stat{display:inline-flex;align-items:center;gap:8px;font-size:.86rem;color:var(--txt2,#4a5568)}'
6256      + '#osaiSocial .os-stat b{color:var(--txt,#1a2540);font-size:1.02rem}'
6257      + '#osaiSocial .os-stat svg{width:16px;height:16px;flex:none;opacity:.75}'
6258      + '#osaiSocial .os-sep{width:1px;height:20px;background:var(--border,rgba(0,0,0,.1))}'
6259      + '#osaiSocial .os-t{font-size:.68rem;font-weight:800;letter-spacing:.08em;text-transform:uppercase;'
6260      + 'color:var(--muted,#7a8fa8);margin:16px 0 9px}'
6261      + '#osaiSocial .os-list{display:grid;gap:4px}'
6262      + '#osaiSocial .os-list a{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:10px;'
6263      + 'text-decoration:none;color:var(--txt,#1a2540);font-size:.88rem}'
6264      + '#osaiSocial .os-list a:hover{background:var(--surface2,rgba(0,0,0,.03))}'
6265      + '#osaiSocial .os-list img{width:24px;height:24px;border-radius:7px;background:var(--surface2,#eee);flex:none}'
6266      + '#osaiSocial .os-list b{flex:none;font-weight:600}'
6267      + '#osaiSocial .os-list .by{flex:1;min-width:0;color:var(--muted,#7a8fa8);font-size:.8rem;'
6268      + 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap}'
6269      + '#osaiSocial .os-list .k{flex:none;font-size:.6rem;font-weight:800;letter-spacing:.05em;text-transform:uppercase;'
6270      + 'padding:2px 7px;border-radius:20px;background:rgba(99,102,241,.13);color:var(--accent,#6366f1)}'
6271      + '#osaiSocial .os-list em{font-style:normal;color:var(--muted,#7a8fa8);flex:none}'
6272      + '#osaiSocial .os-faces{display:flex;align-items:center;gap:-6px;flex-wrap:wrap}'
6273      + '#osaiSocial .os-faces a{display:block;width:34px;height:34px;border-radius:11px;overflow:hidden;'
6274      + 'margin-right:-6px;border:2px solid var(--surface,#fff);transition:transform .15s,z-index 0s}'
6275      + '#osaiSocial .os-faces a:hover{transform:translateY(-2px) scale(1.08);position:relative;z-index:2}'
6276      + '#osaiSocial .os-faces img{width:100%;height:100%;display:block;background:var(--surface2,#eee)}'
6277      + '#osaiSocial .os-faces .ph{display:flex;width:100%;height:100%;align-items:center;justify-content:center;'
6278      + 'background:var(--accent,#6366f1);color:#fff;font-weight:800;font-size:.82rem}'
6279      + '#osaiSocial .os-more{margin-left:12px;font-size:.8rem;font-weight:700;color:var(--muted,#7a8fa8)}'
6280      + '@media(max-width:560px){#osaiSocial .os-sep{display:none}#osaiSocial .os-stats{gap:12px}}';
6281    document.head.appendChild(s);
6282  }
6283
6284  var IC = {
6285    heart: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1.1-1.1a5.5 5.5 0 0 0-7.8 7.8l1.1 1.1L12 21.2l7.8-7.8 1-1.1a5.5 5.5 0 0 0 0-7.7z"/></svg>',
6286    stack: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>',
6287    folder:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>'
6288  };
6289
6290  function render(j){
6291    var total = (j.favorites || 0) + (j.stacks || 0) + (j.collections || 0);
6292    if (!total) return;                       /* rien à montrer : on n'affiche rien */
6293
6294    var anchor = document.querySelector('.hero-main .cta-row') || document.querySelector('.cta-row')
6295              || document.querySelector('main h1, .hero h1, h1');
6296    if (!anchor || document.getElementById('osaiSocial')) return;
6297    css();
6298
6299    var box = document.createElement('div');
6300    box.id = 'osaiSocial';
6301
6302    var stats = document.createElement('div');
6303    stats.className = 'os-stats';
6304    var parts = [];
6305    if (j.favorites) parts.push([IC.heart, j.favorites, j.favorites === 1 ? T('member saved this') : T('members saved this')]);
6306    if (j.stacks)    parts.push([IC.stack,  j.stacks,    j.stacks === 1 ? T('public stack') : T('public stacks')]);
6307    if (j.collections) parts.push([IC.folder, j.collections, j.collections === 1 ? T('collection') : T('collections')]);
6308    parts.forEach(function(p, i){
6309      if (i) { var sp = document.createElement('span'); sp.className = 'os-sep'; stats.appendChild(sp); }
6310      var s = document.createElement('span');
6311      s.className = 'os-stat';
6312      s.innerHTML = p[0] + '<b>' + p[1] + '</b><span>' + p[2] + '</span>';
6313      stats.appendChild(s);
6314    });
6315    box.appendChild(stats);
6316
6317    /* Les membres qui exposent publiquement ce projet (favoris ou watchlist).
6318       C'est ainsi qu'on se découvre : par les projets qu'on partage, jamais par
6319       un annuaire de trombines. Aucune donnée privée ici — seuls apparaissent
6320       ceux qui ont explicitement rendu leurs favoris ou leur stack publics. */
6321    if (j.members && j.members.length) {
6322      var mt = document.createElement('div');
6323      mt.className = 'os-t';
6324      mt.textContent = T('Members who use it');
6325      box.appendChild(mt);
6326      var mrow = document.createElement('div');
6327      mrow.className = 'os-faces';
6328      j.members.forEach(function(m){
6329        var a = document.createElement('a');
6330        a.href = lp('/u/' + encodeURIComponent(m.handle));
6331        a.title = (m.name || ('@' + m.handle));
6332        if (m.gh) {
6333          a.innerHTML = '<img loading="lazy" alt="" src="https://github.com/' + encodeURIComponent(m.gh)
6334            + '.png?size=64" onerror="this.outerHTML=\'<span class=&quot;ph&quot;>'
6335            + String(m.name || m.handle).charAt(0).toUpperCase() + '</span>\'">';
6336        } else {
6337          a.innerHTML = '<span class="ph">' + String(m.name || m.handle).charAt(0).toUpperCase() + '</span>';
6338        }
6339        mrow.appendChild(a);
6340      });
6341      if (j.members_more > 0) {
6342        var more = document.createElement('span');
6343        more.className = 'os-more';
6344        more.textContent = '+' + j.members_more;
6345        mrow.appendChild(more);
6346      }
6347      box.appendChild(mrow);
6348    }
6349
6350    if (j.items && j.items.length) {
6351      var t = document.createElement('div');
6352      t.className = 'os-t';
6353      t.textContent = T('Featured in');
6354      box.appendChild(t);
6355      var list = document.createElement('div');
6356      list.className = 'os-list';
6357      j.items.forEach(function(it){
6358        var who = (it.name && it.name !== '') ? it.name : '@' + it.handle;
6359        var a = document.createElement('a');
6360        a.href = lp('/c/' + encodeURIComponent(it.slug));
6361        var img = '';
6362        if (it.gh) img = '<img loading="lazy" alt="" src="https://github.com/' + encodeURIComponent(it.gh)
6363                       + '.png?size=48" onerror="this.style.visibility=\'hidden\'">';
6364        var used = Number(it.copies) > 0
6365          ? ' · <b style="color:var(--accent,#6366f1)">' + it.copies + '</b> ' + T('use it')
6366          : '';
6367        a.innerHTML = img + '<b>' + String(it.title).replace(/[<>&]/g, '') + '</b>'
6368          + (it.kind === 'stack' ? '<span class="k">' + T('Stack') + '</span>' : '')
6369          + '<span class="by">' + T('by') + ' ' + String(who).replace(/[<>&]/g, '') + ' · '
6370          + it.n + ' ' + (Number(it.n) === 1 ? T('project') : T('projects')) + used + '</span><em>→</em>';
6371        list.appendChild(a);
6372      });
6373      box.appendChild(list);
6374    }
6375
6376    if (anchor.classList && anchor.classList.contains('cta-row')) anchor.insertAdjacentElement('afterend', box);
6377    else anchor.insertAdjacentElement('afterend', box);
6378  }
6379
6380  function boot(){
6381    var repo = findRepo();
6382    if (!repo) return;
6383    fetch('/api/project-stats.php?repo=' + encodeURIComponent(repo))
6384      .then(function(r){ return r.ok ? r.json() : null; })
6385      .then(function(j){ if (j) render(j); })
6386      .catch(function(){});
6387  }
6388  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();
6389})();
6390
6391
6392/* ═══ V106 — CŒUR + PARTAGE SUR LES CARTES DE VEILLE (.tcard) ══════════════
6393   /latest.html (et toute page bâtie sur .tcard) affichait des cartes sans
6394   aucune prise : impossible de garder un projet repéré, impossible de le
6395   partager. Or c'est LA page où l'on découvre — celle où l'envie d'agir naît.
6396
6397   Équipé DEPUIS LE NAV, comme les pages /alternatives/ : aucune régénération
6398   des 6 langues, et toute page qui utilisera .tcard demain en héritera.
6399
6400   Les cartes étant elles-mêmes des <a>, les actions sont posées en overlay et
6401   coupent la propagation ; l'intercepteur de liens ci-dessus les ignore. */
6402(function(){
6403  /* .tgrid est présent statiquement même quand les cartes arrivent en JS après fetch */
6404  if (!document.querySelector('.tcard, .tgrid, #gridModels, #gridProjects')) return;
6405
6406  /* Étiquette du favori : dérivée de la grille, pas du titre (traduit en 6 langues) */
6407  var TAGS = {
6408    gridModels:   'Model',
6409    gridProjects: 'Project',
6410    gridSpaces:   'Space',
6411    gridReleases: 'Release',
6412    gridMovers:   'Trending',
6413    gridEmerging: 'Emerging'
6414  };
6415
6416  function txt(el){ return el ? String(el.textContent || '').trim() : ''; }
6417
6418  /* Clé du favori : on retire le préfixe de langue pour qu'une carte sauvegardée
6419     depuis /fr/latest.html soit LA MÊME que depuis /latest.html. */
6420  function favKey(href){
6421    if (!href) return null;
6422    if (href.charAt(0) === '/') return href.replace(/^\/(fr|it|es|de|pt)(?=\/)/, '');
6423    return href;
6424  }
6425
6426  /* URL de partage : la fiche du site quand elle existe (favorites.js sait faire
6427     le lien dépôt → /project/<slug>.html), sinon la source. */
6428  function shareUrl(href){
6429    var u = href;
6430    if (typeof window.osaiFicheFor === 'function') u = window.osaiFicheFor(href) || href;
6431    if (u && u.charAt(0) === '/') u = location.origin + u;
6432    return u;
6433  }
6434
6435  function tagFor(card){
6436    var g = card.closest('[id]');
6437    while (g && !TAGS[g.id]) g = g.parentElement && g.parentElement.closest ? g.parentElement.closest('[id]') : null;
6438    return (g && TAGS[g.id]) || 'Saved';
6439  }
6440
6441  function decorate(){
6442    if (typeof window.favBtnHtml !== 'function') return false;
6443    var cards = document.querySelectorAll('a.tcard');
6444    for (var i = 0; i < cards.length; i++){
6445      var card = cards[i];
6446      if (card.querySelector('.tc-actions')) continue;
6447
6448      var href = card.getAttribute('href') || '';
6449      var name = txt(card.querySelector('.tcard-name'));
6450      if (!name || !href || href === '#') continue;
6451
6452      var desc = txt(card.querySelector('.tcard-desc'))
6453              || txt(card.querySelector('.tcard-by'));
6454      var key  = favKey(href);
6455      var tag  = tagFor(card);
6456
6457      var box = document.createElement('div');
6458      /* Sur les cartes a image banniere (OpenGraph GitHub), l'avatar est en haut a
6459         droite : on descend l'overlay en bas a droite pour ne pas le masquer. */
6460      box.className = card.querySelector('.tcard-img') ? 'tc-actions on-img' : 'tc-actions';
6461      if (!card.querySelector('.tcard-img')) card.classList.add('has-acts');
6462      box.innerHTML = window.favBtnHtml(name, desc, tag, key)
6463        + '<button class="osai-share tc-share" type="button" aria-label="Share" title="Share"'
6464        + ' data-url="' + shareUrl(href).replace(/"/g, '&quot;') + '"'
6465        + ' data-text="' + name.replace(/"/g, '&quot;') + '">'
6466        + (window.osaiShareIcon || '') + '</button>';
6467      card.appendChild(box);
6468    }
6469    refreshShares();
6470    syncFavState();
6471    if (typeof window.favUpdateBadge === 'function') window.favUpdateBadge();
6472    return true;
6473  }
6474
6475  /* Le cœur allumé garde son étui visible (pas seulement au survol) */
6476  function syncFavState(){
6477    var as = document.querySelectorAll('a.tcard .tc-actions');
6478    for (var i = 0; i < as.length; i++){
6479      as[i].classList.toggle('has-fav', !!as[i].querySelector('.fav-btn.is-fav'));
6480    }
6481  }
6482  document.addEventListener('click', function(e){
6483    if (e.target && e.target.closest && e.target.closest('.tc-actions .fav-btn')) setTimeout(syncFavState, 0);
6484  });
6485
6486  /* project-slugs.json arrive en asynchrone : quand il atterrit, les cartes déjà
6487     équipées doivent repointer leur partage vers la fiche du site. */
6488  function refreshShares(){
6489    var bs = document.querySelectorAll('a.tcard .tc-share');
6490    for (var i = 0; i < bs.length; i++){
6491      var card = bs[i].closest('a.tcard');
6492      if (card) bs[i].setAttribute('data-url', shareUrl(card.getAttribute('href') || ''));
6493    }
6494  }
6495
6496  function css(){
6497    if (document.getElementById('tcActCSS')) return;
6498    var s = document.createElement('style');
6499    s.id = 'tcActCSS';
6500    s.textContent =
6501      'a.tcard{position:relative}'
6502      + '.tc-actions{position:absolute;top:8px;right:8px;z-index:2;display:flex;align-items:center;gap:2px;'
6503      + 'padding:2px;border-radius:9px;background:var(--surface,#fff);border:1px solid var(--border,rgba(0,0,0,.08));'
6504      /* [31/07] visibles en PERMANENCE (Romain) : au survol seulement, la
6505         moitie des visiteurs ignorait que le coeur et le partage existaient */
6506      + 'opacity:1;transition:opacity .18s}'
6507      /* et la carte equipee reserve la place du bloc : il ne recouvre plus
6508         la fin des noms longs */
6509      + 'a.tcard.has-acts .tcard-name{padding-right:66px}'
6510      /* un favori posé reste visible sans survol — sinon on ne voit plus ce qu'on a gardé.
6511         Règle séparée : groupée avec :has(), un vieux navigateur jetterait tout le bloc. */
6512      + '.tc-actions.has-fav{opacity:1;transform:none}'
6513      + '@media(hover:none){.tc-actions{opacity:1;transform:none}}'
6514      + '.tc-actions .fav-btn{padding:5px}'
6515      + '.tc-actions .fav-btn svg{width:16px;height:16px}'
6516      + '.tc-share{padding:5px 6px}'
6517      + '.tc-share svg{width:15px;height:15px}'
6518      + '.tc-actions.on-img{top:auto;bottom:10px;right:10px}'
6519      + 'body.dark .tc-actions{background:var(--surface,#141a29);border-color:var(--border2,rgba(255,255,255,.18))}';
6520    document.head.appendChild(s);
6521  }
6522
6523  function boot(){
6524    css();
6525    if (typeof window.osaiLoadFiches === 'function') window.osaiLoadFiches(function(){ decorate(); refreshShares(); });
6526    var ok = decorate();
6527
6528    /* Les grilles sont réécrites en JS (fetch → innerHTML) : on redécore après coup. */
6529    if (window.MutationObserver){
6530      var t = null;
6531      new MutationObserver(function(){
6532        clearTimeout(t);
6533        t = setTimeout(decorate, 60);
6534      }).observe(document.body, {childList:true, subtree:true});
6535    }
6536    return ok;
6537  }
6538
6539  /* favorites.js est injecté par le nav : on attend son API */
6540  var n = 0;
6541  (function wait(){
6542    if (boot() || ++n > 40) return;
6543    setTimeout(wait, 200);
6544  })();
6545})();
6546/* WebMCP : expose les actions du site aux agents qui pilotent un navigateur.
6547   Charge en differe et sans bloquer - la quasi-totalite des navigateurs
6548   n'implemente pas encore la norme, et le script sort alors sans rien faire. */
6549(function(){
6550  try{
6551    var s = document.createElement('script');
6552    s.src = '/webmcp.js';
6553    s.defer = true;
6554    (document.body || document.head).appendChild(s);
6555  }catch(e){}
6556})();
6557
6558/* ═══ [24/07] Google One Tap sur l'ACCUEIL pour les visiteurs deconnectes ═══════
6559   Le login Google existe deja (account.html + /api/google-auth.php + client_id).
6560   Ici on le PROPOSE directement : la fenetre One Tap apparait en haut a droite
6561   des l'arrivee sur l'accueil, uniquement si l'utilisateur n'est pas connecte.
6562   Reutilise le meme client_id et le meme endpoint -> aucune nouvelle infra. */
6563(function(){
6564  try{
6565    var isHome = /^\/([a-z]{2}\/)?(index\.html)?$/.test(location.pathname);
6566    if (!isHome) return;
6567    fetch('/api/me.php', { credentials:'same-origin' })
6568      .then(function(r){ return r.ok ? r.json() : null; })
6569      .then(function(me){
6570        if (me && me.email) return;               /* deja connecte -> ne rien proposer */
6571        var g = document.createElement('script');
6572        g.src = 'https://accounts.google.com/gsi/client'; g.async = true; g.defer = true;
6573        g.onload = function(){
6574          if (!(window.google && google.accounts && google.accounts.id)) return;
6575          google.accounts.id.initialize({
6576            client_id: '221130680652-dcpjc9ajo0vgd8sbal4k768pbosa8km1.apps.googleusercontent.com',
6577            auto_select: false,
6578            cancel_on_tap_outside: true,
6579            callback: function(resp){
6580              if (!resp || !resp.credential) return;
6581              fetch('/api/google-auth.php', { method:'POST', headers:{'Content-Type':'application/json'},
6582                body: JSON.stringify({ credential: resp.credential }) })
6583                .then(function(r){ return r.ok; })
6584                .then(function(ok){ if (ok) location.reload(); })
6585                .catch(function(){});
6586            }
6587          });
6588          google.accounts.id.prompt();            /* affiche One Tap en haut a droite */
6589        };
6590        document.head.appendChild(g);
6591      })
6592      .catch(function(){});
6593  }catch(e){}
6594})();
6595
6596/* ═══ [24/07] Commentaires Giscus sur les ARTICLES de blog ═══════════════════
6597   Login GitHub, aucun backend ni spam a moderer (modere sur GitHub). Injecte
6598   UNIQUEMENT sur /blog/<slug>/ (pas l'index, pas les alternatives, pas les 60k
6599   fiches -> pas d'effet ville-fantome). Theme synchronise clair/sombre, langue
6600   = celle de la page. Depot: opensourceaitech/comments. */
6601(function(){
6602  try{
6603    var m = location.pathname.match(/^\/([a-z]{2}\/)?blog\/([^\/]+)\/$/);
6604    if (!m || m[2] === 'alternatives') return;
6605    if (document.getElementById('giscus-wrap')) return;
6606    var anchor = document.getElementById('site-footer') || document.querySelector('footer');
6607    if (!anchor || !anchor.parentNode) return;
6608    var lang = (document.documentElement.getAttribute('lang') || 'en').slice(0,2).toLowerCase();
6609    if (!/^(en|fr|de|es|it|pt)$/.test(lang)) lang = 'en';
6610    var titles = { en:'Comments', fr:'Commentaires', de:'Kommentare', es:'Comentarios', it:'Commenti', pt:'Comentários' };
6611    var wrap = document.createElement('section');
6612    wrap.id = 'giscus-wrap';
6613    wrap.style.cssText = 'max-width:820px;margin:2.5rem auto 1rem;padding:0 1rem';
6614    wrap.innerHTML = '<h2 style="font-family:Poppins,sans-serif;font-size:1.25rem;font-weight:600;margin:0 0 1rem;color:var(--txt)">' + titles[lang] + '</h2>';
6615    var g = document.createElement('script');
6616    g.src = 'https://giscus.app/client.js';
6617    var attrs = {
6618      'data-repo':'opensourceaitech/comments',
6619      'data-repo-id':'R_kgDOTiEmZQ',
6620      'data-category':'Announcements',
6621      'data-category-id':'DIC_kwDOTiEmZc4DB3CK',
6622      'data-mapping':'pathname',
6623      'data-strict':'0',
6624      'data-reactions-enabled':'1',
6625      'data-emit-metadata':'0',
6626      'data-input-position':'bottom',
6627      'data-theme': document.body.classList.contains('dark') ? 'dark' : 'light',
6628      'data-lang': lang,
6629      'crossorigin':'anonymous'
6630    };
6631    for (var k in attrs) g.setAttribute(k, attrs[k]);
6632    g.async = true;
6633    wrap.appendChild(g);
6634    anchor.parentNode.insertBefore(wrap, anchor);
6635    /* Sync du theme quand l'utilisateur bascule le mode clair/sombre du site */
6636    var mo = new MutationObserver(function(){
6637      var f = document.querySelector('iframe.giscus-frame');
6638      if (f && f.contentWindow) f.contentWindow.postMessage(
6639        { giscus: { setConfig: { theme: document.body.classList.contains('dark') ? 'dark' : 'light' } } },
6640        'https://giscus.app');
6641    });
6642    mo.observe(document.body, { attributes:true, attributeFilter:['class'] });
6643  }catch(e){}
6644})();
6645
6646/* ═══ [24/07] Like / Dislike sur les fiches projet ══════════════════════════
6647   Signal de popularite anonyme via /api/vote.php (score = somme des votes).
6648   Injecte cote client dans la .cta-row -> aucune regeneration des 60k fiches.
6649   ▲ vert = like, ▼ rouge = dislike, score gris a 0 (vert si +, rouge si -). */
6650(function(){
6651  try{
6652    if (!/^\/([a-z]{2}\/)?project\/[^\/]+\.html$/.test(location.pathname)) return;
6653    var repoEl = document.querySelector('[data-repo]');
6654    var row = document.querySelector('.cta-row');
6655    if (!repoEl || !row || document.getElementById('osai-vote')) return;
6656    var repo = (repoEl.getAttribute('data-repo') || '').toLowerCase();
6657    if (!/^[^\/]+\/[^\/]+$/.test(repo)) return;
6658
6659    var st = document.createElement('style');
6660    st.textContent = '.osai-votes{display:inline-flex;align-items:center;gap:5px}'
6661      + '.osai-vb{display:inline-flex;align-items:center;justify-content:center;width:32px;height:30px;'
6662      + 'border:1px solid var(--border2,rgba(0,0,0,.15));background:var(--surface2,#f5f5f7);border-radius:8px;'
6663      + 'cursor:pointer;font-size:.82rem;line-height:1;'
6664      + 'transition:transform .15s,background .15s,border-color .15s}'
6665      + '.osai-vb:hover{transform:scale(1.12)}'
6666      + '.osai-vb.up.on{background:rgba(22,163,74,.16);border-color:#16a34a}'
6667      + '.osai-vb.down.on{background:rgba(224,36,94,.16);border-color:#e0245e}'
6668      + '.osai-vb:disabled{opacity:.5;cursor:default}'
6669      + '.osai-score{min-width:24px;text-align:center;font-weight:700;font-size:.9rem;color:var(--muted,#8a99ad)}'
6670      + '.osai-score.pos{color:#16a34a}.osai-score.neg{color:#e0245e}';
6671    document.head.appendChild(st);
6672
6673    var box = document.createElement('span');
6674    box.id = 'osai-vote'; box.className = 'osai-votes';
6675    box.innerHTML = '<button type="button" class="osai-vb up" aria-label="Like" title="Like">&#128077;</button>'
6676      + '<span class="osai-score">&middot;</span>'
6677      + '<button type="button" class="osai-vb down" aria-label="Dislike" title="Dislike">&#128078;
6677</button>';
6678    row.appendChild(box);
6679    var up = box.querySelector('.up'), down = box.querySelector('.down'), sc = box.querySelector('.osai-score');
6680
6681    /* [29/07] Le « chiffre de depart » fabrique est SUPPRIME. Il affichait
6682       5 a 80 pouces leves sur des fiches ou PERSONNE n'avait vote (46 sur
6683       SeekStorm, vrai compteur en base : zero) — repere par Romain au premier
6684       regard. Regle du site : aucun chiffre invente. Score reel uniquement ;
6685       un point median tant que personne n'a vote. */
6686    function render(score, dir){
6687      sc.textContent = (score === 0) ? '\u00B7' : score;
6688      sc.className = 'osai-score' + (score > 0 ? ' pos' : score < 0 ? ' neg' : '');
6689      up.classList.toggle('on', dir === 1);
6690      down.classList.toggle('on', dir === -1);
6691    }
6692    fetch('/api/vote.php?kind=project&ids=' + encodeURIComponent(repo))
6693      .then(function(r){ return r.ok ? r.json() : null; })
6694      .then(function(j){ var v = (j && j.votes && j.votes[repo]) || { score:0, dir:0 }; render(v.score||0, v.dir||0); })
6695      .catch(function(){ render(0, 0); });
6696
6697    function vote(dir){
6698      up.disabled = down.disabled = true;
6699      fetch('/api/vote.php', { method:'POST', headers:{'Content-Type':'application/json'},
6700        credentials:'same-origin', body: JSON.stringify({ kind:'project', id: repo, dir: dir }) })
6701        .then(function(r){
6702          if (r.status === 401){ location.href = '/account.html'; return null; }  /* login requis pour voter */
6703          return r.ok ? r.json() : null;
6704        })
6705        .then(function(j){ if (j && typeof j.score === 'number') render(j.score, j.dir); })
6706        .catch(function(){})
6707        .then(function(){ up.disabled = down.disabled = false; });
6708    }
6709    up.addEventListener('click', function(){ vote(1); });
6710    down.addEventListener('click', function(){ vote(-1); });
6711  }catch(e){}
6712})();
6713
6714/* ═══ [24/07] Commentaires Giscus sur les FICHES projet — CHARGEMENT AU CLIC ═══
6715   Disponible sur TOUTES les fiches, mais Giscus ne se charge qu'au clic sur le
6716   bouton -> pas de section vide (ville-fantome) ni de cout de perf par defaut.
6717   Chaque fiche a son propre fil (mapping pathname), cree seulement au 1er message. */
6718(function(){
6719  try{
6720    if (!/^\/([a-z]{2}\/)?project\/[^\/]+\.html$/.test(location.pathname)) return;
6721    if (document.getElementById('giscus-wrap')) return;
6722    var anchor = document.getElementById('site-footer') || document.querySelector('footer');
6723    if (!anchor || !anchor.parentNode) return;
6724    var lang = (document.documentElement.getAttribute('lang') || 'en').slice(0,2).toLowerCase();
6725    if (!/^(en|fr|de|es|it|pt)$/.test(lang)) lang = 'en';
6726    var L = { en:['Comments','💬 View / add a comment','Share your take and see what others say.'],
6727      fr:['Commentaires','💬 Voir / laisser un commentaire','Donnez votre avis et découvrez celui des autres.'],
6728      de:['Kommentare','💬 Kommentare ansehen / schreiben','Teile deine Meinung und sieh, was andere sagen.'],
6729      es:['Comentarios','💬 Ver / dejar un comentario','Comparte tu opinión y descubre la de los demás.'],
6730      it:['Commenti','💬 Vedi / lascia un commento','Condividi la tua opinione e scopri quella degli altri.'],
6731      pt:['Comentários','💬 Ver / deixar um comentário','Partilhe a sua opinião e veja a dos outros.'] }[lang];
6732    var wrap = document.createElement('section');
6733    wrap.id = 'giscus-wrap';
6734    wrap.style.cssText = 'max-width:820px;margin:2.5rem auto 3rem;padding:0 1rem';
6735    wrap.innerHTML = '<div style="text-align:center;border:1px solid var(--border2,rgba(0,0,0,.15));border-radius:16px;padding:26px 22px;background:var(--surface,#fff)">'
6736      + '<div style="font-size:1.7rem;line-height:1;margin-bottom:8px">💬</div>'
6737      + '<div style="font-family:Poppins,sans-serif;font-size:1.15rem;font-weight:600;color:var(--txt);margin-bottom:4px">' + L[0] + '</div>'
6738      + '<div style="color:var(--muted,#8a99ad);font-size:.9rem;margin-bottom:16px">' + L[2] + '</div>'
6739      + '<button type="button" class="osai-load-comments" style="font-family:inherit;font-size:.92rem;font-weight:600;color:#fff;background:var(--accent,#6366f1);border:none;border-radius:12px;padding:12px 26px;cursor:pointer">' + L[1] + '</button>'
6740      + '</div>';
6741    anchor.parentNode.insertBefore(wrap, anchor);
6742    var btn = wrap.querySelector('.osai-load-comments');
6743    btn.addEventListener('click', function(){
6744      if (wrap.getAttribute('data-loaded')) return;
6745      wrap.setAttribute('data-loaded', '1');
6746      wrap.innerHTML = '<h2 style="font-family:Poppins,sans-serif;font-size:1.25rem;font-weight:600;margin:0 0 1rem;color:var(--txt)">' + L[0] + '</h2>';
6747      var g = document.createElement('script');
6748      g.src = 'https://giscus.app/client.js';
6749      var a = { 'data-repo':'opensourceaitech/comments','data-repo-id':'R_kgDOTiEmZQ',
6750        'data-category':'Announcements','data-category-id':'DIC_kwDOTiEmZc4DB3CK',
6751        'data-mapping':'pathname','data-strict':'0','data-reactions-enabled':'1',
6752        'data-emit-metadata':'0','data-input-position':'bottom',
6753        'data-theme': document.body.classList.contains('dark') ? 'dark' : 'light',
6754        'data-lang': lang, 'crossorigin':'anonymous' };
6755      for (var k in a) g.setAttribute(k, a[k]);
6756      g.async = true;
6757      wrap.appendChild(g);
6758      var mo = new MutationObserver(function(){
6759        var f = document.querySelector('iframe.giscus-frame');
6760        if (f && f.contentWindow) f.contentWindow.postMessage(
6761          { giscus: { setConfig: { theme: document.body.classList.contains('dark') ? 'dark' : 'light' } } },
6762          'https://giscus.app');
6763      });
6764      mo.observe(document.body, { attributes:true, attributeFilter:['class'] });
6765    });
6766  }catch(e){}
6767})();
6768
6769
6770/* ═══════════════════════════════════════════════════════════════════════════
6771   FICHE RÉCLAMÉE → LIEN VERS LE PROFIL DU MAINTENEUR (Phase 0, p0-3)
6772
6773   Le flux de revendication existait de bout en bout — marqueur dans le README,
6774   vérification, enrichissement — mais rien n'était VISIBLE : claims-public.json
6775   restait vide (personne ne l'écrivait, corrigé côté api/) et aucune fiche ne
6776   montrait qui maintient le projet. Ce bloc ferme la boucle : si la fiche est
6777   revendiquée, une pastille « Maintenu par @pseudo » pointe vers /u/pseudo.
6778   C'est aussi la récompense qui donne envie de revendiquer. */
6779(function(){
6780  try{
6781    if (!/^\/(?:[a-z]{2}\/)?project\/[^\/]+\.html$/.test(location.pathname)) return;
6782    var L = { en:'Maintained by', fr:'Maintenu par', de:'Betreut von',
6783              es:'Mantenido por', it:'Mantenuto da', pt:'Mantido por' };
6784    var lab = L[(document.documentElement.lang || 'en').slice(0,2)] || L.en;
6785
6786    function repoOnPage(){
6787      var links = document.querySelectorAll('a[href*="github.com/"]');
6788      for (var j = 0; j < links.length; j++){
6789        if (links[j].closest && links[j].closest('#site-nav,#site-footer,.osaiMM')) continue;
6790        var mm = (links[j].getAttribute('href') || '').match(/^https?:\/\/(?:www\.)?github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?(?:[\/#?].*)?$/);
6791        if (mm && ['topics','sponsors','features','orgs','search','trending'].indexOf(mm[1].toLowerCase()) < 0)
6792          return (mm[1] + '/' + mm[2]).toLowerCase();
6793      }
6794      return null;
6795    }
6796
6797    fetch('/claims-public.json?d=' + new Date().toISOString().slice(0,10))
6798      .then(function(r){ return r.ok ? r.json() : null; })
6799      .then(function(j){
6800        if (!j || !j.claims) return;
6801        var tries = 0;
6802        (function tick(){
6803          var repo = repoOnPage();
6804          var row  = document.querySelector('.cta-row');
6805          if ((!repo || !row) && ++tries < 20) { setTimeout(tick, 300); return; }
6806          if (!repo || !row) return;
6807          var c = j.claims[repo];
6808          if (!c || !c.handle || document.getElementById('osaiClaimChip')) return;
6809          var a = document.createElement('a');
6810          a.id = 'osaiClaimChip';
6811          a.href = '/u/' + encodeURIComponent(c.handle);
6812          /* liseres 2 px : charte graphique */
6813          a.style.cssText = 'display:inline-flex;align-items:center;gap:7px;height:38px;padding:0 14px;'
6814            + 'border-radius:12px;border:2px solid var(--accent,#6366f1);background:rgba(99,102,241,.08);'
6815            + 'color:var(--accent,#6366f1);font:inherit;font-size:.82rem;font-weight:600;text-decoration:none;order:4';
6816          a.innerHTML = '<span style="font-size:.95rem">&#10003;</span><span>' + lab + ' @'
6817            + String(c.handle).replace(/[<>&"]/g, '') + '</span>';
6818          a.title = (c.name && c.name !== c.handle) ? c.name : '';
6819          row.appendChild(a);
6820          if (window.osaiTrack) osaiTrack('claim_chip_shown', { repo: repo });
6821        })();
6822      })
6823      .catch(function(){});
6824  }catch(e){}
6825})();
6826
6827
6828/* ═══════════════════════════════════════════════════════════════════════════
6829   NOTIFICATIONS NAVIGATEUR — le nudge au bon moment (Phase 3, p3-6)
6830
6831   Toute la pile existe depuis le 25/07 : abonnements (api/push.php), envoyeur
6832   VAPID « tickle » (api/osai-push.php), worker (osai-push-sw.js) et envoi reel
6833   dans send-alerts.php. Mais le bouton « Enable » vivait uniquement dans les
6834   reglages du compte, ou personne ne va. Le bon moment pour proposer les
6835   notifications, c'est la seconde ou le membre vient de suivre un projet :
6836   il vient LITTERALEMENT de demander a etre tenu au courant.
6837
6838   Regles : membres seulement, permission encore non demandee, pas deja abonne,
6839   une proposition par session, jamais d'auto-popup — le prompt natif du
6840   navigateur n'est déclenché que par le clic sur « Activer ». */
6841(function(){
6842  window.osaiPushNudge = function(anchor){
6843    try{
6844      if (!('serviceWorker' in navigator) || !('PushManager' in window)
6845          || !('Notification' in window) || !window.isSecureContext) return;
6846      if (Notification.permission !== 'default') return;
6847      try { if (sessionStorage.getItem('osai_pushnudge') === '1') return; } catch(e){}
6848      try { sessionStorage.setItem('osai_pushnudge', '1'); } catch(e){}
6849
6850      var L = {
6851        en:['Get these alerts in your browser too?','Enable','Not now'],
6852        fr:['Recevoir ces alertes aussi dans le navigateur ?','Activer','Plus tard'],
6853        de:['Diese Alerts auch im Browser erhalten?','Aktivieren','Spaeter'],
6854        es:['Recibir estas alertas tambien en el navegador?','Activar','Ahora no'],
6855        it:['Ricevere questi avvisi anche nel browser?','Attiva','Non ora'],
6856        pt:['Receber estes alertas tambem no navegador?','Ativar','Agora nao'] };
6857      var t = L[(document.documentElement.lang || 'en').slice(0,2)] || L.en;
6858
6859      var card = document.createElement('div');
6860      card.id = 'osaiPushNudge';
6861      /* liseres 2 px : charte graphique */
6862      card.style.cssText = 'position:absolute;z-index:2147483000;max-width:300px;padding:14px 16px;'
6863        + 'border:2px solid var(--accent,#6366f1);border-radius:14px;background:var(--surface,#fff);'
6864        + 'box-shadow:0 14px 40px rgba(0,0,0,.20);font-family:Poppins,system-ui,sans-serif;'
6865        + 'font-size:.85rem;line-height:1.45;color:var(--txt,#111)';
6866      card.innerHTML = '<div style="margin-bottom:10px">' + t[0] + '</div>'
6867        + '<div style="display:flex;gap:8px">'
6868        + '<button type="button" data-on style="background:var(--accent,#6366f1);color:#fff;border:none;'
6869        + 'border-radius:10px;padding:8px 14px;font:inherit;font-weight:600;cursor:pointer">' + t[1] + '</button>'
6870        + '<button type="button" data-off style="background:none;border:2px solid var(--border2,rgba(0,0,0,.15));'
6871        + 'border-radius:10px;padding:8px 12px;font:inherit;color:var(--muted,#888);cursor:pointer">' + t[2] + '</button></div>';
6872      document.body.appendChild(card);
6873      var r = anchor.getBoundingClientRect();
6874      card.style.top  = (r.bottom + window.scrollY + 10) + 'px';
6875      card.style.left = Math.max(10, Math.min(r.left + window.scrollX, window.scrollX + window.innerWidth - 320)) + 'px';
6876
6877      function kill(){ card.remove(); document.removeEventListener('keydown', esc); }
6878      function esc(e){ if (e.key === 'Escape') kill(); }
6879      document.addEventListener('keydown', esc);
6880      card.querySelector('[data-off]').addEventListener('click', kill);
6881
6882      function b64u(str){
6883        str = String(str).replace(/-/g, '+').replace(/_/g, '/');
6884        str += '=='.slice(0, (4 - str.length % 4) % 4);
6885        var raw = atob(str), arr = new Uint8Array(raw.length);
6886        for (var i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
6887        return arr;
6888      }
6889      card.querySelector('[data-on]').addEventListener('click', function(){
6890        /* meme flux que account.html : meme worker, memes endpoints */
6891        navigator.serviceWorker.register('/osai-push-sw.js').then(function(reg){
6892          return (reg.pushManager ? Promise.resolve(reg) : navigator.serviceWorker.ready);
6893        }).then(function(reg){
6894          return fetch('/api/push.php').then(function(x){ return x.json(); }).then(function(j){
6895            if (!j || !j.publicKey) throw new Error('no key');
6896            return reg.pushManager.subscribe({ userVisibleOnly:true, applicationServerKey: b64u(j.publicKey) });
6897          });
6898        }).then(function(sub){
6899          return fetch('/api/push.php', { method:'POST', headers:{'Content-Type':'application/json'},
6900            body: JSON.stringify({ action:'subscribe', subscription: sub.toJSON ? sub.toJSON() : sub }) });
6901        }).then(function(){
6902          if (window.osaiTrack) osaiTrack('push_enabled', { from:'follow' });
6903          kill();
6904        }).catch(function(){ kill(); });
6905      });
6906    }catch(e){}
6907  };
6908})();
6909
6910/* ═══════════════════════════════════════════════════════════════════════════
6911   NUDGE D'INSCRIPTION (Phase 2, p2-4) — au moment de l'intention, jamais avant.
6912
6913   Avant : un visiteur non connecte qui cliquait « Follow » ou « Collection »
6914   etait REDIRIGE d'un coup vers /account.html. Il perdait sa fiche, sans un mot
6915   d'explication — le pire des nudges, au seul moment ou le visiteur venait de
6916   manifester un interet.
6917
6918   Maintenant : une petite carte s'ouvre a cote du bouton. Elle dit ce que le
6919   compte apporte, propose de le creer, et se referme d'un clic ailleurs ou d'un
6920   Echap. Pas d'interstitiel, pas d'apparition spontanee : elle ne repond qu'a un
6921   geste du visiteur — c'est la contrainte posee au plan (penalite Google mobile
6922   sur les interstitiels, et simple politesse).
6923   ═══════════════════════════════════════════════════════════════════════════ */
6924(function(){
6925  var M = {
6926    follow: {
6927      en:'Follow projects and get alerts when they move — with a free account.',
6928      fr:'Suivez des projets et recevez des alertes quand ils bougent — avec un compte gratuit.',
6929      de:'Folge Projekten und erhalte Alerts, wenn sich etwas tut — mit einem kostenlosen Konto.',
6930      es:'Sigue proyectos y recibe alertas cuando se muevan — con una cuenta gratuita.',
6931      it:'Segui i progetti e ricevi avvisi quando si muovono — con un account gratuito.',
6932      pt:'Siga projetos e receba alertas quando algo mudar — com uma conta gratuita.'
6933    },
6934    collection: {
6935      en:'Save projects into collections — with a free account.',
6936      fr:'Rangez des projets en collections — avec un compte gratuit.',
6937      de:'Speichere Projekte in Sammlungen — mit einem kostenlosen Konto.',
6938      es:'Guarda proyectos en colecciones — con una cuenta gratuita.',
6939      it:'Salva i progetti in raccolte — con un account gratuito.',
6940      pt:'Guarde projetos em colecoes — com uma conta gratuita.'
6941    },
6942    cta: { en:'Create a free account', fr:'Creer un compte gratuit', de:'Kostenloses Konto erstellen',
6943           es:'Crear una cuenta gratuita', it:'Crea un account gratuito', pt:'Criar uma conta gratuita' }
6944  };
6945  function lang(){ var l=(document.documentElement.lang||'en').slice(0,2); return M.cta[l]?l:'en'; }
6946  function acct(){ var lg=(location.pathname.match(/^\/(fr|it|es|de|pt)\//)||[,''])[1]; return (lg?'/'+lg:'')+'/account.html'; }
6947
6948  window.osaiSignupNudge = function(anchor, kind){
6949    try{
6950      var old = document.getElementById('osaiNudge');
6951      if (old) old.remove();
6952      var l = lang();
6953      var card = document.createElement('div');
6954      card.id = 'osaiNudge';
6955      card.setAttribute('role','dialog');
6956      /* liseres 2 px : charte graphique */
6957      card.style.cssText = 'position:absolute;z-index:2147483000;max-width:290px;padding:14px 16px;'
6958        + 'border:2px solid var(--accent,#6366f1);border-radius:14px;background:var(--surface,#fff);'
6959        + 'box-shadow:0 14px 40px rgba(0,0,0,.20);font-family:Poppins,system-ui,sans-serif;'
6960        + 'font-size:.85rem;line-height:1.45;color:var(--txt,#111)';
6961      card.innerHTML = '<div style="display:flex;gap:8px;align-items:flex-start">'
6962        + '<span style="flex:1">' + (M[kind]||M.follow)[l] + '</span>'
6963        + '<button type="button" aria-label="close" style="flex:none;background:none;border:none;font-size:1.1rem;'
6964        + 'line-height:1;color:var(--muted,#888);cursor:pointer;padding:0 2px">&times;</button></div>'
6965        + '<a href="' + acct() + '" style="display:inline-block;margin-top:10px;background:var(--accent,#6366f1);'
6966        + 'color:#fff;text-decoration:none;font-weight:600;border-radius:10px;padding:8px 14px;font-size:.83rem">'
6967        + M.cta[l] + '</a>';
6968      document.body.appendChild(card);
6969      var r = anchor.getBoundingClientRect();
6970      var top = r.bottom + window.scrollY + 10;
6971      var left = Math.max(10, Math.min(r.left + window.scrollX, window.scrollX + window.innerWidth - 310));
6972      card.style.top = top + 'px'; card.style.left = left + 'px';
6973      function kill(){ card.remove();
6974        document.removeEventListener('pointerdown', out, true);
6975        document.removeEventListener('keydown', esc); }
6976      function out(e){ if (!card.contains(e.target) && e.target !== anchor) kill(); }
6977      function esc(e){ if (e.key === 'Escape') kill(); }
6978      card.querySelector('button').addEventListener('click', kill);
6979      document.addEventListener('pointerdown', out, true);
6980      document.addEventListener('keydown', esc);
6981      if (window.osaiTrack) osaiTrack('signup_nudge', { kind: kind });
6982    }catch(e){ location.href = acct(); }   /* au pire : l'ancien comportement */
6983  };
6984})();
6985
6986/* ═══════════════════════════════════════════════════════════════════════════
6987   COMPTEUR DE FICHES — CONSTRUIT, MAIS DÉSACTIVÉ
6988
6989   Demande de Romain : « préparer une solution technique, sans l'activer dans
6990   l'immédiat pour ne pas faire fuir les visiteurs, permettant de limiter la
6991   consultation à 10 fiches pour les utilisateurs sans compte ».
6992
6993   Trois principes tenus, parce qu'ils décident si ça rapporte ou si ça coûte :
6994
6995   1. ÇA NE BLOQUE JAMAIS. Le contenu reste entier et lisible. Un interstitiel
6996      qui masque la page est pénalisé par Google sur mobile, et il fait fuir
6997      justement le visiteur qu'on voulait convertir. On affiche un bandeau
6998      discret en bas, refermable — une invitation, pas un péage.
6999   2. RIEN N'EST CACHÉ À L'INDEXATION. Aucun texte, aucun lien n'est retiré du
7000      DOM : ce que voit Google reste ce que voit le visiteur.
7001   3. ÇA NE HARCÈLE PAS. Une fois refermé, on ne le remontre plus (mémorisé) ;
7002      jamais pour un membre ; au plus une fois par session.
7003
7004   POUR ACTIVER : passer ON à true ci-dessous. LIMIT règle le seuil.
7005   Le compteur, lui, tourne déjà : quand tu activeras, tu sauras combien de
7006   visiteurs atteignent réellement le seuil au lieu de le deviner.
7007   ═══════════════════════════════════════════════════════════════════════════ */
7008(function(){
7009  try{
7010    var ON    = false;   /* ← passer à true pour activer le bandeau */
7011    var LIMIT = 10;
7012    var KEY   = 'osai_seen_fiches', DISMISS = 'osai_softwall_off';
7013
7014    /* on ne compte que les fiches projet, en anglais comme en traduction */
7015    var m = location.pathname.match(/^\/(?:[a-z]{2}\/)?project\/([^\/]+)\.html$/);
7016    if (!m) return;
7017    if (/(?:^|;\s*)osai_m=1(?:;|$)/.test(document.cookie)) return;   /* membre : jamais */
7018
7019    var seen = [];
7020    try { seen = JSON.parse(localStorage.getItem(KEY) || '[]') || []; } catch(e){ seen = []; }
7021    if (!Array.isArray(seen)) seen = [];
7022    if (seen.indexOf(m[1]) < 0) {
7023      seen.push(m[1]);
7024      if (seen.length > 200) seen = seen.slice(-200);   /* borne : c'est un compteur, pas un historique */
7025      try { localStorage.setItem(KEY, JSON.stringify(seen)); } catch(e){}
7026    }
7027    window.osaiFichesSeen = seen.length;                /* lisible en console pour mesurer */
7028
7029    if (!ON || seen.length < LIMIT) return;
7030    try { if (localStorage.getItem(DISMISS) === '1') return; } catch(e){}
7031    try { if (sessionStorage.getItem(DISMISS + '_s') === '1') return; } catch(e){}
7032
7033    function tt(en, fr){ return (document.documentElement.lang || 'en').slice(0,2) === 'fr' ? fr : en; }
7034    var bar = document.createElement('div');
7035    bar.setAttribute('role', 'complementary');
7036    bar.style.cssText = 'position:fixed;left:50%;transform:translateX(-50%);bottom:16px;z-index:2147483000;'
7037      + 'display:flex;align-items:center;gap:14px;flex-wrap:wrap;justify-content:center;'
7038      + 'max-width:min(680px,94vw);padding:13px 18px;border-radius:14px;'
7039      + 'border:2px solid var(--accent,#6366f1);background:var(--surface,#fff);'
7040      + 'box-shadow:0 14px 40px rgba(0,0,0,.22);font-family:Poppins,system-ui,sans-serif;'
7041      + 'font-size:.88rem;color:var(--txt,#111)';
7042    bar.innerHTML = '<span>' + tt('You have explored ' + seen.length + ' projects. Save them, follow their health and get alerts — free.',
7043                                  'Vous avez exploré ' + seen.length + ' projets. Enregistrez-les, suivez leur santé et recevez des alertes — gratuit.') + '</span>'
7044      + '<a href="/account.html" style="flex:none;background:var(--accent,#6366f1);color:#fff;text-decoration:none;'
7045      + 'font-weight:600;border-radius:10px;padding:9px 16px">' + tt('Create a free account', 'Créer un compte gratuit') + '</a>'
7046      + '<button type="button" aria-label="' + tt('Close','Fermer') + '" style="flex:none;background:none;border:none;'
7047      + 'font-size:1.2rem;line-height:1;color:var(--muted,#888);cursor:pointer;padding:4px 6px">&times;</button>';
7048    bar.querySelector('button').addEventListener('click', function(){
7049      bar.remove();
7050      try { localStorage.setItem(DISMISS, '1'); } catch(e){}
7051      try { sessionStorage.setItem(DISMISS + '_s', '1'); } catch(e){}
7052      if (window.osaiTrack) osaiTrack('softwall_dismiss', { seen: seen.length });
7053    });
7054    (document.body || document.documentElement).appendChild(bar);
7055    try { sessionStorage.setItem(DISMISS + '_s', '1'); } catch(e){}
7056    if (window.osaiTrack) osaiTrack('softwall_shown', { seen: seen.length });
7057  }catch(e){}
7058})();
7059
7060/* ═══════════════════════════════════════════════════════════════════════════
7061   [16/08/2026] NEWSLETTER SUR LES FICHES — piste n°1 de la feuille de route
7062
7063   Le formulaire d'inscription existait UNIQUEMENT dans le pied de page,
7064   3e colonne, tout en bas de pages de plusieurs milliers de pixels —
7065   invisible par construction, meme lecon que le lien « Claim » du footer.
7066   Ce bloc remonte l'inscription sur les fiches, la ou les visiteurs
7067   atterrissent, sans regenerer une seule page : c'est la nav qui l'injecte.
7068
7069   Regles tenues :
7070   . jamais pour un membre connecte (cookie osai_m=1) : il a deja le
7071     reglage dans son compte ;
7072   . jamais apres une inscription reussie (localStorage osai_nl=1) ;
7073   . les TROIS chaines affichees existent deja dans les dictionnaires plus
7074     haut (« Stay in the loop », le pitch, « Subscribe ») : les six langues
7075     sont couvertes sans une traduction de plus ;
7076   . le POST recopie le contrat de wireSub() (api/subscribe.php, double
7077     opt-in). Recopie et non reutilise : initNewsletter() ne cable que les
7078     formulaires presents au moment ou il passe, et ce bloc peut naitre
7079     apres lui — un formulaire jamais cable serait un bouton mort.
7080   ═══════════════════════════════════════════════════════════════════════════ */
7081(function(){
7082  try{
7083    var m = location.pathname.match(/^\/(?:[a-z]{2}\/)?(project|model|tool)\/[^\/]+\.html$/);
7084    if (!m) return;
7085    if (/(?:^|;\s*)osai_m=1(?:;|$)/.test(document.cookie)) return;   /* membre : jamais */
7086    try { if (localStorage.getItem('osai_nl') === '1') return; } catch(e){}
7087    var t = function(s){ return (typeof window.osaiT === 'function') ? window.osaiT(s) : s; };
7088
7089    function build(){
7090      if (document.getElementById('oNlBand')) return;
7091      var band = document.createElement('div');
7092      band.id = 'oNlBand';
7093      band.style.cssText = 'margin:26px auto;max-width:760px;padding:20px 22px;border-radius:14px;'
7094        + 'border:1px solid var(--border2,rgba(99,102,241,.25));background:var(--surface2,rgba(99,102,241,.05));'
7095        + 'font-family:inherit;box-sizing:border-box';
7096      band.innerHTML =
7097          '<div style="font-weight:700;font-size:1rem;margin:0 0 4px;color:var(--txt,#111)">' + t('Stay in the loop') + '</div>'
7098        + '<p style="margin:0 0 12px;font-size:.88rem;line-height:1.55;color:var(--muted,#5a6d87)">'
7099        +   t('The best of open-source AI in your inbox — new models, trending projects and releases. No spam, unsubscribe anytime.') + '</p>'
7100        + '<form class="osai-sub" data-src="fiche" style="display:flex;gap:8px;flex-wrap:wrap;margin:0">'
7101        +   '<input type="email" required placeholder="[email protected]" aria-label="Email address" '
7102        +     'style="flex:1 1 200px;min-width:0;height:42px;padding:0 13px;border-radius:10px;'
7103        +     'border:1px solid var(--border2,rgba(0,0,0,.18));background:var(--surface,#fff);color:var(--txt,#111);font:inherit;font-size:.9rem">'
7104        +   '<button type="submit" style="flex:none;height:42px;padding:0 18px;border:none;border-radius:10px;'
7105        +     'background:var(--accent,#6366f1);color:#fff;font:inherit;font-size:.9rem;font-weight:600;cursor:pointer">'
7106        +     t('Subscribe') + '</button>'
7107        + '</form>'
7108        + '<div class="sub-msg" style="margin:8px 0 0;font-size:.85rem;min-height:1em"></div>';
7109
7110      /* ANCRE — verifie dans un vrai navigateur le 16/08 : PAS une <section>.
7111         Les sections des fiches vivent dans .fwrap, qui porte l'animation
7112         d'apparition au defilement : tout element etranger qu'on y insere
7113         reste ecrase (46 px mesures) car la revelation ne cible que les .sec.
7114         On accroche donc le bloc en FIN DE HEROS (.hero-main, flux normal,
7115         ~20 % de profondeur, visible sans defiler beaucoup) ; a defaut,
7116         juste avant le pied de page — moins bien, mais jamais casse. */
7117      var hero = document.querySelector('.hero-main');
7118      var foot = document.getElementById('site-footer');
7119      if (hero) { band.style.margin = '18px 0 0'; hero.appendChild(band); }
7120      else if (foot && foot.parentNode) foot.parentNode.insertBefore(band, foot);
7121      else return;
7122
7123      var form = band.querySelector('form');
7124      form.__wired = true;   /* si initNewsletter() passe apres nous, il verra
7125                                le drapeau et n'attachera pas un 2e gestionnaire
7126                                — sans lui, chaque clic posterait DEUX fois */
7127      form.addEventListener('submit', function(e){
7128        e.preventDefault();
7129        var email = form.querySelector('input[type=email]');
7130        var msg = band.querySelector('.sub-msg');
7131        var btn = form.querySelector('button');
7132        if (!email || !email.value) return;
7133        btn.disabled = true; var orig = btn.textContent; btn.textContent = 'Subscribing…';
7134        fetch('/api/subscribe.php', { method:'POST', headers:{'Content-Type':'application/json'},
7135          body: JSON.stringify({ email: email.value, src: 'fiche', freq: 'weekly' }) })
7136        .then(function(r){ return r.json(); })
7137        .then(function(j){
7138          if (j && j.ok){
7139            msg.style.color = 'var(--accent,#6366f1)';
7140            msg.textContent = j.already ? "You're already on the list." : t('Check your inbox — one click to confirm.');
7141            try { localStorage.setItem('osai_nl', '1'); } catch(e){}
7142            if (window.osaiTrack) osaiTrack('nl_subscribe', { src: 'fiche' });
7143            form.reset();
7144          } else {
7145            msg.style.color = '#e0245e';
7146            msg.textContent = (j && j.error) ? j.error : "Hmm, that didn't work — please try again.";
7147          }
7148        })
7149        .catch(function(){ msg.style.color = '#e0245e'; msg.textContent = 'Network error — please try again.'; })
7150        .then(function(){ btn.disabled = false; btn.textContent = orig; });
7151      });
7152      if (window.osaiTrack) osaiTrack('nl_seen', { page: m[1] });
7153    }
7154
7155    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', build);
7156    else build();
7157  }catch(e){}
7158})();
7159
7160/* ── [04/08] compteur de visites maison (api/pv.php) : une date, un chemin,
7161   un nombre — RIEN sur le visiteur (pas d'IP, pas de cookie). Ne doit
7162   jamais bloquer la page : tout echec est avale. ── */
7163(function(){
7164  try{
7165    if (/localhost|127\.0\.0\.1/.test(location.hostname)) return;
7166    var chemin = location.pathname || '/';
7167    var drapeaux = '';
7168    try {
7169      /* [04/08 - corrige apres « 1 vue et 0 visiteur ? » de Romain]
7170         DEUX choses differentes, que la v1 confondait :
7171           s=1 : premiere page de la session   -> une VISITE du site
7172           u=1 : premiere fois que CETTE page est vue dans la session
7173                 -> un VISITEUR de plus pour cette page.
7174         Sans u, une page atteinte en 2e clic recoltait une vue et zero
7175         visiteur. Le navigateur se souvient, pas nous : rien ne sort d'ici. */
7176      if (!sessionStorage.getItem('osai_v')) { sessionStorage.setItem('osai_v', '1'); drapeaux += '&s=1'; }
7177      var cle = 'osai_p:' + chemin;
7178      if (!sessionStorage.getItem(cle)) { sessionStorage.setItem(cle, '1'); drapeaux += '&u=1'; }
7179    } catch(e){}
7180    var u = '/api/pv.php?p=' + encodeURIComponent(chemin) + drapeaux;
7181    if (navigator.sendBeacon) { navigator.sendBeacon(u); }
7182    else { var img = new Image(); img.src = u; }
7183  }catch(e){}
7184})();
7185
7186/* ═══════════════════════════════════════════════════════════════════════════
7187   TELECHARGEMENTS SUR LES FICHES PROJET            [09/08/2026]
7188   ───────────────────────────────────────────────────────────────────────────
7189   Une etoile ne redescend jamais : un projet arrete depuis deux ans garde
7190   chacune de celles qu'il a gagnees. Les telechargements, eux, baissent quand
7191   les gens s'en vont. C'est la seule mesure d'usage REEL du catalogue, et on
7192   la repete dans nos articles depuis ce matin sans l'afficher nulle part.
7193
7194   Pourquoi ici et pas dans le generateur : baker le chiffre dans le HTML
7195   demanderait de regenerer 10 000 fiches x 6 langues, un marathon de plusieurs
7196   heures avec les traducteurs. L'injection cote navigateur donne le meme
7197   resultat visible aujourd'hui, sur toutes les langues d'un coup. Le chiffre
7198   passera dans le HTML a la prochaine regeneration naturelle.
7199
7200   L'index fait 40 Ko et n'est demande QUE sur une fiche projet, une fois par
7201   session (le navigateur le garde ensuite). 1 413 projets sur 6 402 en ont un :
7202   l'absence est la norme, et une fiche sans chiffre ne montre rien plutot
7203   qu'un zero qui laisserait croire a l'abandon.
7204   ═══════════════════════════════════════════════════════════════════════════ */
7205(function(){
7206  try{
7207    var chemin = location.pathname.replace(/^\/(fr|it|es|de|pt)(?=\/)/, '');
7208    var m = chemin.match(/^\/project\/([a-z0-9-]+)\.html$/);
7209    if (!m) return;
7210    var slug = m[1];
7211    var grille = document.querySelector('.facts');
7212    if (!grille) return;
7213
7214    var abrege = function(n){
7215      if (n >= 1e9) return (Math.round(n/1e8)/10).toString().replace(/\.0$/,'') + 'B';
7216      if (n >= 1e6) return (Math.round(n/1e5)/10).toString().replace(/\.0$/,'') + 'M';
7217      if (n >= 1e3) return (Math.round(n/100)/10).toString().replace(/\.0$/,'') + 'k';
7218      return String(n);
7219    };
7220
7221    fetch('/downloads-index.json', {cache:'force-cache'})
7222      .then(function(r){ return r.ok ? r.json() : null; })
7223      .then(function(j){
7224        var n = j && j.t && j.t[slug];
7225        if (!n) return;                       /* pas de paquet : on n'affiche rien */
7226        var d = document.createElement('div');
7227        d.className = 'fact';
7228        d.innerHTML =
7229          '<svg class="fk-ic" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"'
7230        + ' stroke-width="2" stroke-linecap="round" stroke-linejoin="round">'
7231        + '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/>'
7232        + '<line x1="12" y1="15" x2="12" y2="3"/></svg>'
7233        + '<div class="fb"><div class="v">' + abrege(n) + '</div>'
7234        + '<div class="k">Downloads / month</div></div>';
7235        d.title = n.toLocaleString('en-US') + ' downloads in the last month (npm or PyPI)';
7236        grille.appendChild(d);
7237
7238        /* ── Le badge propose au mainteneur ───────────────────────────────
7239           La fiche ne proposait que le badge etoiles. Or GitHub affiche deja
7240           le compteur d'etoiles juste a cote : le repeter n'apporte rien, et
7241           c'est probablement pourquoi deux badges seulement ont ete poses en
7242           un mois. Le nombre de telechargements, lui, n'est visible nulle
7243           part ailleurs sur sa page.
7244           Quand le projet a un paquet, on propose donc CELUI-LA par defaut,
7245           avec un lien pour revenir aux etoiles : le choix reste au
7246           mainteneur, mais le meilleur des deux est devant. */
7247        try{
7248          var apercu = document.querySelector('.badge-preview');
7249          var code   = document.getElementById('osaiBadgeSnip');
7250          var texte  = document.querySelector('.badge-p');
7251          if (!apercu || !code) return;
7252
7253          var urlEt = '/badge.php?tool=' + slug;
7254          var urlDl = urlEt + '&dl=1';
7255          var mdEt  = code.textContent;
7256          var mdDl  = mdEt.replace('/badge.php?tool=' + slug, '/badge.php?tool=' + slug + '&dl=1');
7257          var txtEt = texte ? texte.textContent : '';
7258          var txtDl = 'Add this live badge to your README \u2014 how many times your package was '
7259                    + 'downloaded last month, refreshed every week. GitHub already shows your stars; '
7260                    + 'this is the number nobody else displays.';
7261
7262          var bascule = document.createElement('a');
7263          bascule.href = '#';
7264          bascule.style.cssText = 'display:inline-block;font-size:.82rem;'
7265                                + 'color:var(--muted,#667);text-decoration:underline;cursor:pointer';
7266          var mode = 'dl';
7267          var poser = function(){
7268            var dl = (mode === 'dl');
7269            apercu.src = dl ? urlDl : urlEt;
7270            code.textContent = dl ? mdDl : mdEt;
7271            if (texte) texte.textContent = dl ? txtDl : txtEt;
7272            bascule.textContent = dl ? 'Prefer the stars badge?' : 'Show the downloads badge instead';
7273          };
7274          bascule.addEventListener('click', function(e){
7275            e.preventDefault(); mode = (mode === 'dl') ? 'et' : 'dl'; poser();
7276          });
7277          poser();
7278          /* Pas dans la rangee de l'extrait : celle-ci est un flex en
7279             align-items:stretch, le lien s'y etirait sur toute la hauteur du
7280             bouton Copy et tombait de travers. Les deux actions secondaires se
7281             tiennent mieux cote a cote, sous l'extrait.
7282             Une rangee souple plutot qu'un point de separation : a 375 px les
7283             deux liens passent a la ligne, et un point tout seul restait
7284             orphelin au bout de la premiere. Le gap fait le meme travail sans
7285             rien laisser trainer. */
7286          var plus = document.querySelector('.badge-more');
7287          if (plus && plus.parentNode) {
7288            var rangee = document.createElement('div');
7289            rangee.style.cssText = 'display:flex;flex-wrap:wrap;align-items:baseline;'
7290                                 + 'gap:4px 18px;margin-top:.6rem';
7291            plus.parentNode.insertBefore(rangee, plus);
7292            plus.style.marginTop = '0';
7293            rangee.appendChild(plus);
7294            rangee.appendChild(bascule);
7295          } else {
7296            bascule.style.marginTop = '.5rem';
7297            (code.parentNode || apercu.parentNode).appendChild(bascule);
7298          }
7299        }catch(e){}
7300      })
7301      .catch(function(){});
7302  }catch(e){}
7303})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.