← Tous les articles

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 :

  1. Le CSS critique se charge : --gutter: 48px
  2. Le hero s’affiche avec 48px de padding latéral
  3. Le CSS complet se charge de manière asynchrone
  4. La media query définit --gutter: 24px
  5. Le padding du hero passe de 48px à 24px
  6. 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 :

Lighthouse desktop audit showing 100/100/100/100 scores

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

Lighthouse mobile audit showing 100/100/100/100 scores


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
  • grep et regex101.com pour 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