De 76 à 100 : Atteindre un Score Lighthouse Parfait
En résumé : Un site portfolio personnel est passé d’un score de performance Lighthouse mobile de 76 avec un CLS de 0,493 à un parfait 100/100/100/100 dans toutes les catégories. Ce parcours a révélé des problèmes subtils de chargement CSS, des bugs de regex, et une redéfinition de variable CSS particulièrement sournoise qui causait des décalages de mise en page sur mobile.
Le Point de Départ
Le site était un portfolio FastAPI + Jinja2 avec HTMX et Alpine.js pour l’interactivité. Audit Lighthouse mobile initial :
| Métrique | Score |
|---|---|
| Performance | 76 |
| Accessibilité | 91 |
| Bonnes Pratiques | 100 |
| SEO | 100 |
| CLS | 0,493 |
Ce chiffre de CLS est brutal. Google considère tout ce qui dépasse 0,1 comme « médiocre ». À 0,493, la page sautait visiblement pendant le chargement.
Phase 1 : Gains Rapides en Accessibilité
Avant de m’attaquer à la performance, j’ai corrigé les problèmes d’accessibilité pour établir une base de référence :
Labels de Formulaire
J’ai transformé les spans décoratifs en éléments <label> appropriés :
<!-- Before: span with aria-describedby -->
<span class="contact-form__label">Email</span>
<input id="email" aria-describedby="...">
<!-- After: proper label association -->
<label for="email" class="contact-form__label">Email</label>
<input id="email">
Ratios de Contraste
Les titres du footer et les labels de formulaire utilisaient --color-text-tertiary (blanc à 40% d’opacité). J’ai augmenté à --color-text-secondary (65% d’opacité) pour respecter les exigences de contraste WCAG AA de 4,5:1.
Texte Alt Redondant
Les icônes de réseaux sociaux avaient un texte alt comme « LinkedIn icon » alors qu’elles étaient à l’intérieur de liens qui avaient déjà des aria-labels. J’ai changé en alt="" avec aria-hidden="true" pour éviter la répétition par les lecteurs d’écran.
Textes de Liens Identiques
Plusieurs liens « View Case Study » étaient indiscernables pour les lecteurs d’écran. J’ai ajouté des attributs aria-label avec les noms des projets :
<a href="/work/introl" aria-label="View Introl Branding case study">
View Case Study
</a>
Résultat : L’accessibilité est passée de 91 à 100.
Phase 2 : Le Problème du CSS Bloquant le Rendu
Lighthouse montrait un « Element render delay » de 2 460 ms dans la décomposition du LCP. Le coupable : un fichier CSS synchrone bloquant le premier affichage.
<!-- The problem: render-blocking stylesheet -->
<link rel="stylesheet" href="/static/css/styles.min.css">
Le navigateur devait télécharger et analyser l’intégralité de la feuille de style de 25 Ko avant d’afficher quoi que ce soit.
La Solution : CSS Critique + Chargement Asynchrone
Étape 1 : Extraire le CSS critique (au-dessus de la ligne de flottaison) dans un fichier séparé qui est intégré en ligne dans <head>.
Étape 2 : Charger la feuille de style complète de manière asynchrone en utilisant l’astuce media="print" :
<!-- Critical CSS inlined for instant first paint -->
<style>{% include "components/_critical.css" %}</style>
<!-- Full CSS loaded async (doesn't block render) -->
<link rel="stylesheet" href="/static/css/styles.min.css"
media="print" onload="this.media='all'">
<noscript>
<link rel="stylesheet" href="/static/css/styles.min.css">
</noscript>
L’attribut media="print" indique au navigateur « cette feuille de style n’est pas nécessaire pour le rendu à l’écran » donc elle ne bloque pas. Une fois chargée, le gestionnaire onload la bascule en media="all".
Le Script d’Extraction du CSS Critique
J’ai écrit un script Python pour extraire automatiquement le CSS critique des composants au-dessus de la ligne de flottaison :
CRITICAL_PREFIXES = [
".container",
".header",
".nav",
".hero",
".personal-photos",
]
def extract_critical_css(css_content: str) -> str:
# Strip comments to prevent regex pollution
css_no_comments = re.sub(r'/\*[\s\S]*?\*/', '', css_content)
# Extract :root variables
# Extract base reset rules
# Extract component rules by prefix
# Extract media queries containing critical selectors
...
Résultat : Le délai de rendu de l’élément LCP est passé de 2 460 ms à ~300 ms.
Phase 3 : Le Cauchemar du CLS Commence
Après avoir implémenté le CSS asynchrone, le CLS a en fait empiré—passant à 0,119. Lighthouse montrait <main> comme l’élément qui se décalait.
Bug #1 : Les Commentaires CSS Polluent la Regex
Le script d’extraction utilisait une regex pour faire correspondre les sélecteurs :
rule_pattern = r"([.#\w][^{]+)\{([^}]+)\}"
Problème : Les commentaires CSS n’étaient pas supprimés avant l’extraction. Un commentaire comme :
/* Hero Section - Editorial */
.hero { ... }
Causait la correspondance de la regex depuis « Hero Section » jusqu’à l’accolade ouvrante, créant des sélecteurs malformés qui échouaient à la vérification de préfixe. Les styles critiques de .hero étaient silencieusement ignorés.
Correction : Supprimer les commentaires avant toute opération regex :
css_no_comments = re.sub(r'/\*[\s\S]*?\*/', '', css_content)
Bug #2 : Les Règles des Media Queries Extraites Comme Autonomes
La regex correspondait aux règles à l’intérieur des media queries et les extrayait comme règles autonomes :
/* Full CSS structure */
.hero__title { font-size: 5rem; } /* Desktop */
@media (max-width: 768px) {
.hero__title { font-size: 1.875rem; } /* Mobile */
}
Le script extrayait les deux règles au niveau supérieur :
/* Broken critical CSS */
.hero__title { font-size: 5rem; }
.hero__title { font-size: 1.875rem; } /* Overrides desktop! */
Sur mobile, le titre s’affichait à la taille mobile depuis le CSS critique, puis… restait là parce que le CSS complet avait les mêmes règles. Mais sur desktop, la redéfinition mobile s’appliquait incorrectement.
Correction : Suivre les positions des media queries et ignorer les règles à l’intérieur :
# Find all media query ranges
media_ranges = []
for match in re.finditer(media_pattern, css_no_comments):
media_ranges.append((match.start(), match.end()))
def is_inside_media_query(pos: int) -> bool:
return any(start <= pos < end for start, end in media_ranges)
# Skip rules inside media queries during extraction
for match in re.finditer(rule_pattern, css_no_comments):
if is_inside_media_query(match.start()):
continue # Will be included with full media query block
# ... extract rule
Phase 4 : L’Hypothèse du 100vh
Le CLS était toujours à 0,116. Théorie : si rien sous la ligne de flottaison n’est visible lors de l’affichage initial, cela ne peut pas contribuer au CLS.
J’ai changé le hero de 85vh à 100vh :
.hero {
/* 100vh ensures nothing below fold is visible on initial paint */
min-height: 100vh;
}
Résultat : Aucun changement. CLS toujours à 0,116. Le décalage se produisait dans la zone visible.
Phase 5 : La Preuve Irréfutable
La pellicule Lighthouse montrait le texte du hero se décalant visiblement entre les images. Pas un fondu—un mouvement horizontal.
Analyse approfondie des styles qui affectent le positionnement horizontal :
- .hero__content utilise padding: 0 var(--gutter)
- --gutter est défini comme 48px dans :root
Puis j’ai trouvé :
/* In full CSS, but NOT in critical CSS */
@media (max-width: 768px) {
:root {
--gutter: var(--spacing-md); /* 24px */
}
}
La séquence sur mobile :
- Le CSS critique se charge :
--gutter: 48px - Le hero s’affiche avec 48px de padding latéral
- Le CSS complet se charge de manière asynchrone
- La media query définit
--gutter: 24px - Le padding du hero passe de 48px à 24px
- Le texte se redistribue et se décale = CLS 0,116
La Correction
Extraire les media queries :root comme partie du CSS critique :
# Extract :root media queries (variable overrides are critical)
root_media_pattern = r"@media[^{]+\{\s*:root\s*\{[^}]+\}\s*\}"
for match in re.finditer(root_media_pattern, css_no_comments):
critical_rules.append(match.group())
Maintenant le CSS critique inclut :
:root { --gutter: 48px; /* ... */ }
@media (max-width: 768px) {
:root { --gutter: var(--spacing-md); }
}
Le mobile s’affiche avec la gouttière correcte de 24px dès le départ. Quand le CSS complet se charge, --gutter est déjà à 24px—pas de changement, pas de décalage.
Phase 6 : CSP et Alpine.js
Un dernier problème : Alpine.js générait des erreurs de console concernant des violations de Content Security Policy. Alpine utilise l’évaluation dynamique d’expressions en interne.
# In security headers middleware
CSP_DIRECTIVES = {
"script-src": "'self' 'unsafe-inline' 'unsafe-eval'",
# ...
}
La directive 'unsafe-eval' est requise pour l’analyse des expressions d’Alpine.js. L’alternative est d’utiliser la version compatible CSP d’Alpine, qui a certaines limitations.
Résultats Finaux
| Métrique | Avant | Après |
|---|---|---|
| Performance | 76 | 100 |
| Accessibilité | 91 | 100 |
| Bonnes Pratiques | 100 | 100 |
| SEO | 100 | 100 |
| CLS | 0,493 | 0,033 |
| LCP | 2,6s | 0,8s |
La Preuve
Audit desktop montrant des scores parfaits dans les quatre catégories :

Audit mobile—celui qui compte le plus—atteignant également des notes parfaites :

Points Clés à Retenir
1. Les Variables CSS Peuvent Causer du CLS
Les redéfinitions par media query des propriétés personnalisées CSS sont invisibles dans le panneau des styles calculés de DevTools—vous ne voyez que la valeur finale. Si votre CSS critique n’inclut pas les redéfinitions de variables, la mise en page se décalera quand la feuille de style complète se chargera.
2. L’Extraction du CSS Critique Est Délicate
Les approches simples par regex échouent sur :
- Les commentaires CSS (peuvent polluer la correspondance des sélecteurs)
- Les règles à l’intérieur des media queries (doivent rester dans leurs blocs)
- Les media queries :root (les changements de variables affectent tout)
3. L’Astuce media="print" Fonctionne
Charger le CSS non critique avec media="print" onload="this.media='all'" est une façon légitime de différer le chargement des feuilles de style sans complexité JavaScript.
4. Le Débogage du CLS Nécessite la Pellicule
La vue pellicule de Lighthouse montre exactement quand les décalages se produisent. Sans elle, vous devinez. L’élément mis en évidence comme « se décalant » peut n’être qu’un symptôme—cherchez ce qui cause le décalage.
5. Les Sections Hero en 100vh Ont des Avantages Au-delà de l’Esthétique
Si votre hero remplit la zone visible, le contenu sous la ligne de flottaison ne peut pas contribuer au CLS parce qu’il n’est pas mesuré tant que l’utilisateur ne fait pas défiler.
La Stack Technique
- Backend : FastAPI + Jinja2
- Frontend : HTMX + Alpine.js (auto-hébergé, pas de CDN)
- CSS : CSS pur avec propriétés personnalisées, pas de préprocesseurs
- Optimisation : Script Python personnalisé pour l’extraction du CSS critique
- Hébergement : Railway avec compression GZip et mise en cache immuable
Outils Utilisés
- Lighthouse (Chrome DevTools)
- Vue pellicule Lighthouse pour le débogage du CLS
grepetregex101.compour l’analyse CSS- Python pour le script d’extraction du CSS critique
- iTerm 2
- Claude Code CLI avec Opus 4.5 et beaucoup d’ultrathink