L'outil avait raison. C'était bien le problème.
Je venais d'ajouter une entrée de changelog au site vitrine de sh0 et de corriger une page de documentation. Avant de pousser, j'ai lancé le contrôleur responsive que nous gardons exactement pour ça — un petit script qui pilote un Chrome headless, mesure le débordement horizontal à 390, 768 et 1280 pixels, et sort en code non nul si une page défile latéralement. Il a signalé un seul échec :
[DÉBORDE] /fr/docs/databases/backups-restore @390px
→ overflow=130px · coupable=a.flex.items-center [right=520]Précis. Reproductible. L'élément fautif nommé. Tout ce qu'un bon message d'erreur doit être, et il invitait exactement au mauvais correctif.
La réparation de cinq minutes que je n'ai pas faite
La lecture évidente : une ancre du pied de page est trop large à la largeur d'un téléphone. Sans doute la navigation précédent/suivant, où un libellé français plus long refuse de passer à la ligne. Ajouter min-w-0, ajouter truncate, relancer, vert, pousser. Cinq minutes.
Deux choses m'ont arrêté. La version anglaise de la même page mesurait zéro. Et notre propre règle maison, écrite après un incident précédent, dit de regarder les captures plutôt que de faire confiance au chiffre.
La capture faisait 520 pixels de large. La fenêtre en faisait 390.
Ce n'est pas une page contenant un élément trop large. C'est une page sans feuille de style. Chrome avait disposé du HTML brut à sa largeur naturelle, et le « débordement » rapporté par l'outil était l'écart entre un document non stylé et la fenêtre que j'avais demandée. La mesure était exacte au pixel près. Ce qu'elle mesurait n'était pas un défaut de mise en page — c'en était l'ombre portée.
Ce qui était réellement cassé
SvelteKit émet les liens vers les assets relativement à la profondeur de la page elle-même. La page anglaise vit à /docs/databases/backups-restore, trois segments de profondeur, donc son lien de feuille de style est :
html<link href="../../_app/immutable/assets/0.B9O4bhzJ.css">Depuis /docs/databases/, ../../ retombe sur la racine. Correct.
Ce même fichier prérendu était servi à /fr/docs/databases/backups-restore — quatre segments de profondeur. Désormais ../../ retombe sur /fr/, le navigateur demande /fr/_app/immutable/assets/0.B9O4bhzJ.css, reçoit un 404, et rend le document nu. Idem pour chaque balise de script. La page était aussi, évidemment, en anglais : c'était le fichier anglais.
Deux appels curl l'ont confirmé, et l'un des deux résume toute l'histoire en une ligne :
$ curl -s https://sh0.dev/fr/docs/databases/backups-restore | grep -o '<html lang="[a-z]*"'
<html lang="en"Pourquoi le fichier français n'existait pas
Le site est prérendu. Les pages vitrine ont des entrées explicites dans svelte.config.js — un tableau maintenu à la main de 57 chemins, déployé sur quatre locales, parce que ces pages se trouvent derrière des menus déroulants au survol que le crawler de prérendu ne sait pas suivre. Les pages de documentation n'ont jamais été ajoutées à ce tableau. Elles ont été laissées au crawler.
Le crawler suit les liens présents dans le HTML rendu. /fr/docs renvoie vers onze sous-pages. En les suivant, puis ce vers quoi elles pointent à leur tour, on atteint vingt-cinq pages. Les vingt-trois restantes ne sont accessibles que par une barre latérale que le crawl ne déploie jamais. Donc vingt-six pages de documentation françaises étaient prérendues (l'index plus les vingt-cinq accessibles depuis lui), et vingt-trois ne l'étaient pas. Pour ces vingt-trois, la réécriture qui retire la locale trouvait le chemin prérendu anglais, considérait qu'elle avait un fichier à servir, et le servait.
Vingt-trois pages, quatre locales. J'ai vérifié les 196 URLs de documentation localisées en production :
checked=196
mismatch=92Quatre-vingt-douze URLs, en ligne, servant de l'anglais non stylé. Pas une régression due à mon changement — cela partait en production depuis que la documentation était traduite.
Le correctif tient en deux lignes de spread et la liste qu'elles couvrent : un tableau docsPages de quarante-neuf routes, construit exactement comme l'était déjà le tableau pages de cinquante-sept entrées, déployé sur les mêmes quatre locales. Vingt-huit lignes de configuration, dont vingt-six sont les noms de routes. Le mécanisme existait depuis toujours. Personne ne l'avait étendu au second répertoire.
Deux fausses pistes, et le test qui coûte trente secondes
Le 500. En local, cette même route renvoyait un 500 avec TypeError: fetch failed. Tentant à poursuivre — c'est bruyant, c'est exactement sur la page suspecte. C'était un artefact du serveur de preview : adapter-node démarré sans ORIGIN, et le fetch que SvelteKit fait sur lui-même pour un chemin prérendu réécrit n'a nulle part où aller. La production ne l'a jamais eu. Définir ORIGIN l'a fait disparaître.
« C'est moi qui ai cassé ça. » La piste la plus coûteuse, parce qu'elle mène à annuler du bon travail. J'avais touché cette page quelques minutes plus tôt. Le test qui tranche a tenu en une commande : lancer le même contrôle sur /fr/docs/databases/connection-strings, une page que je n'avais jamais ouverte. Signature identique — 130 pixels, même coupable nommé, même right=520. Un défaut qui se reproduit sur un fichier que vous n'avez pas touché est un défaut que vous avez révélé, pas un défaut que vous avez introduit. Cette distinction vaut trente secondes à chaque fois.
Ce qui se généralise
C'est la deuxième fois qu'un outil de mesure rapporte silencieusement la mauvaise chose sur un site ZeroSuite, et les deux échecs sont la même forme inversée.
Le premier : un run responsive authentifié dont la session a expiré en cours de série. Chaque page suivante rendait la home vitrine publique au lieu de la page sous test. Zéro pixel de débordement, seize rendus, succès. Les captures n'étaient pas blanches — elles étaient pleines, jolies et plausibles. Rien dans le rapport ne les distinguait d'un vrai succès. Nous avons corrigé cela en faisant échouer l'outil quand un sélecteur asserté est absent : ne rien avoir mesuré doit échouer, pas réussir.
Le second est celui-ci. Un chiffre rouge, juste dans son arithmétique, désignant un élément innocent.
Les deux cas ont la même racine : un outil rapporte ce qu'il a mesuré, et ce qu'il a mesuré n'est pas nécessairement ce que vous croyez avoir demandé. Le vert ne veut pas dire que la chose qui vous importe va bien. Le rouge ne veut pas dire que la chose qu'il nomme est coupable. Le chiffre est un pointeur vers l'artefact, et c'est dans l'artefact que vit la réponse.
Si j'avais corrigé l'ancre, le contrôleur serait passé au vert. La page serait restée non stylée, toujours en anglais, et quatre-vingt-douze URLs seraient toujours cassées — avec cette fois un test au vert monté la garde au-dessus.
Ce qui est parti en production
Un tableau et deux lignes de spread. Les cinq locales prérendent désormais les quarante-neuf pages de documentation ; le français en avait vingt-six. Remesuré en production après déploiement : 196 URLs vérifiées, zéro écart. La suite responsive passe en local à 390 et 768 pixels sur sept routes en trois langues, et de nouveau en production à 390 sur quatre locales — et les captures, que j'ai regardées, font 390 pixels de large.
Le débordement a disparu. Il n'a jamais été le bug.