CODE SOURCE
Compresser une image : les sources
Une page indépendante, une logique séparée et les composants communs du site.
- src/pages/image-compress.mjs
- src/assets/image-compress.mjs
- src/assets/image-compress-calcul.mjs
- src/shared/layout.mjs
- src/shared/support.mjs
- src/assets/style.css
- src/assets/calculator.css
- src/assets/conversion.css
- src/assets/percentage.css
- src/assets/image-compress.css
- src/assets/ad-layout.css
- src/assets/ads.mjs
- src/assets/webmcp.mjs
- site.config.mjs
src/pages/image-compress.mjs
export default {route:'/outils/compresser-image',active:'image-compress',script:'image-compress.mjs',title:'Compresser une image gratuitement en ligne',description:'Réduisez la taille de vos images JPG, PNG et WebP directement dans votre navigateur. Comparez les tailles et téléchargez vos images optimisées.',body:`<div class="page-heading"><a class="back" href="/">Tous les outils</a><div class="eyebrow">IMAGES</div><h1>Compresser une image</h1><p class="intro">Réduisez la taille de vos images gratuitement, directement dans votre navigateur.</p><p class="assurance">Gratuit <span>·</span> Sans inscription <span>·</span> Traitement dans votre navigateur</p></div><div class="image-compressor"><section class="workspace" id="compressor-settings"><h2>1. Importer vos images</h2><p class="muted small">JPG, JPEG, PNG ou WebP · Jusqu’à 20 images et 100 Mo par opération · 40 millions de pixels par image.</p><div class="dropzone" id="image-dropzone"><span class="upload-symbol" aria-hidden="true">↑</span><h2>Déposez vos images ici</h2><p>ou sélectionnez-les sur votre appareil</p><label class="button primary file-picker">Choisir mes images<input id="image-input" type="file" accept="image/jpeg,image/png,image/webp,.jpg,.jpeg,.png,.webp" multiple></label><p class="muted small">Le glisser-déposer est facultatif sur mobile</p></div><p class="small muted">Vos images sont traitées dans votre navigateur et ne sont pas envoyées sur nos serveurs.</p><div id="image-errors" aria-live="polite"></div><div id="image-selection" hidden><div class="list-heading"><h2>Vos images</h2><button id="image-clear" class="text-button">Réinitialiser</button></div><ul id="image-list" class="file-list"></ul></div><h2>2. Choisir le niveau</h2><div class="mode-picker compression-levels"><label><input type="radio" name="compression-level" value="light"><span>Léger<small>Qualité élevée, réduction modérée</small></span></label><label><input type="radio" name="compression-level" value="balanced" checked><span>Équilibré<small>Compromis recommandé</small></span></label><label><input type="radio" name="compression-level" value="strong"><span>Fort<small>Poids réduit, qualité réduite</small></span></label></div><h2>3. Régler la sortie</h2><label for="target-mode">Objectif de taille</label><select id="target-mode"><option value="level">Utiliser le niveau choisi</option><option value="target">Viser une taille cible par image</option><option value="total">Viser une taille cible pour l’ensemble</option></select><div id="target-panel" hidden><div class="target-input"><label for="target-size">Taille cible</label><input id="target-size" type="text" inputmode="decimal" placeholder="Ex. : 500"><select id="target-unit" aria-label="Unité de la taille cible"><option value="ko">Ko</option><option value="mo">Mo</option></select></div><p class="muted small">Une cible est un objectif, jamais une garantie. L’image reste utilisable si elle ne peut pas être réduite davantage.</p></div><details class="advanced-image"><summary>Paramètres avancés</summary><div class="field-pair"><div><label for="max-width">Largeur maximale (px)</label><input id="max-width" type="number" min="1" max="10000" placeholder="Aucune"></div><div><label for="max-height">Hauteur maximale (px)</label><input id="max-height" type="number" min="1" max="10000" placeholder="Aucune"></div></div><label class="check"><input id="keep-ratio" type="checkbox" checked> Conserver les proportions</label><label for="output-format">Format de sortie</label><select id="output-format"><option value="original">Conserver le format d’origine</option><option value="jpeg">JPG · photos, avec perte</option><option value="png">PNG · netteté et transparence</option><option value="webp">WebP · format moderne</option></select><p class="muted small">Une image PNG transparente exportée en JPG reçoit un fond blanc. Les images réencodées perdent généralement leurs EXIF.</p></details><div class="action-bar"><p id="image-count">Ajoutez une image pour commencer.</p><button id="compress" class="primary" disabled>Compresser les images</button></div></section><section id="image-results" class="result" hidden><div class="eyebrow">RÉSULTATS</div><h2>Vos images optimisées</h2><p id="image-summary" class="small"></p><div id="image-downloads"></div><button id="image-zip" class="primary" hidden>Télécharger tout en ZIP</button><div class="actions"><button id="image-edit" class="secondary">Modifier les réglages</button><button id="image-restart" class="text-button">Recommencer</button></div><p id="image-zip-error" class="field-error" hidden></p></section></div><aside class="ad-space" data-ad-tool="image" hidden aria-label="Publicité"><span>Publicité</span></aside><section class="guide"><div class="eyebrow">MODE D’EMPLOI</div><h2>Compresser une image en trois étapes</h2><ol class="steps"><li><h3>Importez vos images</h3><p>Ajoutez une ou plusieurs photos en JPG, PNG ou WebP.</p></li><li><h3>Choisissez la qualité</h3><p>Utilisez un niveau ou fixez une taille cible.</p></li><li><h3>Comparez et téléchargez</h3><p>Vérifiez les tailles puis téléchargez les fichiers optimisés ou le ZIP.</p></li></ol><h2>Qualité, dimensions et poids</h2><p>Le JPG convient aux photos mais compresse avec perte. Le PNG conserve la netteté et la transparence, avec une réduction parfois limitée. Le WebP est moderne mais sa compatibilité dépend de l’application qui recevra le fichier. Une résolution plus élevée augmente le poids et la mémoire nécessaire. Les pages ne sont jamais étirées, agrandies ou tournées.</p></section><section class="faq"><h2>Questions fréquentes</h2><details><summary>Mes images sont-elles envoyées sur un serveur ?</summary><p>Non. La lecture, le redimensionnement, la compression et le ZIP se font dans votre navigateur. Les fichiers et leurs noms ne sont pas transmis aux publicités ou statistiques.</p></details><details><summary>Pourquoi la cible n’est-elle pas toujours atteinte ?</summary><p>La cible est un objectif. L’outil s’arrête avant de rendre l’image inutilisable et affiche la taille obtenue. Essayez le niveau fort ou des dimensions maximales.</p></details><details><summary>Le PNG transparent peut-il devenir un JPG ?</summary><p>Oui, avec un fond blanc, car le JPG ne représente pas la transparence.</p></details><details><summary>Que faire si une image échoue ?</summary><p>Les autres images valides restent traitées. Vérifiez le format, la taille et les dimensions du fichier concerné, puis réessayez avec un lot plus petit.</p></details></section><div class="related-tools"><h2>Autres outils</h2><a href="/outils/pdf-vers">Convertir un PDF</a><a href="/outils/fusion-pdf">Fusionner des PDF</a><a href="/outils/calculateur-pourcentage">Calculateur de pourcentage</a><a href="/outils/calculateur-immobilier">Calculateur immobilier</a></div><div class="source-link"><a href="/outils/compresser-image/source">Consulter le code source du compresseur</a></div>`};
src/assets/image-compress.mjs
import {IMAGE_LIMITS,parseTarget,formatBytes,reduction,outputName,dimensions} from './image-compress-calcul.mjs';
import {zipFiles} from './conversion-engine.mjs';
const $=id=>document.getElementById(id);let items=[],busy=false,results=[];
const levelQuality=()=>({light:.9,balanced:.78,strong:.58})[document.querySelector('input[name=compression-level]:checked').value];
function msg(text){$('image-errors').textContent=text}
function refresh(){
$('image-selection').hidden=!items.length;$('image-list').replaceChildren();items.forEach((it,i)=>{const li=document.createElement('li');li.className='file-row';const img=document.createElement('img');img.src=it.url;img.alt=`Aperçu de ${it.file.name}`;img.width=64;img.height=64;img.className='image-thumb';const d=document.createElement('div');d.className='file-details';d.innerHTML=`<span class="file-name"></span><span class="file-meta"></span>`;d.querySelector('.file-name').textContent=it.file.name;d.querySelector('.file-meta').textContent=`${it.file.type.split('/')[1].toUpperCase()} · ${it.width} × ${it.height} px · ${formatBytes(it.file.size)}`;const b=document.createElement('button');b.className='text-button';b.textContent='Supprimer';b.onclick=()=>{URL.revokeObjectURL(it.url);items.splice(i,1);refresh()};li.append(img,d,b);$('image-list').append(li)});$('image-count').textContent=items.length?`${items.length} image(s) · ${formatBytes(items.reduce((n,i)=>n+i.file.size,0))}`:'Ajoutez une image pour commencer.';$('compress').disabled=busy||!items.length}
async function decode(file){if(!['image/jpeg','image/png','image/webp'].includes(file.type))throw new Error('Format non pris en charge. Choisissez un JPG, PNG ou WebP.');if(!file.size)throw new Error('Cette image est vide.');if(file.size>IMAGE_LIMITS.maxBytes)throw new Error(`Cette image dépasse ${formatBytes(IMAGE_LIMITS.maxBytes)}.`);const bitmap=await createImageBitmap(file);if(bitmap.width*bitmap.height>IMAGE_LIMITS.maxPixels||bitmap.width>IMAGE_LIMITS.maxSide||bitmap.height>IMAGE_LIMITS.maxSide){bitmap.close();throw new Error('Cette image est trop grande. Réduisez ses dimensions avant de continuer.')}return {bitmap,width:bitmap.width,height:bitmap.height}}
function outputSettings(){let format=$('output-format').value;return {format,level:levelQuality(),targetMode:$('target-mode').value,target:parseTarget($('target-size').value,$('target-unit').value),maxWidth:$('max-width').value,maxHeight:$('max-height').value,keepRatio:$('keep-ratio').checked}}
async function encode(item,settings){const dim=dimensions(item.width,item.height,settings);const canvas=document.createElement('canvas');canvas.width=dim.width;canvas.height=dim.height;const ctx=canvas.getContext('2d');if(settings.format==='jpeg')ctx.fillStyle='#fff',ctx.fillRect(0,0,dim.width,dim.height);ctx.drawImage(item.bitmap,0,0,dim.width,dim.height);const type=settings.format==='original'?item.file.type:`image/${settings.format}`;let quality=settings.level;let blob=await new Promise(r=>canvas.toBlob(r,type,quality));if(!blob)throw new Error('Le navigateur ne sait pas produire ce format. Essayez PNG ou JPG.');let target=settings.targetMode==='target'?settings.target.value:settings.targetMode==='total'?settings.target.value/(items.length||1):null;let attempts=0;while(target&&blob.size>target&&quality>.35&&attempts++<8){quality=Math.max(.35,quality-.08);blob=await new Promise(r=>canvas.toBlob(r,type,quality))}canvas.width=canvas.height=0;return {blob,quality,dim,format:type.split('/')[1]}}
async function clear(){if(busy)return;items.forEach(i=>{URL.revokeObjectURL(i.url);i.bitmap.close()});items=[];results=[];$('image-results').hidden=true;refresh();msg('');}
$('image-input').onchange=e=>add([...e.target.files]);const zone=$('image-dropzone');for(const t of ['dragenter','dragover'])zone.addEventListener(t,e=>{e.preventDefault();zone.classList.add('dragover')});for(const t of ['dragleave','drop'])zone.addEventListener(t,e=>{e.preventDefault();zone.classList.remove('dragover')});zone.ondrop=e=>add([...e.dataTransfer.files]);
async function add(files){if(busy)return;for(const file of files){if(items.length>=IMAGE_LIMITS.maxFiles){msg(`La liste est limitée à ${IMAGE_LIMITS.maxFiles} images.`);break}try{const d=await decode(file);items.push({file,url:URL.createObjectURL(file),...d})}catch(e){msg(`${file.name} : ${e.message}`)}}refresh()}
document.querySelectorAll('input[name=compression-level]').forEach(e=>e.onchange=()=>{if(results.length){results=[];$('image-results').hidden=true}});$('target-mode').onchange=()=>{$('target-panel').hidden=$('target-mode').value==='level';};$('image-clear').onclick=clear;$('image-restart').onclick=clear;$('image-edit').onclick=()=>{results=[];$('image-results').hidden=true};
$('compress').onclick=async()=>{if(busy||!items.length)return;busy=true;$('compress').disabled=true;results=[];const s=outputSettings();if(s.targetMode!=='level'&&s.target.error){msg(s.target.error);busy=false;refresh();return}try{for(let i=0;i<items.length;i++){const it=items[i];$('image-count').textContent=`Compression de ${i+1} sur ${items.length}…`;const out=await encode(it,s);results.push({item:it,...out});it.bitmap.close()}}catch(e){msg(e.message)}finally{busy=false;refresh();render()}};
function render(){const host=$('image-downloads');host.replaceChildren();let missed=0,totalBefore=0,totalAfter=0;for(const r of results){totalBefore+=r.item.file.size;totalAfter+=r.blob.size;const row=document.createElement('div');row.className='download-row';const info=document.createElement('span');const red=reduction(r.item.file.size,r.blob.size);info.textContent=`${r.item.file.name} · ${r.dim.width} × ${r.dim.height} px · ${formatBytes(r.item.file.size)} → ${formatBytes(r.blob.size)} · ${red>=0?red+' % réduit':`+${Math.abs(red)} % plus lourd`}`;const b=document.createElement('button');b.className='primary';b.textContent='Télécharger';b.onclick=()=>{const a=document.createElement('a');a.href=URL.createObjectURL(r.blob);a.download=outputName(r.item.file.name,r.format==='jpeg'?'jpeg':r.format);a.click();setTimeout(()=>URL.revokeObjectURL(a.href),30000)};row.append(info,b);host.append(row);if(safeTarget(r)&&r.blob.size>safeTarget(r))missed++}const s=outputSettings();$('image-summary').textContent=`${results.length} image(s) · ${formatBytes(totalBefore)} avant · ${formatBytes(totalAfter)} après · ${reduction(totalBefore,totalAfter)>=0?reduction(totalBefore,totalAfter):0} % de réduction totale${missed?' · '+missed+' cible(s) non atteinte(s)':''}`;$('image-results').hidden=!results.length;$('image-zip').hidden=results.length<2;$('image-zip').onclick=async()=>{const files=await Promise.all(results.map(async r=>({name:outputName(r.item.file.name,r.format==='jpeg'?'jpeg':r.format),buffer:await r.blob.arrayBuffer()})));try{const buf=await zipFiles(files);const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([buf],{type:'application/zip'}));a.download='images-compressees.zip';a.click()}catch(e){$('image-zip-error').hidden=false;$('image-zip-error').textContent=e.message}}}
function safeTarget(r){const s=outputSettings();if(s.targetMode==='level'||s.target.error)return null;return s.targetMode==='target'?s.target.value:s.target.value/(items.length||1)}
refresh();
src/assets/image-compress-calcul.mjs
export const IMAGE_LIMITS={maxFiles:20,maxBytes:100*1024*1024,maxPixels:40_000_000,maxSide:10000,maxOutputBytes:200*1024*1024};
export function parseTarget(value,unit){const s=String(value??'').trim().replace(',','.');if(!s)return {value:null};if(!/^\d+(?:\.\d+)?$/.test(s))return {error:'Saisissez une taille cible positive, par exemple 500 Ko ou 2 Mo.'};const n=Number(s)*(unit==='mo'?1024*1024:1024);return Number.isFinite(n)&&n>0&&n<=IMAGE_LIMITS.maxOutputBytes?{value:n}:{error:'Cette cible dépasse la limite de sortie autorisée.'}}
export const formatBytes=b=>b<1024?`${b} o`:b<1024*1024?`${(b/1024).toLocaleString('fr-FR',{maximumFractionDigits:1})} Ko`:`${(b/1024/1024).toLocaleString('fr-FR',{maximumFractionDigits:2})} Mo`;
export const reduction=(before,after)=>before?Math.max(-999,Math.round((1-after/before)*1000)/10):0;
export function outputName(name,format){const base=name.replace(/\.[^.]+$/,'').replace(/[<>:"/\\|?*\x00-\x1f]/g,'-').replace(/[. ]+$/,'').trim().slice(0,100)||'image';return `${base}-compressee.${format==='jpeg'?'jpg':format}`}
export function dimensions(width,height,{maxWidth,maxHeight,keepRatio=true}={}){let w=width,h=height;const mw=Number(maxWidth)||Infinity,mh=Number(maxHeight)||Infinity;if(w<=mw&&h<=mh)return {width:w,height:h,scaled:false};if(!keepRatio)return {width:Math.min(w,mw),height:Math.min(h,mh),scaled:true};const scale=Math.min(mw/w,mh/h);return {width:Math.max(1,Math.floor(w*scale)),height:Math.max(1,Math.floor(h*scale)),scaled:true}}
src/shared/layout.mjs
import { support } from './support.mjs';
import config from '../../site.config.mjs';
export const escape = s => String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
export function layout({ title, description, route, body, script, active }) {
const navigation=[['fusion-pdf','fusion','Fusionner'],['separation-pdf','separation','Séparer'],['pdf-vers','pdf-vers','PDF vers'],['compresser-image','image-compress','Images'],['calculateur-immobilier','immobilier','Immobilier'],['calculateur-pourcentage','pourcentage','% Pourcentage']].map(([slug,key,label])=>`<a href="/outils/${slug}" ${active===key?'aria-current="page"':''}>${label}</a>`).join('');
return `<!doctype html><html lang="fr"><head><meta charset="utf-8"><meta name="google-adsense-account" content="ca-pub-5015303387352167"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escape(title)} | ${escape(config.name)}</title><meta name="description" content="${escape(description)}"><link rel="canonical" href="${config.origin}${route}"><link rel="icon" href="/assets/favicon.svg" type="image/svg+xml"><link rel="stylesheet" href="/assets/style.css">
<!-- Chargé en production par ads.mjs, avec la CMP Google publiée dans AdSense. -->
<link rel="stylesheet" href="/assets/calculator.css">
<link rel="stylesheet" href="/assets/conversion.css">
<link rel="stylesheet" href="/assets/percentage.css">
<link rel="stylesheet" href="/assets/image-compress.css">
<link rel="stylesheet" href="/assets/ad-layout.css">
<template id="adsense-script"><script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-5015303387352167" crossorigin="anonymous"></script></template>
<script type="module" src="/assets/ads.mjs"></script></head><body><a class="skip" href="#contenu">Aller au contenu</a><header><nav aria-label="Navigation principale"><a class="brand" href="/"><span class="brand-mark" aria-hidden="true">✳</span>${escape(config.name)}</a><div class="nav-links">${navigation}</div><a class="header-kofi" href="https://ko-fi.com/S0D7273NVT" target="_blank" rel="noopener noreferrer"><span aria-hidden="true">☕</span> Soutenez moi</a></nav></header><div class="site-layout"><aside class="ad-rail ad-rail-left" data-ad-rail="left" hidden aria-label="Publicité"><span>Publicité</span></aside><main id="contenu">${body}</main></div>${support}<footer><span>${escape(config.name)} · Des outils à portée de main</span><div><a href="/privacy">Confidentialité</a><a href="/cookies">Cookies</a><a href="/mentions-legales">Mentions légales</a></div></footer>${script ? `<script type="module" src="/assets/${script}"></script>` : ''}</body></html>`;
}
export const heading = (id, title, intro) => `<div class="page-heading"><a class="back" href="/">Tous les outils</a><div class="eyebrow">OUTILS PDF</div><h1>${title}</h1><p class="intro">${intro}</p><p class="assurance">Gratuit <span>·</span> Sans inscription <span>·</span> Traitement dans votre navigateur</p></div>`;
export const upload = multiple => `<div class="dropzone" id="dropzone"><span class="upload-symbol" aria-hidden="true">↑</span><h2>Déposez ${multiple ? 'vos PDF' : 'votre PDF'} ici</h2><p>ou sélectionnez ${multiple ? 'des fichiers' : 'un fichier'} sur votre appareil</p><label class="button primary file-picker">${multiple ? 'Choisir mes PDF' : 'Choisir mon PDF'}<input id="file-input" type="file" accept="application/pdf,.pdf" ${multiple ? 'multiple' : ''}></label><p class="muted small">PDF uniquement · Vos documents restent sur votre appareil</p></div>`;
export const feedback = `<div id="import-errors" aria-live="polite"></div><p id="status" role="status" aria-live="polite"></p><div id="error" role="alert" hidden></div>`;
export const result = `<section id="result" class="result" aria-labelledby="result-title" hidden><div class="eyebrow">TERMINÉ</div><h2 id="result-title">Vos documents sont prêts</h2><div id="downloads"></div><div class="actions"><button id="back-settings" class="secondary">Revenir aux réglages</button><button id="restart" class="text-button">Recommencer</button></div><p id="zip-error" role="alert" hidden></p></section>`;
export function guide(kind, steps, faqs) { return `<aside class="ad-space" data-ad-tool="${kind}" hidden aria-label="Publicité"><span>Publicité</span></aside><section class="guide"><div class="section-heading"><span class="eyebrow">MODE D’EMPLOI</span><h2>Trois étapes, tout simplement</h2></div><ol class="steps">${steps.map(([title, text]) => `<li><h3>${title}</h3><p>${text}</p></li>`).join('')}</ol></section><section class="faq"><h2>Vos questions, nos réponses</h2>${faqs.map(([q,a])=>`<details><summary>${q}</summary><p>${a}</p></details>`).join('')}</section><div class="source-link"><a href="/outils/${kind === 'fusion' ? 'fusion' : 'separation'}-pdf/source">Consulter le code source de cet outil</a></div>`; }
export const commonFaqs = [
['Mes documents sont-ils envoyés quelque part ?', 'La lecture, les aperçus, les PDF générés et les ZIP sont traités dans votre navigateur. Aucun document ni nom de fichier n’est envoyé à un serveur. Les documents ne sont pas enregistrés dans un stockage persistant. Les ressources du site et, si elles sont activées à l’avenir, les annonces peuvent effectuer des requêtes réseau.'],
['Puis-je utiliser un PDF protégé ?', 'Les PDF chiffrés ou protégés ne sont pas pris en charge. Utilisez une copie non protégée que vous êtes autorisé à modifier. Cet outil ne retire pas les protections.'],
['La mise en page est-elle conservée ?', 'Les pages sont copiées sans être converties en images, en conservant leurs dimensions et leur orientation. Les signatures numériques, formulaires interactifs, signets et fonctions avancées peuvent être perdus ou ne plus fonctionner. Vérifiez le résultat avant de l’utiliser.'],
['Et pour les documents volumineux ?', 'La capacité dépend de la mémoire de votre appareil. Cette version limite la sélection à 150 Mo, 5 000 pages et 500 fichiers de sortie par opération. En cas de difficulté, fermez les autres onglets ou traitez moins de pages à la fois. Un fichier très complexe peut atteindre les limites de mémoire plus tôt.']
];
src/shared/support.mjs
export const support = `<aside class="support" aria-label="Soutenir le site"><div><h2>Ces outils vous sont utiles ?</h2><p>Un don aide à les maintenir gratuits et à en créer de nouveaux.</p></div><div class="support-button"><a class="kofi-button" href="https://ko-fi.com/S0D7273NVT" target="_blank" rel="noopener noreferrer"><span aria-hidden="true">☕</span> Soutenez moi</a></div></aside>`;
src/assets/style.css
:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#17243b;background:#f5f7fb;font-size:16px;line-height:1.55;font-synthesis:none;--blue:#2457db;--line:#dce3ee;--muted:#59677d}*{box-sizing:border-box}body{margin:0}button,input,a,label{ -webkit-tap-highlight-color:transparent}a{color:var(--blue);text-underline-offset:4px}button,input{font:inherit}button,.button{display:inline-flex;align-items:center;justify-content:center;gap:.5rem;min-height:46px;padding:.65rem 1.05rem;border-radius:8px;border:1px solid transparent;font-size:.9375rem;font-weight:600;cursor:pointer;text-decoration:none}button:disabled{cursor:not-allowed;opacity:.45}button:hover:not(:disabled),.button:hover{filter:brightness(.95)}:focus-visible{outline:3px solid #e39612;outline-offset:4px}.primary{color:#fff;background:var(--blue)}.secondary{background:#fff;border-color:var(--line);color:#263953}.text-button{background:transparent;color:var(--blue);padding-inline:.5rem}.skip{position:absolute;top:-70px;left:16px;background:#fff;padding:12px;z-index:10}.skip:focus{top:10px}header{background:#fff;border-bottom:1px solid var(--line)}nav{max-width:1160px;min-height:80px;padding:0 32px;margin:auto;display:flex;align-items:center;gap:42px}.brand{display:flex;gap:10px;align-items:center;color:#182844;text-decoration:none;font-weight:750;font-size:1.2rem;white-space:nowrap}.brand-mark{background:var(--blue);color:white;border-radius:8px;width:32px;height:32px;text-align:center;font-size:1.4rem;line-height:32px}.nav-links{display:flex;gap:28px}.nav-links a{text-decoration:none;color:var(--muted);font-size:.9375rem;padding:26px 0;border-bottom:3px solid transparent}.nav-links a[aria-current]{border-color:var(--blue);color:var(--blue);font-weight:650}.local-label{margin-left:auto;color:var(--muted);font-size:.8125rem;border:1px solid var(--line);padding:4px 9px;border-radius:5px}main{max-width:1000px;margin:auto;padding:32px 32px 56px}.back{color:var(--muted);font-size:.875rem;text-decoration:none;display:inline-block;margin-bottom:28px}.back:before{content:'←';margin-right:9px}.eyebrow{font-size:.75rem;letter-spacing:.12em;font-weight:750;color:var(--blue);margin-bottom:10px}h1{font-size:clamp(2rem,5vw,2.8rem);letter-spacing:-.04em;line-height:1.15;margin:0 0 16px;font-weight:750}h2{font-size:1.35rem;letter-spacing:-.02em;line-height:1.3;margin:0 0 12px}h3{font-size:1rem;margin:0 0 7px}.intro{font-size:1.0625rem;color:var(--muted);margin:0 0 18px}.assurance{font-size:.875rem;color:#475770;display:flex;gap:10px;flex-wrap:wrap;margin:0 0 28px}.workspace{background:#fff;border:1px solid var(--line);border-radius:14px;padding:24px;box-shadow:0 4px 16px #17243b04}fieldset{min-width:0;margin:0;padding:0;border:0}.dropzone{background:#f8faff;border:1.5px dashed #b9c9eb;border-radius:9px;padding:28px 18px;text-align:center}.dropzone.dragover{border-color:var(--blue);background:#edf2ff}.dropzone h2{font-size:1.2rem;margin:8px 0}.dropzone p{margin:6px 0 16px;color:var(--muted);font-size:.9375rem}.dropzone .small{margin:14px 0 0;font-size:.8125rem}.upload-symbol{display:block;font-size:1.8rem;line-height:1;margin:0 auto 14px;color:var(--blue);font-weight:500}.file-picker{position:relative}.file-picker:focus-within{outline:3px solid #e39612;outline-offset:4px}.file-picker input{position:absolute;width:100%;height:100%;opacity:0;inset:0;cursor:pointer}.muted{color:var(--muted)}.small{font-size:.875rem}.action-bar{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-top:24px;padding-top:20px;border-top:1px solid var(--line)}.action-bar p{font-size:.875rem;color:var(--muted);margin:0}.action-bar button{white-space:nowrap}.list-heading{display:flex;align-items:center;justify-content:space-between;margin-top:24px}.list-heading h2{margin:0}.file-list{list-style:none;padding:0;display:grid;gap:10px}.file-row{display:flex;align-items:center;gap:14px;border:1px solid var(--line);border-radius:8px;padding:12px}.file-row.dragging{opacity:.5}.position{color:var(--muted);font-size:.875rem;width:22px;flex:none;text-align:center}.pdf-icon{color:var(--blue);background:#edf2ff;padding:8px 6px;border-radius:5px;font-size:.75rem;font-weight:750}.file-details{min-width:0;flex:1}.file-name{display:block;overflow-wrap:anywhere;font-weight:600;font-size:.9375rem}.file-meta{font-size:.8125rem;color:var(--muted)}.file-actions{display:flex;flex-wrap:wrap;gap:4px}.file-actions button{min-height:40px;padding:5px 9px;font-size:.875rem}.totals{font-weight:600;font-size:.9375rem}.warning{color:#795012;font-size:.875rem;background:#fff8e8;padding:10px;border-radius:6px}.field-error,#error,.import-error,#zip-error{color:#9b263a;font-size:.9375rem}#error,.import-error{background:#fff2f3;border:1px solid #f2ccd1;padding:12px;border-radius:8px;margin-top:16px}.import-error{display:flex;justify-content:space-between;align-items:center;gap:12px}.import-error span{overflow-wrap:anywhere}#status:not(:empty){color:#3058aa;margin:16px 0;font-size:.9375rem}#status:empty{margin:0}#status[data-busy=true]:before{content:'';display:inline-block;width:12px;height:12px;margin-right:9px;border:2px solid #aec1ed;border-top-color:var(--blue);border-radius:50%;animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}@media(prefers-reduced-motion:reduce){#status[data-busy=true]:before{animation:none}}.result{margin-top:24px;padding:24px;border-radius:9px;background:#f1f7ff;border:1px solid #c9dbf9}.actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:20px}.download-row{display:flex;justify-content:space-between;align-items:center;gap:14px;padding:12px 0;border-bottom:1px solid #d7e3f6}.download-row span{overflow-wrap:anywhere}.download-row a{flex:none}input[type=text],input[type=number]{background:white;border:1px solid #aab9cf;border-radius:7px;padding:10px 12px;min-height:46px;max-width:100%;color:#17243b}input[type=text]{width:100%;margin-top:7px}label{font-size:.9375rem;font-weight:550}input[aria-invalid=true]{border-color:#a0263b}.document-info{margin:20px 0;display:flex;align-items:center;gap:12px;padding-bottom:20px;border-bottom:1px solid var(--line)}.document-info .file-details{flex:1}.mode-picker{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:24px 0}.mode-picker legend{font-weight:650;margin-bottom:14px}.mode-picker label{display:flex;align-items:flex-start;gap:8px;border:1px solid var(--line);border-radius:8px;padding:14px 10px;cursor:pointer}.mode-picker label:has(:checked){border-color:var(--blue);background:#f2f6ff}.mode-picker input{margin-top:5px;accent-color:var(--blue);flex:none}.mode-picker small{display:block;font-size:.8125rem;font-weight:400;color:var(--muted);margin-top:4px}.selection-actions{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:16px 0}.selection-actions span{margin-left:auto;font-size:.875rem}.thumbnails{display:grid;grid-template-columns:repeat(auto-fill,minmax(115px,1fr));gap:12px;max-height:530px;overflow:auto;padding:6px}.thumbnail{display:flex;flex-direction:column;background:#f6f8fb;border:2px solid var(--line);padding:9px;position:relative;min-height:173px;border-radius:7px}.thumbnail[aria-pressed=true]{border-color:var(--blue);background:#edf3ff}.thumbnail img{width:100%;height:132px;object-fit:contain;background:#fff;pointer-events:none}.thumbnail .placeholder{height:132px;display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:.75rem;font-weight:400}.thumbnail .page-label{font-size:.8125rem;margin-top:8px}.thumbnail[aria-pressed=true] .page-label:before{content:'✓ ';color:var(--blue)}.range-row{display:flex;gap:12px;align-items:end;margin:12px 0;flex-wrap:wrap}.range-row label{flex:1;min-width:115px}.range-row input{display:block;width:100%;margin-top:6px}.range-row span{align-self:center;min-width:80px;font-size:.875rem;color:var(--muted)}.ad-space{margin:48px 0;min-height:120px;text-align:center;font-size:.75rem;color:var(--muted)}.guide{margin-top:56px}.steps{display:grid;grid-template-columns:repeat(3,1fr);gap:32px;list-style:none;padding:0;counter-reset:step}.steps li:before{counter-increment:step;content:'0' counter(step);display:block;color:var(--blue);font-size:.875rem;font-weight:700;margin-bottom:13px}.steps p{font-size:.9375rem;color:var(--muted);margin:0}.section-heading{margin-bottom:28px}.faq{margin-top:48px;max-width:800px}.faq h2{margin-bottom:24px}details{border-top:1px solid var(--line);padding:18px 0}details:last-child{border-bottom:1px solid var(--line)}summary{cursor:pointer;font-weight:600;font-size:.9375rem}details p{font-size:.9375rem;color:var(--muted);margin:14px 0 0}.source-link{margin-top:32px;font-size:.875rem}footer{border-top:1px solid var(--line);max-width:1096px;margin:auto;padding:28px 32px 36px;display:flex;justify-content:space-between;gap:20px;color:var(--muted);font-size:.8125rem}footer div{display:flex;gap:20px;flex-wrap:wrap}footer a{color:var(--muted)}.home{padding-top:28px}.home .intro{max-width:580px}.tool-grid{display:grid;grid-template-columns:1fr 1fr;gap:22px;margin-top:36px}.tool-card{display:block;padding:30px;background:#fff;border:1px solid var(--line);border-radius:12px;text-decoration:none;color:inherit}.tool-card:hover{border-color:#9bb3ee}.tool-card h2{margin-top:20px;font-size:1.5rem}.tool-card p{color:var(--muted);font-size:.9375rem;margin-bottom:24px}.tool-card .go{font-size:.9375rem;color:var(--blue);font-weight:650}.tool-number{color:var(--blue);font-size:.875rem}.prose{max-width:780px}.prose p{color:var(--muted)}pre{padding:20px;background:#142239;color:#e5ecf7;border-radius:8px;overflow:auto;font-size:.8125rem;line-height:1.65;tab-size:2}code{font-family:ui-monospace,Consolas,monospace}.source-section{margin-top:28px}.source-section h2{overflow-wrap:anywhere}[hidden]{display:none!important}@media(max-width:720px){nav{padding:0 20px;gap:22px;min-height:70px;flex-wrap:wrap}.local-label{display:none}.nav-links{gap:18px;margin-left:auto}.nav-links a{padding:22px 0}main{padding:24px 18px 40px}.back{margin-bottom:24px}.workspace{padding:16px}.dropzone{padding:24px 10px}.action-bar{align-items:stretch;flex-direction:column;gap:14px}.action-bar button{width:100%}.steps,.tool-grid,.mode-picker{grid-template-columns:1fr}.steps{gap:24px}.guide{margin-top:38px}.file-row{gap:10px;flex-wrap:wrap}.file-details{flex-basis:65%}.file-actions{margin-left:auto}.mode-picker{gap:8px}.mode-picker label{padding:12px}.mode-picker small{margin:0}.download-row{align-items:stretch;flex-direction:column}.selection-actions span{width:100%;margin:0}.result{padding:16px}.document-info{flex-wrap:wrap}footer{padding:24px 18px;flex-direction:column}.tool-card{padding:24px}.thumbnails{grid-template-columns:repeat(auto-fill,minmax(100px,1fr))}}@media(min-width:721px){.page-heading{padding-top:4px}}
src/assets/calculator.css
/* Le calculateur reprend les couleurs et commandes du site. */
.calculator{display:grid;grid-template-columns:minmax(0,1.05fr) minmax(0,1fr);gap:24px;align-items:start}.calculator .workspace{padding:24px}.calculator fieldset+fieldset{margin-top:28px;padding-top:24px;border-top:1px solid var(--line)}.calculator legend{font-size:1.15rem;font-weight:700;margin-bottom:16px}.calculator legend span{font-size:.8rem;color:var(--blue);margin-right:8px}.calc-field{margin:16px 0}.calc-field p{margin:6px 0}.unit-input{position:relative}.unit-input input{width:100%;padding-right:70px;font-variant-numeric:tabular-nums}.unit-input span{position:absolute;right:12px;top:18px;color:var(--muted);font-size:.8125rem;pointer-events:none}.field-pair{display:grid;grid-template-columns:1fr 1fr;gap:14px}.required{color:var(--blue)}select{font:inherit;font-size:.9375rem;color:#17243b;border:1px solid #aab9cf;border-radius:7px;width:100%;padding:11px 30px 11px 10px;background:white;margin:8px 0;min-height:46px}.calc-result{background:#fff;border:1px solid var(--line);border-radius:14px;padding:26px;position:sticky;top:20px;min-width:0}.main-metric{background:#eef3ff;border-radius:9px;padding:20px;margin:20px 0}.main-metric span{display:block;font-size:.9375rem}.main-metric strong{display:block;font-size:clamp(1.75rem,3vw,2.3rem);letter-spacing:-.03em;color:var(--blue);margin:4px 0;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.main-metric small{font-size:.8125rem;color:var(--muted)}.key-results{margin:0 0 26px}.key-results>div{padding:13px 0;border-bottom:1px solid var(--line)}.key-results dt{font-size:.875rem;color:var(--muted)}.key-results dt small{display:block;font-size:.75rem}.key-results dd{margin:3px 0 0;font-weight:700;font-size:1.15rem}.result-details{margin:16px 0;font-size:.875rem}.result-details>div{display:flex;justify-content:space-between;gap:18px;margin:10px 0;break-inside:avoid}.result-details dt{color:var(--muted)}.result-details dd{margin:0;text-align:right;font-variant-numeric:tabular-nums;font-weight:550;white-space:nowrap}.simulation-limit{font-size:.8125rem;color:var(--muted);border-top:1px solid var(--line);padding-top:16px;margin:24px 0 0}.notice{background:#edf3ff;border-left:3px solid var(--blue);padding:12px;font-size:.875rem}.calc-guide p{color:var(--muted);font-size:.9375rem}.finance-details{margin-top:20px}.stale{opacity:.48}#calc-stale{opacity:1}figure{margin:24px 0}figcaption{font-size:.9375rem;font-weight:650;margin-bottom:12px}.repayment-bars{height:18px;display:flex;overflow:hidden;border-radius:4px}.repayment-bars span{height:100%}#repayment-legend{list-style:none;padding:0;font-size:.8125rem}#repayment-legend li{margin:7px 0}#repayment-legend li:before{content:'';display:inline-block;width:10px;height:10px;background:var(--legend-color);margin-right:8px;border-radius:2px}.schedule{margin-top:28px}.table-scroll{max-width:100%;overflow:auto}table{border-collapse:collapse;min-width:780px;width:100%;font-size:.8125rem}th,td{text-align:right;padding:12px 10px;border-bottom:1px solid var(--line);white-space:nowrap}thead th{background:#f2f5fc;font-weight:650}tbody tr:nth-child(even){background:#fafbfe}.pagination{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:20px}.pagination span{font-size:.875rem}.print-only{display:none}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}#calc-errors{color:#9b263a;background:#fff2f3;padding:12px;border-radius:7px}.tool-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.tool-card{padding:24px}.tool-card h2{font-size:1.3rem}.nav-links{gap:22px}@media(max-width:850px){.calculator{grid-template-columns:1fr}.calc-result{position:static}.tool-grid{grid-template-columns:1fr}.nav-links{gap:14px}.nav-links a{font-size:.875rem}}@media(max-width:450px){.brand{font-size:1rem;gap:7px}.brand-mark{width:26px;height:26px;font-size:1.15rem;line-height:26px}nav{gap:10px;padding:0 14px}.nav-links{gap:12px}.nav-links a{font-size:.8125rem}.calculator .workspace,.calc-result{padding:18px}.field-pair{gap:12px}.result-details>div{gap:10px;flex-wrap:wrap}.result-details dd{margin-left:auto}.pagination{flex-wrap:wrap}.pagination span{order:-1;flex-basis:100%;text-align:center}}
@media print{@page{size:A4;margin:15mm}body{background:#fff;color:#000;font-size:11pt}header,footer,.back,.assurance,.eyebrow,.calculator form,.print-hide,.source-link,.ad-space,.calc-guide,.faq,.schedule,.skip,#calc-stale{display:none!important}main{max-width:none;padding:0}.calculator{display:block}.calc-result{position:static;border:0;padding:0}.page-heading h1{font-size:24pt}.calc-result h2{font-size:16pt}.main-metric{background:none;padding:0}.main-metric strong{font-size:22pt;color:black}.key-results{display:grid;grid-template-columns:1fr 1fr 1fr;gap:18px}.print-only{display:block}.print-only h2{margin-top:25px}.main-metric,.key-results,figure{break-inside:avoid}.repayment-bars{-webkit-print-color-adjust:exact;print-color-adjust:exact}.simulation-limit:after{content:' Le taux nominal n’est pas le TAEG. Modèle à taux fixe sur périodes mensuelles régulières, hors prêts variables, différés, remboursements anticipés et montages multiprêts. Les frais estimés peuvent différer des frais réels. Ce calcul ne vaut pas accord de financement.'}.print-invalid #calc-values,.print-invalid #print-parameters{display:none!important}.print-invalid .calc-result:before{content:'Aucune simulation à jour. Recalculez les résultats avant de les imprimer.';display:block;font-weight:bold}.stale{opacity:1}}
src/assets/conversion.css
.conversion-steps{list-style:none;display:flex;gap:10px;flex-wrap:wrap;padding:0;margin:0 0 24px;counter-reset:steps}.conversion-steps li{font-size:.8125rem;padding:7px 12px;border:1px solid var(--line);background:#fff;border-radius:6px;color:var(--muted)}.conversion-heading{margin:30px 0 14px;font-size:1.15rem}.conversion-heading:first-of-type{margin-top:0}.converter .dropzone{padding:22px 15px}.converter .dropzone h2{margin-top:0}.converter .upload-symbol{display:none}.conversion-formats{margin:16px 0}.page-range-choice{margin-top:22px;padding-top:20px;border-top:1px solid var(--line)}.page-range-choice>select{max-width:340px;display:block}.conversion-file-status{margin:5px 0;font-size:.875rem;font-weight:600}.conversion-output-group{margin:22px 0;padding-bottom:12px;border-bottom:1px solid var(--line)}.conversion-output-group h3{overflow-wrap:anywhere}.text-preview{white-space:pre-wrap;max-height:320px;overflow:auto;overflow-wrap:anywhere}.conversion-help{max-width:800px}.conversion-help p{font-size:.9375rem;color:var(--muted)}.related-tools{margin-top:32px;display:flex;gap:14px;flex-wrap:wrap;font-size:.875rem}.related-tools h2{width:100%;font-size:1.1rem}.tool-grid{grid-template-columns:repeat(2,minmax(0,1fr))}#conversion-files .file-details{flex-basis:60%}.source-index{overflow-wrap:anywhere}.nav-links{flex-wrap:wrap}html:has(.converter) .field-pair{align-items:start}@media(max-width:720px){.conversion-steps{gap:6px}.conversion-steps li{font-size:.75rem;padding:5px 8px}.tool-grid{grid-template-columns:1fr}nav{padding-top:12px;gap:6px}.nav-links{width:100%;margin-left:0;justify-content:space-between;gap:12px}.nav-links a{padding:10px 0}.brand{margin-bottom:2px}.conversion-formats label{padding:12px}.converter .field-pair{grid-template-columns:1fr;gap:4px}}@media print{html{background:#fff}}
src/assets/percentage.css
.percentage-layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(320px,.8fr);gap:24px;align-items:start}.percentage-tool{padding:24px}.percent-modes{grid-template-columns:1fr 1fr;margin:0 0 24px}.percent-modes label{min-height:86px}.direction{display:flex;gap:22px;align-items:center;margin:10px 0 20px}.direction legend{font-weight:600;margin-right:8px}.percent-result{background:#fff;border:1px solid var(--line);border-radius:14px;padding:24px;position:sticky;top:20px}.percent-main{background:#eef3ff;border-radius:9px;padding:20px;margin:20px 0}.percent-main span{display:block;font-size:.9rem;color:var(--muted)}.percent-main strong{display:block;color:var(--blue);font-size:2.4rem;letter-spacing:-.04em;margin-top:5px;overflow-wrap:anywhere}.formula{font-size:.9rem}.formula>div{padding:13px 0;border-bottom:1px solid var(--line)}.formula dt{color:var(--muted)}.formula dd{margin:5px 0 0;font-weight:600;overflow-wrap:anywhere}.local-note{margin-top:24px;padding-top:16px;border-top:1px solid var(--line);color:var(--muted);font-size:.8125rem}.explanation-title{margin-top:42px!important}.percentage-guide>p{max-width:820px;color:var(--muted)}.percent-result .stale{opacity:.5}.percent-result .notice{font-size:.875rem}.copy-status{color:#216b4b;min-height:1.4em}.percentage-tool .calc-field{margin:18px 0}.percentage-tool .unit-input input{padding-right:55px}.percentage-tool .rounding{border-top:1px solid var(--line);padding-top:18px;margin-top:8px}.rounding label{font-weight:600}.rounding select{width:auto;min-width:145px;margin-left:10px}.tool-grid{grid-template-columns:repeat(3,minmax(0,1fr))}@media(max-width:850px){.percentage-layout{grid-template-columns:1fr}.percent-result{position:static}.percent-modes{grid-template-columns:1fr 1fr}}@media(max-width:520px){.percent-modes{grid-template-columns:1fr}.rounding label{display:block}.rounding select{margin:8px 0 0;width:100%}.direction{flex-wrap:wrap;gap:12px}.tool-grid{grid-template-columns:1fr}}
src/assets/image-compress.css
.image-compressor{display:grid;grid-template-columns:minmax(0,1fr) minmax(320px,.8fr);gap:24px;align-items:start}.image-compressor>.workspace{padding:24px}.image-compressor h2{margin-top:28px}.image-compressor h2:first-child{margin-top:0}.compression-levels{grid-template-columns:repeat(3,1fr);margin:14px 0 24px}.compression-levels label{min-height:76px}.advanced-image{margin-top:22px}.advanced-image .field-pair{margin-top:18px}.advanced-image input[type=number]{width:100%;min-height:46px}.advanced-image label{display:block;margin:14px 0}.advanced-image .check{display:flex;gap:9px;align-items:center}.target-input{display:grid;grid-template-columns:1fr 120px;gap:10px;align-items:end;margin-top:14px}.target-input input{width:100%;min-height:46px;padding:10px 12px;border:1px solid #aab9cf;border-radius:7px}.target-input select{margin:0}.image-thumb{object-fit:contain;border-radius:6px;background:#f4f6fa;border:1px solid var(--line);flex:none}.image-compressor .file-row{align-items:center}.image-compressor .file-details{flex:1}.image-compressor .download-row{align-items:flex-start}.image-compressor .download-row span{line-height:1.5}.image-guide{max-width:820px}.image-guide p{color:var(--muted);font-size:.9375rem}.image-compressor .result{position:sticky;top:20px}.image-compressor .result>button{margin-top:16px}.related-tools{margin-top:32px;display:flex;gap:14px;flex-wrap:wrap;font-size:.875rem}.related-tools h2{width:100%;font-size:1.1rem;margin:0}.related-tools a{color:var(--blue)}@media(max-width:850px){.image-compressor{grid-template-columns:1fr}.image-compressor .result{position:static}}@media(max-width:560px){.compression-levels{grid-template-columns:1fr}.target-input{grid-template-columns:1fr}.image-compressor .file-row{flex-wrap:wrap}}
src/assets/ad-layout.css
.site-layout{max-width:1000px;margin:auto;display:grid;grid-template-columns:minmax(0,1fr);align-items:start}
.site-layout main{grid-column:1;min-width:0;width:100%;max-width:none;margin:0}
.ad-rail{display:none;min-height:600px;margin-top:80px;text-align:center;font-size:.75rem;color:var(--muted)}
.support{max-width:936px;margin:0 auto 32px;padding:24px;background:#fff;border:1px solid var(--line);border-radius:12px;display:flex;align-items:center;justify-content:space-between;gap:24px}
.support p{margin:0;color:var(--muted);font-size:.9375rem}.support h2{font-size:1.1rem;margin-bottom:6px}.support-button{flex:none}.support .kofi-button{background:#72a4f2;color:#142239;border-radius:8px;padding:12px 20px;font-weight:700;text-decoration:none;display:inline-flex;gap:8px;align-items:center}
nav{flex-wrap:wrap;gap:16px 28px}.nav-links{flex-wrap:wrap;gap:8px 22px}.nav-links a{white-space:nowrap}.local-label{white-space:nowrap}
@media(min-width:1100px){.site-layout:has(.ad-rail:not([hidden])){max-width:1260px;grid-template-columns:180px minmax(0,1fr);gap:24px}.site-layout:has(.ad-rail:not([hidden])) main{grid-column:2}.ad-rail:not([hidden]){display:block;grid-column:1;grid-row:1}}
@media(max-width:720px){.site-layout main{padding:24px 18px 40px}.support{margin:0 18px 24px;flex-direction:column;align-items:flex-start;gap:16px}.nav-links{margin-left:0;width:100%}.nav-links a{padding:8px 0}nav{padding-top:14px;padding-bottom:10px}}
@media print{.support,.ad-rail{display:none!important}}
.header-kofi{margin-left:auto;flex:none;display:inline-flex;align-items:center;gap:8px;padding:9px 14px;border-radius:8px;background:#72a4f2;color:#142239;text-decoration:none;font-size:.875rem;font-weight:700;white-space:nowrap}.privacy h2{margin-top:32px}.privacy{max-width:850px}.privacy p{line-height:1.75}
@media(max-width:1000px){nav .brand{order:0}.header-kofi{order:1}.nav-links{order:2;width:100%;margin-left:0}.nav-links a{padding:8px 0}nav{padding-block:12px}}
@media print{.header-kofi{display:none!important}}
.privacy-settings{font:inherit;color:var(--muted);background:transparent;padding:0;min-height:32px;text-decoration:underline;text-underline-offset:4px;text-align:left}footer{flex-wrap:wrap}
src/assets/ads.mjs
import config from './config.mjs';
// The AdSense tag deploys the Google CMP published in Privacy & messaging.
// Google's native TCF integration handles consent and ad eligibility.
// Do not wait for a custom consent event before loading the tag: it also loads the CMP.
export function startGoogleAds() {
if (!config.ads.enabled || location.hostname !== 'lupus-duj.pages.dev') return false;
if (document.head.querySelector('script[data-adsense-loaded]')) return false;
const template = document.getElementById('adsense-script');
if (!template) return false;
window.googlefc = window.googlefc || {};
window.googlefc.callbackQueue = window.googlefc.callbackQueue || [];
const settings = document.createElement('button');
settings.type = 'button';
settings.className = 'privacy-settings';
settings.textContent = 'Paramètres de confidentialité et de cookies';
settings.hidden = true;
document.querySelector('footer')?.append(settings);
window.googlefc.callbackQueue.push({ CONSENT_API_READY: () => {
if (typeof window.googlefc.showRevocationMessage === 'function') settings.hidden = false;
}});
settings.addEventListener('click', () => {
window.googlefc.callbackQueue.push(() => window.googlefc.showRevocationMessage());
});
const positions = [...document.querySelectorAll('[data-ad-tool],[data-ad-rail]')];
const desktop = window.matchMedia('(min-width:1100px)');
// No ad units on policy or source pages, but keep the CMP available for revocation.
const contentPage = /^\/outils\/?$/.test(location.pathname) || document.querySelector('[data-ad-tool]');
function placeAds() {
if (!contentPage) return;
for (const position of positions) {
if (position.querySelector('ins') || (position.dataset.adRail && !desktop.matches)) continue;
const slot = config.ads.slots[position.dataset.adTool || position.dataset.adRail];
if (!/^\d+$/.test(slot || '')) continue;
position.hidden = false;
const ad = document.createElement('ins');
ad.className = 'adsbygoogle';
ad.style.display = 'block';
ad.dataset.adClient = config.ads.client;
ad.dataset.adSlot = slot;
ad.dataset.adFormat = 'auto';
ad.dataset.fullWidthResponsive = 'true';
position.append(ad);
const observer = new MutationObserver(() => {
position.hidden = ad.dataset.adStatus === 'unfilled';
});
observer.observe(ad, { attributes: true, attributeFilter: ['data-ad-status'] });
(window.adsbygoogle = window.adsbygoogle || []).push({});
}
}
const script = template.content.querySelector('script').cloneNode(true);
script.dataset.adsenseLoaded = 'true';
script.referrerPolicy = 'strict-origin-when-cross-origin';
script.addEventListener('load', () => {
placeAds();
desktop.addEventListener('change', placeAds);
});
script.addEventListener('error', () => {
for (const position of positions) position.hidden = true;
});
document.head.append(script);
return true;
}
startGoogleAds();
src/assets/webmcp.mjs
// Extension facultative du navigateur ; ne reçoit ni contenu ni nom de document.
export function registerSelectionTool(tool) {
if(!document.modelContext?.registerTool)return;
const lifecycle=new AbortController();
try{Promise.resolve(document.modelContext.registerTool(tool,{signal:lifecycle.signal})).catch(()=>{})}catch{}
window.addEventListener('pagehide',()=>lifecycle.abort(),{once:true});
}
site.config.mjs
export default {
name: 'Les outils',
origin: 'https://lupus-duj.pages.dev',
ads: { enabled: true, client: 'ca-pub-5015303387352167', slots: { fusion: '4967098598', separation: '4967098598', immobilier: '4967098598', conversion: '4967098598', percentage: '4967098598', image: '4967098598', left: '6872786213' } }
};