← Tous les articles

De 76 à 100 : obtenir un score Lighthouse parfait

En bref : Un site portfolio personnel est passé d’un score de performance mobile Lighthouse 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 substitution de variable CSS particulièrement sournoise qui provoquait 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 initial Lighthouse sur mobile :

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 dans tous les sens pendant le chargement.


Phase 1 : gains rapides en accessibilité

Avant de s’attaquer à la performance, j’ai corrigé les problèmes d’accessibilité pour établir une base de référence :

Labels de formulaire

Remplacement des spans décoratifs par de véritables éléments <label> :

<!-- 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 pied de page et les labels de formulaire utilisaient --color-text-tertiary (blanc à 40 % d’opacité). Passage à --color-text-secondary (65 % d’opacité) pour respecter les exigences de contraste WCAG AA de 4,5:1.

Texte alternatif redondant

Les icônes de réseaux sociaux avaient un texte alternatif comme « LinkedIn icon » alors qu’elles se trouvaient dans des liens possédant déjà des aria-labels. Remplacement par 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. Ajout d’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 indiquait un « Element render delay » de 2 460 ms dans le détail 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 de pouvoir 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é directement dans le <head>.

Étape 2 : Charger la feuille de style complète de manière asynchrone grâce à 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 l’affichage à l’écran », ce qui évite le blocage. Une fois chargée, le gestionnaire onload la bascule sur 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 à environ 300 ms.


Phase 3 : le cauchemar du CLS commence

Après l’implémentation du CSS asynchrone, le CLS a en réalité empiré — passant à 0,119. Lighthouse indiquait <main> comme élément décalé.

Bug n° 1 : les commentaires CSS polluant la regex

Le script d’extraction utilisait une regex pour identifier 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 { ... }

Provoquait une correspondance de la regex depuis « Hero Section » jusqu’à l’accolade ouvrante, créant des sélecteurs malformés qui échouaient à la vérification des préfixes. 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 n° 2 : les règles des media queries extraites comme règles autonomes

La regex identifiait les règles à l’intérieur des media queries et les extrayait comme des 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 racine :

/* 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 grâce au CSS critique, puis… y restait parce que le CSS complet contenait les mêmes règles. Mais sur desktop, la surcharge mobile s’appliquait incorrectement.

Correction : suivre les positions des media queries et ignorer les règles qu’elles contiennent :

# 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 au premier affichage, cela ne peut pas contribuer au CLS.

Passage du 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 à l’intérieur de la zone visible.


Phase 5 : la preuve irréfutable

Le filmstrip de Lighthouse montrait le texte du hero se décalant visiblement entre les images. Pas un fondu — un déplacement horizontal.

Analyse approfondie des styles affectant le positionnement horizontal : - .hero__content utilise padding: 0 var(--gutter) - --gutter est défini à 48px dans :root

Et 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 de :root dans le 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())

Le CSS critique inclut désormais :

: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 — aucun changement, aucun décalage.


Phase 6 : CSP et Alpine.js

Un dernier problème : Alpine.js générait des erreurs dans la console concernant des violations de la Content Security Policy. Alpine utilise en interne l’évaluation dynamique d’expressions.

# In security headers middleware
CSP_DIRECTIVES = {
    "script-src": "'self' 'unsafe-inline' 'unsafe-eval'",
    # ...
}

La directive 'unsafe-eval' est nécessaire pour l’analyse d’expressions d’Alpine.js. L’alternative consiste à utiliser le build compatible CSP d’Alpine, qui présente 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,6 s 0,8 s

La preuve

Audit desktop affichant des scores parfaits dans les quatre catégories :

Audit Lighthouse desktop affichant des scores de 100/100/100/100

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

Audit Lighthouse mobile affichant des scores de 100/100/100/100


Points clés à retenir

1. Les variables CSS peuvent provoquer du CLS

Les surcharges de propriétés CSS personnalisées par media query 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 surcharges de variables, la mise en page se décalera au chargement de la feuille de style complète.

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 de :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 un moyen légitime de différer le chargement des feuilles de style sans complexité JavaScript supplémentaire.

4. Le débogage du CLS nécessite le filmstrip

La vue filmstrip de Lighthouse montre exactement quand les décalages se produisent. Sans elle, vous tâtonnez. L’élément signalé comme « décalé » n’est peut-être qu’un symptôme — cherchez ce qui provoque 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 car il n’est mesuré qu’au défilement de l’utilisateur.


La stack technique

  • Backend : FastAPI + Jinja2
  • Frontend : HTMX + Alpine.js (auto-hébergé, sans CDN)
  • CSS : CSS pur avec propriétés personnalisées, sans 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 filmstrip de 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

Articles connexes

Midjourney V8 Killed Your V7 Workflow: What Actually Changed

V8 isn't a better V7. It's a different creative loop: personalization over prompting, native 2K, conversation mode, and …

15 min de lecture

Every Iteration Makes Your Code Less Secure

43.7% of LLM iteration chains introduce more vulnerabilities than baseline. Adding SAST scanners makes it worse. SCAFFOL…

11 min de lecture

Your Agent Sandbox Is a Suggestion

An attacker opened a GitHub issue and shipped malware in Cline's next release. Agent sandboxes fail at three levels. Her…

18 min de lecture